cad-editor/src/scene/pipeline/mod.rs

2884 lines
131 KiB
Rust
Raw Normal View History

pub mod face3d_gpu;
2026-03-22 14:56:27 +03:00
pub mod hatch_gpu;
/// WebGL2 texture-backed hatch renderer (wasm only) — the storage-buffer
/// `hatch_gpu` path is unavailable on WebGL2, so hatches render through this.
#[cfg(target_arch = "wasm32")]
pub mod hatch_web_gpu;
pub mod wipeout_gpu;
pub mod image_gpu;
2026-03-22 14:56:27 +03:00
pub mod mesh_gpu;
pub mod text_gpu;
2026-03-22 14:56:27 +03:00
pub mod uniforms;
pub mod viewcube;
pub mod wire_gpu;
use iced::wgpu;
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
use iced::wgpu::util::DeviceExt;
2026-03-22 14:56:27 +03:00
use iced::{Rectangle, Size};
pub use face3d_gpu::Face3DGpu;
pub use wipeout_gpu::WipeoutGpu;
pub use image_gpu::ImageGpu;
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
pub use mesh_gpu::MeshLodGpu;
2026-03-22 14:56:27 +03:00
pub use uniforms::Uniforms;
pub use viewcube::ViewCubePipeline;
pub use wire_gpu::WireGpu;
use crate::scene::model::hatch_model::HatchModel;
use crate::scene::model::image_model::ImageModel;
use crate::scene::model::mesh_model::MeshLodSet;
use crate::scene::model::wire_model::WireModel;
2026-03-22 14:56:27 +03:00
/// MSAA sample count for the main drawing pipelines.
const MSAA_SAMPLES: u32 = 4;
2026-03-22 14:56:27 +03:00
pub struct Pipeline {
wire_pipeline: wgpu::RenderPipeline,
/// Black-fragment variant of `wire_pipeline` for 3D mesh outline edges in
/// filled render modes.
wire_black_pipeline: wgpu::RenderPipeline,
/// Same shader as wire_pipeline but depth_compare=Greater, depth_write_enabled=false.
/// Used to draw ghost copies of selected wires through occluding geometry.
wire_xray_pipeline: wgpu::RenderPipeline,
/// Layout for the per-wire `WireConst` storage buffer (group 1 of the wire /
/// xray pipelines). `Some` on native; `None` on web (fat instance, no
/// storage). Passed to `WireGpu::from_run` / `from_batch` so they can build
/// each batch's per-wire bind group.
wire_const_bgl: Option<wgpu::BindGroupLayout>,
wipeout_pipeline: wgpu::RenderPipeline,
/// Phase 4-B — single-draw batched hatch pipeline. Per-instance
/// data lives in storage buffers; one draw call covers every
/// hatch in the frame. `None` on WebGL2 (wasm), which lacks
/// vertex-stage storage buffers.
hatch_pipeline: Option<wgpu::RenderPipeline>,
/// WebGL2 (wasm) texture-backed hatch pipeline + its group-1 layout. Used
/// where `hatch_pipeline` is `None` because WebGL2 lacks storage buffers.
#[cfg(target_arch = "wasm32")]
hatch_web_bgl1: wgpu::BindGroupLayout,
#[cfg(target_arch = "wasm32")]
hatch_web_pipeline: wgpu::RenderPipeline,
image_pipeline: wgpu::RenderPipeline,
/// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the
/// shared glyph atlas. Fed only when `OCS_TEXT_SDF` is set (else no verts).
text_pipeline: wgpu::RenderPipeline,
2026-03-22 14:56:27 +03:00
mesh_pipeline: wgpu::RenderPipeline,
/// Depth-write-disabled variant of `mesh_pipeline` for non-opaque solids.
mesh_transparent_pipeline: wgpu::RenderPipeline,
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
mesh_highlight_pipeline: wgpu::RenderPipeline,
/// Wireframe variant of the mesh pipeline (LineList topology, same
/// vertex layout / shader). Used when the active render mode is
/// Wireframe 2D or Wireframe 3D so 3D solids draw as their
/// triangle edges instead of filled faces.
mesh_wireframe_pipeline: wgpu::RenderPipeline,
/// Edge pipeline that forces black, for the edge overlay in filled modes.
mesh_edge_black_pipeline: wgpu::RenderPipeline,
/// Depth-only variant of the mesh pipeline (TriangleList, no color
/// writes, writes depth). Used in HiddenLine mode so 3D solids
/// occlude wires behind them without painting visible pixels.
mesh_depth_pipeline: wgpu::RenderPipeline,
face3d_pipeline: wgpu::RenderPipeline,
/// Depth-only variant of the face3d pipeline (no color writes,
/// writes depth). Paired with `mesh_depth_pipeline` for HiddenLine.
face3d_depth_pipeline: wgpu::RenderPipeline,
2026-03-22 14:56:27 +03:00
uniform_buffer: wgpu::Buffer,
uniform_bind_group: wgpu::BindGroup,
wipeout_bgl1: wgpu::BindGroupLayout,
/// Group-1 layout for the batched hatch pipeline (storage buffers
/// for instances / boundary / families / dashes). `None` on WebGL2, where
/// hatches render through `hatch_web_gpu` and this is never read.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
hatch_bgl1: Option<wgpu::BindGroupLayout>,
image_bgl1: wgpu::BindGroupLayout,
/// Group-1 layout for the text pipeline (atlas texture + sampler).
text_atlas_bgl: wgpu::BindGroupLayout,
/// GPU glyph atlas (texture + sampler + bind group). Rebuilt when the shared
/// CPU atlas grows (new glyphs baked). `None` until the first text upload.
text_atlas_gpu: Option<text_gpu::TextAtlasGpu>,
/// All glyph-quad vertices for the frame, one buffer, `None` when empty.
text_vbuf: Option<wgpu::Buffer>,
text_vcount: u32,
/// Tinted glyph quads of just the selected / hovered text, drawn over the
/// base text pass so a selection / rollover recolours the glyphs (the text
/// analogue of the selected-wire xray overlay). Rebuilt on selection change.
text_highlight_vbuf: Option<wgpu::Buffer>,
text_highlight_vcount: u32,
/// Live grip-drag / command-preview SDF glyph quads. The base text buffer
/// only re-uploads on a geometry-epoch change, but a grip drag hides the
/// dragged entity from the base wire set and shows it as a per-frame
/// overlay — so its glyphs ride here, re-uploaded every frame like
/// `gpu_preview_wires`, or the dragged text vanishes until release re-tesses
/// it back into the base set (issue #316).
text_preview_vbuf: Option<wgpu::Buffer>,
text_preview_vcount: u32,
/// Last requested render size (the full viewport rect, in pixels). The
/// geometry passes render at this size; the blit UV is scaled by
/// `depth_texture_size / alloc_size` so it samples only the filled region.
2026-03-22 14:56:27 +03:00
depth_texture_size: Size<u32>,
/// Actual allocated size of the depth / MSAA / resolve textures. Rounded
/// up from the requested size to a coarse grid so a divider drag (which
/// changes the pane size a few pixels every frame) doesn't recreate these
/// textures every frame. Recreation is what hangs the ANGLE → D3D11 path
/// on Windows-Firefox (issue #191); grow-only bucketing makes it rare.
alloc_size: Size<u32>,
2026-03-22 14:56:27 +03:00
depth_view: wgpu::TextureView,
/// 4× MSAA color buffer for the main drawing passes.
msaa_view: wgpu::TextureView,
/// Single-sample texture that receives the MSAA resolve result.
resolve_view: wgpu::TextureView,
/// Pipeline + resources for blitting the resolve texture to the surface target.
blit_pipeline: wgpu::RenderPipeline,
blit_bind_group_layout: wgpu::BindGroupLayout,
blit_sampler: wgpu::Sampler,
blit_bind_group: wgpu::BindGroup,
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
/// UV transform (offset + scale) consumed by the blit shader so a
/// partially off-canvas viewport still composites the right portion of
/// its resolve texture to the visible portion of the surface.
blit_uniform_buffer: wgpu::Buffer,
/// Cached texture format (needed to recreate MSAA / depth textures on resize).
surface_format: wgpu::TextureFormat,
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
/// The resident wire batches, shared by `std::sync::Arc` with every other
/// pipeline slot rendering the *same* `wire_content_id` (see
/// `MultiPipeline::wire_buffer_cache`). A `wgpu::Buffer` is internally
/// ref-counted, so N paper viewports (or Model tiles) drawing one identical
/// resident set hold one copy of the GPU vertex buffers between them —
/// their camera uniforms + scissor stay per-slot, only the geometry is
/// deduplicated. Never mutated in place, only reassigned on content change.
pub(crate) gpu_wires: std::sync::Arc<Vec<WireGpu>>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped wires. Recomputed each frame.
wire_pixel_scissors: Vec<Option<[u32; 4]>>,
/// Ghost copies (25% alpha) of selected wires for the X-ray depth pass.
gpu_selected_wires: Vec<WireGpu>,
/// Command-preview / interim / grip-drag overlay wires. Re-uploaded every
/// frame they are present (small), drawn on top of the base wire pass — so
/// a live drag never re-uploads the resident base buffer.
gpu_preview_wires: Vec<WireGpu>,
/// The canonical hatch fills — a single batched GPU resource drawn in one
/// call with per-instance visibility masking the rest. All pattern line
/// families are uploaded (no cap). `None` on WebGL2.
gpu_hatch: Option<hatch_gpu::HatchGpu>,
/// WebGL2 hatch fills — one texture-backed `HatchWebGpu` per hatch, drawn in
/// the hatch pass where the batched `gpu_hatch` is `None` (wasm).
#[cfg(target_arch = "wasm32")]
gpu_hatches_web: Vec<hatch_web_gpu::HatchWebGpu>,
/// Wipeout masks — solid fills rendered after wires in a separate pass via
/// the legacy per-primitive `WipeoutGpu` renderer.
gpu_wipeouts: Vec<WipeoutGpu>,
/// Per-wipeout draw-time skip flag (Phase 2.3 frustum cull). `true`
/// when the wipeout's projected AABB sits entirely outside the
/// viewport rect. Recomputed by `compute_wipeout_lod`.
wipeout_skip_flags: Vec<bool>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped wipeouts. Recomputed each frame.
wipeout_pixel_scissors: Vec<Option<[u32; 4]>>,
gpu_images: Vec<ImageGpu>,
/// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame.
image_pixel_scissors: Vec<Option<[u32; 4]>>,
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
gpu_meshes: Vec<MeshLodGpu>,
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
/// Batched mesh geometry — every solid's LOD0 concatenated into a few large
/// buffers so the whole set draws in a handful of calls instead of one per
/// solid. Rebuilt only when the geometry epoch changes (see
/// `cached_mesh_batch_epoch`), so hover / selection never re-pack it.
gpu_mesh_batch: Vec<mesh_gpu::MeshBatchChunk>,
/// `geometry_epoch` the batch was built for. `u64::MAX` = not built yet.
pub cached_mesh_batch_epoch: u64,
/// Tinted copies of just the selected / hovered solids, drawn over the
/// static base batch so highlight shows without re-packing the whole set.
/// Rebuilt only when the selection/hover or geometry changes — O(highlighted).
gpu_mesh_highlight: Vec<mesh_gpu::MeshGpu>,
/// `(geometry_epoch, selection_generation)` the highlight overlay was built for.
pub cached_highlight_key: (u64, u64),
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
/// Per-mesh LOD level (0=high, 1=mid, 2=low) picked each frame from
/// the projected pixel diagonal. Mirrors `hatch_pixel_scissors` —
/// recomputed in `compute_mesh_lod`.
mesh_lod_levels: Vec<usize>,
/// Per-mesh frustum-visible flag (Phase 2.2). `false` when the
/// mesh's projected AABB falls entirely outside the viewport rect
/// — the draw loop skips it. Mirrors `mesh_lod_levels` length /
/// index space; recomputed alongside it.
mesh_visible: Vec<bool>,
/// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire).
gpu_face3d_fill: Option<Face3DGpu>,
gpu_face3d_edges: Vec<WireGpu>,
2026-03-22 14:56:27 +03:00
pub viewcube: ViewCubePipeline,
/// Last `(geometry_epoch, camera_generation)` value for which GPU buffers
/// were uploaded. We re-upload when either side changes — pan/zoom bumps
/// camera_generation, which triggers re-culling and a fresh upload.
/// `(geometry_epoch, camera_generation, selection_generation)` of the
/// last static-buffer upload. Selection is tracked so the selected-hatch
/// tint refreshes on select / deselect (issue #71).
pub cached_epoch: (u64, u64, u64),
/// Content id of the wire buffer currently resident on the GPU (Phase
/// 3.2). When the incoming `ViewportData.wire_content_id` matches, the
/// world-space wire vertices are unchanged (e.g. a pure pan reused the
/// Model-tile tessellation) and `upload_wires` is skipped. `u64::MAX` =
/// nothing uploaded yet.
pub cached_wire_id: u64,
/// `(wire_content_id, selection_generation)` the selection xray overlay
/// (`gpu_selected_wires`) was last built for. Rebuilt when either changes —
/// a pick bumps only `selection_generation`, refreshing the overlay without
/// touching the main wire buffers.
pub cached_selection: (u64, u64),
/// `(geometry_epoch, selection_generation)` the resident mesh buffers were
/// uploaded for. Bumps on a selection/hover change so highlighted solids
/// re-upload with their tint.
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
#[allow(dead_code)] // per-mesh LOD path, bypassed by the batched mesh draw
pub cached_mesh_key: (u64, u64),
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
/// `(geometry_epoch, face3d_fill_active)` the Face3D edge/fill buffers were
/// uploaded for. The buffers are world-space and selection-independent, so
/// they only change when the geometry changes or the 3D-fill mode toggles —
/// never on a pan/orbit. Keyed separately from `cached_epoch` (which carries
/// `camera_generation`) so a camera move no longer re-walks every wire to
/// rebuild the Face3D fill buffer.
pub cached_face3d_key: (u64, bool),
/// Handle → indices into the resident wire set, built once per wire upload
/// (when `cached_wire_id` changes). Lets the selection/hover xray overlay
/// gather just the highlighted entity's wires (`O(highlighted)`) instead of
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
/// scanning and string-parsing every wire on each hover change. Shared by
/// `std::sync::Arc` alongside `gpu_wires` — built once per `wire_content_id`
/// and cloned into every slot that renders it.
pub(crate) wire_handle_index: std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
perf(render): cache rendered scene, skip re-raster on unchanged frames Every cursor movement over the viewport re-rendered the entire GPU scene (all wires, hatches, meshes) even when nothing visible changed: iced redraws the whole window on each input message, and the shader's draw/prepare/render runs every time. On large drawings one such redraw exceeds the frame budget, so moving the mouse or panning stutters — and it gets worse the bigger the drawing. The pipeline already resolves each frame into a persistent, clip-sized resolve texture and blits that to the surface. This hashes the inputs that determine the rendered image (camera uniforms, geometry/selection generations, wire content id, render-mode flags, clip size, live preview coordinates) into a per-viewport signature. When a frame's signature matches the previous one the image is pixel-identical, so `prepare` skips the buffer uploads + per-frame scissor/LOD recompute and `render` skips passes 1-7 plus the MSAA resolve, re-blitting the resolve texture instead. A pure cursor move becomes a single fullscreen blit rather than an O(N) re-rasterization. The ViewCube is excluded from the signature (it renders in its own always-on pass) so cube hover still tracks while the scene is cached. Any real change — edit (geometry_epoch), select/hover (selection_generation), camera move or render-mode toggle — differs the signature and forces a full render, so nothing goes stale. Navigation still renders every frame (the view genuinely changes); this targets the wasted re-render on cursor-only movement.
2026-07-05 08:11:35 +02:00
/// Signature of the last frame's rendered scene (camera uniforms, geometry
/// / selection generations, wire content id, render-mode flags, clip size,
/// live preview). When the next frame's signature matches, the image is
/// pixel-identical, so `render` skips every geometry pass + the MSAA
/// resolve and only re-blits the resolve texture (which still holds it).
/// This turns a pure cursor move over a large drawing from an O(N)
/// re-rasterization into a single fullscreen blit. `u64::MAX` = nothing
/// rendered yet (forces the first frame to render fully).
pub render_sig: u64,
/// Set by `prepare` each frame: `true` when this frame's signature matched
/// `render_sig`, so `render` may skip the geometry passes. Read (never
/// written) by `render`, which always runs after `prepare` for the frame.
pub skip_geometry: bool,
/// Interaction LOD: set by `prepare` when the view is actively navigating, so
/// `render` skips the (per-pixel, GPU-dominating) hatch pass this frame. The
/// scene-render cache holds the full-quality frame once the view settles.
pub skip_hatch_frame: bool,
/// Stable identity of the viewport that last used this (index-addressed)
/// pipeline slot. The renderer's viewport list drops off-canvas viewports,
/// so a slot can be reused by a *different* viewport across frames; when the
/// occupant changes, all cache keys above belong to the previous one and are
/// reset so every buffer re-uploads. `u64::MAX` = never used.
pub slot_id: u64,
2026-03-22 14:56:27 +03:00
}
impl Pipeline {
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
// ── Shared frame uniform buffer (view_proj etc.) ───────────────────
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("viewer.uniform_buffer"),
size: std::mem::size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Bind group layout 0 — shared by wire and hatch pipelines.
let frame_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("viewer.frame_bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("viewer.bind_group"),
layout: &frame_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
}],
});
// ── Wire pipeline ──────────────────────────────────────────────────
// Native: per-wire constants live in a storage buffer (group 1), so the
// slim instance only carries per-segment data. WebGL2 has no
// vertex-stage storage buffers, so the wasm build keeps the fat instance
// and a single bind group.
#[cfg(not(target_arch = "wasm32"))]
let wire_const_bgl = wire_gpu::WireConst::bind_group_layout(device);
#[cfg(not(target_arch = "wasm32"))]
let wire_bgls: &[&wgpu::BindGroupLayout] = &[&frame_bgl, &wire_const_bgl];
#[cfg(target_arch = "wasm32")]
let wire_bgls: &[&wgpu::BindGroupLayout] = &[&frame_bgl];
2026-03-22 14:56:27 +03:00
let wire_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("wire.pipeline_layout"),
bind_group_layouts: wire_bgls,
2026-03-22 14:56:27 +03:00
push_constant_ranges: &[],
});
let depth_tex = create_depth_texture(device, Size::new(1, 1));
let depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
let wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("wire.shader"),
#[cfg(not(target_arch = "wasm32"))]
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/wire_indexed.wgsl"
))),
#[cfg(target_arch = "wasm32")]
2026-03-22 14:56:27 +03:00
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/wire.wgsl"
))),
});
let wire_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("wire.pipeline"),
layout: Some(&wire_layout),
vertex: wgpu::VertexState {
module: &wire_shader,
entry_point: Some("vs_main"),
buffers: &[wire_gpu::WireInstance::layout()],
2026-03-22 14:56:27 +03:00
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
2026-03-22 14:56:27 +03:00
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &wire_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// Black variant of `wire_pipeline` — same geometry/depth, black fragment
// — for 3D mesh outline edges in filled modes (see the wire draw loop).
let wire_black_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("wire.black.pipeline"),
layout: Some(&wire_layout),
vertex: wgpu::VertexState {
module: &wire_shader,
entry_point: Some("vs_main"),
buffers: &[wire_gpu::WireInstance::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &wire_shader,
entry_point: Some("fs_black"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// Selection overlay variant: renders selected wires on top of everything
// (depth_compare=Always), without writing depth. Ensures selected entities
// are always fully visible regardless of occluding geometry.
let wire_xray_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("wire_xray.pipeline"),
layout: Some(&wire_layout),
vertex: wgpu::VertexState {
module: &wire_shader,
entry_point: Some("vs_main"),
buffers: &[wire_gpu::WireInstance::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &wire_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
2026-03-22 14:56:27 +03:00
// ── Hatch pipeline ─────────────────────────────────────────────────
fix(scene): hatch boundary + pattern precision for large-WCS drawings Two precision problems were producing visibly wrong hatches on UTM-scale (and similarly-offset) drawings even after world_offset was correct: 1. **Boundary tessellation** — `hatch_model_from_dxf`'s polyline-bulge arc tessellation ran the whole arc-center computation in f32. With vertices at WCS magnitude ~1e5 the f32 ULP is ~1 cm, which propagated into the sampled arc points and showed up as visibly wavy / jagged hatch boundaries on edges that crossed the long axis of the drawing. Re-run the full bulge → arc math in f64; only cast at the to_xy call that feeds the (also f64) boundary buffer. 2. **Pattern phase** — the per-fragment `perp / perp_step` modulo blew precision when `xz` was at offset-rel WCS magnitude and perp_step was sub-cm. Move the precision-critical bits out of f32 by keeping each hatch's anchor in f64 on the model side: `HatchModel` now carries a `world_origin: [f64; 2]` and the `boundary: [f32; 2]` vertices are stored as small offsets from it. The GPU vertex shader adds `origin` back inside the view_proj multiply for clip position (sub-pixel-lossy at large hatches but invisible); the `xz` varying that the fragment shader uses for in_polygon + pattern math stays in hatch-local space at full f32 precision. For pattern alignment across adjacent hatches with the same family, the GPU pipeline snaps the anchor to the family's perp/along grid in f64 before sending it down. Snap uses the same QCAD-PAT convention (`fam.dy = perp_step`, `fam.dx = along_step`) the shader's check_family uses — an earlier attempt used the world-frame `(-dx*sin + dy*cos)` formula, which produced a meaningless grid and no alignment. Trade-off: pattern phase is "global modulo per-family-grid" rather than tied to WCS (0,0). Same hatches still align with each other; the absolute phase relative to the WCS origin may differ from the pre-fix behaviour on small drawings. Consumers that read `model.boundary` directly (hit_test, paper_canvas, add_hatch round-trip) reconstruct WCS via `boundary[i] + world_origin`. The hatch wgsl bind-group entry for binding 0 was bumped to `VERTEX | FRAGMENT` because the vertex shader now reads `h.origin`. The fragment-shader hatch LOD substitute + sub-pixel skip from e7675be and the scissor plumbing from fbbac42 still work — those touched different fields of the same struct. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 08:41:23 +03:00
// binding 0 (HatchUniforms) is read by the vertex shader too — it
// pulls `origin` to undo the CPU-side hatch-local pre-shift when
// computing clip position. bindings 1 (Boundary) and 2
// (FamilyBatch) stay fragment-only.
let hatch_entry = |binding: u32, vis: wgpu::ShaderStages| wgpu::BindGroupLayoutEntry {
2026-03-22 14:56:27 +03:00
binding,
fix(scene): hatch boundary + pattern precision for large-WCS drawings Two precision problems were producing visibly wrong hatches on UTM-scale (and similarly-offset) drawings even after world_offset was correct: 1. **Boundary tessellation** — `hatch_model_from_dxf`'s polyline-bulge arc tessellation ran the whole arc-center computation in f32. With vertices at WCS magnitude ~1e5 the f32 ULP is ~1 cm, which propagated into the sampled arc points and showed up as visibly wavy / jagged hatch boundaries on edges that crossed the long axis of the drawing. Re-run the full bulge → arc math in f64; only cast at the to_xy call that feeds the (also f64) boundary buffer. 2. **Pattern phase** — the per-fragment `perp / perp_step` modulo blew precision when `xz` was at offset-rel WCS magnitude and perp_step was sub-cm. Move the precision-critical bits out of f32 by keeping each hatch's anchor in f64 on the model side: `HatchModel` now carries a `world_origin: [f64; 2]` and the `boundary: [f32; 2]` vertices are stored as small offsets from it. The GPU vertex shader adds `origin` back inside the view_proj multiply for clip position (sub-pixel-lossy at large hatches but invisible); the `xz` varying that the fragment shader uses for in_polygon + pattern math stays in hatch-local space at full f32 precision. For pattern alignment across adjacent hatches with the same family, the GPU pipeline snaps the anchor to the family's perp/along grid in f64 before sending it down. Snap uses the same QCAD-PAT convention (`fam.dy = perp_step`, `fam.dx = along_step`) the shader's check_family uses — an earlier attempt used the world-frame `(-dx*sin + dy*cos)` formula, which produced a meaningless grid and no alignment. Trade-off: pattern phase is "global modulo per-family-grid" rather than tied to WCS (0,0). Same hatches still align with each other; the absolute phase relative to the WCS origin may differ from the pre-fix behaviour on small drawings. Consumers that read `model.boundary` directly (hit_test, paper_canvas, add_hatch round-trip) reconstruct WCS via `boundary[i] + world_origin`. The hatch wgsl bind-group entry for binding 0 was bumped to `VERTEX | FRAGMENT` because the vertex shader now reads `h.origin`. The fragment-shader hatch LOD substitute + sub-pixel skip from e7675be and the scissor plumbing from fbbac42 still work — those touched different fields of the same struct. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 08:41:23 +03:00
visibility: vis,
2026-03-22 14:56:27 +03:00
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
fix(scene): hatch boundary + pattern precision for large-WCS drawings Two precision problems were producing visibly wrong hatches on UTM-scale (and similarly-offset) drawings even after world_offset was correct: 1. **Boundary tessellation** — `hatch_model_from_dxf`'s polyline-bulge arc tessellation ran the whole arc-center computation in f32. With vertices at WCS magnitude ~1e5 the f32 ULP is ~1 cm, which propagated into the sampled arc points and showed up as visibly wavy / jagged hatch boundaries on edges that crossed the long axis of the drawing. Re-run the full bulge → arc math in f64; only cast at the to_xy call that feeds the (also f64) boundary buffer. 2. **Pattern phase** — the per-fragment `perp / perp_step` modulo blew precision when `xz` was at offset-rel WCS magnitude and perp_step was sub-cm. Move the precision-critical bits out of f32 by keeping each hatch's anchor in f64 on the model side: `HatchModel` now carries a `world_origin: [f64; 2]` and the `boundary: [f32; 2]` vertices are stored as small offsets from it. The GPU vertex shader adds `origin` back inside the view_proj multiply for clip position (sub-pixel-lossy at large hatches but invisible); the `xz` varying that the fragment shader uses for in_polygon + pattern math stays in hatch-local space at full f32 precision. For pattern alignment across adjacent hatches with the same family, the GPU pipeline snaps the anchor to the family's perp/along grid in f64 before sending it down. Snap uses the same QCAD-PAT convention (`fam.dy = perp_step`, `fam.dx = along_step`) the shader's check_family uses — an earlier attempt used the world-frame `(-dx*sin + dy*cos)` formula, which produced a meaningless grid and no alignment. Trade-off: pattern phase is "global modulo per-family-grid" rather than tied to WCS (0,0). Same hatches still align with each other; the absolute phase relative to the WCS origin may differ from the pre-fix behaviour on small drawings. Consumers that read `model.boundary` directly (hit_test, paper_canvas, add_hatch round-trip) reconstruct WCS via `boundary[i] + world_origin`. The hatch wgsl bind-group entry for binding 0 was bumped to `VERTEX | FRAGMENT` because the vertex shader now reads `h.origin`. The fragment-shader hatch LOD substitute + sub-pixel skip from e7675be and the scissor plumbing from fbbac42 still work — those touched different fields of the same struct. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 08:41:23 +03:00
let frag = wgpu::ShaderStages::FRAGMENT;
let vert_frag = wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT;
let wipeout_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("wipeout.bgl1"),
fix(scene): hatch boundary + pattern precision for large-WCS drawings Two precision problems were producing visibly wrong hatches on UTM-scale (and similarly-offset) drawings even after world_offset was correct: 1. **Boundary tessellation** — `hatch_model_from_dxf`'s polyline-bulge arc tessellation ran the whole arc-center computation in f32. With vertices at WCS magnitude ~1e5 the f32 ULP is ~1 cm, which propagated into the sampled arc points and showed up as visibly wavy / jagged hatch boundaries on edges that crossed the long axis of the drawing. Re-run the full bulge → arc math in f64; only cast at the to_xy call that feeds the (also f64) boundary buffer. 2. **Pattern phase** — the per-fragment `perp / perp_step` modulo blew precision when `xz` was at offset-rel WCS magnitude and perp_step was sub-cm. Move the precision-critical bits out of f32 by keeping each hatch's anchor in f64 on the model side: `HatchModel` now carries a `world_origin: [f64; 2]` and the `boundary: [f32; 2]` vertices are stored as small offsets from it. The GPU vertex shader adds `origin` back inside the view_proj multiply for clip position (sub-pixel-lossy at large hatches but invisible); the `xz` varying that the fragment shader uses for in_polygon + pattern math stays in hatch-local space at full f32 precision. For pattern alignment across adjacent hatches with the same family, the GPU pipeline snaps the anchor to the family's perp/along grid in f64 before sending it down. Snap uses the same QCAD-PAT convention (`fam.dy = perp_step`, `fam.dx = along_step`) the shader's check_family uses — an earlier attempt used the world-frame `(-dx*sin + dy*cos)` formula, which produced a meaningless grid and no alignment. Trade-off: pattern phase is "global modulo per-family-grid" rather than tied to WCS (0,0). Same hatches still align with each other; the absolute phase relative to the WCS origin may differ from the pre-fix behaviour on small drawings. Consumers that read `model.boundary` directly (hit_test, paper_canvas, add_hatch round-trip) reconstruct WCS via `boundary[i] + world_origin`. The hatch wgsl bind-group entry for binding 0 was bumped to `VERTEX | FRAGMENT` because the vertex shader now reads `h.origin`. The fragment-shader hatch LOD substitute + sub-pixel skip from e7675be and the scissor plumbing from fbbac42 still work — those touched different fields of the same struct. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 08:41:23 +03:00
entries: &[
hatch_entry(0, vert_frag),
hatch_entry(1, frag),
hatch_entry(2, frag),
],
2026-03-22 14:56:27 +03:00
});
let wipeout_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("wipeout.pipeline_layout"),
bind_group_layouts: &[&frame_bgl, &wipeout_bgl1],
2026-03-22 14:56:27 +03:00
push_constant_ranges: &[],
});
let wipeout_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("wipeout.shader"),
2026-03-22 14:56:27 +03:00
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/wipeout.wgsl"
2026-03-22 14:56:27 +03:00
))),
});
let wipeout_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("wipeout.pipeline"),
layout: Some(&wipeout_layout),
2026-03-22 14:56:27 +03:00
vertex: wgpu::VertexState {
module: &wipeout_shader,
2026-03-22 14:56:27 +03:00
entry_point: Some("vs_main"),
buffers: &[wipeout_gpu::HatchVertex::layout()],
2026-03-22 14:56:27 +03:00
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
2026-03-22 14:56:27 +03:00
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
2026-03-22 14:56:27 +03:00
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &wipeout_shader,
2026-03-22 14:56:27 +03:00
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// ── Hatch batched pipeline (Phase 4-B) ─────────────────────────────
// WebGL2 (the wasm fallback backend) has no vertex-stage storage
// buffers, which this pipeline's bind group requires. Skip it on wasm
// and run without the batched-hatch optimization.
#[cfg(target_arch = "wasm32")]
let (hatch_bgl1, hatch_pipeline): (
Option<wgpu::BindGroupLayout>,
Option<wgpu::RenderPipeline>,
) = (None, None);
#[cfg(not(target_arch = "wasm32"))]
let (hatch_bgl1, hatch_pipeline) = {
let hatch_bgl1 = hatch_gpu::HatchGpu::bind_group_layout(device);
let hatch_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("hatch.pipeline_layout"),
bind_group_layouts: &[&frame_bgl, &hatch_bgl1],
push_constant_ranges: &[],
});
let hatch_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("hatch.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/hatch.wgsl"
))),
});
let hatch_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("hatch.pipeline"),
layout: Some(&hatch_layout),
vertex: wgpu::VertexState {
module: &hatch_shader,
entry_point: Some("vs_main"),
buffers: &[hatch_gpu::HatchVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &hatch_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
(Some(hatch_bgl1), Some(hatch_pipeline))
};
// WebGL2 hatch pipeline: texture-backed, replaces the storage-buffer
// batched path (disabled above on wasm). Same frame layout / depth /
// MSAA state as the wipeout pass.
#[cfg(target_arch = "wasm32")]
let (hatch_web_bgl1, hatch_web_pipeline) = {
let bgl1 = hatch_web_gpu::HatchWebGpu::bind_group_layout(device);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("hatch_web.pipeline_layout"),
bind_group_layouts: &[&frame_bgl, &bgl1],
push_constant_ranges: &[],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("hatch_web.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/hatch_web.wgsl"
))),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("hatch_web.pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
}],
}],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
(bgl1, pipeline)
};
2026-03-22 14:56:27 +03:00
// ── Mesh pipeline ──────────────────────────────────────────────────
let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mesh.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/mesh.wgsl"
))),
});
let mesh_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("mesh.pipeline_layout"),
bind_group_layouts: &[&frame_bgl],
push_constant_ranges: &[],
});
let mesh_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mesh.pipeline"),
layout: Some(&mesh_layout),
vertex: wgpu::VertexState {
module: &mesh_shader,
entry_point: Some("vs_main"),
buffers: &[mesh_gpu::MeshVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None, // two-sided: ACIS import winding is unreliable
2026-03-22 14:56:27 +03:00
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
2026-03-22 14:56:27 +03:00
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
2026-03-22 14:56:27 +03:00
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &mesh_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// Transparent variant — identical to `mesh_pipeline` but with depth
// writes disabled. Non-opaque solids are drawn after the opaque fills
// with this pipeline so they blend over the geometry behind them (which
// has already written depth) instead of erasing it.
let mesh_transparent_pipeline =
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mesh.transparent.pipeline"),
layout: Some(&mesh_layout),
vertex: wgpu::VertexState {
module: &mesh_shader,
entry_point: Some("vs_main"),
buffers: &[mesh_gpu::MeshVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &mesh_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
// Highlight variant — same shader / vertex layout, but `depth_compare =
// Always` and no depth write, so a hovered / selected solid's tint is
// drawn on top of everything (it shows through occluding geometry) and
// doesn't disturb the depth buffer for later passes.
let mesh_highlight_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mesh.highlight.pipeline"),
layout: Some(&mesh_layout),
vertex: wgpu::VertexState {
module: &mesh_shader,
entry_point: Some("vs_main"),
buffers: &[mesh_gpu::MeshVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &mesh_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// Wireframe variant — same shader / vertex layout / depth state,
// only the input topology changes (LineList) and back-face
// culling drops out (each triangle edge is shared between two
// faces, one of which would otherwise hide the edge).
// Edge/wireframe pipeline (LineList). `fs_edge` outputs the flat entity
// colour — no lighting — for the lines-only modes. A `fs_edge_black`
// twin (below) forces black for the edge overlay in filled modes.
let make_edge_pipeline = |label: &'static str, fs: &'static str| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&mesh_layout),
vertex: wgpu::VertexState {
module: &mesh_shader,
entry_point: Some("vs_main"),
buffers: &[mesh_gpu::MeshVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &mesh_shader,
entry_point: Some(fs),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
})
};
let mesh_wireframe_pipeline = make_edge_pipeline("mesh.wireframe.pipeline", "fs_edge");
let mesh_edge_black_pipeline = make_edge_pipeline("mesh.edge_black.pipeline", "fs_edge_black");
// Depth-only variant — TriangleList, back-face culling stays on
// (we only want front-facing fragments to write depth so wires
// on the far side of the mesh stay hidden), `write_mask` zero
// so no fragment ever reaches the colour buffer.
let mesh_depth_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mesh.depth.pipeline"),
layout: Some(&mesh_layout),
vertex: wgpu::VertexState {
module: &mesh_shader,
entry_point: Some("vs_main"),
buffers: &[mesh_gpu::MeshVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None, // two-sided: ACIS import winding is unreliable
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &mesh_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// ── Face3D pipeline ────────────────────────────────────────────────
let face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("face3d.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/face3d.wgsl"
))),
});
let face3d_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("face3d.pipeline_layout"),
bind_group_layouts: &[&frame_bgl],
push_constant_ranges: &[],
});
let face3d_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("face3d.pipeline"),
layout: Some(&face3d_layout),
vertex: wgpu::VertexState {
module: &face3d_shader,
entry_point: Some("vs_main"),
buffers: &[face3d_gpu::Face3DVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &face3d_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// Depth-only variant — write_mask zero, no blend. The face3d
// shader still runs but its colour output is discarded, so we
// get a pure depth prepass for HiddenLine.
let face3d_depth_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("face3d.depth.pipeline"),
layout: Some(&face3d_layout),
vertex: wgpu::VertexState {
module: &face3d_shader,
entry_point: Some("vs_main"),
buffers: &[face3d_gpu::Face3DVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState {
constant: 1,
slope_scale: 1.0,
clamp: 0.0,
},
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &face3d_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// ── Image pipeline ─────────────────────────────────────────────────
let image_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("image.bgl1"),
entries: &[
// binding 0: texture
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
// binding 1: sampler
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
// binding 2: ImageParams uniform (read in vertex for the
// draw-order z bias and in fragment for opacity).
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let image_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("image.pipeline_layout"),
bind_group_layouts: &[&frame_bgl, &image_bgl1],
push_constant_ranges: &[],
});
let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("image.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/image.wgsl"
))),
});
let image_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("image.pipeline"),
layout: Some(&image_layout),
vertex: wgpu::VertexState {
module: &image_shader,
entry_point: Some("vs_main"),
buffers: &[image_gpu::ImageVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: MSAA_SAMPLES,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &image_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
// ── Text (SDF glyph quads) ─────────────────────────────────────────
let text_atlas_bgl = text_gpu::TextAtlasGpu::bind_group_layout(device);
let text_pipeline =
text_gpu::create_pipeline(device, &frame_bgl, &text_atlas_bgl, format, MSAA_SAMPLES);
2026-03-22 14:56:27 +03:00
let viewcube = ViewCubePipeline::new(device, queue, format);
let init_size = Size::new(1, 1);
let msaa_view = create_msaa_texture(device, init_size, format)
.create_view(&wgpu::TextureViewDescriptor::default());
let resolve_tex = create_resolve_texture(device, init_size, format);
let resolve_view = resolve_tex.create_view(&wgpu::TextureViewDescriptor::default());
// ── Blit pipeline (resolve texture → surface target) ──────────────
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("blit.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/blit.wgsl"
))),
});
let blit_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("blit.bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
// UV crop uniform: [uv_offset_x, uv_offset_y, uv_scale_x, uv_scale_y]
// padded to 16 bytes (std140 vec2 alignment). Defaulted to the
// identity crop (offset 0, scale 1) for the common on-canvas case.
let blit_uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("blit.uniform_buffer"),
contents: bytemuck::cast_slice(&[0.0f32, 0.0, 1.0, 1.0]),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let blit_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("blit.pipeline_layout"),
bind_group_layouts: &[&blit_bgl],
push_constant_ranges: &[],
});
let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("blit.pipeline"),
layout: Some(&blit_layout),
vertex: wgpu::VertexState {
module: &blit_shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module: &blit_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
// Premultiplied-alpha blend: the geometry passes already
// wrote into a transparent MSAA target with standard
// `SrcAlpha / 1-SrcAlpha` blending, so AA-edge fragments
// sit as `(rgb * a, a)` in the resolve texture. Treating
// that as straight alpha during the surface blit would
// multiply by alpha a second time and darken thin lines
// / curves. `PREMULTIPLIED_ALPHA_BLENDING` uses `One` as
// the source colour factor and leaves the dst weighted
// by `1-SrcAlpha`, which is the correct compositing
// operator for already-premultiplied content.
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
let blit_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("blit.sampler"),
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let blit_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("blit.bind_group"),
layout: &blit_bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&resolve_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&blit_sampler),
},
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
wgpu::BindGroupEntry {
binding: 2,
resource: blit_uniform_buffer.as_entire_binding(),
},
],
});
2026-03-22 14:56:27 +03:00
Self {
wire_pipeline,
wire_black_pipeline,
wire_xray_pipeline,
#[cfg(not(target_arch = "wasm32"))]
wire_const_bgl: Some(wire_const_bgl),
#[cfg(target_arch = "wasm32")]
wire_const_bgl: None,
wipeout_pipeline,
2026-03-22 14:56:27 +03:00
hatch_pipeline,
#[cfg(target_arch = "wasm32")]
hatch_web_bgl1,
#[cfg(target_arch = "wasm32")]
hatch_web_pipeline,
image_pipeline,
text_pipeline,
text_atlas_bgl,
text_atlas_gpu: None,
text_vbuf: None,
text_vcount: 0,
text_highlight_vbuf: None,
text_highlight_vcount: 0,
text_preview_vbuf: None,
text_preview_vcount: 0,
2026-03-22 14:56:27 +03:00
mesh_pipeline,
mesh_transparent_pipeline,
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
mesh_highlight_pipeline,
mesh_wireframe_pipeline,
mesh_edge_black_pipeline,
mesh_depth_pipeline,
face3d_pipeline,
face3d_depth_pipeline,
2026-03-22 14:56:27 +03:00
uniform_buffer,
uniform_bind_group,
wipeout_bgl1,
2026-03-22 14:56:27 +03:00
hatch_bgl1,
image_bgl1,
2026-03-22 14:56:27 +03:00
depth_texture_size: Size::new(1, 1),
// (0, 0) forces the first `ensure_depth_texture` to allocate at the
// real rounded size — the constructor textures above are placeholders.
alloc_size: Size::new(0, 0),
2026-03-22 14:56:27 +03:00
depth_view,
msaa_view,
resolve_view,
blit_pipeline,
blit_bind_group_layout: blit_bgl,
blit_sampler,
blit_bind_group,
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
blit_uniform_buffer,
surface_format: format,
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
gpu_wires: std::sync::Arc::new(vec![]),
wire_pixel_scissors: vec![],
gpu_selected_wires: vec![],
gpu_preview_wires: vec![],
gpu_hatch: None,
#[cfg(target_arch = "wasm32")]
gpu_hatches_web: vec![],
gpu_wipeouts: vec![],
wipeout_skip_flags: vec![],
wipeout_pixel_scissors: vec![],
gpu_images: vec![],
image_pixel_scissors: vec![],
2026-03-22 14:56:27 +03:00
gpu_meshes: vec![],
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
gpu_mesh_batch: vec![],
cached_mesh_batch_epoch: u64::MAX,
gpu_mesh_highlight: vec![],
cached_highlight_key: (u64::MAX, u64::MAX),
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
mesh_lod_levels: vec![],
mesh_visible: vec![],
gpu_face3d_fill: None,
gpu_face3d_edges: vec![],
2026-03-22 14:56:27 +03:00
viewcube,
cached_epoch: (u64::MAX, u64::MAX, u64::MAX),
cached_wire_id: u64::MAX,
cached_selection: (u64::MAX, u64::MAX),
cached_mesh_key: (u64::MAX, u64::MAX),
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
cached_face3d_key: (u64::MAX, false),
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
wire_handle_index: std::sync::Arc::new(rustc_hash::FxHashMap::default()),
perf(render): cache rendered scene, skip re-raster on unchanged frames Every cursor movement over the viewport re-rendered the entire GPU scene (all wires, hatches, meshes) even when nothing visible changed: iced redraws the whole window on each input message, and the shader's draw/prepare/render runs every time. On large drawings one such redraw exceeds the frame budget, so moving the mouse or panning stutters — and it gets worse the bigger the drawing. The pipeline already resolves each frame into a persistent, clip-sized resolve texture and blits that to the surface. This hashes the inputs that determine the rendered image (camera uniforms, geometry/selection generations, wire content id, render-mode flags, clip size, live preview coordinates) into a per-viewport signature. When a frame's signature matches the previous one the image is pixel-identical, so `prepare` skips the buffer uploads + per-frame scissor/LOD recompute and `render` skips passes 1-7 plus the MSAA resolve, re-blitting the resolve texture instead. A pure cursor move becomes a single fullscreen blit rather than an O(N) re-rasterization. The ViewCube is excluded from the signature (it renders in its own always-on pass) so cube hover still tracks while the scene is cached. Any real change — edit (geometry_epoch), select/hover (selection_generation), camera move or render-mode toggle — differs the signature and forces a full render, so nothing goes stale. Navigation still renders every frame (the view genuinely changes); this targets the wasted re-render on cursor-only movement.
2026-07-05 08:11:35 +02:00
render_sig: u64::MAX,
skip_geometry: false,
skip_hatch_frame: false,
slot_id: u64::MAX,
2026-03-22 14:56:27 +03:00
}
}
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
/// Build the resident wire batches + handle index for `wires`, wrapped in
/// `Arc` so the caller can cache them by `wire_content_id` and share one
/// copy across every slot that renders that content (see
/// `MultiPipeline::wire_buffer_cache`). Takes `&self` (reads only the const
/// bind-group layout) so the shared cache — not the slot — owns the result.
pub fn build_wire_buffers(
&self,
device: &wgpu::Device,
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, f32>,
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
) -> (
std::sync::Arc<Vec<WireGpu>>,
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
) {
// Batch the wire pass: instead of one GPU buffer + one draw call per
// WireModel (tens of thousands on a large drawing), merge maximal runs
// of *consecutive* wires that share scissor + mesh-edge state into one
// concatenated instance buffer each. The draw loop then issues one draw
// per run. Runs must be consecutive (not regrouped) so the original
// wire order — already sorted by draw order — is preserved; depth bias
// and alpha blending both depend on it. Scissor and mesh-edge stay
// grouping keys because the draw loop sets one scissor per batch and
// skips whole mesh-edge batches in shaded modes.
// A 3D mesh entity (PolyfaceMesh / PolygonMesh) emits its face fill and
// its outline edges as *separate* WireModels sharing the entity handle
// (`name`): the fill carries `fill_tris` + a non-empty `fill_tris_low`
// (real 3D depth, same test face3d uses); the edge carries `points`.
// Flag the edge wire as `is_3d_mesh_edge` so the draw loop can hide it in
// clean-shaded modes and draw it black in filled-with-edges modes.
let mesh_names: rustc_hash::FxHashSet<&str> = wires
.iter()
.filter(|w| !w.fill_tris.is_empty() && !w.fill_tris_low.is_empty())
.map(|w| w.name.as_str())
.collect();
let is_mesh_edge =
|w: &WireModel| !w.points.is_empty() && mesh_names.contains(w.name.as_str());
let mut batches: Vec<WireGpu> = Vec::new();
let mut i = 0;
while i < wires.len() {
let scissor = wires[i].vp_scissor;
let mesh_edge = is_mesh_edge(&wires[i]);
let mut j = i + 1;
while j < wires.len()
&& wires[j].vp_scissor == scissor
&& is_mesh_edge(&wires[j]) == mesh_edge
{
j += 1;
}
batches.extend(WireGpu::from_run(device, &wires[i..j], depth_map, scissor, mesh_edge, self.wire_const_bgl.as_ref()));
i = j;
}
// Index handle → wire slots once, here, so the per-hover selection
// overlay can gather just the highlighted wires instead of scanning +
// string-parsing the whole set every time the hovered entity changes.
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
let mut index: rustc_hash::FxHashMap<u64, Vec<u32>> = rustc_hash::FxHashMap::default();
index.reserve(wires.len());
for (idx, w) in wires.iter().enumerate() {
if let Ok(h) = w.name.parse::<u64>() {
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
index.entry(h).or_default().push(idx as u32);
}
}
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
(std::sync::Arc::new(batches), std::sync::Arc::new(index))
}
/// Build the selection xray overlay: full-brightness copies of the wires
/// whose entity handle is in `highlight`, drawn on top so the selection is
/// always visible. Selection is no longer baked into the wire tessellation,
/// so this is driven by the live highlight set and rebuilt only when the
/// selection (or the underlying wire content) changes — picking an entity
/// refreshes just this overlay instead of re-tessellating the model. The
/// xray pass applies neither scissor nor mesh-edge skip, so everything
/// merges into one order-preserving run.
pub fn upload_selected_wires(
&mut self,
device: &wgpu::Device,
wires: &[WireModel],
selected: &rustc_hash::FxHashSet<acadrust::Handle>,
hover: Option<acadrust::Handle>,
depth_map: &rustc_hash::FxHashMap<u64, f32>,
) {
let hover = hover.filter(|h| !selected.contains(h));
if selected.is_empty() && hover.is_none() {
self.gpu_selected_wires = vec![];
return;
}
// Gather the highlighted entities' wires via the prebuilt index —
// O(highlighted), no per-wire string parse. Selection recolours blue,
// hover orange. Drawn on top (depth-compare Always) over the base pass.
let mut out: Vec<WireModel> = Vec::new();
let push = |handle_val: u64, color: [f32; 4], wires: &[WireModel], out: &mut Vec<WireModel>| {
if let Some(idxs) = self.wire_handle_index.get(&handle_val) {
let mut slots = idxs.clone();
slots.sort_unstable();
for &i in &slots {
if let Some(w) = wires.get(i as usize) {
let mut c = w.clone();
c.color = color;
out.push(c);
}
}
}
};
for h in selected {
push(h.value(), WireModel::SELECTED, wires, &mut out);
}
if let Some(h) = hover {
push(h.value(), WireModel::HOVER, wires, &mut out);
}
self.gpu_selected_wires =
WireGpu::from_run(device, &out, depth_map, None, false, self.wire_const_bgl.as_ref());
2026-03-22 14:56:27 +03:00
}
/// Build the text-highlight overlay: the glyph quads of just the selected /
/// hovered text, recoloured (selection blue, hover orange) and drawn over
/// the base text pass. Uses the same handle→wire index as the selected-wire
/// overlay, so it is O(highlighted). Empty when nothing is highlighted.
pub fn upload_text_highlight(
&mut self,
device: &wgpu::Device,
wires: &[WireModel],
selected: &rustc_hash::FxHashSet<acadrust::Handle>,
hover: Option<acadrust::Handle>,
) {
let hover = hover.filter(|h| !selected.contains(h));
if selected.is_empty() && hover.is_none() {
self.text_highlight_vbuf = None;
self.text_highlight_vcount = 0;
return;
}
let mut out: Vec<text_gpu::TextVertex> = Vec::new();
let push =
|handle_val: u64, tint: [f32; 4], wires: &[WireModel], out: &mut Vec<text_gpu::TextVertex>| {
if let Some(idxs) = self.wire_handle_index.get(&handle_val) {
for &i in idxs {
if let Some(w) = wires.get(i as usize) {
for v in &w.text_verts {
out.push(text_gpu::TextVertex {
color: [tint[0], tint[1], tint[2], v.color[3]],
..*v
});
}
}
}
}
};
for h in selected {
push(h.value(), WireModel::SELECTED, wires, &mut out);
}
if let Some(h) = hover {
push(h.value(), WireModel::HOVER, wires, &mut out);
}
self.text_highlight_vcount = out.len() as u32;
self.text_highlight_vbuf = text_gpu::upload_vertices(device, &out);
}
/// Upload the live overlay (command preview / interim / grip-drag) wires.
/// Small and refreshed each frame they're present; kept separate from the
/// base wire buffer so a drag never re-uploads the resident base set.
pub fn upload_preview_wires(
&mut self,
device: &wgpu::Device,
wires: &[WireModel],
depth_map: &rustc_hash::FxHashMap<u64, f32>,
) {
self.gpu_preview_wires = if wires.is_empty() {
vec![]
} else {
WireGpu::from_run(device, wires, depth_map, None, false, self.wire_const_bgl.as_ref())
};
}
/// Upload the live grip-drag / command-preview SDF glyph quads. Re-uploaded
/// every frame they're present, kept separate from the epoch-cached base
/// text buffer so a drag never re-uploads the (potentially huge) resident
/// text set — and so the dragged entity's glyphs (hidden from the base set
/// while the drag is live) still reach the GPU each frame (issue #316).
pub fn upload_preview_text(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
verts: &[text_gpu::TextVertex],
) {
if verts.is_empty() {
self.text_preview_vbuf = None;
self.text_preview_vcount = 0;
return;
}
// Ensure the glyph atlas exists / is current even when the base text
// pass didn't run this frame (e.g. the dragged text is the only text).
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
if self.text_atlas_gpu.is_none() || atlas.is_dirty() {
self.text_atlas_gpu = Some(text_gpu::TextAtlasGpu::upload(
device,
queue,
&atlas,
&self.text_atlas_bgl,
));
atlas.clear_dirty();
}
}
self.text_preview_vbuf = text_gpu::upload_vertices(device, verts);
self.text_preview_vcount = verts.len() as u32;
}
/// Recompute pixel scissor rects for viewport-clipped wires from the current view_proj.
/// Called every frame from prepare() because scissor pixels shift with pan/zoom.
pub fn compute_wire_scissors(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) {
2026-05-12 10:36:15 +03:00
self.wire_pixel_scissors = self
.gpu_wires
.iter()
.map(|w| project_scissor(w.vp_scissor, view_rot, eye, clip_w, clip_h))
.collect();
}
refactor(pipeline): drop legacy per-hatch path (Phase 4-B, step 5) The batched hatch pipeline has soaked through visibility + LOD; it's correct on every file tested (Floor plan.dxf, UTM site plans, the non-UTM regression case). Time to remove the parallel per-hatch plumbing that's been sitting empty since step 3: - `gpu_hatches: Vec<HatchGpu>` — never populated since `upload_hatches` switched to the batched builder; dropped. - `hatch_pixel_scissors`, `hatch_skip_flags` — same story; dropped. - `compute_hatch_scissors` — no longer has a consumer; dropped, with the render-time call removed from `Primitive::prepare`. - Hatch render pass — the dead `else if` branch over `gpu_hatches` is gone; only the single batched `pass.draw(0..vertex_count, 0..1)` remains. - `compute_hatch_lod` — no fallback path; if `gpu_hatch_batched` is `None` (empty hatch list) it just returns early. `HatchGpu` itself stays — the wipeout pipeline still uses it. So do `hatch_pipeline` / `hatch_bgl1`, kept around for that path. `#[allow(dead_code)]` on the four storage-buffer fields in `HatchBatchedGpu` — they exist only to keep the bind group's referenced resources alive; nothing reads them directly. Known gap left as a follow-up: per-hatch viewport scissor for paper- space MSPACE viewports isn't ported to the batched path yet. Hatches inside a paper-space viewport can render past the viewport border until that's added. Hasn't surfaced as a visible regression in the test files so far. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 20:18:06 +03:00
/// Per-frame visibility refresh for the batched hatch path.
/// Combines Phase 3.3 sub-pixel LOD skip with Phase 2.3 frustum
/// cull and pushes the resulting 0/1 mask through to the GPU
/// `visibility_buffer`. Vertex shader maps 0 → out-of-NDC clip,
/// so the rasterizer culls the primitive before any fragment
/// runs.
pub fn compute_hatch_lod(
&mut self,
queue: &wgpu::Queue,
view_rot: glam::Mat4,
eye: glam::DVec3,
clip_w: u32,
clip_h: u32,
) {
let Some(batch) = &mut self.gpu_hatch else {
return;
refactor(pipeline): drop legacy per-hatch path (Phase 4-B, step 5) The batched hatch pipeline has soaked through visibility + LOD; it's correct on every file tested (Floor plan.dxf, UTM site plans, the non-UTM regression case). Time to remove the parallel per-hatch plumbing that's been sitting empty since step 3: - `gpu_hatches: Vec<HatchGpu>` — never populated since `upload_hatches` switched to the batched builder; dropped. - `hatch_pixel_scissors`, `hatch_skip_flags` — same story; dropped. - `compute_hatch_scissors` — no longer has a consumer; dropped, with the render-time call removed from `Primitive::prepare`. - Hatch render pass — the dead `else if` branch over `gpu_hatches` is gone; only the single batched `pass.draw(0..vertex_count, 0..1)` remains. - `compute_hatch_lod` — no fallback path; if `gpu_hatch_batched` is `None` (empty hatch list) it just returns early. `HatchGpu` itself stays — the wipeout pipeline still uses it. So do `hatch_pipeline` / `hatch_bgl1`, kept around for that path. `#[allow(dead_code)]` on the four storage-buffer fields in `HatchBatchedGpu` — they exist only to keep the bind group's referenced resources alive; nothing reads them directly. Known gap left as a follow-up: per-hatch viewport scissor for paper- space MSPACE viewports isn't ported to the batched path yet. Hatches inside a paper-space viewport can render past the viewport border until that's added. Hasn't surfaced as a visible regression in the test files so far. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 20:18:06 +03:00
};
for (i, aabb) in batch.instance_aabbs.iter().enumerate() {
let skip = aabb_below_pixel(*aabb, view_rot, eye, clip_w, clip_h, 2.0)
|| aabb_offscreen(*aabb, view_rot, eye, clip_w, clip_h);
refactor(pipeline): drop legacy per-hatch path (Phase 4-B, step 5) The batched hatch pipeline has soaked through visibility + LOD; it's correct on every file tested (Floor plan.dxf, UTM site plans, the non-UTM regression case). Time to remove the parallel per-hatch plumbing that's been sitting empty since step 3: - `gpu_hatches: Vec<HatchGpu>` — never populated since `upload_hatches` switched to the batched builder; dropped. - `hatch_pixel_scissors`, `hatch_skip_flags` — same story; dropped. - `compute_hatch_scissors` — no longer has a consumer; dropped, with the render-time call removed from `Primitive::prepare`. - Hatch render pass — the dead `else if` branch over `gpu_hatches` is gone; only the single batched `pass.draw(0..vertex_count, 0..1)` remains. - `compute_hatch_lod` — no fallback path; if `gpu_hatch_batched` is `None` (empty hatch list) it just returns early. `HatchGpu` itself stays — the wipeout pipeline still uses it. So do `hatch_pipeline` / `hatch_bgl1`, kept around for that path. `#[allow(dead_code)]` on the four storage-buffer fields in `HatchBatchedGpu` — they exist only to keep the bind group's referenced resources alive; nothing reads them directly. Known gap left as a follow-up: per-hatch viewport scissor for paper- space MSPACE viewports isn't ported to the batched path yet. Hatches inside a paper-space viewport can render past the viewport border until that's added. Hasn't surfaced as a visible regression in the test files so far. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 20:18:06 +03:00
batch.visibility[i] = if skip { 0 } else { 1 };
}
refactor(pipeline): drop legacy per-hatch path (Phase 4-B, step 5) The batched hatch pipeline has soaked through visibility + LOD; it's correct on every file tested (Floor plan.dxf, UTM site plans, the non-UTM regression case). Time to remove the parallel per-hatch plumbing that's been sitting empty since step 3: - `gpu_hatches: Vec<HatchGpu>` — never populated since `upload_hatches` switched to the batched builder; dropped. - `hatch_pixel_scissors`, `hatch_skip_flags` — same story; dropped. - `compute_hatch_scissors` — no longer has a consumer; dropped, with the render-time call removed from `Primitive::prepare`. - Hatch render pass — the dead `else if` branch over `gpu_hatches` is gone; only the single batched `pass.draw(0..vertex_count, 0..1)` remains. - `compute_hatch_lod` — no fallback path; if `gpu_hatch_batched` is `None` (empty hatch list) it just returns early. `HatchGpu` itself stays — the wipeout pipeline still uses it. So do `hatch_pipeline` / `hatch_bgl1`, kept around for that path. `#[allow(dead_code)]` on the four storage-buffer fields in `HatchBatchedGpu` — they exist only to keep the bind group's referenced resources alive; nothing reads them directly. Known gap left as a follow-up: per-hatch viewport scissor for paper- space MSPACE viewports isn't ported to the batched path yet. Hatches inside a paper-space viewport can render past the viewport border until that's added. Hasn't surfaced as a visible regression in the test files so far. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 20:18:06 +03:00
batch.upload_visibility(queue);
}
/// Recompute pixel scissor rects for viewport-clipped wipeouts.
pub fn compute_wipeout_scissors(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) {
self.wipeout_pixel_scissors = self
.gpu_wipeouts
.iter()
.map(|h| project_scissor(h.vp_scissor, view_rot, eye, clip_w, clip_h))
.collect();
}
/// Per-frame wipeout frustum-skip flag (Phase 2.3). Mirrors
/// `compute_hatch_lod`'s frustum branch. No sub-pixel skip:
/// wipeouts mask, so dropping a sub-pixel one wouldn't be wrong
/// but also wouldn't pay off — they're usually few.
pub fn compute_wipeout_lod(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) {
self.wipeout_skip_flags = self
.gpu_wipeouts
.iter()
.map(|h| aabb_offscreen(h.world_aabb, view_rot, eye, clip_w, clip_h))
.collect();
}
/// Recompute pixel scissor rects for viewport-clipped raster images.
pub fn compute_image_scissors(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) {
self.image_pixel_scissors = self
.gpu_images
.iter()
.map(|i| project_scissor(i.vp_scissor, view_rot, eye, clip_w, clip_h))
2026-05-12 10:36:15 +03:00
.collect();
}
/// Upload all 3DFACE entities as two batched GPU objects:
/// - `gpu_face3d_fill`: filled triangles (1 buffer, 1 draw call)
/// - `gpu_face3d_edges`: merged edge wires (1 buffer, 1 draw call)
2026-05-12 10:36:15 +03:00
pub fn upload_face3d(
&mut self,
device: &wgpu::Device,
face3d_wires: &[WireModel],
all_wires: &[WireModel],
wireframe_only: bool,
depth_map: &rustc_hash::FxHashMap<u64, f32>,
2026-05-12 10:36:15 +03:00
) {
// Edge buffer is always built from `face3d_wires`, so 3DFACE
// outlines stay on the screen regardless of mode.
self.gpu_face3d_edges =
WireGpu::from_batch(device, face3d_wires, depth_map, self.wire_const_bgl.as_ref());
// Fill buffer split: 3D quads + PolyfaceMesh / PolygonMesh face
// tris go to `vertex_buffer_3d` (gated by `keep_3d_mesh_fills`);
// 2D fills (text-LOD greek, MultiLeader background) go to
// `vertex_buffer_2d` and are visible in every mode.
let keep_3d_mesh_fills = !wireframe_only;
let has_any_2d_fill = all_wires
.iter()
.any(|w| !w.fill_tris.is_empty() && w.points.is_empty());
let has_any_3d_fill = !face3d_wires.is_empty()
|| all_wires
.iter()
.any(|w| !w.fill_tris.is_empty() && !w.points.is_empty());
let has_fills = has_any_2d_fill || (keep_3d_mesh_fills && has_any_3d_fill);
if !has_fills {
self.gpu_face3d_fill = None;
} else {
self.gpu_face3d_fill = Some(Face3DGpu::from_wires(
device,
face3d_wires,
all_wires,
keep_3d_mesh_fills,
depth_map,
));
}
}
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
#[allow(dead_code)] // per-mesh upload path, bypassed by upload_mesh_batch
pub fn upload_meshes(
&mut self,
device: &wgpu::Device,
meshes: &[MeshLodSet],
selected: &rustc_hash::FxHashSet<acadrust::Handle>,
hover: Option<acadrust::Handle>,
) {
2026-03-22 14:56:27 +03:00
self.gpu_meshes = meshes
.iter()
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
.filter(|s| s.lods.iter().any(|m| !m.indices.is_empty()))
.map(|s| {
// A mesh's name is its source entity handle (decimal). Selection
// wins over hover when both apply to the same solid.
let h = s
.lods
.first()
.and_then(|m| m.name.parse::<u64>().ok())
.map(acadrust::Handle::new);
let mode = match h {
Some(h) if selected.contains(&h) => mesh_gpu::Highlight::Selected,
Some(h) if Some(h) == hover => mesh_gpu::Highlight::Hover,
_ => mesh_gpu::Highlight::None,
};
MeshLodGpu::new(device, s, mode)
})
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
.collect();
}
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
/// Build the batched mesh buffers (a few large buffers for the whole solid
/// set, drawn in a handful of calls). Selection/hover tint is intentionally
/// not applied here — the batch is geometry-only and stays resident across
/// camera moves and pick changes.
pub fn upload_mesh_batch(&mut self, device: &wgpu::Device, meshes: &[MeshLodSet]) {
let (chunks, _tris) = mesh_gpu::build_mesh_batch(device, meshes);
self.gpu_mesh_batch = chunks;
}
/// Build tinted copies of just the selected / hovered solids. Drawn over the
/// static base batch with the LessEqual mesh pipeline so the tint shows on
/// top at the same depth. O(highlighted), so a pick never touches the rest.
pub fn upload_mesh_highlight(
&mut self,
device: &wgpu::Device,
meshes: &[MeshLodSet],
selected: &rustc_hash::FxHashSet<acadrust::Handle>,
hover: Option<acadrust::Handle>,
) {
use mesh_gpu::{Highlight, MeshGpu};
let mut out = Vec::new();
if selected.is_empty() && hover.is_none() {
self.gpu_mesh_highlight = out;
return;
}
for set in meshes {
let Some(m) = set.lods.iter().find(|m| !m.indices.is_empty()) else {
continue;
};
let h = m.name.parse::<u64>().ok().map(acadrust::Handle::new);
let mode = match h {
Some(h) if selected.contains(&h) => Highlight::Selected,
Some(h) if Some(h) == hover => Highlight::Hover,
_ => continue,
};
out.push(MeshGpu::new(device, m, mode));
}
self.gpu_mesh_highlight = out;
}
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
/// Per-frame mesh LOD selector. Picks slot 0/1/2 based on the
/// projected pixel diagonal of each mesh's `world_aabb` (Phase 3.4
/// ladder: >200 px → 0, 50–200 → 1, <50 → 2). Falls back to the
/// nearest available lower slot when a level wasn't generated.
pub fn compute_mesh_lod(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) {
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
self.mesh_lod_levels = self
.gpu_meshes
.iter()
.map(|m| pick_mesh_lod(m, view_rot, eye, clip_w, clip_h))
2026-03-22 14:56:27 +03:00
.collect();
// Phase 2.2 — frustum-visibility flag per mesh. Cheap: same
// 4-corner projection used for LOD selection, just answering a
// different question (any corner inside the viewport rect?).
self.mesh_visible = self
.gpu_meshes
.iter()
.map(|m| !aabb_offscreen(m.world_aabb, view_rot, eye, clip_w, clip_h))
.collect();
2026-03-22 14:56:27 +03:00
}
pub fn upload_hatches(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
hatches: &[HatchModel],
) {
// WebGL2: no storage-buffer batched pipeline — render each hatch through
// the texture-backed per-hatch renderer instead (issue #204).
#[cfg(target_arch = "wasm32")]
{
let webs: Vec<hatch_web_gpu::HatchWebGpu> = hatches
.iter()
.filter(|h| h.boundary.len() >= 3)
.map(|h| hatch_web_gpu::HatchWebGpu::new(device, queue, h, &self.hatch_web_bgl1))
.collect();
self.gpu_hatches_web = webs;
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = queue;
let Some(bgl1) = &self.hatch_bgl1 else {
self.gpu_hatch = None;
return;
};
let renderable: Vec<HatchModel> =
hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect();
self.gpu_hatch = hatch_gpu::HatchGpu::build(device, bgl1, &renderable);
}
2026-03-22 14:56:27 +03:00
}
pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) {
self.gpu_wipeouts = wipeouts
.iter()
.filter(|h| h.boundary.len() >= 3)
.map(|h| WipeoutGpu::new(device, h, &self.wipeout_bgl1))
.collect();
}
pub fn upload_images(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
images: &[ImageModel],
) {
self.gpu_images = images
.iter()
.filter_map(|m| ImageGpu::new(device, queue, m, &self.image_bgl1))
.collect();
}
/// Upload the frame's SDF text-quad vertices, and (re)build the GPU glyph
/// atlas from the shared CPU atlas when it grew (new glyphs baked by the
/// text collector). `verts` empty (flag off) leaves nothing to draw.
pub fn upload_text(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
verts: &[text_gpu::TextVertex],
) {
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
if self.text_atlas_gpu.is_none() || atlas.is_dirty() {
self.text_atlas_gpu = Some(text_gpu::TextAtlasGpu::upload(
device,
queue,
&atlas,
&self.text_atlas_bgl,
));
atlas.clear_dirty();
}
}
self.text_vbuf = text_gpu::upload_vertices(device, verts);
self.text_vcount = verts.len() as u32;
}
2026-03-22 14:56:27 +03:00
pub fn upload_uniforms(&self, queue: &wgpu::Queue, uniforms: &Uniforms) {
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms));
}
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
/// Write the blit shader's UV crop uniform. Call in `prepare` (the only
/// place with a `&Queue`) — `render` then just submits the draw call.
pub fn upload_blit_uv(&self, queue: &wgpu::Queue, uv_offset: [f32; 2], uv_scale: [f32; 2]) {
// The geometry passes render at `depth_texture_size` into the top-left
// corner of the (possibly larger) `alloc_size` resolve texture, so the
// rendered image occupies UV [0, render/alloc]. Scale the incoming crop
// — computed in full-image-normalized units — down into that region.
let fx = self.depth_texture_size.width as f32 / self.alloc_size.width.max(1) as f32;
let fy = self.depth_texture_size.height as f32 / self.alloc_size.height.max(1) as f32;
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
queue.write_buffer(
&self.blit_uniform_buffer,
0,
bytemuck::cast_slice(&[
uv_offset[0] * fx,
uv_offset[1] * fy,
uv_scale[0] * fx,
uv_scale[1] * fy,
]),
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
);
}
/// Render the geometry passes at `vp_size` (the full viewport size — the
/// MSAA / resolve textures are this size) and blit the resulting resolve
/// to `surface_dest` on the swap-chain. The UV crop is read from the
/// blit uniform buffer (written by `upload_blit_uv` during `prepare`)
/// so a viewport that hangs off the canvas still composites the correct
/// sub-rectangle to the visible portion of the surface.
2026-03-22 14:56:27 +03:00
pub fn render(
&self,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
vp_size: Size<u32>,
surface_dest: Rectangle<u32>,
bg_color: [f32; 4],
mesh_wireframe: bool,
hidden_line: bool,
show_3d_edges: bool,
2026-03-22 14:56:27 +03:00
) {
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
let vp = Rectangle::<u32> {
x: 0,
y: 0,
width: vp_size.width,
height: vp_size.height,
};
let msaa = &self.msaa_view;
let [r, g, b, a] = bg_color;
2026-05-12 10:36:15 +03:00
let clear_color = wgpu::Color {
r: r as f64,
g: g as f64,
b: b as f64,
a: a as f64,
};
2026-03-22 14:56:27 +03:00
perf(render): cache rendered scene, skip re-raster on unchanged frames Every cursor movement over the viewport re-rendered the entire GPU scene (all wires, hatches, meshes) even when nothing visible changed: iced redraws the whole window on each input message, and the shader's draw/prepare/render runs every time. On large drawings one such redraw exceeds the frame budget, so moving the mouse or panning stutters — and it gets worse the bigger the drawing. The pipeline already resolves each frame into a persistent, clip-sized resolve texture and blits that to the surface. This hashes the inputs that determine the rendered image (camera uniforms, geometry/selection generations, wire content id, render-mode flags, clip size, live preview coordinates) into a per-viewport signature. When a frame's signature matches the previous one the image is pixel-identical, so `prepare` skips the buffer uploads + per-frame scissor/LOD recompute and `render` skips passes 1-7 plus the MSAA resolve, re-blitting the resolve texture instead. A pure cursor move becomes a single fullscreen blit rather than an O(N) re-rasterization. The ViewCube is excluded from the signature (it renders in its own always-on pass) so cube hover still tracks while the scene is cached. Any real change — edit (geometry_epoch), select/hover (selection_generation), camera move or render-mode toggle — differs the signature and forces a full render, so nothing goes stale. Navigation still renders every frame (the view genuinely changes); this targets the wasted re-render on cursor-only movement.
2026-07-05 08:11:35 +02:00
// Scene-render cache: when `prepare` found this frame pixel-identical
// to the last (unchanged view / geometry / selection / preview), the
// resolve texture already holds the image. Skip every geometry pass +
// the MSAA resolve and fall straight through to the blit below. This
// is what makes a pure cursor move cost one fullscreen blit instead of
// re-rasterizing the whole drawing every frame.
if !self.skip_geometry {
2026-03-22 14:56:27 +03:00
// ── Pass 1: hatch fills ────────────────────────────────────────────
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("hatch.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
2026-03-22 14:56:27 +03:00
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
// Clear MSAA to background color on the first pass.
load: wgpu::LoadOp::Clear(clear_color),
2026-03-22 14:56:27 +03:00
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
// MSAA texture is clip-bounds-sized, so viewport starts at (0, 0).
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
// Phase 4-B — single batched draw covers every hatch.
refactor(pipeline): drop legacy per-hatch path (Phase 4-B, step 5) The batched hatch pipeline has soaked through visibility + LOD; it's correct on every file tested (Floor plan.dxf, UTM site plans, the non-UTM regression case). Time to remove the parallel per-hatch plumbing that's been sitting empty since step 3: - `gpu_hatches: Vec<HatchGpu>` — never populated since `upload_hatches` switched to the batched builder; dropped. - `hatch_pixel_scissors`, `hatch_skip_flags` — same story; dropped. - `compute_hatch_scissors` — no longer has a consumer; dropped, with the render-time call removed from `Primitive::prepare`. - Hatch render pass — the dead `else if` branch over `gpu_hatches` is gone; only the single batched `pass.draw(0..vertex_count, 0..1)` remains. - `compute_hatch_lod` — no fallback path; if `gpu_hatch_batched` is `None` (empty hatch list) it just returns early. `HatchGpu` itself stays — the wipeout pipeline still uses it. So do `hatch_pipeline` / `hatch_bgl1`, kept around for that path. `#[allow(dead_code)]` on the four storage-buffer fields in `HatchBatchedGpu` — they exist only to keep the bind group's referenced resources alive; nothing reads them directly. Known gap left as a follow-up: per-hatch viewport scissor for paper- space MSPACE viewports isn't ported to the batched path yet. Hatches inside a paper-space viewport can render past the viewport border until that's added. Hasn't surfaced as a visible regression in the test files so far. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 20:18:06 +03:00
// Vertex shader culls per-instance via the `visibility`
// buffer (sub-pixel LOD + frustum cull written each frame
// by `compute_hatch_lod`). Per-hatch viewport scissor
// (paper-space MSPACE) isn't ported to the batched path
// yet — follow-up if it shows up as a visual issue.
if let (Some(batch), Some(pipeline)) =
(&self.gpu_hatch, &self.hatch_pipeline)
{
// Skipped while navigating (interaction LOD) — the per-pixel
// hatch pass dominates the GPU frame on hatch-heavy drawings.
if !self.skip_hatch_frame {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
pass.set_bind_group(1, &batch.bind_group, &[]);
pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
pass.draw(0..batch.vertex_count, 0..1);
}
2026-03-22 14:56:27 +03:00
}
// WebGL2: the batched `gpu_hatch` is always None (no storage
// buffers), so draw the texture-backed per-hatch renderer in this
// same pass (before wires, so wires stay on top). (#204)
#[cfg(target_arch = "wasm32")]
if !self.skip_hatch_frame {
pass.set_pipeline(&self.hatch_web_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for hatch in &self.gpu_hatches_web {
pass.set_bind_group(1, &hatch.bind_group, &[]);
pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..));
pass.draw(0..6, 0..1);
}
}
2026-03-22 14:56:27 +03:00
}
// ── Pass 2: raster images ─────────────────────────────────────────
if !self.gpu_images.is_empty() {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("image.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.image_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
let mut scissor_active = false;
for (i, img) in self.gpu_images.iter().enumerate() {
match self.image_pixel_scissors.get(i) {
Some(Some([x, y, w, h])) => {
pass.set_scissor_rect(*x, *y, *w, *h);
scissor_active = true;
}
_ if scissor_active => {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
scissor_active = false;
}
_ => {}
}
pass.set_bind_group(1, &img.bind_group, &[]);
pass.set_vertex_buffer(0, img.vertex_buffer.slice(..));
pass.draw(0..6, 0..1);
}
if scissor_active {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
}
}
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
// ── Pass 4: solid meshes (batched) ────────────────────────────────
if !self.gpu_mesh_batch.is_empty() {
2026-03-22 14:56:27 +03:00
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mesh.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
2026-03-22 14:56:27 +03:00
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
2026-03-22 14:56:27 +03:00
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
// Four draw paths share this pass:
// - Solid: `mesh_pipeline` + triangle index buf.
// - Wireframe: `mesh_wireframe_pipeline` + the
// pre-built `wire_index_buffer`.
// - HiddenLine: depth prepass (`mesh_depth_pipeline`,
// writes Z, no colour) → wire overlay.
// - Solid+Edges: `mesh_pipeline` shaded fill → wire
// overlay; LessEqual depth test on the
// wire pass keeps the edges crisp on
// top of the shaded surface.
let want_solid_with_edges = !hidden_line && !mesh_wireframe && show_3d_edges;
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
// Each path now binds a chunk's buffers and draws the whole chunk in
// one call — a handful of draws total instead of one per solid. No
// per-solid LOD / frustum cull in the batched path (the batch is
// resident in full); that is reintroduced separately if needed.
if hidden_line {
// Depth-only prepass: every solid surface occludes hidden edges,
// so both the opaque and the transparent tris write depth here.
pass.set_pipeline(&self.mesh_depth_pipeline);
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
for c in &self.gpu_mesh_batch {
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
if c.index_count != 0 {
pass.set_index_buffer(
c.index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..c.index_count, 0, 0..1);
}
if c.transp_index_count != 0 {
pass.set_index_buffer(
c.transp_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..c.transp_index_count, 0, 0..1);
}
}
pass.set_pipeline(&self.mesh_wireframe_pipeline);
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
for c in &self.gpu_mesh_batch {
// Plain-mesh triangulation edges.
if c.wire_index_count != 0 {
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
c.wire_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
}
// ACIS solid B-rep feature edges (LineList, non-indexed).
if c.edge_vertex_count != 0 {
pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..));
pass.draw(0..c.edge_vertex_count, 0..1);
}
}
} else {
if mesh_wireframe {
pass.set_pipeline(&self.mesh_wireframe_pipeline);
for c in &self.gpu_mesh_batch {
if c.wire_index_count != 0 {
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
c.wire_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
}
if c.edge_vertex_count != 0 {
pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..));
pass.draw(0..c.edge_vertex_count, 0..1);
}
}
} else {
// Opaque fills first (they write depth).
pass.set_pipeline(&self.mesh_pipeline);
for c in &self.gpu_mesh_batch {
if c.index_count == 0 {
continue;
}
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(c.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..c.index_count, 0, 0..1);
}
// Transparent fills last, with depth writes disabled, so they
// blend over the opaque geometry behind them instead of
// culling it via the depth buffer.
pass.set_pipeline(&self.mesh_transparent_pipeline);
for c in &self.gpu_mesh_batch {
if c.transp_index_count == 0 {
continue;
}
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
c.transp_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..c.transp_index_count, 0, 0..1);
}
2026-03-22 14:56:27 +03:00
}
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
// Selection / hover highlight: tinted copies of the picked
// solids, drawn last with the Always-depth highlight pipeline so
// the tint shows on top even when the solid is behind others.
if !self.gpu_mesh_highlight.is_empty() {
pass.set_pipeline(&self.mesh_highlight_pipeline);
for g in &self.gpu_mesh_highlight {
if g.index_count == 0 {
continue;
}
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
pass.set_vertex_buffer(0, g.vertex_buffer.slice(..));
pass.set_index_buffer(g.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..g.index_count, 0, 0..1);
}
}
// *WithEdges variants: overlay edge segments on top of the shaded
// fill in black (mesh_edge_black_pipeline). The LessEqual depth
// test keeps the edges visible over the fragments the fill wrote.
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
if want_solid_with_edges {
pass.set_pipeline(&self.mesh_edge_black_pipeline);
perf(render): batch solid meshes, fix per-pick freezes on heavy 3D models Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids) was effectively frozen. Several independent costs, all addressed here: - Face3D edge/fill buffers were re-uploaded on every camera move (keyed on camera_generation). They are world-space and selection-independent, so gate them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every wire. - Solid meshes were drawn one buffer + one draw call per solid (~10k draws/ frame), which strangled the GPU front end. Concatenate every solid's LOD0 into a few large buffers (split only to stay under the 256 MB buffer cap) and draw the whole set in a handful of calls. Built once per geometry epoch, so selection / hover never re-pack it. - Selection / hover highlight is now a small tinted overlay of just the picked solids, drawn last with an Always-depth pipeline so it shows on top even when the solid is occluded — instead of re-tinting and re-uploading all meshes. - Hover / pick hit-testing scanned every triangle of every solid each move (seconds on a heavy model). Add a 3D-AABB screen-rect broad phase (MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh set, so picking is O(solids) cheap projections plus the few solids actually under the cursor. - Block-internal hatch hit-testing exploded every INSERT on every hover. Cache the result per geometry epoch and skip blocks that contain no hatch. - Curved-surface (cone/sphere/torus) tessellation tolerance is now radius- relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and matches the circle/arc wire tessellation, instead of exploding on large radii. Also adds docs/tessellation.md mapping every EntityType to its tessellation path (truck B-rep vs truck curve topology vs direct). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:46:29 +03:00
for c in &self.gpu_mesh_batch {
if c.wire_index_count != 0 {
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
c.wire_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
}
if c.edge_vertex_count != 0 {
pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..));
pass.draw(0..c.edge_vertex_count, 0..1);
}
}
}
2026-03-22 14:56:27 +03:00
}
}
// ── Pass 5a: 3DFACE fills (3D + 2D split) ─────────────────────────
// 3D quads + PolyfaceMesh face tris go through the depth-only
// pipeline in HiddenLine so wires hidden behind them disappear.
// 2D fills (text greek, MultiLeader bg) always draw with colour.
if let Some(ref fill) = self.gpu_face3d_fill {
if fill.vertex_count_3d > 0 || fill.vertex_count_2d > 0 {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("face3d.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
if fill.vertex_count_3d > 0 {
if hidden_line {
pass.set_pipeline(&self.face3d_depth_pipeline);
} else {
pass.set_pipeline(&self.face3d_pipeline);
}
pass.set_vertex_buffer(0, fill.vertex_buffer_3d.slice(..));
pass.draw(0..fill.vertex_count_3d, 0..1);
}
if fill.vertex_count_2d > 0 {
pass.set_pipeline(&self.face3d_pipeline);
pass.set_vertex_buffer(0, fill.vertex_buffer_2d.slice(..));
pass.draw(0..fill.vertex_count_2d, 0..1);
}
}
}
// ── Pass 5b: 3DFACE edges (batched, possibly multiple chunks) ────
// FlatShaded / GouraudShaded hide the 3DFACE outline (the user
// chose a clean shaded look); every other mode keeps it.
if show_3d_edges && !self.gpu_face3d_edges.is_empty() {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("face3d_edges.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.wire_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for edges in &self.gpu_face3d_edges {
if edges.instance_count > 0 {
if let Some(bg) = &edges.const_bind_group {
pass.set_bind_group(1, bg.as_ref(), &[]);
}
pass.set_vertex_buffer(0, edges.instance_buffer.slice(..));
pass.draw(0..6, 0..edges.instance_count);
}
}
}
// ── Pass 5: wires ─────────────────────────────────────────────────
2026-03-22 14:56:27 +03:00
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("wire.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
2026-03-22 14:56:27 +03:00
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
2026-03-22 14:56:27 +03:00
pass.set_pipeline(&self.wire_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
// In a filled-with-edges mode the mesh outline edges frame the shaded
// fill and should read black; wireframe / hidden-line keep the entity
// colour. `wire_black_pipeline` shares the wire layout, so the switch
// needs no bind-group rebind.
let want_solid_with_edges = !hidden_line && !mesh_wireframe && show_3d_edges;
let mut scissor_active = false;
let mut black_active = false;
for (i, wire) in self.gpu_wires.iter().enumerate() {
if wire.instance_count == 0 {
continue;
2026-03-22 14:56:27 +03:00
}
// PolyfaceMesh / PolygonMesh outline edges live in
// `gpu_wires` (their `WireModel` has both `points` and
// `fill_tris`). In FlatShaded / GouraudShaded the user
// wants a clean shaded surface, so the wire pass skips
// these instances; the *WithEdges and pure wireframe
// modes leave the flag at true and draw them.
if !show_3d_edges && wire.is_3d_mesh_edge {
continue;
}
let use_black = want_solid_with_edges && wire.is_3d_mesh_edge;
if use_black != black_active {
pass.set_pipeline(if use_black {
&self.wire_black_pipeline
} else {
&self.wire_pipeline
});
black_active = use_black;
}
match self.wire_pixel_scissors.get(i) {
Some(Some([x, y, w, h])) => {
pass.set_scissor_rect(*x, *y, *w, *h);
scissor_active = true;
}
_ if scissor_active => {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
scissor_active = false;
}
_ => {}
}
if let Some(bg) = &wire.const_bind_group {
pass.set_bind_group(1, bg.as_ref(), &[]);
}
pass.set_vertex_buffer(0, wire.instance_buffer.slice(..));
pass.draw(0..6, 0..wire.instance_count);
}
if scissor_active {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
2026-03-22 14:56:27 +03:00
}
// Live overlay wires (command preview / interim / grip drag) always
// on top: the xray pipeline (depth_compare=Always, no depth write)
// keeps them visible through any occluding geometry — a 3D solid, or
// 2D geometry drawn in front — so a command preview is never hidden.
// No scissor.
if self.gpu_preview_wires.iter().any(|pw| pw.instance_count > 0) {
pass.set_pipeline(&self.wire_xray_pipeline);
for pw in &self.gpu_preview_wires {
if pw.instance_count > 0 {
if let Some(bg) = &pw.const_bind_group {
pass.set_bind_group(1, bg.as_ref(), &[]);
}
pass.set_vertex_buffer(0, pw.instance_buffer.slice(..));
pass.draw(0..6, 0..pw.instance_count);
}
}
}
2026-03-22 14:56:27 +03:00
}
// ── Pass 5c: SDF text quads (drawn over wires) ────────────────────
// The highlight buffer (selected / hovered text, tinted) is drawn in
// the same pass right after the base text so it recolours those glyphs.
if let Some(atlas) = &self.text_atlas_gpu {
let have_base = self.text_vbuf.is_some() && self.text_vcount > 0;
let have_hl = self.text_highlight_vbuf.is_some() && self.text_highlight_vcount > 0;
let have_preview =
self.text_preview_vbuf.is_some() && self.text_preview_vcount > 0;
if have_base || have_hl || have_preview {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("text.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.text_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
pass.set_bind_group(1, &atlas.bind_group, &[]);
if let Some(vbuf) = &self.text_vbuf {
if self.text_vcount > 0 {
pass.set_vertex_buffer(0, vbuf.slice(..));
pass.draw(0..self.text_vcount, 0..1);
}
}
if let Some(hlbuf) = &self.text_highlight_vbuf {
if self.text_highlight_vcount > 0 {
pass.set_vertex_buffer(0, hlbuf.slice(..));
pass.draw(0..self.text_highlight_vcount, 0..1);
}
}
// Grip-drag / command-preview glyphs, drawn over the base text.
if let Some(pbuf) = &self.text_preview_vbuf {
if self.text_preview_vcount > 0 {
pass.set_vertex_buffer(0, pbuf.slice(..));
pass.draw(0..self.text_preview_vcount, 0..1);
}
}
}
}
// ── Pass 6: wipeout fills (drawn after wires to mask them) ────────
if !self.gpu_wipeouts.is_empty() {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("wipeout.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.wipeout_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
let mut scissor_active = false;
for (i, wipeout) in self.gpu_wipeouts.iter().enumerate() {
if self.wipeout_skip_flags.get(i).copied().unwrap_or(false) {
continue;
}
match self.wipeout_pixel_scissors.get(i) {
Some(Some([x, y, w, h])) => {
pass.set_scissor_rect(*x, *y, *w, *h);
scissor_active = true;
}
_ if scissor_active => {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
scissor_active = false;
}
_ => {}
}
pass.set_bind_group(1, &wipeout.bind_group, &[]);
pass.set_vertex_buffer(0, wipeout.vertex_buffer.slice(..));
pass.draw(0..6, 0..1);
}
if scissor_active {
pass.set_scissor_rect(0, 0, vp.width, vp.height);
}
}
// ── Pass 7: selected wire overlay pass ───────────────────────────
// Redraws selected wires with depth_compare=Always so they appear on
// top of all other geometry at full brightness.
if !self.gpu_selected_wires.is_empty() {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("wire_xray.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
pass.set_pipeline(&self.wire_xray_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for wire in &self.gpu_selected_wires {
if wire.instance_count > 0 {
if let Some(bg) = &wire.const_bind_group {
pass.set_bind_group(1, bg.as_ref(), &[]);
}
pass.set_vertex_buffer(0, wire.instance_buffer.slice(..));
pass.draw(0..6, 0..wire.instance_count);
}
}
}
// ── Resolve MSAA → resolve texture ────────────────────────────────
// Both are the same (rounded `alloc_size`) offscreen texture, so the
// resolve never touches the surface. Only the drawn [0, render_size]
// corner holds content; the rounded border stays the cleared bg color
// and is never sampled (the blit UV is scaled to render/alloc).
{
let _resolve = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("msaa.resolve_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: msaa,
depth_slice: None,
resolve_target: Some(&self.resolve_view),
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Discard,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
// No draw calls — the pass itself triggers the MSAA resolve.
}
perf(render): cache rendered scene, skip re-raster on unchanged frames Every cursor movement over the viewport re-rendered the entire GPU scene (all wires, hatches, meshes) even when nothing visible changed: iced redraws the whole window on each input message, and the shader's draw/prepare/render runs every time. On large drawings one such redraw exceeds the frame budget, so moving the mouse or panning stutters — and it gets worse the bigger the drawing. The pipeline already resolves each frame into a persistent, clip-sized resolve texture and blits that to the surface. This hashes the inputs that determine the rendered image (camera uniforms, geometry/selection generations, wire content id, render-mode flags, clip size, live preview coordinates) into a per-viewport signature. When a frame's signature matches the previous one the image is pixel-identical, so `prepare` skips the buffer uploads + per-frame scissor/LOD recompute and `render` skips passes 1-7 plus the MSAA resolve, re-blitting the resolve texture instead. A pure cursor move becomes a single fullscreen blit rather than an O(N) re-rasterization. The ViewCube is excluded from the signature (it renders in its own always-on pass) so cube hover still tracks while the scene is cached. Any real change — edit (geometry_epoch), select/hover (selection_generation), camera move or render-mode toggle — differs the signature and forces a full render, so nothing goes stale. Navigation still renders every frame (the view genuinely changes); this targets the wasted re-render on cursor-only movement.
2026-07-05 08:11:35 +02:00
} // end `if !self.skip_geometry`
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
// ── Blit resolve texture → surface target at surface_dest position ──
// The viewport maps the NDC quad to exactly `surface_dest` in the
// swap-chain; `uv_offset` + `uv_scale` (passed through the blit
// uniform) crop the resolve so we sample only the visible portion
// of the full viewport's MSAA texture.
if surface_dest.width > 0 && surface_dest.height > 0 {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("blit.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
surface_dest.x as f32,
surface_dest.y as f32,
surface_dest.width as f32,
surface_dest.height as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.blit_pipeline);
pass.set_bind_group(0, &self.blit_bind_group, &[]);
pass.draw(0..6, 0..1);
}
2026-03-22 14:56:27 +03:00
}
pub fn ensure_depth_texture(&mut self, device: &wgpu::Device, size: Size<u32>) {
// Record the requested render size every frame (the blit UV scale reads
// it); only reallocate the textures when the *rounded* size changes.
self.depth_texture_size = size;
let alloc = Size::new(round_up_tex(size.width), round_up_tex(size.height));
if self.alloc_size != alloc {
let size = alloc;
let depth_tex = create_depth_texture(device, size);
self.depth_view = depth_tex.create_view(&wgpu::TextureViewDescriptor::default());
let msaa_tex = create_msaa_texture(device, size, self.surface_format);
self.msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default());
let resolve_tex = create_resolve_texture(device, size, self.surface_format);
let resolve_view = resolve_tex.create_view(&wgpu::TextureViewDescriptor::default());
self.blit_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("blit.bind_group"),
layout: &self.blit_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&resolve_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.blit_sampler),
},
fix(viewport): blit UV crop for off-canvas viewports Refactor the per-viewport blit so a content viewport whose screen rect extends off the canvas composites the correct slice of its MSAA target to the visible portion of the surface, instead of being slammed against the canvas corner by an `(f32 as u32)` saturation on the negative offset. - blit.wgsl + Pipeline gain a small uniform buffer (`uv_offset` / `uv_scale`). `Primitive::prepare` computes the per-viewport crop from `screen_rect`'s on-canvas fraction and writes it via the new `upload_blit_uv` helper while it still has the `Queue`. - `Pipeline::render` now takes `vp_size` (full vp — MSAA / camera size) and `surface_dest` (visible intersection — blit destination) and reads the crop from the uniform; passes that don't have a non-empty surface intersection are skipped. - `Primitive::render` clips the full vp rect against the surface clip in `i32` and skips empty intersections. The earlier "skip GPU for inactive off-canvas viewports" workaround in `active_viewports` is no longer needed. - ViewCube draws only when its hosting vp is fully on-canvas (its own pipeline writes directly to the surface and would distort under a clamped `set_viewport`). With the GPU path correct for every vp position, drop `viewport_content_wires` from `paper_canvas_wires` — the inactive viewport CPU projection was only there to mask the blit bug and was duplicating the unified shader's output. The hit-test / snap path (`entity_wires_arc`, `wires_for_block`) still calls `viewport_content_wires` directly, so picking through inactive viewports keeps working. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:15:00 +03:00
wgpu::BindGroupEntry {
binding: 2,
resource: self.blit_uniform_buffer.as_entire_binding(),
},
],
});
self.resolve_view = resolve_view;
self.alloc_size = alloc;
2026-03-22 14:56:27 +03:00
}
}
}
/// Round a texture dimension up to a coarse grid so a live divider drag
/// doesn't recreate the depth / MSAA / resolve textures on every frame. See
/// `alloc_size` — this is the mitigation for the Windows-Firefox freeze (#191).
fn round_up_tex(n: u32) -> u32 {
const GRID: u32 = 128;
((n.max(1) + GRID - 1) / GRID) * GRID
}
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
/// Project a mesh's `world_aabb` and pick a LOD slot:
/// diagonal > 200 px → 0 (HIGH)
/// diagonal 50–200 px → 1 (MID)
/// diagonal < 50 px → 2 (LOW)
/// When the picked slot is missing from `gpu_meshes[i].lods`, walks down
/// to the next available coarser/finer level so the mesh always renders.
fn pick_mesh_lod(
mesh: &MeshLodGpu,
view_rot: glam::Mat4,
eye: glam::DVec3,
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
clip_w: u32,
clip_h: u32,
) -> usize {
let diag_px = aabb_diagonal_pixels(mesh.world_aabb, view_rot, eye, clip_w, clip_h);
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
let target = if diag_px > 200.0 {
0
} else if diag_px > 50.0 {
1
} else {
2
};
// Walk down to nearest available LOD (some entities won't have all 3).
for level in (0..=target).rev() {
if mesh.lods.get(level).is_some() {
return level;
}
}
0
}
fn aabb_diagonal_pixels(
aabb: [f32; 4],
view_rot: glam::Mat4,
eye: glam::DVec3,
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
clip_w: u32,
clip_h: u32,
) -> f32 {
let [x0, y0, x1, y1] = aabb;
if !x0.is_finite() || !y0.is_finite() || !x1.is_finite() || !y1.is_finite() {
return f32::INFINITY;
}
let w = clip_w as f32;
let h = clip_h as f32;
let corners = [
view_rot.project_point3((glam::DVec3::new(x0 as f64, y0 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x1 as f64, y0 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x0 as f64, y1 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x1 as f64, y1 as f64, 0.0) - eye).as_vec3()),
perf(scene): multi-resolution mesh LOD (Phase 3.4) ACIS SAT entities (Solid3D / Region / Body) were tessellated at one fixed sampling density (CIRC_SEGS=48, GRID_U=32, GRID_V=16) regardless of how far they were from the camera. Distant solids paid the full triangle bill for fragments that mostly resolved to sub-pixel size. Generate three pre-built LODs per entity at load time: * LOD 0 (HIGH) — 48/32/16 segments. Use above 200 px projected diag. * LOD 1 (MID) — 24/16/8. Use 50–200 px. * LOD 2 (LOW) — 12/8/4. Use below 50 px. Tessellation cost is paid 3× on load (cheap — typical files have few solids). Render picks per-frame via `compute_mesh_lod` from each mesh's `world_aabb` projected diagonal. When a level is missing (e.g. a solid entity that only emits LOD 0), the selector walks down to the nearest available slot. API ripples: * New `LodConfig` (HIGH/MID/LOW) drives `tess_sat`, `tess_cone_face`, `tess_sphere_face`, `tess_torus_face`. * `tessellate_solid3d / _region / _body` now return `MeshLodSet`. * `MeshLodSet { lods: Vec<MeshModel>, world_aabb }` + `from_single` constructor so interactive truck-based commands (BOX/CYLINDER/etc.) that only generate one tessellation stay one-line. * GPU side gets `MeshLodGpu { lods: Vec<MeshGpu>, world_aabb }` and a `mesh_lod_levels` per-frame index vector. * STL/STEP export pulls slot 0 explicitly so the on-disk geometry isn't downgraded by the view-dependent ladder. Background-thread lazy generation (per the roadmap) is deferred — the synchronous eager build is fine for typical CAD files. Switch to a queue + background tessellator when files with hundreds of solids become a load-time bottleneck. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:30:13 +03:00
];
let mut min_px = f32::INFINITY;
let mut max_px = f32::NEG_INFINITY;
let mut min_py = f32::INFINITY;
let mut max_py = f32::NEG_INFINITY;
for c in &corners {
let px = (c.x + 1.0) * 0.5 * w;
let py = (1.0 - c.y) * 0.5 * h;
if px < min_px { min_px = px; }
if px > max_px { max_px = px; }
if py < min_py { min_py = py; }
if py > max_py { max_py = py; }
}
let dx = max_px - min_px;
let dy = max_py - min_py;
(dx * dx + dy * dy).sqrt()
}
/// `true` when the world-XY AABB projects entirely outside the
/// viewport rect (extended by `MARGIN_FRAC` to absorb pan inertia and
/// avoid edge pop-in). Phase 2.2 mesh-frustum / Phase 2.3 hatch +
/// wipeout cull. Equivalent to a 2D bounding-box rejection test in
/// NDC; uses the same 4-corner projection that LOD picking already
/// does, so the extra cost is negligible.
///
/// IMPORTANT: the AABB must be in the same local space (world_offset
/// subtracted) that `view_proj` expects. `WipeoutGpu.world_aabb` rebuilds
/// the absolute local-space rect from `model.world_origin + boundary
/// extents` for this reason; meshes already store an absolute rect.
fn aabb_offscreen(
aabb: [f32; 4],
view_rot: glam::Mat4,
eye: glam::DVec3,
clip_w: u32,
clip_h: u32,
) -> bool {
let [x0, y0, x1, y1] = aabb;
if !x0.is_finite() || !y0.is_finite() || !x1.is_finite() || !y1.is_finite() {
return false;
}
let w = clip_w as f32;
let h = clip_h as f32;
let corners = [
view_rot.project_point3((glam::DVec3::new(x0 as f64, y0 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x1 as f64, y0 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x0 as f64, y1 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x1 as f64, y1 as f64, 0.0) - eye).as_vec3()),
];
let mut min_px = f32::INFINITY;
let mut max_px = f32::NEG_INFINITY;
let mut min_py = f32::INFINITY;
let mut max_py = f32::NEG_INFINITY;
for c in &corners {
let px = (c.x + 1.0) * 0.5 * w;
let py = (1.0 - c.y) * 0.5 * h;
if px < min_px { min_px = px; }
if px > max_px { max_px = px; }
if py < min_py { min_py = py; }
if py > max_py { max_py = py; }
}
// 25% pad on each side — matches `view_world_aabb` (wire path),
// keeps edge geometry rendered while panning before the next
// upload reaches the GPU.
const MARGIN_FRAC: f32 = 0.25;
let mx = w * MARGIN_FRAC;
let my = h * MARGIN_FRAC;
max_px < -mx || min_px > w + mx || max_py < -my || min_py > h + my
}
/// Return `true` when the world-XY AABB's screen-space size is below the
/// given pixel threshold. Used by LOD passes (hatch skip, etc.) to drop
/// draw calls that wouldn't contribute a visible pixel.
fn aabb_below_pixel(
aabb: [f32; 4],
view_rot: glam::Mat4,
eye: glam::DVec3,
clip_w: u32,
clip_h: u32,
threshold_px: f32,
) -> bool {
let [x0, y0, x1, y1] = aabb;
if !x0.is_finite() || !y0.is_finite() || !x1.is_finite() || !y1.is_finite() {
return false;
}
let w = clip_w as f32;
let h = clip_h as f32;
let corners = [
view_rot.project_point3((glam::DVec3::new(x0 as f64, y0 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x1 as f64, y0 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x0 as f64, y1 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x1 as f64, y1 as f64, 0.0) - eye).as_vec3()),
];
let mut min_px = f32::INFINITY;
let mut max_px = f32::NEG_INFINITY;
let mut min_py = f32::INFINITY;
let mut max_py = f32::NEG_INFINITY;
for c in &corners {
let px = (c.x + 1.0) * 0.5 * w;
let py = (1.0 - c.y) * 0.5 * h;
if px < min_px { min_px = px; }
if px > max_px { max_px = px; }
if py < min_py { min_py = py; }
if py > max_py { max_py = py; }
}
(max_px - min_px).max(max_py - min_py) < threshold_px
}
/// Project a world-space XY scissor rect through `view_proj` into the four
/// pixel-space corners and return the smallest aligned-rect that bounds them,
/// clamped to the clip viewport. Returns `None` when the rect is missing or
/// the projection collapses (off-screen, behind camera).
fn project_scissor(
rect: Option<[f32; 4]>,
view_rot: glam::Mat4,
eye: glam::DVec3,
clip_w: u32,
clip_h: u32,
) -> Option<[u32; 4]> {
let [x0, y0, x1, y1] = rect?;
let w = clip_w as f32;
let h = clip_h as f32;
let corners = [
view_rot.project_point3((glam::DVec3::new(x0 as f64, y0 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x1 as f64, y0 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x0 as f64, y1 as f64, 0.0) - eye).as_vec3()),
view_rot.project_point3((glam::DVec3::new(x1 as f64, y1 as f64, 0.0) - eye).as_vec3()),
];
let px: Vec<f32> = corners.iter().map(|c| (c.x + 1.0) * 0.5 * w).collect();
let py: Vec<f32> = corners.iter().map(|c| (1.0 - c.y) * 0.5 * h).collect();
let sx0 = px.iter().cloned().fold(f32::INFINITY, f32::min).max(0.0) as u32;
let sy0 = py.iter().cloned().fold(f32::INFINITY, f32::min).max(0.0) as u32;
let sx1 = (px.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as u32).min(clip_w);
let sy1 = (py.iter().cloned().fold(f32::NEG_INFINITY, f32::max) as u32).min(clip_h);
if sx1 <= sx0 || sy1 <= sy0 {
return None;
}
Some([sx0, sy0, sx1 - sx0, sy1 - sy0])
}
2026-03-22 14:56:27 +03:00
fn create_depth_texture(device: &wgpu::Device, size: Size<u32>) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer.depth_texture"),
size: wgpu::Extent3d {
width: size.width.max(1),
height: size.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: MSAA_SAMPLES,
2026-03-22 14:56:27 +03:00
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
}
fn create_resolve_texture(
device: &wgpu::Device,
size: Size<u32>,
format: wgpu::TextureFormat,
) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer.resolve_texture"),
size: wgpu::Extent3d {
width: size.width.max(1),
height: size.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
}
fn create_msaa_texture(
device: &wgpu::Device,
size: Size<u32>,
format: wgpu::TextureFormat,
) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer.msaa_texture"),
size: wgpu::Extent3d {
width: size.width.max(1),
height: size.height.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
}
/// Holds one inner `Pipeline` per viewport drawn this frame. A single
/// shader widget owns one `MultiPipeline`; the unified renderer grows the
/// `inners` vector to match the viewport count and draws each into its own
/// screen rectangle. Inner `Pipeline` code (upload / LOD / render / blit)
/// is unchanged — it just runs once per viewport.
pub struct MultiPipeline {
pub(crate) inners: Vec<Pipeline>,
format: wgpu::TextureFormat,
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
/// The resident wire batches, keyed by `wire_content_id` and shared across
/// every slot (and every pane — one `MultiPipeline` backs all of them) that
/// renders the same content. `prepare` builds an entry once on a cache miss
/// then hands `Arc` clones to each slot, so N paper viewports / Model tiles
/// showing one identical resident set upload the wire vertex buffers exactly
/// once between them instead of once per slot. Kept trim by dropping entries
/// no slot still references (`Arc::strong_count == 1`) once it grows past a
/// small bound.
pub(crate) wire_buffer_cache: rustc_hash::FxHashMap<
u64,
(
std::sync::Arc<Vec<WireGpu>>,
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
),
>,
}
impl MultiPipeline {
/// Ensure at least `n` (≥1) inner pipelines exist, creating any missing
/// ones. Grow-only: extra pipelines are NOT dropped, because per-pane Model
/// shader widgets share this storage and each only touches its own slot —
/// truncating mid-frame would destroy another pane's slot. Stale inners (a
/// closed pane, or fewer paper viewports) are harmless idle GPU resources.
pub(crate) fn ensure_len(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
n: usize,
) {
let n = n.max(1);
while self.inners.len() < n {
self.inners.push(Pipeline::new(device, queue, self.format));
}
}
}
impl iced::widget::shader::Pipeline for MultiPipeline {
2026-03-22 14:56:27 +03:00
fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self {
Self {
inners: vec![Pipeline::new(device, queue, format)],
format,
perf(render): share one GPU wire buffer across slots by content id Each pipeline slot owned its own gpu_wires: every paper viewport and every Model tile rebuilt an identical resident wire set into its own GPU vertex buffers and its own handle index. With N content viewports drawing one shared tessellation that is N copies of the (potentially huge) wire buffers in VRAM plus N batch+index builds on every content change — the issue-131 memory exposure multiplied by the viewport count. A wgpu::Buffer is internally ref-counted, so sharing a WireGpu by Arc shares its GPU allocation. Make Pipeline.gpu_wires an Arc<Vec<WireGpu>> (and wire_handle_index an Arc<map>), turn upload_wires into build_wire_buffers(&self) that returns those Arcs, and add MultiPipeline.wire_buffer_cache keyed by wire_content_id. prepare() builds an entry once on a cache miss and hands Arc clones to every slot with that id — so N viewports (and, since one MultiPipeline backs all panes, N panes) upload the wire vertices exactly once between them. Camera uniforms and scissor stay per-slot, so the draw still issues one call per viewport with the right view; only the geometry is deduplicated. The shared Vec is never mutated in place — only reassigned on a content change — so the sharing is sound. The cache drops entries no slot still references (Arc::strong_count == 1) once it passes a small bound, so a live buffer (held by any pane) is never evicted. Model multi-tile gets the same dedup for free, and two panes showing the same model now share buffers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:19:18 +03:00
wire_buffer_cache: rustc_hash::FxHashMap::default(),
}
2026-03-22 14:56:27 +03:00
}
}