2026-05-14 11:12:08 +03:00
|
|
|
// Block-definition tessellation cache.
|
|
|
|
|
//
|
|
|
|
|
// Each block record is tessellated once into block-local coordinates and
|
|
|
|
|
// stored as a list of `LocalSub` (either a tessellated primitive wire OR
|
|
|
|
|
// an unexpanded reference to a nested INSERT). At Insert use-time we walk
|
|
|
|
|
// the defn, transform-copy primitives, and recurse into nested references —
|
|
|
|
|
// each nested defn is itself a cache hit, never re-tessellated.
|
|
|
|
|
//
|
|
|
|
|
// This shape (lazy nested expansion) is essential: a single block like
|
|
|
|
|
// `xref-PLANKOTE` can hold ~4700 nested INSERTs, so build-time inlining
|
|
|
|
|
// produces a combinatorial blowup. Storing references and expanding on
|
|
|
|
|
// demand keeps build work proportional to total entity count.
|
|
|
|
|
//
|
|
|
|
|
// Cycle detection: at expand-time we maintain a recursion-depth limit and
|
|
|
|
|
// a visited set so a self-referential block produces a marker rather than
|
|
|
|
|
// recursing forever.
|
|
|
|
|
|
2026-06-05 00:35:53 +03:00
|
|
|
use rustc_hash::FxHashMap as HashMap;
|
2026-05-14 11:12:08 +03:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
use acadrust::types::{Color as AcadColor, LineWeight, Transform, Vector3};
|
|
|
|
|
use acadrust::{CadDocument, EntityType, Handle};
|
|
|
|
|
|
2026-06-16 11:33:46 +03:00
|
|
|
use crate::scene::convert::tessellate;
|
|
|
|
|
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
|
2026-05-14 11:12:08 +03:00
|
|
|
|
|
|
|
|
const MAX_NESTING_DEPTH: usize = 32;
|
2026-05-14 14:22:39 +03:00
|
|
|
/// Skip wires whose world-AABB projects to fewer than this many pixels in
|
2026-05-14 15:01:51 +03:00
|
|
|
/// the active view. Picks up tiny detail at zoom-out so the tessellator
|
|
|
|
|
/// doesn't waste time on geometry that contributes a few sub-pixel marks
|
|
|
|
|
/// to the final image. 2 px is the AutoCAD-default "small element" floor
|
|
|
|
|
/// — visibly the same image, dramatically fewer wires.
|
|
|
|
|
const MIN_PIXEL_SIZE: f32 = 2.0;
|
2026-05-14 11:12:08 +03:00
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub struct LocalWire {
|
|
|
|
|
pub points: Vec<[f32; 3]>,
|
2026-06-23 17:44:09 +03:00
|
|
|
/// Low-bit residual paired with `points` so block-instance wires keep
|
|
|
|
|
/// sub-f32 precision once the renderer translates them to world space.
|
|
|
|
|
pub points_low: Vec<[f32; 3]>,
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
/// SDF glyph quads for block-internal text, in block-local coordinates.
|
|
|
|
|
/// Non-empty only when SDF text is on and this sub is a TEXT/MTEXT. The
|
|
|
|
|
/// expand-time transform (`emit_wire`) maps each vertex to world exactly
|
|
|
|
|
/// like `points`, so block-instance text lands at the right place/scale.
|
|
|
|
|
pub text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
|
2026-06-23 22:23:56 +03:00
|
|
|
pub key_vertices: Vec<[f64; 3]>,
|
2026-06-23 18:52:42 +03:00
|
|
|
pub snap_pts: Vec<(glam::DVec3, SnapHint)>,
|
2026-05-14 11:12:08 +03:00
|
|
|
pub tangent_geoms: Vec<TangentGeom>,
|
|
|
|
|
pub fill_tris: Vec<[f32; 3]>,
|
2026-06-28 18:45:57 +01:00
|
|
|
pub fill_tris_low: Vec<[f32; 3]>,
|
fix(pick): hit-test lineweight width and thickness walls
Selection tested a flat 8 px radius around each wire's centreline and
nothing else, so two things the user can see were not pickable.
A wire renders as a band `line_weight_px` wide, so pick now widens to that
rendered half-width via `pick_tolerance_px`, mirroring the wire shader's
`select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for
every standard weight (the widest, 2.11 mm, renders 7.97 px half-width —
just under the 8 px threshold); this keeps the two sides from drifting
apart if the display boost in `lineweight_to_px` ever changes.
Entities extruded by a DXF thickness draw their wall as four edges with
nothing in between, so the interior was a hole the cursor fell through.
WireModel gains `pick_tris`: triangles hit-testing treats as solid and the
renderer never uploads. Kept apart from `fill_tris` because that channel
reaches the GPU, and a wall that rendered shaded would change how every
thickness drawing looks. Ranked below `fill_tris` — those are drawn, these
are not, so where they overlap the drawn face is what the user means.
Walls are dropped when a wire is projected into a floating viewport:
`points` are reprojected and clipped into paper coords there, so cloned
model-space triangles would hit-test at a model-scale offset. Extruded
entities stay selectable by their edges, which do get projected.
The wall loop runs on every hover that misses everything else, so it
rejects on the wire's AABB first — an extruded circle is ~128 triangles
and a drawing full of thickness would otherwise project every wall on
each mouse move.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
|
|
|
/// Thickness-wall pick geometry, transformed by the insert like `points`
|
|
|
|
|
/// so a block child's extruded wall stays selectable at the instance's
|
|
|
|
|
/// place and scale. Never reaches the GPU.
|
|
|
|
|
pub pick_tris: Vec<[f32; 3]>,
|
|
|
|
|
pub pick_tris_low: Vec<[f32; 3]>,
|
2026-06-28 18:45:57 +01:00
|
|
|
/// Per-wire colour from the tessellator output. For most entities this
|
|
|
|
|
/// equals the sub-entity's resolved colour. For colour-split MTEXT
|
|
|
|
|
/// (`\C`/`\c` inline overrides) each wire carries its own override colour.
|
2026-05-14 11:12:08 +03:00
|
|
|
pub color: [f32; 4],
|
|
|
|
|
pub aci: u8,
|
|
|
|
|
pub pattern_length: f32,
|
|
|
|
|
pub pattern: [f32; 8],
|
|
|
|
|
pub line_weight_px: f32,
|
2026-07-20 16:04:03 +03:00
|
|
|
/// World-space band width for a wide polyline (see `WireModel.world_width`).
|
|
|
|
|
/// Block-local; the expand-time transform scales it by the insert so the
|
|
|
|
|
/// shader band grows with a scaled insert. `0.0` = a normal wire.
|
|
|
|
|
pub world_width: f32,
|
2026-05-14 11:12:08 +03:00
|
|
|
pub plinegen: bool,
|
2026-06-28 18:45:57 +01:00
|
|
|
/// Set at construction; used to discriminate fill-only GPU batches from
|
|
|
|
|
/// stroke batches in [`StyleKey`]. Derived from
|
|
|
|
|
/// `points.is_empty() && !fill_tris.is_empty()`.
|
|
|
|
|
pub is_fill_only: bool,
|
2026-05-14 11:12:08 +03:00
|
|
|
pub color_is_byblock: bool,
|
|
|
|
|
pub lt_is_byblock: bool,
|
|
|
|
|
pub lw_is_byblock: bool,
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
/// Set when this child sits on layer "0" and the matching property is
|
|
|
|
|
/// ByLayer. At expand time the value is then taken from the INSERT's
|
|
|
|
|
/// *layer* (the layer-0 inheritance rule) instead of the cached layer-0
|
|
|
|
|
/// value baked here.
|
|
|
|
|
pub color_l0: bool,
|
|
|
|
|
pub lt_l0: bool,
|
|
|
|
|
pub lw_l0: bool,
|
2026-05-14 13:52:04 +03:00
|
|
|
/// XY bounding box of this wire in block-local coordinates.
|
|
|
|
|
/// `[min_x, min_y, max_x, max_y]`. Used for view-frustum culling at
|
|
|
|
|
/// expand-time: transform corners by the Insert transform → world AABB
|
|
|
|
|
/// → test against the camera's world-space view rect.
|
|
|
|
|
pub aabb_local: [f32; 4],
|
2026-07-21 15:53:28 +03:00
|
|
|
/// This child's signed draw-order rank in (-1,1) within its owning block
|
|
|
|
|
/// (from the scene draw-depth map, so SortEntitiesTable overrides apply).
|
|
|
|
|
/// Composed at expand time into a `depth_override` for wide-polyline
|
|
|
|
|
/// bands so a band orders against its block siblings.
|
|
|
|
|
pub local_rank: f32,
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
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,
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
/// 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,
|
2026-05-14 11:12:08 +03:00
|
|
|
pub instance_offsets: Vec<[f64; 3]>,
|
2026-06-24 09:53:28 +03:00
|
|
|
/// XCLIP boundary for this nested insert, in the parent defn's local frame
|
|
|
|
|
/// (`None` = unclipped). Baked at build time because the clip's spatial
|
|
|
|
|
/// filter lives in `doc.objects`, which isn't reachable at expand time; on
|
|
|
|
|
/// expansion it is mapped to world by the accumulated transform and the
|
|
|
|
|
/// nested wires are clipped to it.
|
|
|
|
|
pub clip_poly: Option<Vec<[f64; 2]>>,
|
2026-07-21 15:53:28 +03:00
|
|
|
/// The nested INSERT's own signed draw-order rank in (-1,1) within the
|
|
|
|
|
/// parent block — narrows the depth sub-range its children may occupy.
|
|
|
|
|
pub local_rank: f32,
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub enum LocalSub {
|
|
|
|
|
Wire(LocalWire),
|
|
|
|
|
Nested(NestedRef),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
|
|
|
pub struct BlockDefn {
|
|
|
|
|
pub subs: Vec<LocalSub>,
|
2026-05-14 13:52:04 +03:00
|
|
|
/// Union of every sub's local AABB (including nested-INSERT contributions
|
|
|
|
|
/// resolved at expand time via their own defn's `aabb_local`). XY only —
|
2026-05-14 21:45:37 +03:00
|
|
|
/// the wire renderer is 2D-dominant. Expressed in this defn's *offset*
|
2026-06-24 20:37:52 +03:00
|
|
|
/// Absolute world-space XY (the double-single render path keeps it precise).
|
2026-05-14 13:52:04 +03:00
|
|
|
pub aabb_local: [f32; 4],
|
2026-07-21 15:53:28 +03:00
|
|
|
/// 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.
|
|
|
|
|
pub child_count: usize,
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Default, Debug)]
|
|
|
|
|
pub struct BlockCache {
|
|
|
|
|
defns: HashMap<String, Arc<BlockDefn>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl BlockCache {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self::default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn defn(&self, block_name: &str) -> Option<&Arc<BlockDefn>> {
|
|
|
|
|
self.defns.get(block_name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build (flat) defns only for block records actually referenced by
|
|
|
|
|
/// Inserts in the document — transitively, so nested-insert targets are
|
|
|
|
|
/// included too. The Model_Space / Paper_Space block_records are skipped
|
|
|
|
|
/// because their entities are emitted as top-level wires, not via the
|
|
|
|
|
/// cache.
|
2026-07-21 15:53:28 +03:00
|
|
|
pub fn build(
|
|
|
|
|
doc: &CadDocument,
|
|
|
|
|
anno_scale: f32,
|
|
|
|
|
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.
|
|
|
|
|
depth_map: &HashMap<u64, [f32; 2]>,
|
|
|
|
|
) -> Self {
|
2026-06-16 17:11:20 +03:00
|
|
|
use crate::par::prelude::*;
|
2026-05-14 11:12:08 +03:00
|
|
|
let mut cache = Self::new();
|
|
|
|
|
let referenced = collect_referenced_blocks(doc);
|
2026-06-05 00:46:21 +03:00
|
|
|
// Each defn is built independently: nested INSERTs are stored as
|
|
|
|
|
// by-name references (`LocalSub::Nested`), never expanded here, so a
|
|
|
|
|
// block's build never depends on another block's defn. That makes the
|
|
|
|
|
// builds embarrassingly parallel over the read-only `doc` — no
|
|
|
|
|
// topological ordering required. `compute_block_aabbs` stays a serial
|
|
|
|
|
// post-pass (it resolves nested references and is comparatively cheap).
|
|
|
|
|
cache.defns = referenced
|
|
|
|
|
.par_iter()
|
2026-07-21 15:53:28 +03:00
|
|
|
.map(|name| {
|
|
|
|
|
(
|
|
|
|
|
name.clone(),
|
|
|
|
|
Arc::new(build_defn(doc, name, anno_scale, bg_color, depth_map)),
|
|
|
|
|
)
|
|
|
|
|
})
|
2026-06-05 00:46:21 +03:00
|
|
|
.collect();
|
2026-05-14 13:52:04 +03:00
|
|
|
cache.compute_block_aabbs(&referenced);
|
2026-05-14 11:12:08 +03:00
|
|
|
cache
|
|
|
|
|
}
|
2026-05-14 13:52:04 +03:00
|
|
|
|
|
|
|
|
/// 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
|
|
|
|
|
/// nested Insert's transform before unioning.
|
|
|
|
|
///
|
|
|
|
|
/// Run as a post-pass so it doesn't matter which order build_defn was
|
|
|
|
|
/// called in. Cycle guard: a self-referential block keeps an empty AABB
|
|
|
|
|
/// (will fail every frustum test → not emitted, which is correct).
|
|
|
|
|
fn compute_block_aabbs(&mut self, names: &[String]) {
|
2026-06-16 17:11:20 +03:00
|
|
|
use crate::par::prelude::*;
|
2026-06-05 08:52:16 +03:00
|
|
|
// Phase 1 (parallel, read-only): each defn's union AABB is resolved
|
|
|
|
|
// by `defn_aabb_recursive`, which only *reads* `self.defns` (the map
|
|
|
|
|
// is fully built by now). There's no memoization, so a defn shared by
|
|
|
|
|
// many parents is re-walked per parent — real work on block-heavy
|
|
|
|
|
// drawings — and the per-name walks are independent, so they fan out.
|
|
|
|
|
let this: &Self = self;
|
|
|
|
|
let resolved: Vec<(&String, [f32; 4])> = names
|
|
|
|
|
.par_iter()
|
|
|
|
|
.map(|name| {
|
|
|
|
|
let mut visited: Vec<String> = Vec::new();
|
|
|
|
|
(name, this.defn_aabb_recursive(name, &mut visited))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
// Phase 2 (serial): store the AABB back into each defn.
|
|
|
|
|
for (name, aabb) in resolved {
|
2026-05-14 13:52:04 +03:00
|
|
|
if let Some(defn_arc) = self.defns.get_mut(name) {
|
|
|
|
|
let mut defn = (**defn_arc).clone();
|
|
|
|
|
defn.aabb_local = aabb;
|
|
|
|
|
*defn_arc = Arc::new(defn);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 21:45:37 +03:00
|
|
|
/// Returns the union AABB for `block_name`'s defn, expressed in **that
|
|
|
|
|
/// defn's offset frame** (so its caller can store it in
|
|
|
|
|
/// `BlockDefn.aabb_local` without a coordinate-frame mismatch).
|
|
|
|
|
///
|
|
|
|
|
/// LocalWire contributions are already in the parent defn's offset
|
|
|
|
|
/// frame. Nested-INSERT contributions live in the *child* defn's offset
|
|
|
|
|
/// frame, so we re-add `child.local_offset` (f64), apply the nested
|
|
|
|
|
/// Insert's transform to get parent-native coordinates, then subtract
|
|
|
|
|
/// `parent.local_offset` to land back in the parent's offset frame.
|
2026-05-14 13:52:04 +03:00
|
|
|
fn defn_aabb_recursive(&self, block_name: &str, visited: &mut Vec<String>) -> [f32; 4] {
|
|
|
|
|
if visited.iter().any(|n| n == block_name) {
|
|
|
|
|
return [0.0, 0.0, 0.0, 0.0];
|
|
|
|
|
}
|
|
|
|
|
let Some(defn) = self.defns.get(block_name) else {
|
|
|
|
|
return [0.0, 0.0, 0.0, 0.0];
|
|
|
|
|
};
|
|
|
|
|
visited.push(block_name.to_string());
|
|
|
|
|
let mut acc = [0.0_f32, 0.0, 0.0, 0.0];
|
|
|
|
|
for sub in &defn.subs {
|
|
|
|
|
let aabb = match sub {
|
|
|
|
|
LocalSub::Wire(lw) => lw.aabb_local,
|
|
|
|
|
LocalSub::Nested(nref) => {
|
2026-06-24 20:37:52 +03:00
|
|
|
let nested = self.defn_aabb_recursive(&nref.block_name, visited);
|
|
|
|
|
transform_aabb_xy(nested, &nref.xform)
|
2026-05-14 13:52:04 +03:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
acc = aabb_union(acc, aabb);
|
|
|
|
|
}
|
|
|
|
|
visited.pop();
|
|
|
|
|
acc
|
|
|
|
|
}
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Walk all entities + all block_record contents collecting every distinct
|
|
|
|
|
/// `block_name` that appears in an Insert (transitively).
|
|
|
|
|
fn collect_referenced_blocks(doc: &CadDocument) -> Vec<String> {
|
2026-06-05 00:35:53 +03:00
|
|
|
use rustc_hash::FxHashSet as HashSet;
|
|
|
|
|
let mut seen: HashSet<String> = HashSet::default();
|
2026-05-14 11:12:08 +03:00
|
|
|
let mut queue: Vec<String> = Vec::new();
|
|
|
|
|
|
|
|
|
|
for entity in doc.entities() {
|
|
|
|
|
if let EntityType::Insert(ins) = entity {
|
|
|
|
|
if seen.insert(ins.block_name.clone()) {
|
|
|
|
|
queue.push(ins.block_name.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
while let Some(name) = queue.pop() {
|
|
|
|
|
let Some(br) = doc.block_records.get(&name) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
for &eh in &br.entity_handles {
|
|
|
|
|
let Some(entity) = doc.get_entity(eh) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if let EntityType::Insert(ins) = entity {
|
|
|
|
|
if seen.insert(ins.block_name.clone()) {
|
|
|
|
|
queue.push(ins.block_name.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
seen.into_iter().collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 19:57:19 +03:00
|
|
|
/// True when `layer` is turned off or frozen — entities on it never render.
|
|
|
|
|
fn layer_hidden(doc: &CadDocument, layer: &str) -> bool {
|
|
|
|
|
doc.layers
|
|
|
|
|
.get(layer)
|
|
|
|
|
.map(|l| l.flags.off || l.flags.frozen)
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 11:12:08 +03:00
|
|
|
fn build_defn(
|
|
|
|
|
doc: &CadDocument,
|
|
|
|
|
block_name: &str,
|
|
|
|
|
anno_scale: f32,
|
|
|
|
|
bg_color: [f32; 4],
|
2026-07-21 15:53:28 +03:00
|
|
|
depth_map: &HashMap<u64, [f32; 2]>,
|
2026-05-14 11:12:08 +03:00
|
|
|
) -> BlockDefn {
|
|
|
|
|
let br = match doc.block_records.get(block_name) {
|
|
|
|
|
Some(br) => br,
|
|
|
|
|
None => return BlockDefn::default(),
|
|
|
|
|
};
|
2026-05-14 21:45:37 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── Pass 2: tessellate each sub with the chosen offset so stored
|
|
|
|
|
// coordinates fit into f32 without precision loss.
|
2026-05-14 11:12:08 +03:00
|
|
|
let cap = br.entity_handles.len();
|
|
|
|
|
let mut subs: Vec<LocalSub> = Vec::with_capacity(cap);
|
|
|
|
|
for &eh in &br.entity_handles {
|
|
|
|
|
let Some(entity) = doc.get_entity(eh) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-06-11 19:06:55 +03:00
|
|
|
// Skip entities flagged invisible. Dynamic blocks (e.g. a visibility-
|
|
|
|
|
// state parametric block) keep the geometry for every state in one
|
|
|
|
|
// anonymous block and mark all but the active state's entities
|
|
|
|
|
// invisible — honouring the flag is what shows a single profile
|
|
|
|
|
// instead of every variant stacked on top of each other.
|
|
|
|
|
if entity.common().invisible {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-06-19 19:57:19 +03:00
|
|
|
// A sub-entity on a layer that is off or frozen must not render, same
|
|
|
|
|
// as a top-level entity on that layer. The defn cache is rebuilt on
|
|
|
|
|
// every layer off/freeze toggle (bump_geometry bumps block_epoch), so
|
|
|
|
|
// baking the visibility here stays in sync.
|
|
|
|
|
if layer_hidden(doc, &entity.common().layer) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-14 11:12:08 +03:00
|
|
|
match entity {
|
2026-07-18 17:57:23 +03:00
|
|
|
EntityType::Block(_) | EntityType::BlockEnd(_) => continue,
|
|
|
|
|
// A non-constant ATTDEF is only a template — the insert supplies an
|
|
|
|
|
// ATTRIB with the real value (tessellated separately). A CONSTANT
|
|
|
|
|
// attribute has no ATTRIB; its value lives in the block itself, so
|
|
|
|
|
// it must render as part of the block content (unless flagged
|
|
|
|
|
// invisible).
|
|
|
|
|
EntityType::AttributeDefinition(ad) if !ad.flags.constant || ad.flags.invisible => {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-05-14 11:12:08 +03:00
|
|
|
EntityType::Insert(nested_ins) => {
|
2026-07-21 15:53:28 +03:00
|
|
|
subs.push(LocalSub::Nested(build_nested_ref(
|
|
|
|
|
nested_ins, doc, bg_color, depth_map,
|
|
|
|
|
)));
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
_ => {
|
2026-07-20 16:04:03 +03:00
|
|
|
// A wide polyline inside a block carries its `world_width` on
|
|
|
|
|
// the LocalWire; `emit_wire` scales it by the insert transform
|
|
|
|
|
// so the shader band matches the scaled geometry (same band the
|
|
|
|
|
// top-level path draws — depth-tested + linetype-dashed).
|
2026-07-21 15:53:28 +03:00
|
|
|
for lw in tessellate_sub_local(doc, entity, anno_scale, bg_color, depth_map) {
|
2026-05-14 11:12:08 +03:00
|
|
|
subs.push(LocalSub::Wire(lw));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 13:52:04 +03:00
|
|
|
BlockDefn {
|
|
|
|
|
subs,
|
|
|
|
|
aabb_local: [0.0; 4],
|
2026-07-21 15:53:28 +03:00
|
|
|
child_count: br.entity_handles.len(),
|
2026-05-14 13:52:04 +03:00
|
|
|
}
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_nested_ref(
|
|
|
|
|
nested_ins: &acadrust::entities::Insert,
|
|
|
|
|
doc: &CadDocument,
|
|
|
|
|
bg_color: [f32; 4],
|
2026-07-21 15:53:28 +03:00
|
|
|
depth_map: &HashMap<u64, [f32; 2]>,
|
2026-05-14 11:12:08 +03:00
|
|
|
) -> NestedRef {
|
2026-05-28 22:43:13 +03:00
|
|
|
// 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, _) =
|
2026-06-16 11:33:46 +03:00
|
|
|
crate::scene::view::render::render_style_for(doc, &EntityType::Insert(nested_ins.clone()));
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
// 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);
|
2026-05-28 22:43:13 +03:00
|
|
|
let _ = bg_color;
|
2026-05-14 11:12:08 +03:00
|
|
|
|
2026-06-24 09:53:28 +03:00
|
|
|
// 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 clip_poly = crate::scene::pick::xclip::insert_spatial_filter(doc, nested_ins)
|
refactor: remove dead world_offset plumbing (RTE migration is complete)
Now that geometry reaches the GPU/CPU as absolute coordinates via the
double-single relative-to-eye path, world_offset is always [0,0,0]. Strip
the parameter that was threaded through the whole tessellation / block-
expansion / fallback / camera-decode chain and the ExpandCtx field that
carried it — ~340 references across 20 files, all subtracting zero.
- truck_tess: to_local / to_local_low / tessellate_* drop the offset arg;
to_local is now a pure double-single split.
- tessellate: offset_to_ds → points_to_ds (pure DS split); tessellate(),
tessellate_entity(), fallback_geometry(), solid_wire_fallback(),
entity_aabb(), expand_insert(), expand_block_meshes(), the dimension
helpers (vec3_local, dimension_snap_pts, …), text_support, leader,
multileader, image_model, xclip and camera_from_view all lose the param.
- block_cache: ExpandCtx loses its world_offset field.
- Remove the now-dead offset_snap_pts and the unused set_grid_snap.
Behaviour is unchanged (the offset was zero everywhere); the compiler
verifies every call site.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:35:02 +03:00
|
|
|
.map(|sf| crate::scene::pick::xclip::world_clip_polygon_f64(sf, nested_ins));
|
2026-06-24 09:53:28 +03:00
|
|
|
|
2026-05-14 11:12:08 +03:00
|
|
|
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: 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),
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
color_is_bylayer: 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: nested_ins.common.layer == "0",
|
|
|
|
|
l0,
|
2026-05-14 11:12:08 +03:00
|
|
|
instance_offsets: array_offsets(nested_ins),
|
2026-06-24 09:53:28 +03:00
|
|
|
clip_poly,
|
2026-07-21 15:53:28 +03:00
|
|
|
local_rank: depth_map
|
|
|
|
|
.get(&nested_ins.common.handle.value())
|
|
|
|
|
.map_or(0.0, |d| d[0]),
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn tessellate_sub_local(
|
|
|
|
|
doc: &CadDocument,
|
|
|
|
|
sub: &EntityType,
|
|
|
|
|
anno_scale: f32,
|
|
|
|
|
bg_color: [f32; 4],
|
2026-07-21 15:53:28 +03:00
|
|
|
depth_map: &HashMap<u64, [f32; 2]>,
|
2026-06-28 18:45:57 +01:00
|
|
|
) -> Vec<LocalWire> {
|
2026-05-14 11:12:08 +03:00
|
|
|
let h = sub.common().handle;
|
|
|
|
|
|
|
|
|
|
// Sanity guard: skip sub-entities whose primary dimension is so large
|
|
|
|
|
// that adaptive tessellation will explode into hundreds of millions
|
|
|
|
|
// of points. These are typically corrupt-radius primitives that slipped
|
|
|
|
|
// past purge_corrupt_entities (finite but absurd values).
|
|
|
|
|
if is_unreasonable_extent(sub) {
|
2026-06-28 18:45:57 +01:00
|
|
|
return vec![];
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-28 22:43:13 +03:00
|
|
|
// Store the RAW colour. `Batches::finalize` applies `adapt_to_bg`
|
|
|
|
|
// with the per-render bg, so the cache no longer has to rebuild on
|
|
|
|
|
// BACKGROUND / layout-switch — the dynamic adaptation tracks the
|
|
|
|
|
// live bg at render time.
|
2026-06-16 11:33:46 +03:00
|
|
|
let (sub_color, pat_len, pat, lw_px, aci) = crate::scene::view::render::render_style_for(doc, sub);
|
2026-05-28 22:43:13 +03:00
|
|
|
let _ = bg_color;
|
2026-05-14 11:12:08 +03:00
|
|
|
|
|
|
|
|
let color_is_byblock = sub.common().color == AcadColor::ByBlock;
|
|
|
|
|
let lt_is_byblock = sub.common().linetype.eq_ignore_ascii_case("byblock");
|
|
|
|
|
let lw_is_byblock = matches!(sub.common().line_weight, LineWeight::ByBlock);
|
|
|
|
|
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
// Layer-0 rule: a child on layer "0" with ByLayer properties inherits the
|
|
|
|
|
// INSERT's layer at expand time. Flag each ByLayer property so emit_wire
|
|
|
|
|
// can override the cached (layer-0-resolved) value with the insert layer's.
|
|
|
|
|
let on_l0 = sub.common().layer == "0";
|
|
|
|
|
let color_l0 = on_l0 && sub.common().color == AcadColor::ByLayer;
|
|
|
|
|
let lt_l0 = on_l0 && {
|
|
|
|
|
let lt = &sub.common().linetype;
|
|
|
|
|
lt.is_empty() || lt.eq_ignore_ascii_case("bylayer")
|
|
|
|
|
};
|
|
|
|
|
let lw_l0 = on_l0
|
|
|
|
|
&& matches!(
|
|
|
|
|
sub.common().line_weight,
|
|
|
|
|
LineWeight::ByLayer | LineWeight::Default
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-14 21:45:37 +03:00
|
|
|
// Pass `local_offset` as the f64 world-offset so tessellate subtracts it
|
|
|
|
|
// before casting to f32 — same precision-preservation trick used for
|
|
|
|
|
// top-level entities, applied per-defn.
|
2026-06-28 18:45:57 +01:00
|
|
|
let wires_out = tessellate::tessellate(
|
2026-07-11 10:38:41 +03:00
|
|
|
doc, h, sub, false, sub_color, pat_len, pat, lw_px, anno_scale, None, bg_color, false,
|
2026-05-14 11:12:08 +03:00
|
|
|
);
|
2026-05-21 23:42:22 +03:00
|
|
|
if wires_out.is_empty() {
|
2026-06-28 18:45:57 +01:00
|
|
|
return vec![];
|
2026-05-21 23:42:22 +03:00
|
|
|
}
|
2026-05-14 13:52:04 +03:00
|
|
|
|
2026-06-28 18:45:57 +01:00
|
|
|
let mut result = Vec::with_capacity(wires_out.len());
|
|
|
|
|
for wire in wires_out {
|
|
|
|
|
// Per-wire point-count cap: a single wire that exceeds this is skipped
|
|
|
|
|
// rather than aborting the whole sub-entity — with per-wire separation,
|
|
|
|
|
// other colour segments from the same entity still render.
|
|
|
|
|
if wire.points.len() > 100_000 {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Geometry is stored absolute; the double-single (high/low) render path
|
|
|
|
|
// keeps it precise at UTM scale, so no per-defn offset is subtracted.
|
refactor(text): SDF for all text kinds; remove the legacy stroke text path
SDF is the only text renderer now — the stroke path and its LOD ladder are
gone, and the remaining text kinds render as SDF glyph quads too.
- ATTDEF / ATTRIB: carry a GlyphRun so single-line attribute text renders as
SDF (multi-line already did via layout_mtext). Tolerance / complex-linetype
text already SDF.
- Remove `sdf_text_enabled()` (the OCS_TEXT_SDF flag / kill switch) and take
the SDF path unconditionally everywhere: tessellate Text arm (per-group by
GlyphRun presence), hit_test fallbacks, render gather, complex_lt, table,
mleader.
- Delete the text LOD ladder: tess.rs baseline/greek block and the
block_cache greek/baseline path (emit_greeked_text / emit_text_baseline),
plus the now-dead helpers text_baseline_points / text_greek_obb_tris /
text_obb_corners_native / mtext_line_count and the LocalWire
text_height_local / text_obb_local fields. All text is crisp at every zoom;
text entities are exempt from the sub-pixel stub cull.
- Fix a latent bug: block-internal SDF text was culled because its LocalWire
aabb_local was [0,0,0,0] (no points) — fold the glyph-quad extent into
aabb_local and exempt text_verts-carrying wires from the sub-pixel cull.
- Clear the pre-existing dead-code warnings (TextPlacement.elevation, TabKind,
MTextRunKind::Tab).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:07:00 +03:00
|
|
|
// SDF text wires have no points/fills — fold in the glyph-quad positions
|
|
|
|
|
// so the view-frustum cull uses the text's real box, not a degenerate
|
|
|
|
|
// point at the block origin (which would drop the text entirely).
|
fix(pick): catch a wide polyline on its band, not just its centreline
A wide polyline draws a solid band, but hit-testing only ever saw the
centreline 8 px out, so a donut could only be selected by clicking the
phantom circle inside the disc it paints. `self.hatches` — the map the
hatch pick channel walks — is filled from HATCH and SOLID alone, so the
band `wide_fills` produces was drawn (via `synced_hatch_models`) and never
picked.
The band rides in on `pick_tris`, the same channel a thickness wall uses:
`wide_fills` already computes it, so it costs one tessellation and inherits
the block transform. It ear clips rather than fans — an arc band is an
annular sector, concave on its inner edge — reusing `triangulate_planar`.
Wiring `pick_tris` through `Lines` alone was not enough: a plain wide
polyline leaves `to_truck` as `Contour`, and a PLINEGEN=0 one as
`SegmentedLines`. Both now carry it.
This needs the wire's cullable box to cover the band, and it did not.
`bounding_box()` boxes a polyline's stored vertices, knowing nothing of the
width painted around them: a donut's two vertices sit on one horizontal
line, so the box had zero height and rejected every click on the disc
before any triangle was tested. Every site that stamps the entity box onto
a wire now goes through `set_wire_aabb`, which widens it over `pick_tris`
(a no-op for the entities that have none, which is nearly all), and the
block cache chains `pick_tris` into its own box for the same reason.
That box was already too small for the centreline itself — an arc leaves
the vertex box — so a donut was unselectable at its top and bottom at
enough zoom. Widening fixes that too.
Verified end-to-end: tessellate a width-0.5 donut on a 0.05 centreline
radius, then `click_hit` five points 20 px clear of the centreline — all
land, and a click outside the disc still misses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:22:56 +03:00
|
|
|
// `pick_tris` is in here for the same reason `fill_tris` is: hit-testing
|
|
|
|
|
// rejects on this box before it looks at the triangles, so a box drawn
|
|
|
|
|
// only around `points` would make a block child's thickness wall or wide
|
|
|
|
|
// polyline band unpickable — `points` merely bounds those.
|
2026-06-28 18:45:57 +01:00
|
|
|
let aabb_local = aabb_from_points_iter(
|
refactor(text): SDF for all text kinds; remove the legacy stroke text path
SDF is the only text renderer now — the stroke path and its LOD ladder are
gone, and the remaining text kinds render as SDF glyph quads too.
- ATTDEF / ATTRIB: carry a GlyphRun so single-line attribute text renders as
SDF (multi-line already did via layout_mtext). Tolerance / complex-linetype
text already SDF.
- Remove `sdf_text_enabled()` (the OCS_TEXT_SDF flag / kill switch) and take
the SDF path unconditionally everywhere: tessellate Text arm (per-group by
GlyphRun presence), hit_test fallbacks, render gather, complex_lt, table,
mleader.
- Delete the text LOD ladder: tess.rs baseline/greek block and the
block_cache greek/baseline path (emit_greeked_text / emit_text_baseline),
plus the now-dead helpers text_baseline_points / text_greek_obb_tris /
text_obb_corners_native / mtext_line_count and the LocalWire
text_height_local / text_obb_local fields. All text is crisp at every zoom;
text entities are exempt from the sub-pixel stub cull.
- Fix a latent bug: block-internal SDF text was culled because its LocalWire
aabb_local was [0,0,0,0] (no points) — fold the glyph-quad extent into
aabb_local and exempt text_verts-carrying wires from the sub-pixel cull.
- Clear the pre-existing dead-code warnings (TextPlacement.elevation, TabKind,
MTextRunKind::Tab).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:07:00 +03:00
|
|
|
wire.points
|
|
|
|
|
.iter()
|
|
|
|
|
.copied()
|
|
|
|
|
.chain(wire.fill_tris.iter().copied())
|
fix(pick): catch a wide polyline on its band, not just its centreline
A wide polyline draws a solid band, but hit-testing only ever saw the
centreline 8 px out, so a donut could only be selected by clicking the
phantom circle inside the disc it paints. `self.hatches` — the map the
hatch pick channel walks — is filled from HATCH and SOLID alone, so the
band `wide_fills` produces was drawn (via `synced_hatch_models`) and never
picked.
The band rides in on `pick_tris`, the same channel a thickness wall uses:
`wide_fills` already computes it, so it costs one tessellation and inherits
the block transform. It ear clips rather than fans — an arc band is an
annular sector, concave on its inner edge — reusing `triangulate_planar`.
Wiring `pick_tris` through `Lines` alone was not enough: a plain wide
polyline leaves `to_truck` as `Contour`, and a PLINEGEN=0 one as
`SegmentedLines`. Both now carry it.
This needs the wire's cullable box to cover the band, and it did not.
`bounding_box()` boxes a polyline's stored vertices, knowing nothing of the
width painted around them: a donut's two vertices sit on one horizontal
line, so the box had zero height and rejected every click on the disc
before any triangle was tested. Every site that stamps the entity box onto
a wire now goes through `set_wire_aabb`, which widens it over `pick_tris`
(a no-op for the entities that have none, which is nearly all), and the
block cache chains `pick_tris` into its own box for the same reason.
That box was already too small for the centreline itself — an arc leaves
the vertex box — so a donut was unselectable at its top and bottom at
enough zoom. Widening fixes that too.
Verified end-to-end: tessellate a width-0.5 donut on a 0.05 centreline
radius, then `click_hit` five points 20 px clear of the centreline — all
land, and a click outside the disc still misses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:22:56 +03:00
|
|
|
.chain(wire.pick_tris.iter().copied())
|
refactor(text): SDF for all text kinds; remove the legacy stroke text path
SDF is the only text renderer now — the stroke path and its LOD ladder are
gone, and the remaining text kinds render as SDF glyph quads too.
- ATTDEF / ATTRIB: carry a GlyphRun so single-line attribute text renders as
SDF (multi-line already did via layout_mtext). Tolerance / complex-linetype
text already SDF.
- Remove `sdf_text_enabled()` (the OCS_TEXT_SDF flag / kill switch) and take
the SDF path unconditionally everywhere: tessellate Text arm (per-group by
GlyphRun presence), hit_test fallbacks, render gather, complex_lt, table,
mleader.
- Delete the text LOD ladder: tess.rs baseline/greek block and the
block_cache greek/baseline path (emit_greeked_text / emit_text_baseline),
plus the now-dead helpers text_baseline_points / text_greek_obb_tris /
text_obb_corners_native / mtext_line_count and the LocalWire
text_height_local / text_obb_local fields. All text is crisp at every zoom;
text entities are exempt from the sub-pixel stub cull.
- Fix a latent bug: block-internal SDF text was culled because its LocalWire
aabb_local was [0,0,0,0] (no points) — fold the glyph-quad extent into
aabb_local and exempt text_verts-carrying wires from the sub-pixel cull.
- Clear the pre-existing dead-code warnings (TextPlacement.elevation, TabKind,
MTextRunKind::Tab).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:07:00 +03:00
|
|
|
.chain(wire.text_verts.iter().map(|v| v.pos)),
|
2026-06-28 18:45:57 +01:00
|
|
|
);
|
|
|
|
|
let is_fill_only = wire.points.is_empty() && !wire.fill_tris.is_empty();
|
2026-07-07 22:17:27 +03:00
|
|
|
// A wire whose colour differs from the entity's resolved base colour
|
|
|
|
|
// carries an explicit per-segment override (e.g. an MTEXT `\C1;` inline
|
|
|
|
|
// colour). ByBlock / layer-0 inheritance applies only to wires still on
|
|
|
|
|
// the base colour — folding an explicit segment into the inherited
|
|
|
|
|
// colour would collapse colour-split geometry to one colour. (PR #301,
|
|
|
|
|
// Kevin Griffin — extended to SDF text per-vertex in emit_wire.)
|
|
|
|
|
let wire_on_base_color = wire.color == sub_color;
|
2026-06-28 18:45:57 +01:00
|
|
|
|
|
|
|
|
result.push(LocalWire {
|
|
|
|
|
points: wire.points,
|
|
|
|
|
points_low: wire.points_low,
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
text_verts: wire.text_verts,
|
2026-06-28 18:45:57 +01:00
|
|
|
key_vertices: wire.key_vertices,
|
|
|
|
|
snap_pts: wire.snap_pts,
|
|
|
|
|
tangent_geoms: wire.tangent_geoms,
|
|
|
|
|
fill_tris: wire.fill_tris,
|
|
|
|
|
fill_tris_low: wire.fill_tris_low,
|
fix(pick): hit-test lineweight width and thickness walls
Selection tested a flat 8 px radius around each wire's centreline and
nothing else, so two things the user can see were not pickable.
A wire renders as a band `line_weight_px` wide, so pick now widens to that
rendered half-width via `pick_tolerance_px`, mirroring the wire shader's
`select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for
every standard weight (the widest, 2.11 mm, renders 7.97 px half-width —
just under the 8 px threshold); this keeps the two sides from drifting
apart if the display boost in `lineweight_to_px` ever changes.
Entities extruded by a DXF thickness draw their wall as four edges with
nothing in between, so the interior was a hole the cursor fell through.
WireModel gains `pick_tris`: triangles hit-testing treats as solid and the
renderer never uploads. Kept apart from `fill_tris` because that channel
reaches the GPU, and a wall that rendered shaded would change how every
thickness drawing looks. Ranked below `fill_tris` — those are drawn, these
are not, so where they overlap the drawn face is what the user means.
Walls are dropped when a wire is projected into a floating viewport:
`points` are reprojected and clipped into paper coords there, so cloned
model-space triangles would hit-test at a model-scale offset. Extruded
entities stay selectable by their edges, which do get projected.
The wall loop runs on every hover that misses everything else, so it
rejects on the wire's AABB first — an extruded circle is ~128 triangles
and a drawing full of thickness would otherwise project every wall on
each mouse move.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
|
|
|
pick_tris: wire.pick_tris,
|
|
|
|
|
pick_tris_low: wire.pick_tris_low,
|
2026-06-28 18:45:57 +01:00
|
|
|
color: wire.color,
|
|
|
|
|
aci,
|
|
|
|
|
pattern_length: pat_len,
|
|
|
|
|
pattern: pat,
|
|
|
|
|
line_weight_px: lw_px,
|
2026-07-20 16:04:03 +03:00
|
|
|
world_width: wire.world_width,
|
2026-06-28 18:45:57 +01:00
|
|
|
plinegen: wire.plinegen,
|
|
|
|
|
is_fill_only,
|
2026-07-07 22:17:27 +03:00
|
|
|
color_is_byblock: color_is_byblock && wire_on_base_color,
|
2026-06-28 18:45:57 +01:00
|
|
|
lt_is_byblock,
|
|
|
|
|
lw_is_byblock,
|
2026-07-07 22:17:27 +03:00
|
|
|
color_l0: color_l0 && wire_on_base_color,
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
lt_l0,
|
|
|
|
|
lw_l0,
|
2026-06-28 18:45:57 +01:00
|
|
|
aabb_local,
|
2026-07-21 15:53:28 +03:00
|
|
|
local_rank: depth_map.get(&h.value()).map_or(0.0, |d| d[0]),
|
2026-06-28 18:45:57 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
result
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 13:52:04 +03:00
|
|
|
fn aabb_from_points_iter<I: IntoIterator<Item = [f32; 3]>>(pts: I) -> [f32; 4] {
|
|
|
|
|
let mut min_x = f32::INFINITY;
|
|
|
|
|
let mut min_y = f32::INFINITY;
|
|
|
|
|
let mut max_x = f32::NEG_INFINITY;
|
|
|
|
|
let mut max_y = f32::NEG_INFINITY;
|
|
|
|
|
for p in pts {
|
|
|
|
|
if !p[0].is_finite() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if p[0] < min_x {
|
|
|
|
|
min_x = p[0];
|
|
|
|
|
}
|
|
|
|
|
if p[1] < min_y {
|
|
|
|
|
min_y = p[1];
|
|
|
|
|
}
|
|
|
|
|
if p[0] > max_x {
|
|
|
|
|
max_x = p[0];
|
|
|
|
|
}
|
|
|
|
|
if p[1] > max_y {
|
|
|
|
|
max_y = p[1];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if min_x.is_infinite() {
|
|
|
|
|
[0.0, 0.0, 0.0, 0.0]
|
|
|
|
|
} else {
|
|
|
|
|
[min_x, min_y, max_x, max_y]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 20:37:52 +03:00
|
|
|
/// Transform an absolute XY AABB by `t` and return the world-space XY AABB of
|
|
|
|
|
/// the transformed corners (computed in f64 so it stays accurate for distant
|
|
|
|
|
/// content).
|
|
|
|
|
fn transform_aabb_xy(local: [f32; 4], t: &Transform) -> [f32; 4] {
|
2026-05-14 13:52:04 +03:00
|
|
|
let [x0, y0, x1, y1] = local;
|
|
|
|
|
let corners = [
|
2026-06-24 20:37:52 +03:00
|
|
|
Vector3::new(x0 as f64, y0 as f64, 0.0),
|
|
|
|
|
Vector3::new(x1 as f64, y0 as f64, 0.0),
|
|
|
|
|
Vector3::new(x1 as f64, y1 as f64, 0.0),
|
|
|
|
|
Vector3::new(x0 as f64, y1 as f64, 0.0),
|
2026-05-14 13:52:04 +03:00
|
|
|
];
|
|
|
|
|
let mut min_x = f64::INFINITY;
|
|
|
|
|
let mut min_y = f64::INFINITY;
|
|
|
|
|
let mut max_x = f64::NEG_INFINITY;
|
|
|
|
|
let mut max_y = f64::NEG_INFINITY;
|
|
|
|
|
for c in corners {
|
|
|
|
|
let v = t.apply(c);
|
|
|
|
|
if v.x < min_x {
|
|
|
|
|
min_x = v.x;
|
|
|
|
|
}
|
|
|
|
|
if v.y < min_y {
|
|
|
|
|
min_y = v.y;
|
|
|
|
|
}
|
|
|
|
|
if v.x > max_x {
|
|
|
|
|
max_x = v.x;
|
|
|
|
|
}
|
|
|
|
|
if v.y > max_y {
|
|
|
|
|
max_y = v.y;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
[min_x as f32, min_y as f32, max_x as f32, max_y as f32]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn aabb_union(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
|
|
|
|
|
// [0,0,0,0] is the "empty AABB" sentinel produced by aabb_from_points_iter
|
|
|
|
|
// when a wire has no finite points — treat it as if the other side wins.
|
|
|
|
|
if a == [0.0, 0.0, 0.0, 0.0] {
|
|
|
|
|
return b;
|
|
|
|
|
}
|
|
|
|
|
if b == [0.0, 0.0, 0.0, 0.0] {
|
|
|
|
|
return a;
|
|
|
|
|
}
|
|
|
|
|
[a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn aabb_disjoint_xy(a: [f32; 4], b: [f32; 4]) -> bool {
|
|
|
|
|
a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 11:12:08 +03:00
|
|
|
// ── Use-time expansion ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Expand one top-level INSERT into world-space WireModels via the cache.
|
|
|
|
|
///
|
|
|
|
|
/// Returns `None` if no defn is cached for `ins.block_name`. Returns
|
|
|
|
|
/// `Some(empty)` if the defn exists but is empty.
|
|
|
|
|
pub fn expand_insert(
|
|
|
|
|
cache: &BlockCache,
|
|
|
|
|
ins: &acadrust::entities::Insert,
|
|
|
|
|
ins_handle: Handle,
|
|
|
|
|
ins_resolved_color: [f32; 4],
|
|
|
|
|
ins_pat_len: f32,
|
|
|
|
|
ins_pat: [f32; 8],
|
|
|
|
|
ins_lw_px: f32,
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
// The INSERT's own layer style — layer-0 inheritance target for children.
|
|
|
|
|
ins_layer: crate::scene::view::render::InheritStyle,
|
2026-05-14 11:12:08 +03:00
|
|
|
selected: bool,
|
|
|
|
|
pslt_factor: f32,
|
2026-05-14 13:52:04 +03:00
|
|
|
// World-space XY view AABB (with world_offset already subtracted, so the
|
|
|
|
|
// comparison is in the same f32 space as emitted wires). `None` disables
|
|
|
|
|
// frustum culling — every cached sub is emitted.
|
|
|
|
|
view_aabb: Option<[f32; 4]>,
|
2026-05-14 14:22:39 +03:00
|
|
|
// World units per screen pixel. When `Some`, wires whose AABB projects
|
|
|
|
|
// smaller than `MIN_PIXEL_SIZE` get skipped entirely (LOD).
|
|
|
|
|
world_per_pixel: Option<f32>,
|
2026-05-14 21:45:37 +03:00
|
|
|
// True when `ins.block_name` resolves to an xref BlockRecord. All emitted
|
|
|
|
|
// colors are faded toward `bg_color` so xrefs are visually distinguishable
|
|
|
|
|
// from native content.
|
|
|
|
|
is_xref: bool,
|
|
|
|
|
bg_color: [f32; 4],
|
2026-07-12 23:16:26 +03:00
|
|
|
// Current annotation scale. An annotative block scales as one uniform unit
|
|
|
|
|
// about its insertion point; a non-annotative block is unaffected.
|
|
|
|
|
anno_scale: f32,
|
2026-05-14 11:12:08 +03:00
|
|
|
) -> Option<Vec<WireModel>> {
|
|
|
|
|
let defn = cache.defn(&ins.block_name)?;
|
2026-07-12 23:16:26 +03:00
|
|
|
let mut xform = 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
|
|
|
|
|
// this transform, never scaled individually (which would double-scale).
|
|
|
|
|
if (anno_scale - 1.0).abs() > 1e-6
|
|
|
|
|
&& ins
|
|
|
|
|
.common
|
|
|
|
|
.extended_data
|
|
|
|
|
.get_record("AcAnnotativeData")
|
|
|
|
|
.is_some()
|
|
|
|
|
{
|
|
|
|
|
let p = ins.insert_point;
|
|
|
|
|
let scale_about_p = Transform::from_translation(Vector3::new(-p.x, -p.y, -p.z))
|
|
|
|
|
.then(&Transform::from_scale(anno_scale as f64))
|
|
|
|
|
.then(&Transform::from_translation(Vector3::new(p.x, p.y, p.z)));
|
|
|
|
|
xform = xform.then(&scale_about_p);
|
|
|
|
|
}
|
2026-05-14 11:12:08 +03:00
|
|
|
let name = ins_handle.value().to_string();
|
|
|
|
|
let mut batches = Batches::default();
|
|
|
|
|
let mut visited: Vec<String> = Vec::with_capacity(8);
|
2026-05-14 13:52:04 +03:00
|
|
|
|
2026-05-14 21:45:37 +03:00
|
|
|
// `defn.aabb_local` is in the defn's offset frame — re-add
|
|
|
|
|
// `defn.local_offset` (f64) before transforming so the world AABB is
|
|
|
|
|
// accurate for distant content.
|
2026-06-24 20:37:52 +03:00
|
|
|
let insert_world = transform_aabb_xy(defn.aabb_local, &xform);
|
2026-05-14 14:22:39 +03:00
|
|
|
let insert_local = [
|
2026-06-24 18:38:23 +03:00
|
|
|
insert_world[0] as f32,
|
|
|
|
|
insert_world[1] as f32,
|
|
|
|
|
insert_world[2] as f32,
|
|
|
|
|
insert_world[3] as f32,
|
2026-05-14 14:22:39 +03:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Whole-Insert frustum cull.
|
2026-05-14 13:52:04 +03:00
|
|
|
if let Some(view) = view_aabb {
|
|
|
|
|
if aabb_disjoint_xy(insert_local, view) {
|
|
|
|
|
return Some(vec![]);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 14:22:39 +03:00
|
|
|
// Whole-Insert pixel-size LOD: if the entire Insert footprint projects
|
|
|
|
|
// to sub-pixel size, skip it entirely.
|
|
|
|
|
if let Some(wpp) = world_per_pixel {
|
|
|
|
|
if aabb_pixel_size(insert_local, wpp) < MIN_PIXEL_SIZE {
|
|
|
|
|
return Some(vec![]);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 11:12:08 +03:00
|
|
|
|
|
|
|
|
for offset in &array_offsets(ins) {
|
|
|
|
|
let base_xform = if offset == &[0.0; 3] {
|
|
|
|
|
xform.clone()
|
|
|
|
|
} else {
|
|
|
|
|
let translation = Transform::from_translation(Vector3::new(
|
|
|
|
|
offset[0], offset[1], offset[2],
|
|
|
|
|
));
|
|
|
|
|
translation.then(&xform)
|
|
|
|
|
};
|
|
|
|
|
let ctx = ExpandCtx {
|
|
|
|
|
cache,
|
|
|
|
|
ins_color: ins_resolved_color,
|
|
|
|
|
ins_pat_len,
|
|
|
|
|
ins_pat,
|
|
|
|
|
ins_lw_px,
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
l0: ins_layer,
|
2026-05-14 11:12:08 +03:00
|
|
|
selected,
|
|
|
|
|
pslt_factor,
|
2026-05-14 13:52:04 +03:00
|
|
|
view_aabb,
|
2026-05-14 14:22:39 +03:00
|
|
|
world_per_pixel,
|
2026-05-14 21:45:37 +03:00
|
|
|
is_xref,
|
|
|
|
|
bg_color,
|
2026-05-14 11:12:08 +03:00
|
|
|
};
|
2026-07-21 15:53:28 +03:00
|
|
|
expand_defn(defn, &base_xform, &ctx, &mut batches, &mut visited, 0, (0.0, 1.0));
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
2026-05-28 22:43:13 +03:00
|
|
|
Some(batches.finalize(&name, selected, bg_color))
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:22:39 +03:00
|
|
|
fn aabb_pixel_size(local_aabb: [f32; 4], world_per_pixel: f32) -> f32 {
|
|
|
|
|
let w = (local_aabb[2] - local_aabb[0]).abs();
|
|
|
|
|
let h = (local_aabb[3] - local_aabb[1]).abs();
|
|
|
|
|
w.max(h) / world_per_pixel
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 11:12:08 +03:00
|
|
|
struct ExpandCtx<'a> {
|
|
|
|
|
cache: &'a BlockCache,
|
|
|
|
|
ins_color: [f32; 4],
|
|
|
|
|
ins_pat_len: f32,
|
|
|
|
|
ins_pat: [f32; 8],
|
|
|
|
|
ins_lw_px: f32,
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
/// Layer-0 inheritance target — the current INSERT's *layer* style, used
|
|
|
|
|
/// for child wires on layer "0" whose properties are ByLayer.
|
|
|
|
|
l0: crate::scene::view::render::InheritStyle,
|
2026-05-14 11:12:08 +03:00
|
|
|
selected: bool,
|
|
|
|
|
pslt_factor: f32,
|
2026-05-14 13:52:04 +03:00
|
|
|
// World-space XY view AABB (post world_offset). `None` = no culling.
|
|
|
|
|
view_aabb: Option<[f32; 4]>,
|
2026-05-14 14:22:39 +03:00
|
|
|
// World units per screen pixel. `None` = no pixel-size LOD.
|
|
|
|
|
world_per_pixel: Option<f32>,
|
2026-05-14 21:45:37 +03:00
|
|
|
// True when this expansion descends from an xref INSERT. Causes emitted
|
|
|
|
|
// colors to be faded toward `bg_color` so the user can tell at a glance
|
|
|
|
|
// which geometry comes from an external reference.
|
|
|
|
|
is_xref: bool,
|
|
|
|
|
bg_color: [f32; 4],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fade `color` toward `bg` by 50%, preserving alpha. Used to mark xref
|
|
|
|
|
/// geometry — the hue stays recognizable but the contrast against the
|
|
|
|
|
/// background drops, reading as "washed out".
|
2026-05-23 23:26:14 +03:00
|
|
|
pub(crate) fn fade_toward_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
|
2026-05-14 21:45:37 +03:00
|
|
|
const T: f32 = 0.5;
|
|
|
|
|
[
|
|
|
|
|
color[0] * (1.0 - T) + bg[0] * T,
|
|
|
|
|
color[1] * (1.0 - T) + bg[1] * T,
|
|
|
|
|
color[2] * (1.0 - T) + bg[2] * T,
|
|
|
|
|
color[3],
|
|
|
|
|
]
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Style fingerprint used to group local wires into a single GPU buffer.
|
|
|
|
|
/// f32 fields are bit-cast to u32 to make the key Hash + Eq.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
|
|
|
|
struct StyleKey {
|
|
|
|
|
color: [u32; 4],
|
|
|
|
|
pattern_length: u32,
|
|
|
|
|
pattern: [u32; 8],
|
|
|
|
|
line_weight_px: u32,
|
2026-07-20 16:04:03 +03:00
|
|
|
/// Wide-polyline band width (bit-cast). Keeps bands of different widths — and
|
|
|
|
|
/// bands vs thin wires of the same colour/style — in separate batches so the
|
|
|
|
|
/// finalized WireModel carries one correct `world_width`.
|
|
|
|
|
world_width: u32,
|
2026-05-14 11:12:08 +03:00
|
|
|
aci: u8,
|
|
|
|
|
plinegen: bool,
|
2026-05-15 16:32:27 +03:00
|
|
|
/// Marks batches that emit only `fill_tris` with no wire `points`. The
|
|
|
|
|
/// face3d pipeline uses `wire.points.is_empty()` as the "skip dim"
|
|
|
|
|
/// discriminator, so greek fills must stay in their own batches even
|
|
|
|
|
/// when their color/style would otherwise collide with regular wires.
|
|
|
|
|
is_fill_only: bool,
|
2026-07-21 15:53:28 +03:00
|
|
|
/// Bit-cast composed block-local depth for band wires (`0` = no override).
|
|
|
|
|
/// Keeps bands of different in-block draw ranks in separate batches so
|
|
|
|
|
/// each finalized WireModel carries one correct `depth_override`.
|
|
|
|
|
depth_bits: u32,
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Default, Debug)]
|
|
|
|
|
struct BatchEntry {
|
|
|
|
|
color: [f32; 4],
|
|
|
|
|
pattern_length: f32,
|
|
|
|
|
pattern: [f32; 8],
|
|
|
|
|
line_weight_px: f32,
|
2026-07-20 16:04:03 +03:00
|
|
|
world_width: f32,
|
2026-05-14 11:12:08 +03:00
|
|
|
aci: u8,
|
|
|
|
|
plinegen: bool,
|
2026-07-21 15:53:28 +03:00
|
|
|
/// Composed block-local draw-order offset for a band batch (see
|
|
|
|
|
/// `WireModel::depth_override`). `None` for every other batch.
|
|
|
|
|
local_depth: Option<f32>,
|
2026-05-14 11:12:08 +03:00
|
|
|
points: Vec<[f32; 3]>,
|
2026-06-23 17:44:09 +03:00
|
|
|
points_low: Vec<[f32; 3]>,
|
2026-06-23 18:52:42 +03:00
|
|
|
snap_pts: Vec<(glam::DVec3, SnapHint)>,
|
2026-06-23 22:23:56 +03:00
|
|
|
key_vertices: Vec<[f64; 3]>,
|
2026-05-14 11:12:08 +03:00
|
|
|
tangent_geoms: Vec<TangentGeom>,
|
|
|
|
|
fill_tris: Vec<[f32; 3]>,
|
2026-06-24 20:04:41 +03:00
|
|
|
/// Double-single low residual paired with `fill_tris`, so block fills stay
|
|
|
|
|
/// precise at UTM-scale coordinates (the renderer's relative-to-eye path
|
|
|
|
|
/// reconstructs `high + low`). Without it absolute f32 fills quantize to
|
|
|
|
|
/// ~0.5 m and the greek-text rectangles shear.
|
|
|
|
|
fill_tris_low: Vec<[f32; 3]>,
|
fix(pick): hit-test lineweight width and thickness walls
Selection tested a flat 8 px radius around each wire's centreline and
nothing else, so two things the user can see were not pickable.
A wire renders as a band `line_weight_px` wide, so pick now widens to that
rendered half-width via `pick_tolerance_px`, mirroring the wire shader's
`select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for
every standard weight (the widest, 2.11 mm, renders 7.97 px half-width —
just under the 8 px threshold); this keeps the two sides from drifting
apart if the display boost in `lineweight_to_px` ever changes.
Entities extruded by a DXF thickness draw their wall as four edges with
nothing in between, so the interior was a hole the cursor fell through.
WireModel gains `pick_tris`: triangles hit-testing treats as solid and the
renderer never uploads. Kept apart from `fill_tris` because that channel
reaches the GPU, and a wall that rendered shaded would change how every
thickness drawing looks. Ranked below `fill_tris` — those are drawn, these
are not, so where they overlap the drawn face is what the user means.
Walls are dropped when a wire is projected into a floating viewport:
`points` are reprojected and clipped into paper coords there, so cloned
model-space triangles would hit-test at a model-scale offset. Extruded
entities stay selectable by their edges, which do get projected.
The wall loop runs on every hover that misses everything else, so it
rejects on the wire's AABB first — an extruded circle is ~128 triangles
and a drawing full of thickness would otherwise project every wall on
each mouse move.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
|
|
|
/// Accumulated thickness-wall pick geometry, paired high/low like
|
|
|
|
|
/// `fill_tris`. Pick-only — no GPU batch reads this.
|
|
|
|
|
pick_tris: Vec<[f32; 3]>,
|
|
|
|
|
pick_tris_low: Vec<[f32; 3]>,
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
/// Accumulated SDF glyph quads (world space) for block-instance text.
|
|
|
|
|
text_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
|
2026-05-14 11:12:08 +03:00
|
|
|
min_x: f32,
|
|
|
|
|
min_y: f32,
|
|
|
|
|
max_x: f32,
|
|
|
|
|
max_y: f32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Hard cap on point count for a single batched WireModel. Above this the
|
|
|
|
|
/// current batch is finalized (pushed into `closed`) and a fresh one is
|
|
|
|
|
/// started under the same style. Each WireModel point becomes ~6 GPU
|
|
|
|
|
/// vertices of 96 bytes — 200k points fits well under wgpu's 256 MB
|
|
|
|
|
/// per-buffer ceiling.
|
|
|
|
|
const MAX_POINTS_PER_BATCH: usize = 200_000;
|
|
|
|
|
|
|
|
|
|
#[derive(Default, Debug)]
|
|
|
|
|
struct Batches {
|
|
|
|
|
by_style: HashMap<StyleKey, BatchEntry>,
|
|
|
|
|
/// Batches that overflowed `MAX_POINTS_PER_BATCH` and have been closed.
|
|
|
|
|
closed: Vec<BatchEntry>,
|
2026-06-24 09:53:28 +03:00
|
|
|
/// Already-finalized wires that bypass the point batcher — currently the
|
|
|
|
|
/// clipped output of an XCLIP'd nested insert, which is produced as whole
|
|
|
|
|
/// WireModels by `clip_wires`. Appended verbatim at `finalize` (only their
|
|
|
|
|
/// name/selected flag are stamped to match the host insert).
|
|
|
|
|
extra_wires: Vec<WireModel>,
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl BatchEntry {
|
2026-05-15 16:32:27 +03:00
|
|
|
fn new(
|
|
|
|
|
color: [f32; 4],
|
|
|
|
|
pat_len: f32,
|
|
|
|
|
pat: [f32; 8],
|
|
|
|
|
lw_px: f32,
|
2026-07-20 16:04:03 +03:00
|
|
|
world_width: f32,
|
2026-05-15 16:32:27 +03:00
|
|
|
aci: u8,
|
|
|
|
|
plinegen: bool,
|
|
|
|
|
_is_fill_only: bool,
|
|
|
|
|
) -> Self {
|
|
|
|
|
// `is_fill_only` is part of the StyleKey hash so greek fills never
|
|
|
|
|
// share a batch with regular wires (otherwise the finalized
|
|
|
|
|
// WireModel would have both `points` and `fill_tris`, defeating
|
|
|
|
|
// the face3d-dim discriminator). It isn't stored on the entry
|
|
|
|
|
// itself — the empty `points` field is enough at finalize time.
|
2026-05-14 11:12:08 +03:00
|
|
|
Self {
|
|
|
|
|
color,
|
|
|
|
|
pattern_length: pat_len,
|
|
|
|
|
pattern: pat,
|
|
|
|
|
line_weight_px: lw_px,
|
2026-07-20 16:04:03 +03:00
|
|
|
world_width,
|
2026-05-14 11:12:08 +03:00
|
|
|
aci,
|
|
|
|
|
plinegen,
|
|
|
|
|
min_x: f32::INFINITY,
|
|
|
|
|
min_y: f32::INFINITY,
|
|
|
|
|
max_x: f32::NEG_INFINITY,
|
|
|
|
|
max_y: f32::NEG_INFINITY,
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Batches {
|
2026-05-28 22:43:13 +03:00
|
|
|
fn finalize(self, name: &str, selected: bool, bg_color: [f32; 4]) -> Vec<WireModel> {
|
2026-06-24 09:53:28 +03:00
|
|
|
let extra = self.extra_wires;
|
|
|
|
|
let mut out: Vec<WireModel> = self
|
|
|
|
|
.closed
|
2026-05-14 11:12:08 +03:00
|
|
|
.into_iter()
|
|
|
|
|
.chain(self.by_style.into_values())
|
|
|
|
|
.map(|b| {
|
|
|
|
|
let aabb = if b.min_x.is_infinite() {
|
|
|
|
|
WireModel::UNBOUNDED_AABB
|
|
|
|
|
} else {
|
|
|
|
|
[b.min_x, b.min_y, b.max_x, b.max_y]
|
|
|
|
|
};
|
2026-05-28 22:43:13 +03:00
|
|
|
// RAW colour came from `tessellate_sub_local` (and from
|
|
|
|
|
// `expand_defn`'s ByBlock fallbacks); apply `adapt_to_bg`
|
|
|
|
|
// now so each render against a different bg gets the
|
|
|
|
|
// right pure-black ↔ pure-white flip without rebuilding
|
|
|
|
|
// the cached defn.
|
2026-06-16 11:33:46 +03:00
|
|
|
let color = crate::scene::view::render::adapt_to_bg(b.color, bg_color);
|
2026-05-14 11:12:08 +03:00
|
|
|
WireModel {
|
2026-07-20 16:36:06 +03:00
|
|
|
taper_widths: Vec::new(),
|
2026-07-20 16:04:03 +03:00
|
|
|
world_width: b.world_width,
|
2026-07-21 15:53:28 +03:00
|
|
|
depth_override: b.local_depth,
|
2026-07-18 15:15:24 +03:00
|
|
|
fill_is_3d: false,
|
fix(pick): hit-test lineweight width and thickness walls
Selection tested a flat 8 px radius around each wire's centreline and
nothing else, so two things the user can see were not pickable.
A wire renders as a band `line_weight_px` wide, so pick now widens to that
rendered half-width via `pick_tolerance_px`, mirroring the wire shader's
`select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for
every standard weight (the widest, 2.11 mm, renders 7.97 px half-width —
just under the 8 px threshold); this keeps the two sides from drifting
apart if the display boost in `lineweight_to_px` ever changes.
Entities extruded by a DXF thickness draw their wall as four edges with
nothing in between, so the interior was a hole the cursor fell through.
WireModel gains `pick_tris`: triangles hit-testing treats as solid and the
renderer never uploads. Kept apart from `fill_tris` because that channel
reaches the GPU, and a wall that rendered shaded would change how every
thickness drawing looks. Ranked below `fill_tris` — those are drawn, these
are not, so where they overlap the drawn face is what the user means.
Walls are dropped when a wire is projected into a floating viewport:
`points` are reprojected and clipped into paper coords there, so cloned
model-space triangles would hit-test at a model-scale offset. Extruded
entities stay selectable by their edges, which do get projected.
The wall loop runs on every hover that misses everything else, so it
rejects on the wire's AABB first — an extruded circle is ~128 triangles
and a drawing full of thickness would otherwise project every wall on
each mouse move.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
|
|
|
pick_tris: b.pick_tris,
|
|
|
|
|
pick_tris_low: b.pick_tris_low,
|
2026-07-17 01:49:18 +03:00
|
|
|
dash_from_start: false,
|
2026-07-17 02:59:11 +03:00
|
|
|
dash_align_end: None,
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
text_verts: b.text_verts,
|
2026-05-14 11:12:08 +03:00
|
|
|
name: name.to_string(),
|
|
|
|
|
points: b.points,
|
2026-06-23 17:44:09 +03:00
|
|
|
points_low: b.points_low,
|
2026-05-28 22:43:13 +03:00
|
|
|
color,
|
2026-05-14 11:12:08 +03:00
|
|
|
selected,
|
|
|
|
|
pattern_length: b.pattern_length,
|
|
|
|
|
pattern: b.pattern,
|
|
|
|
|
line_weight_px: b.line_weight_px,
|
|
|
|
|
aci: b.aci,
|
|
|
|
|
snap_pts: b.snap_pts,
|
|
|
|
|
tangent_geoms: b.tangent_geoms,
|
|
|
|
|
key_vertices: b.key_vertices,
|
|
|
|
|
aabb,
|
|
|
|
|
plinegen: b.plinegen,
|
|
|
|
|
fill_tris: b.fill_tris,
|
2026-06-24 20:04:41 +03:00
|
|
|
fill_tris_low: b.fill_tris_low,
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
})
|
2026-06-24 09:53:28 +03:00
|
|
|
.collect();
|
|
|
|
|
// Clipped nested-insert wires are already whole WireModels; stamp the
|
|
|
|
|
// host insert's name/selected so picking maps them back correctly.
|
|
|
|
|
for mut w in extra {
|
|
|
|
|
w.name = name.to_string();
|
|
|
|
|
w.selected = selected;
|
|
|
|
|
out.push(w);
|
|
|
|
|
}
|
|
|
|
|
out
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn style_key(
|
|
|
|
|
color: [f32; 4],
|
|
|
|
|
pat_len: f32,
|
|
|
|
|
pat: [f32; 8],
|
|
|
|
|
lw_px: f32,
|
2026-07-20 16:04:03 +03:00
|
|
|
world_width: f32,
|
2026-05-14 11:12:08 +03:00
|
|
|
aci: u8,
|
|
|
|
|
plinegen: bool,
|
2026-05-15 16:32:27 +03:00
|
|
|
is_fill_only: bool,
|
2026-07-21 15:53:28 +03:00
|
|
|
local_depth: Option<f32>,
|
2026-05-14 11:12:08 +03:00
|
|
|
) -> StyleKey {
|
|
|
|
|
StyleKey {
|
|
|
|
|
color: [
|
|
|
|
|
color[0].to_bits(),
|
|
|
|
|
color[1].to_bits(),
|
|
|
|
|
color[2].to_bits(),
|
|
|
|
|
color[3].to_bits(),
|
|
|
|
|
],
|
|
|
|
|
pattern_length: pat_len.to_bits(),
|
|
|
|
|
pattern: [
|
|
|
|
|
pat[0].to_bits(),
|
|
|
|
|
pat[1].to_bits(),
|
|
|
|
|
pat[2].to_bits(),
|
|
|
|
|
pat[3].to_bits(),
|
|
|
|
|
pat[4].to_bits(),
|
|
|
|
|
pat[5].to_bits(),
|
|
|
|
|
pat[6].to_bits(),
|
|
|
|
|
pat[7].to_bits(),
|
|
|
|
|
],
|
|
|
|
|
line_weight_px: lw_px.to_bits(),
|
2026-07-20 16:04:03 +03:00
|
|
|
world_width: world_width.to_bits(),
|
2026-05-14 11:12:08 +03:00
|
|
|
aci,
|
|
|
|
|
plinegen,
|
2026-05-15 16:32:27 +03:00
|
|
|
is_fill_only,
|
2026-07-21 15:53:28 +03:00
|
|
|
depth_bits: local_depth.map_or(0, f32::to_bits),
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn expand_defn(
|
|
|
|
|
defn: &BlockDefn,
|
|
|
|
|
accum_xform: &Transform,
|
|
|
|
|
ctx: &ExpandCtx,
|
|
|
|
|
out: &mut Batches,
|
|
|
|
|
visited: &mut Vec<String>,
|
|
|
|
|
depth: usize,
|
2026-07-21 15:53:28 +03:00
|
|
|
// Block-local depth sub-range `(base, scale)` accumulated through nested
|
|
|
|
|
// inserts: a child at rank r lands at `base + r * scale`, all within
|
|
|
|
|
// (-1,1) of the top-level insert. Seeded `(0.0, 1.0)` by `expand_insert`.
|
|
|
|
|
d_range: (f32, f32),
|
2026-05-14 11:12:08 +03:00
|
|
|
) {
|
|
|
|
|
if depth > MAX_NESTING_DEPTH {
|
|
|
|
|
eprintln!("block_cache: nested-block depth > {MAX_NESTING_DEPTH}, truncating");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for sub in &defn.subs {
|
|
|
|
|
match sub {
|
2026-05-14 13:52:04 +03:00
|
|
|
LocalSub::Wire(lw) => {
|
2026-05-14 21:45:37 +03:00
|
|
|
// `lw.aabb_local` is in the defn's offset frame; re-add
|
|
|
|
|
// `defn_lo` (in f64) before composing with `accum_xform`
|
|
|
|
|
// so culling uses correct world-space corners.
|
2026-06-24 20:37:52 +03:00
|
|
|
let world = transform_aabb_xy(lw.aabb_local, accum_xform);
|
2026-05-14 14:22:39 +03:00
|
|
|
let local = [
|
2026-06-24 18:38:23 +03:00
|
|
|
world[0] as f32,
|
|
|
|
|
world[1] as f32,
|
|
|
|
|
world[2] as f32,
|
|
|
|
|
world[3] as f32,
|
2026-05-14 14:22:39 +03:00
|
|
|
];
|
2026-05-14 13:52:04 +03:00
|
|
|
if let Some(view) = ctx.view_aabb {
|
|
|
|
|
if aabb_disjoint_xy(local, view) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 14:22:39 +03:00
|
|
|
if let Some(wpp) = ctx.world_per_pixel {
|
refactor(text): SDF for all text kinds; remove the legacy stroke text path
SDF is the only text renderer now — the stroke path and its LOD ladder are
gone, and the remaining text kinds render as SDF glyph quads too.
- ATTDEF / ATTRIB: carry a GlyphRun so single-line attribute text renders as
SDF (multi-line already did via layout_mtext). Tolerance / complex-linetype
text already SDF.
- Remove `sdf_text_enabled()` (the OCS_TEXT_SDF flag / kill switch) and take
the SDF path unconditionally everywhere: tessellate Text arm (per-group by
GlyphRun presence), hit_test fallbacks, render gather, complex_lt, table,
mleader.
- Delete the text LOD ladder: tess.rs baseline/greek block and the
block_cache greek/baseline path (emit_greeked_text / emit_text_baseline),
plus the now-dead helpers text_baseline_points / text_greek_obb_tris /
text_obb_corners_native / mtext_line_count and the LocalWire
text_height_local / text_obb_local fields. All text is crisp at every zoom;
text entities are exempt from the sub-pixel stub cull.
- Fix a latent bug: block-internal SDF text was culled because its LocalWire
aabb_local was [0,0,0,0] (no points) — fold the glyph-quad extent into
aabb_local and exempt text_verts-carrying wires from the sub-pixel cull.
- Clear the pre-existing dead-code warnings (TextPlacement.elevation, TabKind,
MTextRunKind::Tab).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:07:00 +03:00
|
|
|
// SDF text wires carry glyph quads but no points; they
|
|
|
|
|
// render at every zoom (no text LOD), so exempt them from
|
|
|
|
|
// the sub-pixel cull that drops tiny stroke / fill geometry.
|
|
|
|
|
let is_text = !lw.text_verts.is_empty();
|
2026-05-15 13:19:43 +03:00
|
|
|
if !is_text && aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE {
|
2026-05-14 14:22:39 +03:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-21 15:53:28 +03:00
|
|
|
emit_wire(lw, accum_xform, ctx, out, d_range);
|
2026-05-14 13:52:04 +03:00
|
|
|
}
|
2026-05-14 11:12:08 +03:00
|
|
|
LocalSub::Nested(nref) => {
|
|
|
|
|
if visited.iter().any(|n| n == &nref.block_name) {
|
|
|
|
|
// Cycle — skip.
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-05-14 14:22:39 +03:00
|
|
|
// Nested-INSERT cull: union AABB of the nested defn,
|
|
|
|
|
// transformed by composed xform, vs view rect + pixel size.
|
2026-05-14 21:45:37 +03:00
|
|
|
// `nested_defn.aabb_local` lives in the nested defn's offset
|
|
|
|
|
// frame — re-add `nested_defn.local_offset` in f64 before
|
|
|
|
|
// composing with the parent transforms.
|
2026-05-14 14:22:39 +03:00
|
|
|
let composed = nref.xform.then(accum_xform);
|
2026-06-24 20:37:52 +03:00
|
|
|
let world = transform_aabb_xy(nested_defn.aabb_local, &composed);
|
2026-05-14 14:22:39 +03:00
|
|
|
let local = [
|
2026-06-24 18:38:23 +03:00
|
|
|
world[0] as f32,
|
|
|
|
|
world[1] as f32,
|
|
|
|
|
world[2] as f32,
|
|
|
|
|
world[3] as f32,
|
2026-05-14 14:22:39 +03:00
|
|
|
];
|
2026-05-14 13:52:04 +03:00
|
|
|
if let Some(view) = ctx.view_aabb {
|
|
|
|
|
if aabb_disjoint_xy(local, view) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-14 14:22:39 +03:00
|
|
|
if let Some(wpp) = ctx.world_per_pixel {
|
|
|
|
|
if aabb_pixel_size(local, wpp) < MIN_PIXEL_SIZE {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
// Resolve the nested insert's own style against the outer ctx:
|
|
|
|
|
// ByBlock inherits the outer insert; a nested insert that is
|
|
|
|
|
// 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.
|
2026-05-14 11:12:08 +03:00
|
|
|
let nested_color = if nref.color_is_byblock {
|
|
|
|
|
ctx.ins_color
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
} else if nref.layer_is_zero && nref.color_is_bylayer {
|
|
|
|
|
ctx.l0.color
|
2026-05-14 11:12:08 +03:00
|
|
|
} else {
|
|
|
|
|
nref.ins_color
|
|
|
|
|
};
|
|
|
|
|
let (nested_pat_len, nested_pat) = if nref.lt_is_byblock {
|
|
|
|
|
(ctx.ins_pat_len, ctx.ins_pat)
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
} else if nref.layer_is_zero && nref.lt_is_bylayer {
|
|
|
|
|
(ctx.l0.pat_len, ctx.l0.pat)
|
2026-05-14 11:12:08 +03:00
|
|
|
} else {
|
|
|
|
|
(nref.ins_pat_len, nref.ins_pat)
|
|
|
|
|
};
|
|
|
|
|
let nested_lw_px = if nref.lw_is_byblock {
|
|
|
|
|
ctx.ins_lw_px
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
} else if nref.layer_is_zero && nref.lw_is_bylayer {
|
|
|
|
|
ctx.l0.lw_px
|
2026-05-14 11:12:08 +03:00
|
|
|
} else {
|
|
|
|
|
nref.ins_lw_px
|
|
|
|
|
};
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
// 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 };
|
2026-05-14 11:12:08 +03:00
|
|
|
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,
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
l0: nested_l0,
|
2026-05-14 11:12:08 +03:00
|
|
|
selected: ctx.selected,
|
|
|
|
|
pslt_factor: ctx.pslt_factor,
|
2026-05-14 13:52:04 +03:00
|
|
|
view_aabb: ctx.view_aabb,
|
2026-05-14 14:22:39 +03:00
|
|
|
world_per_pixel: ctx.world_per_pixel,
|
2026-05-14 21:45:37 +03:00
|
|
|
is_xref: ctx.is_xref,
|
|
|
|
|
bg_color: ctx.bg_color,
|
2026-05-14 11:12:08 +03:00
|
|
|
};
|
|
|
|
|
visited.push(nref.block_name.clone());
|
2026-07-21 15:53:28 +03:00
|
|
|
// Children of this nested insert stack inside the slot its own
|
|
|
|
|
// rank owns — same composition the exploded-fill path 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),
|
|
|
|
|
);
|
2026-06-24 09:53:28 +03:00
|
|
|
if let Some(cp) = &nref.clip_poly {
|
|
|
|
|
// XCLIP'd nested insert: expand into an isolated batch set,
|
|
|
|
|
// finalize to whole wires, clip them to the boundary mapped
|
|
|
|
|
// into world by the accumulated transform, then carry the
|
|
|
|
|
// clipped wires out via `extra_wires`. Single instance only —
|
|
|
|
|
// an array of clipped nested inserts is vanishingly rare, so
|
|
|
|
|
// the array case is not handled (boundary placement per
|
|
|
|
|
// instance would differ).
|
|
|
|
|
let composed = nref.xform.then(accum_xform);
|
|
|
|
|
let mut sub = Batches::default();
|
2026-07-21 15:53:28 +03:00
|
|
|
expand_defn(
|
|
|
|
|
nested_defn,
|
|
|
|
|
&composed,
|
|
|
|
|
&inner_ctx,
|
|
|
|
|
&mut sub,
|
|
|
|
|
visited,
|
|
|
|
|
depth + 1,
|
|
|
|
|
nested_range,
|
|
|
|
|
);
|
2026-06-24 09:53:28 +03:00
|
|
|
let mut wires = sub.finalize("", ctx.selected, ctx.bg_color);
|
|
|
|
|
let world_poly: Vec<[f64; 2]> = cp
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|&[x, y]| {
|
|
|
|
|
let w = accum_xform.apply(Vector3::new(x, y, 0.0));
|
|
|
|
|
[w.x, w.y]
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
crate::scene::pick::xclip::clip_wires(&mut wires, &world_poly);
|
|
|
|
|
out.extra_wires.append(&mut wires);
|
|
|
|
|
} else {
|
|
|
|
|
for offset in &nref.instance_offsets {
|
|
|
|
|
let composed = if offset == &[0.0; 3] {
|
|
|
|
|
nref.xform.then(accum_xform)
|
|
|
|
|
} else {
|
|
|
|
|
let translation = Transform::from_translation(Vector3::new(
|
|
|
|
|
offset[0], offset[1], offset[2],
|
|
|
|
|
));
|
|
|
|
|
translation.then(&nref.xform).then(accum_xform)
|
|
|
|
|
};
|
|
|
|
|
expand_defn(
|
|
|
|
|
nested_defn,
|
|
|
|
|
&composed,
|
|
|
|
|
&inner_ctx,
|
|
|
|
|
out,
|
|
|
|
|
visited,
|
|
|
|
|
depth + 1,
|
2026-07-21 15:53:28 +03:00
|
|
|
nested_range,
|
2026-06-24 09:53:28 +03:00
|
|
|
);
|
|
|
|
|
}
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
visited.pop();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
/// Resolve a cached LocalWire's final colour against the current expansion
|
|
|
|
|
/// context: selection override first, then ByBlock → insert colour, then the
|
|
|
|
|
/// layer-0 rule → insert-layer colour, else the cached colour; finally xref
|
|
|
|
|
/// fade. Shared by the stroke, fill, and greeked-text emit paths.
|
|
|
|
|
fn resolve_wire_color(lw: &LocalWire, ctx: &ExpandCtx) -> [f32; 4] {
|
|
|
|
|
let c = if ctx.selected {
|
|
|
|
|
WireModel::SELECTED
|
|
|
|
|
} else if lw.color_is_byblock {
|
|
|
|
|
ctx.ins_color
|
|
|
|
|
} else if lw.color_l0 {
|
|
|
|
|
// Inherit the insert layer's RGB but keep the child's own transparency.
|
|
|
|
|
[ctx.l0.color[0], ctx.l0.color[1], ctx.l0.color[2], lw.color[3]]
|
|
|
|
|
} else {
|
|
|
|
|
lw.color
|
|
|
|
|
};
|
|
|
|
|
if ctx.is_xref && !ctx.selected {
|
|
|
|
|
fade_toward_bg(c, ctx.bg_color)
|
|
|
|
|
} else {
|
|
|
|
|
c
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 21:45:37 +03:00
|
|
|
fn emit_wire(
|
|
|
|
|
lw: &LocalWire,
|
|
|
|
|
accum_xform: &Transform,
|
|
|
|
|
ctx: &ExpandCtx,
|
|
|
|
|
out: &mut Batches,
|
2026-07-21 15:53:28 +03:00
|
|
|
d_range: (f32, f32),
|
2026-05-14 21:45:37 +03:00
|
|
|
) {
|
fix(pick): hit-test lineweight width and thickness walls
Selection tested a flat 8 px radius around each wire's centreline and
nothing else, so two things the user can see were not pickable.
A wire renders as a band `line_weight_px` wide, so pick now widens to that
rendered half-width via `pick_tolerance_px`, mirroring the wire shader's
`select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for
every standard weight (the widest, 2.11 mm, renders 7.97 px half-width —
just under the 8 px threshold); this keeps the two sides from drifting
apart if the display boost in `lineweight_to_px` ever changes.
Entities extruded by a DXF thickness draw their wall as four edges with
nothing in between, so the interior was a hole the cursor fell through.
WireModel gains `pick_tris`: triangles hit-testing treats as solid and the
renderer never uploads. Kept apart from `fill_tris` because that channel
reaches the GPU, and a wall that rendered shaded would change how every
thickness drawing looks. Ranked below `fill_tris` — those are drawn, these
are not, so where they overlap the drawn face is what the user means.
Walls are dropped when a wire is projected into a floating viewport:
`points` are reprojected and clipped into paper coords there, so cloned
model-space triangles would hit-test at a model-scale offset. Extruded
entities stay selectable by their edges, which do get projected.
The wall loop runs on every hover that misses everything else, so it
rejects on the wire's AABB first — an extruded circle is ~128 triangles
and a drawing full of thickness would otherwise project every wall on
each mouse move.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
|
|
|
if lw.points.is_empty()
|
|
|
|
|
&& lw.fill_tris.is_empty()
|
|
|
|
|
&& lw.text_verts.is_empty()
|
|
|
|
|
&& lw.pick_tris.is_empty()
|
|
|
|
|
{
|
2026-05-14 11:12:08 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve final style for this LocalWire against the outer Insert ctx
|
|
|
|
|
// before we hash it into a batch.
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
let final_color = resolve_wire_color(lw, ctx);
|
2026-05-14 11:12:08 +03:00
|
|
|
let (final_pat_len, final_pat) = if lw.lt_is_byblock {
|
|
|
|
|
(ctx.ins_pat_len, ctx.ins_pat)
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
} else if lw.lt_l0 {
|
|
|
|
|
(ctx.l0.pat_len, ctx.l0.pat)
|
2026-05-14 11:12:08 +03:00
|
|
|
} else {
|
|
|
|
|
(lw.pattern_length, lw.pattern)
|
|
|
|
|
};
|
|
|
|
|
let final_lw_px = if lw.lw_is_byblock {
|
|
|
|
|
ctx.ins_lw_px
|
fix(blocks): layer-0 children inherit the block instance layer (#221)
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:24:04 +03:00
|
|
|
} else if lw.lw_l0 {
|
|
|
|
|
ctx.l0.lw_px
|
2026-05-14 11:12:08 +03:00
|
|
|
} else {
|
|
|
|
|
lw.line_weight_px
|
|
|
|
|
};
|
|
|
|
|
let final_pat_len = final_pat_len * ctx.pslt_factor;
|
|
|
|
|
let final_pat = final_pat.map(|v| v * ctx.pslt_factor);
|
|
|
|
|
|
2026-07-20 16:04:03 +03:00
|
|
|
// A wide polyline's band width is baked in block-local units; scale it by
|
|
|
|
|
// the insert transform so the shader band matches the scaled geometry.
|
|
|
|
|
// Average the X and Y axis image lengths — exact for a uniform insert, a
|
|
|
|
|
// sensible mean for a non-uniform one (the band carries one width).
|
|
|
|
|
let final_world_width = if lw.world_width > 0.0 {
|
|
|
|
|
let o = accum_xform.apply(Vector3::new(0.0, 0.0, 0.0));
|
|
|
|
|
let ax = accum_xform.apply(Vector3::new(1.0, 0.0, 0.0));
|
|
|
|
|
let ay = accum_xform.apply(Vector3::new(0.0, 1.0, 0.0));
|
|
|
|
|
let sx = ((ax.x - o.x).powi(2) + (ax.y - o.y).powi(2) + (ax.z - o.z).powi(2)).sqrt();
|
|
|
|
|
let sy = ((ay.x - o.x).powi(2) + (ay.y - o.y).powi(2) + (ay.z - o.z).powi(2)).sqrt();
|
|
|
|
|
lw.world_width * ((sx + sy) * 0.5) as f32
|
|
|
|
|
} else {
|
|
|
|
|
0.0
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-21 15:53:28 +03:00
|
|
|
// Only band wires take a per-child composed depth: their solid area is
|
|
|
|
|
// what covers siblings, and their width already splits them into their own
|
|
|
|
|
// batches — thin wires keep the shared whole-insert depth so same-style
|
|
|
|
|
// batches stay merged.
|
|
|
|
|
let local_depth = (lw.world_width > 0.0)
|
|
|
|
|
.then(|| d_range.0 + lw.local_rank * d_range.1);
|
|
|
|
|
|
2026-05-14 11:12:08 +03:00
|
|
|
let key = style_key(
|
|
|
|
|
final_color,
|
|
|
|
|
final_pat_len,
|
|
|
|
|
final_pat,
|
|
|
|
|
final_lw_px,
|
2026-07-20 16:04:03 +03:00
|
|
|
final_world_width,
|
2026-05-14 11:12:08 +03:00
|
|
|
lw.aci,
|
|
|
|
|
lw.plinegen,
|
2026-06-28 18:45:57 +01:00
|
|
|
lw.is_fill_only,
|
2026-07-21 15:53:28 +03:00
|
|
|
local_depth,
|
2026-05-14 11:12:08 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// If the open batch for this style would exceed wgpu's per-buffer limit
|
|
|
|
|
// after appending this wire, finalize it now and start a fresh batch.
|
|
|
|
|
if let Some(existing) = out.by_style.get(&key) {
|
|
|
|
|
if existing.points.len() + lw.points.len() + 1 > MAX_POINTS_PER_BATCH {
|
|
|
|
|
if let Some(closed) = out.by_style.remove(&key) {
|
|
|
|
|
out.closed.push(closed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let entry = out.by_style.entry(key).or_insert_with(|| {
|
|
|
|
|
BatchEntry::new(
|
|
|
|
|
final_color,
|
|
|
|
|
final_pat_len,
|
|
|
|
|
final_pat,
|
|
|
|
|
final_lw_px,
|
2026-07-20 16:04:03 +03:00
|
|
|
final_world_width,
|
2026-05-14 11:12:08 +03:00
|
|
|
lw.aci,
|
|
|
|
|
lw.plinegen,
|
2026-06-28 18:45:57 +01:00
|
|
|
lw.is_fill_only,
|
2026-05-14 11:12:08 +03:00
|
|
|
)
|
|
|
|
|
});
|
2026-07-21 15:53:28 +03:00
|
|
|
entry.local_depth = local_depth;
|
2026-05-14 11:12:08 +03:00
|
|
|
|
|
|
|
|
// NaN separator between previously-appended geometry and this wire so the
|
|
|
|
|
// GPU shader treats them as disconnected polylines within one buffer.
|
|
|
|
|
let needs_sep = !entry.points.is_empty()
|
|
|
|
|
&& !entry.points.last().map(|p| p[0].is_nan()).unwrap_or(false);
|
|
|
|
|
|
|
|
|
|
if !lw.points.is_empty() {
|
|
|
|
|
if needs_sep {
|
|
|
|
|
entry.points.push([f32::NAN; 3]);
|
2026-06-23 17:44:09 +03:00
|
|
|
entry.points_low.push([0.0; 3]);
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
2026-06-23 17:44:09 +03:00
|
|
|
// Iterate paired with the matching low residual so the GPU keeps
|
|
|
|
|
// sub-f32 precision once the INSERT transform lands the wire in
|
|
|
|
|
// world space at UTM-scale coordinates.
|
|
|
|
|
for (idx, p) in lw.points.iter().enumerate() {
|
2026-05-14 11:12:08 +03:00
|
|
|
if p[0].is_nan() {
|
|
|
|
|
entry.points.push([f32::NAN; 3]);
|
2026-06-23 17:44:09 +03:00
|
|
|
entry.points_low.push([0.0; 3]);
|
2026-05-14 11:12:08 +03:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-06-23 17:44:09 +03:00
|
|
|
let pl = lw.points_low.get(idx).copied().unwrap_or([0.0; 3]);
|
|
|
|
|
// Reconstruct the f64 source from (high, low) before applying the
|
|
|
|
|
// insert transform — otherwise the low half is silently dropped.
|
2026-05-14 21:45:37 +03:00
|
|
|
let v = accum_xform.apply(Vector3::new(
|
2026-06-24 20:37:52 +03:00
|
|
|
p[0] as f64 + pl[0] as f64,
|
|
|
|
|
p[1] as f64 + pl[1] as f64,
|
|
|
|
|
p[2] as f64 + pl[2] as f64,
|
2026-05-14 21:45:37 +03:00
|
|
|
));
|
2026-06-24 18:38:23 +03:00
|
|
|
let qx = (v.x) as f32;
|
|
|
|
|
let qy = (v.y) as f32;
|
|
|
|
|
let qz = (v.z) as f32;
|
|
|
|
|
let qx_l = ((v.x) - qx as f64) as f32;
|
|
|
|
|
let qy_l = ((v.y) - qy as f64) as f32;
|
|
|
|
|
let qz_l = ((v.z) - qz as f64) as f32;
|
2026-06-23 17:44:09 +03:00
|
|
|
let q = [qx, qy, qz];
|
|
|
|
|
if qx < entry.min_x {
|
|
|
|
|
entry.min_x = qx;
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
2026-06-23 17:44:09 +03:00
|
|
|
if qy < entry.min_y {
|
|
|
|
|
entry.min_y = qy;
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
2026-06-23 17:44:09 +03:00
|
|
|
if qx > entry.max_x {
|
|
|
|
|
entry.max_x = qx;
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
2026-06-23 17:44:09 +03:00
|
|
|
if qy > entry.max_y {
|
|
|
|
|
entry.max_y = qy;
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
entry.points.push(q);
|
2026-06-23 17:44:09 +03:00
|
|
|
entry.points_low.push([qx_l, qy_l, qz_l]);
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for p in &lw.key_vertices {
|
2026-05-14 21:45:37 +03:00
|
|
|
let v = accum_xform.apply(Vector3::new(
|
2026-06-24 20:37:52 +03:00
|
|
|
p[0] as f64,
|
|
|
|
|
p[1] as f64,
|
|
|
|
|
p[2] as f64,
|
2026-05-14 21:45:37 +03:00
|
|
|
));
|
2026-06-24 18:38:23 +03:00
|
|
|
entry.key_vertices.push([v.x, v.y, v.z]);
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
for (p, hint) in &lw.snap_pts {
|
2026-05-14 21:45:37 +03:00
|
|
|
let v = accum_xform.apply(Vector3::new(
|
2026-06-24 20:37:52 +03:00
|
|
|
p.x as f64,
|
|
|
|
|
p.y as f64,
|
|
|
|
|
p.z as f64,
|
2026-05-14 21:45:37 +03:00
|
|
|
));
|
2026-05-14 11:12:08 +03:00
|
|
|
entry.snap_pts.push((
|
2026-06-24 18:38:23 +03:00
|
|
|
glam::DVec3::new(v.x, v.y, v.z),
|
2026-05-14 11:12:08 +03:00
|
|
|
*hint,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
for tg in &lw.tangent_geoms {
|
|
|
|
|
entry
|
|
|
|
|
.tangent_geoms
|
2026-06-24 20:37:52 +03:00
|
|
|
.push(transform_tangent(tg, accum_xform));
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
2026-07-07 21:32:17 +03:00
|
|
|
// Per the WireModel contract an empty `fill_tris_low` means "all-zero low
|
|
|
|
|
// half" (e.g. a Leader / dimension arrowhead fill, which the tessellator
|
|
|
|
|
// emits without a low half). Only a *partially* populated low half is a
|
|
|
|
|
// real bug — keep the tripwire for that, but permit the empty case so debug
|
|
|
|
|
// builds don't panic on legitimate geometry.
|
|
|
|
|
debug_assert!(
|
|
|
|
|
lw.fill_tris_low.is_empty() || lw.fill_tris.len() == lw.fill_tris_low.len(),
|
|
|
|
|
"fill_tris_low must be empty or the same length as fill_tris (got {} vs {})",
|
|
|
|
|
lw.fill_tris.len(),
|
|
|
|
|
lw.fill_tris_low.len(),
|
|
|
|
|
);
|
2026-06-28 18:45:57 +01:00
|
|
|
for (idx, p) in lw.fill_tris.iter().enumerate() {
|
2026-06-28 21:42:16 +03:00
|
|
|
// Empty/short fill_tris_low means "no low half" (all-zero), per the
|
|
|
|
|
// WireModel contract — same panic-safe access the other fill consumers
|
|
|
|
|
// use (face3d_gpu, xclip). A Leader with a filled arrowhead nested in a
|
|
|
|
|
// block reaches here with populated fill_tris but empty fill_tris_low;
|
|
|
|
|
// a raw `[idx]` would panic in release (bounds checks are not gated by
|
|
|
|
|
// debug-assertions). The debug_assert above stays as a tripwire.
|
|
|
|
|
let pl = lw.fill_tris_low.get(idx).copied().unwrap_or([0.0; 3]);
|
2026-05-14 21:45:37 +03:00
|
|
|
let v = accum_xform.apply(Vector3::new(
|
2026-06-28 18:45:57 +01:00
|
|
|
p[0] as f64 + pl[0] as f64,
|
|
|
|
|
p[1] as f64 + pl[1] as f64,
|
|
|
|
|
p[2] as f64 + pl[2] as f64,
|
2026-05-14 21:45:37 +03:00
|
|
|
));
|
2026-06-24 20:04:41 +03:00
|
|
|
let (hx, lx) = WireModel::split_ds(v.x);
|
|
|
|
|
let (hy, ly) = WireModel::split_ds(v.y);
|
|
|
|
|
let (hz, lz) = WireModel::split_ds(v.z);
|
|
|
|
|
entry.fill_tris.push([hx, hy, hz]);
|
|
|
|
|
entry.fill_tris_low.push([lx, ly, lz]);
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
fix(pick): hit-test lineweight width and thickness walls
Selection tested a flat 8 px radius around each wire's centreline and
nothing else, so two things the user can see were not pickable.
A wire renders as a band `line_weight_px` wide, so pick now widens to that
rendered half-width via `pick_tolerance_px`, mirroring the wire shader's
`select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for
every standard weight (the widest, 2.11 mm, renders 7.97 px half-width —
just under the 8 px threshold); this keeps the two sides from drifting
apart if the display boost in `lineweight_to_px` ever changes.
Entities extruded by a DXF thickness draw their wall as four edges with
nothing in between, so the interior was a hole the cursor fell through.
WireModel gains `pick_tris`: triangles hit-testing treats as solid and the
renderer never uploads. Kept apart from `fill_tris` because that channel
reaches the GPU, and a wall that rendered shaded would change how every
thickness drawing looks. Ranked below `fill_tris` — those are drawn, these
are not, so where they overlap the drawn face is what the user means.
Walls are dropped when a wire is projected into a floating viewport:
`points` are reprojected and clipped into paper coords there, so cloned
model-space triangles would hit-test at a model-scale offset. Extruded
entities stay selectable by their edges, which do get projected.
The wall loop runs on every hover that misses everything else, so it
rejects on the wire's AABB first — an extruded circle is ~128 triangles
and a drawing full of thickness would otherwise project every wall on
each mouse move.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
|
|
|
// Thickness walls: same reconstruct → transform → re-split as the fills
|
|
|
|
|
// above, so a block child's wall tracks the insert's placement and scale.
|
|
|
|
|
debug_assert!(
|
|
|
|
|
lw.pick_tris_low.is_empty() || lw.pick_tris.len() == lw.pick_tris_low.len(),
|
|
|
|
|
"pick_tris_low must be empty or the same length as pick_tris (got {} vs {})",
|
|
|
|
|
lw.pick_tris.len(),
|
|
|
|
|
lw.pick_tris_low.len(),
|
|
|
|
|
);
|
|
|
|
|
for (idx, p) in lw.pick_tris.iter().enumerate() {
|
|
|
|
|
let pl = lw.pick_tris_low.get(idx).copied().unwrap_or([0.0; 3]);
|
|
|
|
|
let v = accum_xform.apply(Vector3::new(
|
|
|
|
|
p[0] as f64 + pl[0] as f64,
|
|
|
|
|
p[1] as f64 + pl[1] as f64,
|
|
|
|
|
p[2] as f64 + pl[2] as f64,
|
|
|
|
|
));
|
|
|
|
|
let (hx, lx) = WireModel::split_ds(v.x);
|
|
|
|
|
let (hy, ly) = WireModel::split_ds(v.y);
|
|
|
|
|
let (hz, lz) = WireModel::split_ds(v.z);
|
|
|
|
|
entry.pick_tris.push([hx, hy, hz]);
|
|
|
|
|
entry.pick_tris_low.push([lx, ly, lz]);
|
|
|
|
|
}
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
// SDF glyph quads: reconstruct each block-local f64 position, apply the
|
|
|
|
|
// insert transform, re-split — same path as points/fills so block-instance
|
|
|
|
|
// text lands at the right world place and scale. Colour resolves to the
|
|
|
|
|
// batch's final colour (ByBlock / layer-0 block text follows the insert).
|
|
|
|
|
for tv in &lw.text_verts {
|
|
|
|
|
let wx = tv.pos[0] as f64 + tv.pos_low[0] as f64;
|
|
|
|
|
let wy = tv.pos[1] as f64 + tv.pos_low[1] as f64;
|
|
|
|
|
let wz = tv.pos[2] as f64 + tv.pos_low[2] as f64;
|
|
|
|
|
let v = accum_xform.apply(Vector3::new(wx, wy, wz));
|
|
|
|
|
let (hx, lx) = WireModel::split_ds(v.x);
|
|
|
|
|
let (hy, ly) = WireModel::split_ds(v.y);
|
|
|
|
|
let (hz, lz) = WireModel::split_ds(v.z);
|
|
|
|
|
// Grow the batch AABB by the glyph extent so a text-only block wire
|
|
|
|
|
// (no points) still finalizes to a bounded pick box.
|
|
|
|
|
if hx < entry.min_x {
|
|
|
|
|
entry.min_x = hx;
|
|
|
|
|
}
|
|
|
|
|
if hy < entry.min_y {
|
|
|
|
|
entry.min_y = hy;
|
|
|
|
|
}
|
|
|
|
|
if hx > entry.max_x {
|
|
|
|
|
entry.max_x = hx;
|
|
|
|
|
}
|
|
|
|
|
if hy > entry.max_y {
|
|
|
|
|
entry.max_y = hy;
|
|
|
|
|
}
|
2026-07-07 22:17:27 +03:00
|
|
|
// Base glyphs inherit the resolved (ByBlock / layer-0) colour; a glyph
|
|
|
|
|
// carrying an inline `\C` / `\c` override — colour differs from the
|
|
|
|
|
// wire's base — keeps it, so block-nested colour-split MTEXT stays
|
|
|
|
|
// multi-colour. Per-vertex analogue of PR #301's wire-level gate.
|
|
|
|
|
let rgb = if [tv.color[0], tv.color[1], tv.color[2]]
|
|
|
|
|
== [lw.color[0], lw.color[1], lw.color[2]]
|
|
|
|
|
{
|
|
|
|
|
[final_color[0], final_color[1], final_color[2]]
|
|
|
|
|
} else {
|
|
|
|
|
[tv.color[0], tv.color[1], tv.color[2]]
|
|
|
|
|
};
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
entry.text_verts.push(crate::scene::pipeline::text_gpu::TextVertex {
|
|
|
|
|
pos: [hx, hy, hz],
|
|
|
|
|
pos_low: [lx, ly, lz],
|
|
|
|
|
uv: tv.uv,
|
2026-07-07 22:17:27 +03:00
|
|
|
color: [rgb[0], rgb[1], rgb[2], tv.color[3]],
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
draw_depth: tv.draw_depth,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 21:45:37 +03:00
|
|
|
fn transform_tangent(
|
|
|
|
|
tg: &TangentGeom,
|
|
|
|
|
t: &Transform,
|
|
|
|
|
) -> TangentGeom {
|
2026-05-14 11:12:08 +03:00
|
|
|
match tg {
|
|
|
|
|
TangentGeom::Line { p1, p2 } => {
|
2026-05-14 21:45:37 +03:00
|
|
|
let q1 = t.apply(Vector3::new(
|
2026-06-24 20:37:52 +03:00
|
|
|
p1[0] as f64,
|
|
|
|
|
p1[1] as f64,
|
|
|
|
|
p1[2] as f64,
|
2026-05-14 21:45:37 +03:00
|
|
|
));
|
|
|
|
|
let q2 = t.apply(Vector3::new(
|
2026-06-24 20:37:52 +03:00
|
|
|
p2[0] as f64,
|
|
|
|
|
p2[1] as f64,
|
|
|
|
|
p2[2] as f64,
|
2026-05-14 21:45:37 +03:00
|
|
|
));
|
2026-05-14 11:12:08 +03:00
|
|
|
TangentGeom::Line {
|
2026-06-24 18:38:23 +03:00
|
|
|
p1: [(q1.x) as f32, (q1.y) as f32, (q1.z) as f32],
|
|
|
|
|
p2: [(q2.x) as f32, (q2.y) as f32, (q2.z) as f32],
|
2026-05-14 11:12:08 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
TangentGeom::Circle { center, radius } => {
|
|
|
|
|
let c = t.apply(Vector3::new(
|
2026-06-24 20:37:52 +03:00
|
|
|
center[0] as f64,
|
|
|
|
|
center[1] as f64,
|
|
|
|
|
center[2] as f64,
|
2026-05-14 11:12:08 +03:00
|
|
|
));
|
|
|
|
|
let m = &t.matrix.m;
|
|
|
|
|
let sx = ((m[0][0] * m[0][0] + m[0][1] * m[0][1] + m[0][2] * m[0][2]) as f64).sqrt();
|
|
|
|
|
let sy = ((m[1][0] * m[1][0] + m[1][1] * m[1][1] + m[1][2] * m[1][2]) as f64).sqrt();
|
|
|
|
|
let s = ((sx + sy) * 0.5) as f32;
|
|
|
|
|
TangentGeom::Circle {
|
2026-06-24 18:38:23 +03:00
|
|
|
center: [(c.x) as f32, (c.y) as f32, (c.z) as f32],
|
2026-05-14 11:12:08 +03:00
|
|
|
radius: radius * s,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Radius / coordinate cap above which adaptive curve tessellation will
|
|
|
|
|
/// allocate hundreds of millions of points. `parameter_division` samples
|
|
|
|
|
/// to a fixed chord tolerance, so a Circle of radius 1e10 already produces
|
|
|
|
|
/// tens of millions of points.
|
|
|
|
|
const SANE_EXTENT: f64 = 1.0e8;
|
|
|
|
|
|
|
|
|
|
fn is_unreasonable_extent(e: &EntityType) -> bool {
|
|
|
|
|
// Adaptive curve tessellation also explodes on degenerate primitives
|
|
|
|
|
// (radius = 0, axes of length 0): `parameter_division` allocates
|
|
|
|
|
// proportional to range/tolerance, which underflows when the curve
|
|
|
|
|
// collapses to a point. Drop both ends of the spectrum.
|
|
|
|
|
match e {
|
|
|
|
|
EntityType::Circle(c) => c.radius.abs() < 1.0e-9 || c.radius.abs() > SANE_EXTENT,
|
|
|
|
|
EntityType::Arc(a) => a.radius.abs() < 1.0e-9 || a.radius.abs() > SANE_EXTENT,
|
|
|
|
|
EntityType::Ellipse(el) => {
|
|
|
|
|
let mx = el.major_axis.x.abs() + el.major_axis.y.abs() + el.major_axis.z.abs();
|
|
|
|
|
mx < 1.0e-9
|
|
|
|
|
|| el.major_axis.x.abs() > SANE_EXTENT
|
|
|
|
|
|| el.major_axis.y.abs() > SANE_EXTENT
|
|
|
|
|
|| el.major_axis.z.abs() > SANE_EXTENT
|
|
|
|
|
}
|
|
|
|
|
_ => 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
|
|
|
|
|
}
|
|
|
|
|
|