2026-04-28 21:53:31 +03:00
|
|
|
|
pub mod face3d_gpu;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub mod hatch_gpu;
|
feat(web): render hatches on WebGL2 via a texture-backed renderer
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).
Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.
Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".
Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.
Closes #204.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:51 +03:00
|
|
|
|
/// 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;
|
2026-07-09 10:45:40 +03:00
|
|
|
|
pub mod wipeout_gpu;
|
2026-04-06 23:19:08 +03:00
|
|
|
|
pub mod image_gpu;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub mod mesh_gpu;
|
feat(text): SDF glyph-atlas foundation for shader text rendering
Groundwork to render CAD text as per-glyph SDF quads (box-quad snap,
perf, no LOD) instead of stroke-polyline wires. CPU side fully tested;
GPU side compiles but is not yet wired into the render loop.
- scene::text::sdf_atlas — GlyphAtlas: shelf packer + R8 SDF tiles baked
per (font, char). Unified field so one shader draws both kinds: TrueType
= signed distance to the filled outline, LFF stroke = pen band. Uses only
crates already in the tree (ttf-parser/lyon via the existing Glyph).
- scene::text::glyph_quads — layout_glyph_quads: lays a run out as per-glyph
quads using the exact tessellate_text_run transform, so quads land where
the strokes would. Per-char advance (TTF shaping is a follow-up).
- shaders/text.wgsl — SDF fragment shader (smoothstep over fwidth), RTE
vertex path mirroring image.wgsl, per-vertex colour + draw-order bias.
- scene::pipeline::text_gpu — TextVertex + double-single vertex builder,
R8 atlas texture upload, bind group, and the image-cloned pipeline.
13 unit tests across the three CPU stages, all green. Modules carry
#![allow(dead_code)] until the render-loop integration lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:03:36 +03:00
|
|
|
|
pub mod text_gpu;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub mod uniforms;
|
|
|
|
|
|
pub mod viewcube;
|
2026-07-15 06:02:25 +03:00
|
|
|
|
/// Persistent per-entity wire instance arena (native), enabled by
|
|
|
|
|
|
/// `OCS_WIRE_GPU_PATCH` — patches one entity's slab via `write_buffer` instead
|
|
|
|
|
|
/// of re-uploading the whole wire set on an edit.
|
|
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
|
|
|
|
pub mod wire_arena;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub mod wire_gpu;
|
|
|
|
|
|
|
|
|
|
|
|
use iced::wgpu;
|
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};
|
|
|
|
|
|
|
2026-04-28 21:53:31 +03:00
|
|
|
|
pub use face3d_gpu::Face3DGpu;
|
2026-07-09 10:45:40 +03:00
|
|
|
|
pub use wipeout_gpu::WipeoutGpu;
|
2026-04-06 23:19:08 +03:00
|
|
|
|
pub use image_gpu::ImageGpu;
|
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;
|
|
|
|
|
|
|
2026-06-16 11:33:46 +03:00
|
|
|
|
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
|
|
|
|
|
2026-04-08 00:52:38 +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,
|
2026-07-04 18:28:27 +03:00
|
|
|
|
/// Black-fragment variant of `wire_pipeline` for 3D mesh outline edges in
|
|
|
|
|
|
/// filled render modes.
|
|
|
|
|
|
wire_black_pipeline: wgpu::RenderPipeline,
|
2026-04-27 03:01:42 +03:00
|
|
|
|
/// 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,
|
2026-06-26 10:39:23 +03:00
|
|
|
|
/// 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.
|
2026-07-15 06:02:25 +03:00
|
|
|
|
pub(crate) wire_const_bgl: Option<wgpu::BindGroupLayout>,
|
2026-07-09 10:45:40 +03:00
|
|
|
|
wipeout_pipeline: wgpu::RenderPipeline,
|
2026-05-17 19:52:28 +03:00
|
|
|
|
/// Phase 4-B — single-draw batched hatch pipeline. Per-instance
|
|
|
|
|
|
/// data lives in storage buffers; one draw call covers every
|
2026-06-16 17:53:56 +03:00
|
|
|
|
/// hatch in the frame. `None` on WebGL2 (wasm), which lacks
|
|
|
|
|
|
/// vertex-stage storage buffers.
|
2026-07-09 10:45:40 +03:00
|
|
|
|
hatch_pipeline: Option<wgpu::RenderPipeline>,
|
feat(web): render hatches on WebGL2 via a texture-backed renderer
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).
Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.
Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".
Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.
Closes #204.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:51 +03:00
|
|
|
|
/// 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,
|
2026-04-06 23:19:08 +03:00
|
|
|
|
image_pipeline: wgpu::RenderPipeline,
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
/// 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,
|
2026-07-04 16:27:59 +03:00
|
|
|
|
/// Depth-write-disabled variant of `mesh_pipeline` for non-opaque solids.
|
|
|
|
|
|
mesh_transparent_pipeline: wgpu::RenderPipeline,
|
2026-06-30 22:46:29 +03:00
|
|
|
|
mesh_highlight_pipeline: wgpu::RenderPipeline,
|
2026-05-27 05:28:20 +03:00
|
|
|
|
/// 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,
|
2026-07-04 17:57:00 +03:00
|
|
|
|
/// Edge pipeline that forces black, for the edge overlay in filled modes.
|
|
|
|
|
|
mesh_edge_black_pipeline: wgpu::RenderPipeline,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
/// 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,
|
2026-04-28 21:53:31 +03:00
|
|
|
|
face3d_pipeline: wgpu::RenderPipeline,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
/// 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,
|
2026-07-09 10:45:40 +03:00
|
|
|
|
wipeout_bgl1: wgpu::BindGroupLayout,
|
2026-05-17 19:52:28 +03:00
|
|
|
|
/// Group-1 layout for the batched hatch pipeline (storage buffers
|
feat(web): render hatches on WebGL2 via a texture-backed renderer
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).
Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.
Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".
Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.
Closes #204.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:51 +03:00
|
|
|
|
/// 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))]
|
2026-07-09 10:45:40 +03:00
|
|
|
|
hatch_bgl1: Option<wgpu::BindGroupLayout>,
|
2026-04-06 23:19:08 +03:00
|
|
|
|
image_bgl1: wgpu::BindGroupLayout,
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
/// 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,
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
/// 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,
|
2026-07-10 10:39:44 +03:00
|
|
|
|
/// 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,
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
/// Per-frame DISPSILH silhouette line list — rebuilt every prepare() from
|
|
|
|
|
|
/// the mesh sets' curved-face generators and the current view direction, so
|
|
|
|
|
|
/// the outline tracks the camera. Reuses the mesh vertex format / pipeline.
|
|
|
|
|
|
silhouette_vbuf: Option<wgpu::Buffer>,
|
|
|
|
|
|
silhouette_vcount: u32,
|
2026-07-08 18:20:27 +03:00
|
|
|
|
/// 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>,
|
2026-07-08 18:20:27 +03:00
|
|
|
|
/// 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,
|
2026-04-08 00:52:38 +03:00
|
|
|
|
/// 4× MSAA color buffer for the main drawing passes.
|
|
|
|
|
|
msaa_view: wgpu::TextureView,
|
2026-04-08 12:20:47 +03:00
|
|
|
|
/// 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,
|
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,
|
2026-04-08 00:52:38 +03:00
|
|
|
|
/// Cached texture format (needed to recreate MSAA / depth textures on resize).
|
|
|
|
|
|
surface_format: wgpu::TextureFormat,
|
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>>,
|
2026-07-15 06:02:25 +03:00
|
|
|
|
/// Persistent per-entity wire instance arena (native, `OCS_WIRE_GPU_PATCH`).
|
|
|
|
|
|
/// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an
|
|
|
|
|
|
/// edit patches one entity's slab in place instead of rebuilding every wire.
|
|
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
|
|
|
|
pub(crate) wire_arena: Option<wire_arena::WireArena>,
|
2026-07-15 09:48:20 +03:00
|
|
|
|
/// Second arena for the mesh/solid EDGE wires (drawn with the mesh-edge skip /
|
|
|
|
|
|
/// black treatment); the resident set is split into this + `wire_arena` so
|
|
|
|
|
|
/// both patch incrementally. Shares `wire_arena_id`.
|
|
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
|
|
|
|
pub(crate) wire_arena_mesh: Option<wire_arena::WireArena>,
|
|
|
|
|
|
/// The Model content id both arenas currently mirror (`u64::MAX` = none).
|
2026-07-15 06:02:25 +03:00
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
|
|
|
|
pub(crate) wire_arena_id: u64,
|
2026-05-06 07:12:05 +03:00
|
|
|
|
/// Pixel scissor rects [x, y, w, h] for viewport-clipped wires. Recomputed each frame.
|
|
|
|
|
|
wire_pixel_scissors: Vec<Option<[u32; 4]>>,
|
2026-04-27 03:01:42 +03:00
|
|
|
|
/// Ghost copies (25% alpha) of selected wires for the X-ray depth pass.
|
|
|
|
|
|
gpu_selected_wires: Vec<WireGpu>,
|
2026-06-05 10:57:38 +03:00
|
|
|
|
/// 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>,
|
2026-07-09 10:45:40 +03:00
|
|
|
|
/// 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>,
|
feat(web): render hatches on WebGL2 via a texture-backed renderer
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).
Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.
Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".
Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.
Closes #204.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:51 +03:00
|
|
|
|
/// 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>,
|
2026-07-09 10:45:40 +03:00
|
|
|
|
/// Wipeout masks — solid fills rendered after wires in a separate pass via
|
|
|
|
|
|
/// the legacy per-primitive `WipeoutGpu` renderer.
|
|
|
|
|
|
gpu_wipeouts: Vec<WipeoutGpu>,
|
2026-05-17 15:02:07 +03:00
|
|
|
|
/// 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>,
|
2026-05-15 23:13:16 +03:00
|
|
|
|
/// Pixel scissor rects [x, y, w, h] for viewport-clipped wipeouts. Recomputed each frame.
|
|
|
|
|
|
wipeout_pixel_scissors: Vec<Option<[u32; 4]>>,
|
2026-04-06 23:19:08 +03:00
|
|
|
|
gpu_images: Vec<ImageGpu>,
|
2026-05-15 23:13:16 +03:00
|
|
|
|
/// Pixel scissor rects [x, y, w, h] for viewport-clipped images. Recomputed each frame.
|
|
|
|
|
|
image_pixel_scissors: Vec<Option<[u32; 4]>>,
|
2026-05-15 23:30:13 +03:00
|
|
|
|
gpu_meshes: Vec<MeshLodGpu>,
|
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),
|
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>,
|
2026-05-17 14:54:17 +03:00
|
|
|
|
/// 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>,
|
2026-04-28 21:53:31 +03:00
|
|
|
|
/// Batched 3DFACE fill (all faces in one buffer) and edges (merged wire).
|
|
|
|
|
|
gpu_face3d_fill: Option<Face3DGpu>,
|
2026-04-29 20:58:44 +03:00
|
|
|
|
gpu_face3d_edges: Vec<WireGpu>,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub viewcube: ViewCubePipeline,
|
2026-05-14 13:52:04 +03:00
|
|
|
|
/// 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.
|
2026-06-09 22:47:48 +03:00
|
|
|
|
/// `(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),
|
2026-06-05 09:16:44 +03:00
|
|
|
|
/// 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,
|
2026-06-05 10:04:22 +03:00
|
|
|
|
/// `(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),
|
2026-06-19 09:33:53 +03:00
|
|
|
|
/// `(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.
|
2026-06-30 22:46:29 +03:00
|
|
|
|
#[allow(dead_code)] // per-mesh LOD path, bypassed by the batched mesh draw
|
2026-06-19 09:33:53 +03:00
|
|
|
|
pub cached_mesh_key: (u64, u64),
|
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),
|
2026-06-10 21:01:12 +03:00
|
|
|
|
/// 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
|
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,
|
2026-07-05 09:48:49 +02:00
|
|
|
|
/// 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,
|
2026-07-13 22:38:13 +03:00
|
|
|
|
/// 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 ──────────────────────────────────────────────────
|
2026-06-26 10:39:23 +03:00
|
|
|
|
// 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"),
|
2026-06-26 10:39:23 +03:00
|
|
|
|
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"),
|
2026-06-26 10:39:23 +03:00
|
|
|
|
#[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"),
|
2026-05-23 09:52:23 +03:00
|
|
|
|
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 {
|
2026-04-08 00:52:38 +03:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-04 18:28:27 +03:00
|
|
|
|
// 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-27 03:04:31 +03:00
|
|
|
|
// 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.
|
2026-04-27 03:01:42 +03:00
|
|
|
|
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"),
|
2026-05-23 09:52:23 +03:00
|
|
|
|
buffers: &[wire_gpu::WireInstance::layout()],
|
2026-04-27 03:01:42 +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: false,
|
2026-04-27 03:04:31 +03:00
|
|
|
|
depth_compare: wgpu::CompareFunction::Always,
|
2026-04-27 03:01:42 +03:00
|
|
|
|
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 ─────────────────────────────────────────────────
|
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,
|
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,
|
|
|
|
|
|
};
|
2026-05-16 08:41:23 +03:00
|
|
|
|
let frag = wgpu::ShaderStages::FRAGMENT;
|
|
|
|
|
|
let vert_frag = wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT;
|
2026-07-09 10:45:40 +03:00
|
|
|
|
let wipeout_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
|
|
|
|
label: Some("wipeout.bgl1"),
|
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
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-09 10:45:40 +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: &[],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-09 10:45:40 +03:00
|
|
|
|
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!(
|
2026-07-09 10:45:40 +03:00
|
|
|
|
"../../shaders/wipeout.wgsl"
|
2026-03-22 14:56:27 +03:00
|
|
|
|
))),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-09 10:45:40 +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 {
|
2026-07-09 10:45:40 +03:00
|
|
|
|
module: &wipeout_shader,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
entry_point: Some("vs_main"),
|
2026-07-09 10:45:40 +03:00
|
|
|
|
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(),
|
2026-06-25 18:17:01 +01:00
|
|
|
|
bias: wgpu::DepthBiasState {
|
|
|
|
|
|
constant: 1,
|
|
|
|
|
|
slope_scale: 1.0,
|
|
|
|
|
|
clamp: 0.0,
|
|
|
|
|
|
},
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}),
|
|
|
|
|
|
multisample: wgpu::MultisampleState {
|
2026-04-08 00:52:38 +03:00
|
|
|
|
count: MSAA_SAMPLES,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
mask: !0,
|
|
|
|
|
|
alpha_to_coverage_enabled: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
fragment: Some(wgpu::FragmentState {
|
2026-07-09 10:45:40 +03:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-17 19:52:28 +03:00
|
|
|
|
// ── Hatch batched pipeline (Phase 4-B) ─────────────────────────────
|
2026-06-16 17:53:56 +03:00
|
|
|
|
// 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")]
|
2026-07-09 10:45:40 +03:00
|
|
|
|
let (hatch_bgl1, hatch_pipeline): (
|
2026-06-16 17:53:56 +03:00
|
|
|
|
Option<wgpu::BindGroupLayout>,
|
|
|
|
|
|
Option<wgpu::RenderPipeline>,
|
|
|
|
|
|
) = (None, None);
|
|
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
2026-07-09 10:45:40 +03:00
|
|
|
|
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],
|
2026-05-17 19:52:28 +03:00
|
|
|
|
push_constant_ranges: &[],
|
|
|
|
|
|
});
|
2026-07-09 10:45:40 +03:00
|
|
|
|
let hatch_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
|
|
|
|
|
label: Some("hatch.shader"),
|
2026-05-17 19:52:28 +03:00
|
|
|
|
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
2026-07-09 10:45:40 +03:00
|
|
|
|
"../../shaders/hatch.wgsl"
|
2026-05-17 19:52:28 +03:00
|
|
|
|
))),
|
|
|
|
|
|
});
|
2026-07-09 10:45:40 +03:00
|
|
|
|
let hatch_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
|
|
|
|
|
label: Some("hatch.pipeline"),
|
|
|
|
|
|
layout: Some(&hatch_layout),
|
2026-05-17 19:52:28 +03:00
|
|
|
|
vertex: wgpu::VertexState {
|
2026-07-09 10:45:40 +03:00
|
|
|
|
module: &hatch_shader,
|
2026-05-17 19:52:28 +03:00
|
|
|
|
entry_point: Some("vs_main"),
|
2026-07-09 10:45:40 +03:00
|
|
|
|
buffers: &[hatch_gpu::HatchVertex::layout()],
|
2026-05-17 19:52:28 +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(),
|
2026-06-25 18:17:01 +01:00
|
|
|
|
bias: wgpu::DepthBiasState {
|
|
|
|
|
|
constant: 1,
|
|
|
|
|
|
slope_scale: 1.0,
|
|
|
|
|
|
clamp: 0.0,
|
|
|
|
|
|
},
|
2026-05-17 19:52:28 +03:00
|
|
|
|
}),
|
|
|
|
|
|
multisample: wgpu::MultisampleState {
|
|
|
|
|
|
count: MSAA_SAMPLES,
|
|
|
|
|
|
mask: !0,
|
|
|
|
|
|
alpha_to_coverage_enabled: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
fragment: Some(wgpu::FragmentState {
|
2026-07-09 10:45:40 +03:00
|
|
|
|
module: &hatch_shader,
|
2026-05-17 19:52:28 +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,
|
|
|
|
|
|
});
|
2026-07-09 10:45:40 +03:00
|
|
|
|
(Some(hatch_bgl1), Some(hatch_pipeline))
|
2026-06-16 17:53:56 +03:00
|
|
|
|
};
|
2026-05-17 19:52:28 +03:00
|
|
|
|
|
feat(web): render hatches on WebGL2 via a texture-backed renderer
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).
Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.
Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".
Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.
Closes #204.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:51 +03:00
|
|
|
|
// 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,
|
2026-06-19 15:21:21 +03:00
|
|
|
|
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(),
|
2026-06-25 18:17:01 +01:00
|
|
|
|
bias: wgpu::DepthBiasState {
|
|
|
|
|
|
constant: 1,
|
|
|
|
|
|
slope_scale: 1.0,
|
|
|
|
|
|
clamp: 0.0,
|
|
|
|
|
|
},
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}),
|
|
|
|
|
|
multisample: wgpu::MultisampleState {
|
2026-04-08 00:52:38 +03:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-04 16:27:59 +03:00
|
|
|
|
// 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-27 05:28:20 +03:00
|
|
|
|
// 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).
|
2026-07-04 17:57:00 +03:00
|
|
|
|
// 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| {
|
2026-05-27 05:28:20 +03:00
|
|
|
|
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
2026-07-04 17:57:00 +03:00
|
|
|
|
label: Some(label),
|
2026-05-27 05:28:20 +03:00
|
|
|
|
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,
|
2026-07-04 17:57:00 +03:00
|
|
|
|
entry_point: Some(fs),
|
2026-05-27 05:28:20 +03:00
|
|
|
|
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-07-04 17:57:00 +03:00
|
|
|
|
})
|
|
|
|
|
|
};
|
|
|
|
|
|
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");
|
2026-05-27 05:28:20 +03:00
|
|
|
|
|
2026-05-27 05:40:39 +03:00
|
|
|
|
// 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,
|
2026-06-19 15:21:21 +03:00
|
|
|
|
cull_mode: None, // two-sided: ACIS import winding is unreliable
|
2026-05-27 05:40:39 +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(),
|
2026-06-25 18:17:01 +01:00
|
|
|
|
bias: wgpu::DepthBiasState {
|
|
|
|
|
|
constant: 1,
|
|
|
|
|
|
slope_scale: 1.0,
|
|
|
|
|
|
clamp: 0.0,
|
|
|
|
|
|
},
|
2026-05-27 05:40:39 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-28 21:53:31 +03:00
|
|
|
|
// ── 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(),
|
2026-06-25 18:17:01 +01:00
|
|
|
|
bias: wgpu::DepthBiasState {
|
|
|
|
|
|
constant: 1,
|
|
|
|
|
|
slope_scale: 1.0,
|
|
|
|
|
|
clamp: 0.0,
|
|
|
|
|
|
},
|
2026-04-28 21:53:31 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-27 05:40:39 +03:00
|
|
|
|
// 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(),
|
2026-06-25 18:17:01 +01:00
|
|
|
|
bias: wgpu::DepthBiasState {
|
|
|
|
|
|
constant: 1,
|
|
|
|
|
|
slope_scale: 1.0,
|
|
|
|
|
|
clamp: 0.0,
|
|
|
|
|
|
},
|
2026-05-27 05:40:39 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-06 23:19:08 +03:00
|
|
|
|
// ── 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,
|
|
|
|
|
|
},
|
feat(draworder): cross-type draw order + context menu
DRAWORDER now affects rendering immediately (bump geometry epoch so the
sort cache rebuilds) and front/back anchor to the block's full effective
sort range instead of only existing table entries, so bring-to-front
actually lands on top. The render fallback sorts table-absent entities by
their own handle.
Draw order now applies across entity types, not just within a type. A
per-entity draw-order rank (SortEntitiesTable override or own handle,
ranked per block) is encoded as a small signed clip-z bias in the wire,
hatch, image, and 3DFACE pipelines. The shared LessEqual depth buffer then
orders fills, lines, rasters and 2D solids against each other regardless of
pass order — without touching model coordinates. 3D meshes keep real
geometric depth; wipeouts stay always-on-top.
Adds a Draw Order submenu to the viewport context menu (Bring to Front,
Send to Back, Bring Above/Send Under Object). The above/under items run an
interactive reference-object pick, then reorder the captured selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:29:01 +03:00
|
|
|
|
// binding 2: ImageParams uniform (read in vertex for the
|
|
|
|
|
|
// draw-order z bias and in fragment for opacity).
|
2026-04-06 23:19:08 +03:00
|
|
|
|
wgpu::BindGroupLayoutEntry {
|
|
|
|
|
|
binding: 2,
|
feat(draworder): cross-type draw order + context menu
DRAWORDER now affects rendering immediately (bump geometry epoch so the
sort cache rebuilds) and front/back anchor to the block's full effective
sort range instead of only existing table entries, so bring-to-front
actually lands on top. The render fallback sorts table-absent entities by
their own handle.
Draw order now applies across entity types, not just within a type. A
per-entity draw-order rank (SortEntitiesTable override or own handle,
ranked per block) is encoded as a small signed clip-z bias in the wire,
hatch, image, and 3DFACE pipelines. The shared LessEqual depth buffer then
orders fills, lines, rasters and 2D solids against each other regardless of
pass order — without touching model coordinates. 3D meshes keep real
geometric depth; wipeouts stay always-on-top.
Adds a Draw Order submenu to the viewport context menu (Bring to Front,
Send to Back, Bring Above/Send Under Object). The above/under items run an
interactive reference-object pick, then reorder the captured selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:29:01 +03:00
|
|
|
|
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
2026-04-06 23:19:08 +03:00
|
|
|
|
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 {
|
2026-04-08 00:52:38 +03:00
|
|
|
|
count: MSAA_SAMPLES,
|
2026-04-06 23:19:08 +03:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
// ── 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);
|
|
|
|
|
|
|
2026-04-08 00:52:38 +03:00
|
|
|
|
let init_size = Size::new(1, 1);
|
|
|
|
|
|
let msaa_view = create_msaa_texture(device, init_size, format)
|
|
|
|
|
|
.create_view(&wgpu::TextureViewDescriptor::default());
|
2026-04-08 12:20:47 +03:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
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,
|
|
|
|
|
|
},
|
2026-04-08 12:20:47 +03:00
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-08 12:20:47 +03:00
|
|
|
|
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,
|
2026-05-28 10:29:05 +03:00
|
|
|
|
// 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),
|
2026-04-08 12:20:47 +03:00
|
|
|
|
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),
|
|
|
|
|
|
},
|
2026-05-28 12:15:00 +03:00
|
|
|
|
wgpu::BindGroupEntry {
|
|
|
|
|
|
binding: 2,
|
|
|
|
|
|
resource: blit_uniform_buffer.as_entire_binding(),
|
|
|
|
|
|
},
|
2026-04-08 12:20:47 +03:00
|
|
|
|
],
|
|
|
|
|
|
});
|
2026-04-08 00:52:38 +03:00
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
Self {
|
|
|
|
|
|
wire_pipeline,
|
2026-07-04 18:28:27 +03:00
|
|
|
|
wire_black_pipeline,
|
2026-04-27 03:01:42 +03:00
|
|
|
|
wire_xray_pipeline,
|
2026-06-26 10:39:23 +03:00
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
|
|
|
|
wire_const_bgl: Some(wire_const_bgl),
|
|
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
|
|
|
|
wire_const_bgl: None,
|
2026-07-09 10:45:40 +03:00
|
|
|
|
wipeout_pipeline,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
hatch_pipeline,
|
feat(web): render hatches on WebGL2 via a texture-backed renderer
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).
Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.
Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".
Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.
Closes #204.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:51 +03:00
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
|
|
|
|
hatch_web_bgl1,
|
|
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
|
|
|
|
hatch_web_pipeline,
|
2026-04-06 23:19:08 +03:00
|
|
|
|
image_pipeline,
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
text_pipeline,
|
|
|
|
|
|
text_atlas_bgl,
|
|
|
|
|
|
text_atlas_gpu: None,
|
|
|
|
|
|
text_vbuf: None,
|
|
|
|
|
|
text_vcount: 0,
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
text_highlight_vbuf: None,
|
|
|
|
|
|
text_highlight_vcount: 0,
|
2026-07-10 10:39:44 +03:00
|
|
|
|
text_preview_vbuf: None,
|
|
|
|
|
|
text_preview_vcount: 0,
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
silhouette_vbuf: None,
|
|
|
|
|
|
silhouette_vcount: 0,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
mesh_pipeline,
|
2026-07-04 16:27:59 +03:00
|
|
|
|
mesh_transparent_pipeline,
|
2026-06-30 22:46:29 +03:00
|
|
|
|
mesh_highlight_pipeline,
|
2026-05-27 05:28:20 +03:00
|
|
|
|
mesh_wireframe_pipeline,
|
2026-07-04 17:57:00 +03:00
|
|
|
|
mesh_edge_black_pipeline,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
mesh_depth_pipeline,
|
2026-04-28 21:53:31 +03:00
|
|
|
|
face3d_pipeline,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
face3d_depth_pipeline,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
uniform_buffer,
|
|
|
|
|
|
uniform_bind_group,
|
2026-07-09 10:45:40 +03:00
|
|
|
|
wipeout_bgl1,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
hatch_bgl1,
|
2026-04-06 23:19:08 +03:00
|
|
|
|
image_bgl1,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
depth_texture_size: Size::new(1, 1),
|
2026-07-08 18:20:27 +03:00
|
|
|
|
// (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,
|
2026-04-08 00:52:38 +03:00
|
|
|
|
msaa_view,
|
2026-04-08 12:20:47 +03:00
|
|
|
|
resolve_view,
|
|
|
|
|
|
blit_pipeline,
|
|
|
|
|
|
blit_bind_group_layout: blit_bgl,
|
|
|
|
|
|
blit_sampler,
|
|
|
|
|
|
blit_bind_group,
|
2026-05-28 12:15:00 +03:00
|
|
|
|
blit_uniform_buffer,
|
2026-04-08 00:52:38 +03:00
|
|
|
|
surface_format: format,
|
2026-07-14 21:19:18 +03:00
|
|
|
|
gpu_wires: std::sync::Arc::new(vec![]),
|
2026-07-15 06:02:25 +03:00
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
|
|
|
|
wire_arena: None,
|
|
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
2026-07-15 09:48:20 +03:00
|
|
|
|
wire_arena_mesh: None,
|
|
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
2026-07-15 06:02:25 +03:00
|
|
|
|
wire_arena_id: u64::MAX,
|
2026-05-06 07:12:05 +03:00
|
|
|
|
wire_pixel_scissors: vec![],
|
2026-04-27 03:01:42 +03:00
|
|
|
|
gpu_selected_wires: vec![],
|
2026-06-05 10:57:38 +03:00
|
|
|
|
gpu_preview_wires: vec![],
|
2026-07-09 10:45:40 +03:00
|
|
|
|
gpu_hatch: None,
|
feat(web): render hatches on WebGL2 via a texture-backed renderer
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).
Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.
Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".
Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.
Closes #204.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:51 +03:00
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
|
|
|
|
gpu_hatches_web: vec![],
|
2026-04-06 23:44:36 +03:00
|
|
|
|
gpu_wipeouts: vec![],
|
2026-05-17 15:02:07 +03:00
|
|
|
|
wipeout_skip_flags: vec![],
|
2026-05-15 23:13:16 +03:00
|
|
|
|
wipeout_pixel_scissors: vec![],
|
2026-04-06 23:19:08 +03:00
|
|
|
|
gpu_images: vec![],
|
2026-05-15 23:13:16 +03:00
|
|
|
|
image_pixel_scissors: vec![],
|
2026-03-22 14:56:27 +03:00
|
|
|
|
gpu_meshes: vec![],
|
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),
|
2026-05-15 23:30:13 +03:00
|
|
|
|
mesh_lod_levels: vec![],
|
2026-05-17 14:54:17 +03:00
|
|
|
|
mesh_visible: vec![],
|
2026-04-28 21:53:31 +03:00
|
|
|
|
gpu_face3d_fill: None,
|
2026-04-29 20:58:44 +03:00
|
|
|
|
gpu_face3d_edges: vec![],
|
2026-03-22 14:56:27 +03:00
|
|
|
|
viewcube,
|
2026-06-09 22:47:48 +03:00
|
|
|
|
cached_epoch: (u64::MAX, u64::MAX, u64::MAX),
|
2026-06-05 09:16:44 +03:00
|
|
|
|
cached_wire_id: u64::MAX,
|
2026-06-05 10:04:22 +03:00
|
|
|
|
cached_selection: (u64::MAX, u64::MAX),
|
2026-06-19 09:33:53 +03:00
|
|
|
|
cached_mesh_key: (u64::MAX, u64::MAX),
|
2026-06-30 22:46:29 +03:00
|
|
|
|
cached_face3d_key: (u64::MAX, false),
|
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,
|
2026-07-05 09:48:49 +02:00
|
|
|
|
skip_hatch_frame: false,
|
2026-07-13 22:38:13 +03:00
|
|
|
|
slot_id: u64::MAX,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
feat(draworder): cross-type draw order + context menu
DRAWORDER now affects rendering immediately (bump geometry epoch so the
sort cache rebuilds) and front/back anchor to the block's full effective
sort range instead of only existing table entries, so bring-to-front
actually lands on top. The render fallback sorts table-absent entities by
their own handle.
Draw order now applies across entity types, not just within a type. A
per-entity draw-order rank (SortEntitiesTable override or own handle,
ranked per block) is encoded as a small signed clip-z bias in the wire,
hatch, image, and 3DFACE pipelines. The shared LessEqual depth buffer then
orders fills, lines, rasters and 2D solids against each other regardless of
pass order — without touching model coordinates. 3D meshes keep real
geometric depth; wipeouts stay always-on-top.
Adds a Draw Order submenu to the viewport context menu (Bring to Front,
Send to Back, Bring Above/Send Under Object). The above/under items run an
interactive reference-object pick, then reorder the captured selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:29:01 +03:00
|
|
|
|
device: &wgpu::Device,
|
|
|
|
|
|
wires: &[WireModel],
|
2026-06-05 00:35:53 +03:00
|
|
|
|
depth_map: &rustc_hash::FxHashMap<u64, f32>,
|
2026-07-14 21:19:18 +03:00
|
|
|
|
) -> (
|
|
|
|
|
|
std::sync::Arc<Vec<WireGpu>>,
|
|
|
|
|
|
std::sync::Arc<rustc_hash::FxHashMap<u64, Vec<u32>>>,
|
feat(draworder): cross-type draw order + context menu
DRAWORDER now affects rendering immediately (bump geometry epoch so the
sort cache rebuilds) and front/back anchor to the block's full effective
sort range instead of only existing table entries, so bring-to-front
actually lands on top. The render fallback sorts table-absent entities by
their own handle.
Draw order now applies across entity types, not just within a type. A
per-entity draw-order rank (SortEntitiesTable override or own handle,
ranked per block) is encoded as a small signed clip-z bias in the wire,
hatch, image, and 3DFACE pipelines. The shared LessEqual depth buffer then
orders fills, lines, rasters and 2D solids against each other regardless of
pass order — without touching model coordinates. 3D meshes keep real
geometric depth; wipeouts stay always-on-top.
Adds a Draw Order submenu to the viewport context menu (Bring to Front,
Send to Back, Bring Above/Send Under Object). The above/under items run an
interactive reference-object pick, then reorder the captured selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:29:01 +03:00
|
|
|
|
) {
|
2026-06-05 09:35:18 +03:00
|
|
|
|
// 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.
|
2026-07-04 18:28:27 +03:00
|
|
|
|
// 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());
|
2026-06-05 09:35:18 +03:00
|
|
|
|
let mut batches: Vec<WireGpu> = Vec::new();
|
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
|
while i < wires.len() {
|
|
|
|
|
|
let scissor = wires[i].vp_scissor;
|
2026-07-04 18:28:27 +03:00
|
|
|
|
let mesh_edge = is_mesh_edge(&wires[i]);
|
2026-06-05 09:35:18 +03:00
|
|
|
|
let mut j = i + 1;
|
|
|
|
|
|
while j < wires.len()
|
|
|
|
|
|
&& wires[j].vp_scissor == scissor
|
2026-07-04 18:28:27 +03:00
|
|
|
|
&& is_mesh_edge(&wires[j]) == mesh_edge
|
2026-06-05 09:35:18 +03:00
|
|
|
|
{
|
|
|
|
|
|
j += 1;
|
|
|
|
|
|
}
|
2026-06-26 10:39:23 +03:00
|
|
|
|
batches.extend(WireGpu::from_run(device, &wires[i..j], depth_map, scissor, mesh_edge, self.wire_const_bgl.as_ref()));
|
2026-06-05 09:35:18 +03:00
|
|
|
|
i = j;
|
|
|
|
|
|
}
|
2026-06-10 21:01:12 +03:00
|
|
|
|
|
|
|
|
|
|
// 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.
|
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());
|
2026-06-10 21:01:12 +03:00
|
|
|
|
for (idx, w) in wires.iter().enumerate() {
|
|
|
|
|
|
if let Ok(h) = w.name.parse::<u64>() {
|
2026-07-14 21:19:18 +03:00
|
|
|
|
index.entry(h).or_default().push(idx as u32);
|
2026-06-10 21:01:12 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-14 21:19:18 +03:00
|
|
|
|
(std::sync::Arc::new(batches), std::sync::Arc::new(index))
|
2026-06-05 10:04:22 +03:00
|
|
|
|
}
|
2026-05-07 17:24:28 +03:00
|
|
|
|
|
2026-06-05 10:04:22 +03:00
|
|
|
|
/// 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],
|
2026-06-19 09:33:53 +03:00
|
|
|
|
selected: &rustc_hash::FxHashSet<acadrust::Handle>,
|
|
|
|
|
|
hover: Option<acadrust::Handle>,
|
2026-06-05 10:04:22 +03:00
|
|
|
|
depth_map: &rustc_hash::FxHashMap<u64, f32>,
|
|
|
|
|
|
) {
|
2026-06-19 09:33:53 +03:00
|
|
|
|
let hover = hover.filter(|h| !selected.contains(h));
|
|
|
|
|
|
if selected.is_empty() && hover.is_none() {
|
2026-06-05 10:04:22 +03:00
|
|
|
|
self.gpu_selected_wires = vec![];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-19 09:33:53 +03:00
|
|
|
|
// 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();
|
2026-06-22 02:03:34 +03:00
|
|
|
|
let push = |handle_val: u64, color: [f32; 4], wires: &[WireModel], out: &mut Vec<WireModel>| {
|
2026-06-19 09:33:53 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-10 21:01:12 +03:00
|
|
|
|
}
|
2026-06-19 09:33:53 +03:00
|
|
|
|
};
|
|
|
|
|
|
for h in selected {
|
|
|
|
|
|
push(h.value(), WireModel::SELECTED, wires, &mut out);
|
2026-06-10 21:01:12 +03:00
|
|
|
|
}
|
2026-06-19 09:33:53 +03:00
|
|
|
|
if let Some(h) = hover {
|
|
|
|
|
|
push(h.value(), WireModel::HOVER, wires, &mut out);
|
|
|
|
|
|
}
|
2026-06-26 10:39:23 +03:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
/// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 10:57:38 +03:00
|
|
|
|
/// 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 {
|
2026-06-26 10:39:23 +03:00
|
|
|
|
WireGpu::from_run(device, wires, depth_map, None, false, self.wire_const_bgl.as_ref())
|
2026-06-05 10:57:38 +03:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-10 10:39:44 +03:00
|
|
|
|
/// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
// The math lives outside the GPU method so it can be unit-tested; see the
|
|
|
|
|
|
// module test below.
|
|
|
|
|
|
|
|
|
|
|
|
/// Rebuild the per-frame DISPSILH silhouette line list from the mesh sets'
|
|
|
|
|
|
/// curved-face generators and the current eye. For each cone/cylinder face
|
|
|
|
|
|
/// the silhouette runs at the two angles where the surface turns edge-on to
|
|
|
|
|
|
/// the view — `θ = φ ± acos(-tanα·(view·axis) / |view⊥|)`, which reduces to
|
|
|
|
|
|
/// `φ ± π/2` for a cylinder. Segments are uploaded in the mesh vertex format
|
|
|
|
|
|
/// so they draw through the existing wireframe pipeline.
|
|
|
|
|
|
pub fn upload_silhouettes(
|
|
|
|
|
|
&mut self,
|
|
|
|
|
|
device: &wgpu::Device,
|
|
|
|
|
|
sets: &[crate::scene::model::mesh_model::MeshLodSet],
|
2026-07-17 23:30:05 +03:00
|
|
|
|
view_dir: glam::Vec3,
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
) {
|
2026-07-17 23:30:05 +03:00
|
|
|
|
// Silhouettes follow the view *angle* only — a single parallel direction
|
|
|
|
|
|
// for the whole scene, not the eye-to-surface vector — so the outline
|
|
|
|
|
|
// stays put under pan and doesn't foreshorten. This is the orthographic
|
|
|
|
|
|
// silhouette a CAD wireframe expects.
|
|
|
|
|
|
let view = glam::DVec3::new(view_dir.x as f64, view_dir.y as f64, view_dir.z as f64)
|
|
|
|
|
|
.normalize_or(glam::DVec3::NEG_Z);
|
|
|
|
|
|
use crate::scene::model::mesh_model::CurvedGen;
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
use crate::scene::pipeline::mesh_gpu::MeshVertex;
|
|
|
|
|
|
let mut verts: Vec<MeshVertex> = Vec::new();
|
2026-07-17 23:30:05 +03:00
|
|
|
|
let d3 = |a: [f32; 3]| glam::DVec3::new(a[0] as f64, a[1] as f64, a[2] as f64);
|
|
|
|
|
|
let lo = |c: [f32; 3], l: [f32; 3]| {
|
|
|
|
|
|
glam::DVec3::new(
|
|
|
|
|
|
c[0] as f64 + l[0] as f64,
|
|
|
|
|
|
c[1] as f64 + l[1] as f64,
|
|
|
|
|
|
c[2] as f64 + l[2] as f64,
|
|
|
|
|
|
)
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
};
|
|
|
|
|
|
for set in sets {
|
2026-07-17 23:30:05 +03:00
|
|
|
|
let color = set.lods.first().map(|m| m.color).unwrap_or([0.0, 0.0, 0.0, 1.0]);
|
2026-07-18 01:10:29 +03:00
|
|
|
|
let mk = |w: glam::DVec3| -> MeshVertex {
|
2026-07-17 23:30:05 +03:00
|
|
|
|
let (hx, hy, hz) = (w.x as f32, w.y as f32, w.z as f32);
|
|
|
|
|
|
MeshVertex {
|
|
|
|
|
|
position: [hx, hy, hz],
|
|
|
|
|
|
normal: [0.0, 1.0, 0.0],
|
|
|
|
|
|
color,
|
|
|
|
|
|
position_low: [
|
|
|
|
|
|
(w.x - hx as f64) as f32,
|
|
|
|
|
|
(w.y - hy as f64) as f32,
|
|
|
|
|
|
(w.z - hz as f64) as f32,
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
for g in &set.curved_gens {
|
2026-07-17 23:30:05 +03:00
|
|
|
|
match g {
|
|
|
|
|
|
CurvedGen::Cone {
|
|
|
|
|
|
base, base_low, axis, u_dir, v_dir, radius, tan_a,
|
|
|
|
|
|
h_max, theta_min, theta_span, full,
|
|
|
|
|
|
} => {
|
|
|
|
|
|
let base = lo(*base, *base_low);
|
|
|
|
|
|
let (axis, u, v) = (d3(*axis), d3(*u_dir), d3(*v_dir));
|
|
|
|
|
|
let Some((t0, t1)) =
|
|
|
|
|
|
silhouette_thetas(view.dot(u), view.dot(v), view.dot(axis), *tan_a as f64)
|
|
|
|
|
|
else {
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
continue;
|
2026-07-17 23:30:05 +03:00
|
|
|
|
};
|
|
|
|
|
|
let r0 = *radius as f64;
|
|
|
|
|
|
let r1 = *radius as f64 + *h_max as f64 * *tan_a as f64;
|
|
|
|
|
|
for theta in [t0, t1] {
|
|
|
|
|
|
if !full {
|
|
|
|
|
|
let off = (theta - *theta_min as f64).rem_euclid(std::f64::consts::TAU);
|
|
|
|
|
|
if off > *theta_span as f64 {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let (c, s) = (theta.cos(), theta.sin());
|
|
|
|
|
|
let radial = u * c + v * s;
|
|
|
|
|
|
verts.push(mk(base + radial * r0));
|
|
|
|
|
|
verts.push(mk(base + radial * r1 + axis * *h_max as f64));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
CurvedGen::Sphere {
|
|
|
|
|
|
center, center_low, pole, u_dir, v_dir, radius,
|
|
|
|
|
|
theta_min, theta_span, full, phi_min, phi_max,
|
|
|
|
|
|
} => {
|
|
|
|
|
|
let c = lo(*center, *center_low);
|
|
|
|
|
|
let (pole, u, v) = (d3(*pole), d3(*u_dir), d3(*v_dir));
|
|
|
|
|
|
let r = *radius as f64;
|
|
|
|
|
|
// Great circle in the plane perpendicular to the view.
|
|
|
|
|
|
let mut e1 = view.cross(pole);
|
|
|
|
|
|
if e1.length_squared() < 1e-12 {
|
|
|
|
|
|
e1 = view.cross(u);
|
|
|
|
|
|
}
|
|
|
|
|
|
let e1 = e1.normalize();
|
|
|
|
|
|
let e2 = view.cross(e1).normalize();
|
|
|
|
|
|
const N: usize = 64;
|
|
|
|
|
|
let mut prev: Option<glam::DVec3> = None;
|
|
|
|
|
|
for i in 0..=N {
|
|
|
|
|
|
let a = std::f64::consts::TAU * (i as f64 / N as f64);
|
|
|
|
|
|
let dir = e1 * a.cos() + e2 * a.sin();
|
|
|
|
|
|
// Keep only the arc that lies on the actual face.
|
|
|
|
|
|
let on_face = *full || {
|
|
|
|
|
|
let phi = dir.dot(pole).clamp(-1.0, 1.0).acos();
|
|
|
|
|
|
let th = dir.dot(v).atan2(dir.dot(u));
|
|
|
|
|
|
let toff = (th - *theta_min as f64).rem_euclid(std::f64::consts::TAU);
|
|
|
|
|
|
phi >= *phi_min as f64
|
|
|
|
|
|
&& phi <= *phi_max as f64
|
|
|
|
|
|
&& (toff <= *theta_span as f64)
|
|
|
|
|
|
};
|
|
|
|
|
|
let p = if on_face { Some(c + dir * r) } else { None };
|
|
|
|
|
|
if let (Some(a), Some(b)) = (prev, p) {
|
|
|
|
|
|
verts.push(mk(a));
|
|
|
|
|
|
verts.push(mk(b));
|
|
|
|
|
|
}
|
|
|
|
|
|
prev = p;
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-17 23:30:05 +03:00
|
|
|
|
CurvedGen::Torus {
|
|
|
|
|
|
center, center_low, axis, u_dir, v_dir, major, minor,
|
|
|
|
|
|
phi_min, phi_span, full,
|
|
|
|
|
|
} => {
|
|
|
|
|
|
let ctr = lo(*center, *center_low);
|
|
|
|
|
|
let (axis, u, v) = (d3(*axis), d3(*u_dir), d3(*v_dir));
|
|
|
|
|
|
let (major, minor) = (*major as f64, *minor as f64);
|
|
|
|
|
|
// True silhouette: at each revolution angle the tube is a
|
|
|
|
|
|
// circle; the two points where its normal turns edge-on
|
|
|
|
|
|
// trace two curves around the ring. Sample the revolution
|
|
|
|
|
|
// and connect consecutive edge-on points.
|
|
|
|
|
|
const N: usize = 72;
|
|
|
|
|
|
let span = if *full { std::f64::consts::TAU } else { *phi_span as f64 };
|
|
|
|
|
|
let mut prev: [Option<glam::DVec3>; 2] = [None, None];
|
|
|
|
|
|
for i in 0..=N {
|
|
|
|
|
|
let phi = *phi_min as f64 + span * (i as f64 / N as f64);
|
|
|
|
|
|
let radial = u * phi.cos() + v * phi.sin();
|
|
|
|
|
|
let ring = ctr + radial * major;
|
|
|
|
|
|
let (rv, av) = (radial.dot(view), axis.dot(view));
|
|
|
|
|
|
if rv.abs() < 1e-9 && av.abs() < 1e-9 {
|
|
|
|
|
|
prev = [None, None];
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
// tube normal(θ) = radial·cosθ + axis·sinθ; ⟂ view at
|
|
|
|
|
|
// θ = atan2(-rv, av) and +π.
|
|
|
|
|
|
let th = (-rv).atan2(av);
|
|
|
|
|
|
let cur = [th, th + std::f64::consts::PI];
|
|
|
|
|
|
for k in 0..2 {
|
|
|
|
|
|
let t = cur[k];
|
|
|
|
|
|
let p = ring + (radial * t.cos() + axis * t.sin()) * minor;
|
|
|
|
|
|
if let Some(pp) = prev[k] {
|
|
|
|
|
|
verts.push(mk(pp));
|
|
|
|
|
|
verts.push(mk(p));
|
|
|
|
|
|
}
|
|
|
|
|
|
prev[k] = Some(p);
|
|
|
|
|
|
}
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
}
|
2026-07-17 23:30:05 +03:00
|
|
|
|
}
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if verts.is_empty() {
|
|
|
|
|
|
self.silhouette_vbuf = None;
|
|
|
|
|
|
self.silhouette_vcount = 0;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
use wgpu::util::DeviceExt;
|
|
|
|
|
|
self.silhouette_vbuf = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
|
|
|
|
label: Some("mesh.silhouette.vbuf"),
|
|
|
|
|
|
contents: bytemuck::cast_slice(&verts),
|
|
|
|
|
|
usage: wgpu::BufferUsages::VERTEX,
|
|
|
|
|
|
}));
|
|
|
|
|
|
self.silhouette_vcount = verts.len() as u32;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 07:12:05 +03:00
|
|
|
|
/// 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.
|
2026-06-24 20:14:41 +03:00
|
|
|
|
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()
|
2026-06-24 20:14:41 +03:00
|
|
|
|
.map(|w| project_scissor(w.vp_scissor, view_rot, eye, clip_w, clip_h))
|
2026-05-15 23:13:16 +03:00
|
|
|
|
.collect();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
|
2026-05-17 20:11:28 +03:00
|
|
|
|
pub fn compute_hatch_lod(
|
|
|
|
|
|
&mut self,
|
|
|
|
|
|
queue: &wgpu::Queue,
|
2026-06-24 20:14:41 +03:00
|
|
|
|
view_rot: glam::Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-05-17 20:11:28 +03:00
|
|
|
|
clip_w: u32,
|
|
|
|
|
|
clip_h: u32,
|
|
|
|
|
|
) {
|
2026-07-09 10:45:40 +03:00
|
|
|
|
let Some(batch) = &mut self.gpu_hatch else {
|
2026-05-17 20:11:28 +03:00
|
|
|
|
return;
|
2026-05-17 20:18:06 +03:00
|
|
|
|
};
|
|
|
|
|
|
for (i, aabb) in batch.instance_aabbs.iter().enumerate() {
|
2026-06-24 20:14:41 +03:00
|
|
|
|
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);
|
2026-05-17 20:18:06 +03:00
|
|
|
|
batch.visibility[i] = if skip { 0 } else { 1 };
|
2026-05-17 20:11:28 +03:00
|
|
|
|
}
|
2026-05-17 20:18:06 +03:00
|
|
|
|
batch.upload_visibility(queue);
|
2026-05-15 23:18:21 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 23:13:16 +03:00
|
|
|
|
/// Recompute pixel scissor rects for viewport-clipped wipeouts.
|
2026-06-24 20:14:41 +03:00
|
|
|
|
pub fn compute_wipeout_scissors(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) {
|
2026-05-15 23:13:16 +03:00
|
|
|
|
self.wipeout_pixel_scissors = self
|
|
|
|
|
|
.gpu_wipeouts
|
|
|
|
|
|
.iter()
|
2026-06-24 20:14:41 +03:00
|
|
|
|
.map(|h| project_scissor(h.vp_scissor, view_rot, eye, clip_w, clip_h))
|
2026-05-15 23:13:16 +03:00
|
|
|
|
.collect();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 15:02:07 +03:00
|
|
|
|
/// 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.
|
2026-06-24 20:14:41 +03:00
|
|
|
|
pub fn compute_wipeout_lod(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) {
|
2026-05-17 15:02:07 +03:00
|
|
|
|
self.wipeout_skip_flags = self
|
|
|
|
|
|
.gpu_wipeouts
|
|
|
|
|
|
.iter()
|
2026-06-24 20:14:41 +03:00
|
|
|
|
.map(|h| aabb_offscreen(h.world_aabb, view_rot, eye, clip_w, clip_h))
|
2026-05-17 15:02:07 +03:00
|
|
|
|
.collect();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 23:13:16 +03:00
|
|
|
|
/// Recompute pixel scissor rects for viewport-clipped raster images.
|
2026-06-24 20:14:41 +03:00
|
|
|
|
pub fn compute_image_scissors(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) {
|
2026-05-15 23:13:16 +03:00
|
|
|
|
self.image_pixel_scissors = self
|
|
|
|
|
|
.gpu_images
|
|
|
|
|
|
.iter()
|
2026-06-24 20:14:41 +03:00
|
|
|
|
.map(|i| project_scissor(i.vp_scissor, view_rot, eye, clip_w, clip_h))
|
2026-05-12 10:36:15 +03:00
|
|
|
|
.collect();
|
2026-05-06 07:12:05 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 21:53:31 +03:00
|
|
|
|
/// 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],
|
2026-05-27 00:08:37 +03:00
|
|
|
|
wireframe_only: bool,
|
2026-06-05 00:35:53 +03:00
|
|
|
|
depth_map: &rustc_hash::FxHashMap<u64, f32>,
|
2026-05-12 10:36:15 +03:00
|
|
|
|
) {
|
2026-05-27 00:08:37 +03:00
|
|
|
|
// Edge buffer is always built from `face3d_wires`, so 3DFACE
|
|
|
|
|
|
// outlines stay on the screen regardless of mode.
|
2026-06-26 10:39:23 +03:00
|
|
|
|
self.gpu_face3d_edges =
|
|
|
|
|
|
WireGpu::from_batch(device, face3d_wires, depth_map, self.wire_const_bgl.as_ref());
|
2026-05-27 05:40:39 +03:00
|
|
|
|
// Fill buffer split: 3D quads + PolyfaceMesh / PolygonMesh face
|
2026-07-16 21:32:20 -07:00
|
|
|
|
// tris go to `chunks_3d` (gated by `keep_3d_mesh_fills`);
|
2026-05-27 05:40:39 +03:00
|
|
|
|
// 2D fills (text-LOD greek, MultiLeader background) go to
|
2026-07-16 21:32:20 -07:00
|
|
|
|
// `chunks_2d` and are visible in every mode.
|
2026-05-27 05:28:20 +03:00
|
|
|
|
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());
|
2026-05-27 05:40:39 +03:00
|
|
|
|
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);
|
2026-05-06 23:24:26 +03:00
|
|
|
|
if !has_fills {
|
2026-04-28 21:53:31 +03:00
|
|
|
|
self.gpu_face3d_fill = None;
|
2026-05-27 00:08:37 +03:00
|
|
|
|
} else {
|
2026-05-27 05:28:20 +03:00
|
|
|
|
self.gpu_face3d_fill = Some(Face3DGpu::from_wires(
|
|
|
|
|
|
device,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
face3d_wires,
|
2026-05-27 05:28:20 +03:00
|
|
|
|
all_wires,
|
|
|
|
|
|
keep_3d_mesh_fills,
|
feat(draworder): cross-type draw order + context menu
DRAWORDER now affects rendering immediately (bump geometry epoch so the
sort cache rebuilds) and front/back anchor to the block's full effective
sort range instead of only existing table entries, so bring-to-front
actually lands on top. The render fallback sorts table-absent entities by
their own handle.
Draw order now applies across entity types, not just within a type. A
per-entity draw-order rank (SortEntitiesTable override or own handle,
ranked per block) is encoded as a small signed clip-z bias in the wire,
hatch, image, and 3DFACE pipelines. The shared LessEqual depth buffer then
orders fills, lines, rasters and 2D solids against each other regardless of
pass order — without touching model coordinates. 3D meshes keep real
geometric depth; wipeouts stay always-on-top.
Adds a Draw Order submenu to the viewport context menu (Bring to Front,
Send to Back, Bring Above/Send Under Object). The above/under items run an
interactive reference-object pick, then reorder the captured selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:29:01 +03:00
|
|
|
|
depth_map,
|
2026-05-27 05:28:20 +03:00
|
|
|
|
));
|
2026-04-28 21:53:31 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 22:46:29 +03:00
|
|
|
|
#[allow(dead_code)] // per-mesh upload path, bypassed by upload_mesh_batch
|
2026-06-19 09:33:53 +03:00
|
|
|
|
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()
|
2026-05-15 23:30:13 +03:00
|
|
|
|
.filter(|s| s.lods.iter().any(|m| !m.indices.is_empty()))
|
2026-06-19 09:33:53 +03:00
|
|
|
|
.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)
|
|
|
|
|
|
})
|
2026-05-15 23:30:13 +03:00
|
|
|
|
.collect();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
|
2026-06-24 20:14:41 +03:00
|
|
|
|
pub fn compute_mesh_lod(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) {
|
2026-05-15 23:30:13 +03:00
|
|
|
|
self.mesh_lod_levels = self
|
|
|
|
|
|
.gpu_meshes
|
|
|
|
|
|
.iter()
|
2026-06-24 20:14:41 +03:00
|
|
|
|
.map(|m| pick_mesh_lod(m, view_rot, eye, clip_w, clip_h))
|
2026-03-22 14:56:27 +03:00
|
|
|
|
.collect();
|
2026-05-17 14:54:17 +03:00
|
|
|
|
// 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()
|
2026-06-24 20:14:41 +03:00
|
|
|
|
.map(|m| !aabb_offscreen(m.world_aabb, view_rot, eye, clip_w, clip_h))
|
2026-05-17 14:54:17 +03:00
|
|
|
|
.collect();
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(web): render hatches on WebGL2 via a texture-backed renderer
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).
Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.
Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".
Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.
Closes #204.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:51 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-06 23:44:36 +03:00
|
|
|
|
pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) {
|
|
|
|
|
|
self.gpu_wipeouts = wipeouts
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|h| h.boundary.len() >= 3)
|
2026-07-09 10:45:40 +03:00
|
|
|
|
.map(|h| WipeoutGpu::new(device, h, &self.wipeout_bgl1))
|
2026-04-06 23:44:36 +03:00
|
|
|
|
.collect();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-06 23:19:08 +03:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
/// 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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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]) {
|
2026-07-08 18:20:27 +03:00
|
|
|
|
// 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;
|
2026-05-28 12:15:00 +03:00
|
|
|
|
queue.write_buffer(
|
|
|
|
|
|
&self.blit_uniform_buffer,
|
|
|
|
|
|
0,
|
2026-07-08 18:20:27 +03:00
|
|
|
|
bytemuck::cast_slice(&[
|
|
|
|
|
|
uv_offset[0] * fx,
|
|
|
|
|
|
uv_offset[1] * fy,
|
|
|
|
|
|
uv_scale[0] * fx,
|
|
|
|
|
|
uv_scale[1] * fy,
|
|
|
|
|
|
]),
|
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,
|
2026-05-28 12:15:00 +03:00
|
|
|
|
vp_size: Size<u32>,
|
|
|
|
|
|
surface_dest: Rectangle<u32>,
|
2026-04-08 00:52:38 +03:00
|
|
|
|
bg_color: [f32; 4],
|
2026-05-27 05:28:20 +03:00
|
|
|
|
mesh_wireframe: bool,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
hidden_line: bool,
|
2026-05-27 05:57:19 +03:00
|
|
|
|
show_3d_edges: bool,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
) {
|
2026-05-28 12:15:00 +03:00
|
|
|
|
let vp = Rectangle::<u32> {
|
|
|
|
|
|
x: 0,
|
|
|
|
|
|
y: 0,
|
|
|
|
|
|
width: vp_size.width,
|
|
|
|
|
|
height: vp_size.height,
|
|
|
|
|
|
};
|
2026-04-08 00:52:38 +03:00
|
|
|
|
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 {
|
2026-04-08 00:52:38 +03:00
|
|
|
|
view: msaa,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
depth_slice: None,
|
|
|
|
|
|
resolve_target: None,
|
|
|
|
|
|
ops: wgpu::Operations {
|
2026-04-08 00:52:38 +03:00
|
|
|
|
// 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,
|
|
|
|
|
|
});
|
2026-04-08 12:20:47 +03:00
|
|
|
|
// 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);
|
2026-05-17 19:52:28 +03:00
|
|
|
|
// Phase 4-B — single batched draw covers every hatch.
|
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.
|
2026-06-16 17:53:56 +03:00
|
|
|
|
if let (Some(batch), Some(pipeline)) =
|
2026-07-09 10:45:40 +03:00
|
|
|
|
(&self.gpu_hatch, &self.hatch_pipeline)
|
2026-06-16 17:53:56 +03:00
|
|
|
|
{
|
2026-07-05 09:48:49 +02:00
|
|
|
|
// 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
|
|
|
|
}
|
feat(web): render hatches on WebGL2 via a texture-backed renderer
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).
Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.
Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".
Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.
Closes #204.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:51 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-06 23:19:08 +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 {
|
2026-04-08 00:52:38 +03:00
|
|
|
|
view: msaa,
|
2026-04-06 23:19:08 +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,
|
|
|
|
|
|
});
|
2026-04-08 12:20:47 +03:00
|
|
|
|
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
|
2026-04-06 23:19:08 +03:00
|
|
|
|
pass.set_pipeline(&self.image_pipeline);
|
|
|
|
|
|
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
2026-05-15 23:13:16 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
2026-04-06 23:19:08 +03:00
|
|
|
|
pass.set_bind_group(1, &img.bind_group, &[]);
|
|
|
|
|
|
pass.set_vertex_buffer(0, img.vertex_buffer.slice(..));
|
2026-07-18 20:11:29 +03:00
|
|
|
|
pass.draw(0..img.vertex_count, 0..1);
|
2026-04-06 23:19:08 +03:00
|
|
|
|
}
|
2026-05-15 23:13:16 +03:00
|
|
|
|
if scissor_active {
|
|
|
|
|
|
pass.set_scissor_rect(0, 0, vp.width, vp.height);
|
|
|
|
|
|
}
|
2026-04-06 23:19:08 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
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 {
|
2026-04-08 00:52:38 +03:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-04-08 12:20:47 +03:00
|
|
|
|
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, &[]);
|
2026-05-27 05:57:19 +03:00
|
|
|
|
// 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;
|
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.
|
2026-05-27 05:40:39 +03:00
|
|
|
|
if hidden_line {
|
2026-07-04 16:27:59 +03:00
|
|
|
|
// Depth-only prepass: every solid surface occludes hidden edges,
|
|
|
|
|
|
// so both the opaque and the transparent tris write depth here.
|
2026-05-27 05:40:39 +03:00
|
|
|
|
pass.set_pipeline(&self.mesh_depth_pipeline);
|
2026-06-30 22:46:29 +03:00
|
|
|
|
for c in &self.gpu_mesh_batch {
|
|
|
|
|
|
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
2026-07-04 16:27:59 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-17 14:54:17 +03:00
|
|
|
|
}
|
2026-05-27 05:40:39 +03:00
|
|
|
|
pass.set_pipeline(&self.mesh_wireframe_pipeline);
|
2026-06-30 22:46:29 +03:00
|
|
|
|
for c in &self.gpu_mesh_batch {
|
2026-07-04 17:57:00 +03:00
|
|
|
|
// 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);
|
2026-05-27 05:40:39 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
// DISPSILH silhouettes (whole batch, one buffer).
|
|
|
|
|
|
if let Some(ref vb) = self.silhouette_vbuf {
|
|
|
|
|
|
pass.set_vertex_buffer(0, vb.slice(..));
|
|
|
|
|
|
pass.draw(0..self.silhouette_vcount, 0..1);
|
|
|
|
|
|
}
|
2026-05-27 05:40:39 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
if mesh_wireframe {
|
|
|
|
|
|
pass.set_pipeline(&self.mesh_wireframe_pipeline);
|
2026-07-04 16:27:59 +03:00
|
|
|
|
for c in &self.gpu_mesh_batch {
|
2026-07-04 17:57:00 +03:00
|
|
|
|
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-07-04 16:27:59 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
// DISPSILH silhouettes.
|
|
|
|
|
|
if let Some(ref vb) = self.silhouette_vbuf {
|
|
|
|
|
|
pass.set_vertex_buffer(0, vb.slice(..));
|
|
|
|
|
|
pass.draw(0..self.silhouette_vcount, 0..1);
|
|
|
|
|
|
}
|
2026-05-27 05:28:20 +03:00
|
|
|
|
} else {
|
2026-07-04 16:27:59 +03:00
|
|
|
|
// Opaque fills first (they write depth).
|
2026-05-27 05:40:39 +03:00
|
|
|
|
pass.set_pipeline(&self.mesh_pipeline);
|
2026-07-04 16:27:59 +03:00
|
|
|
|
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-05-27 05:40:39 +03:00
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
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 {
|
2026-05-27 05:57:19 +03:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-04 17:57:00 +03:00
|
|
|
|
// *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.
|
2026-06-30 22:46:29 +03:00
|
|
|
|
if want_solid_with_edges {
|
2026-07-04 17:57:00 +03:00
|
|
|
|
pass.set_pipeline(&self.mesh_edge_black_pipeline);
|
2026-06-30 22:46:29 +03:00
|
|
|
|
for c in &self.gpu_mesh_batch {
|
2026-07-04 17:57:00 +03:00
|
|
|
|
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-05-27 05:57:19 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
// DISPSILH silhouettes over the shaded fill.
|
|
|
|
|
|
if let Some(ref vb) = self.silhouette_vbuf {
|
|
|
|
|
|
pass.set_vertex_buffer(0, vb.slice(..));
|
|
|
|
|
|
pass.draw(0..self.silhouette_vcount, 0..1);
|
|
|
|
|
|
}
|
2026-05-27 05:57:19 +03:00
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 05:40:39 +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.
|
2026-04-28 21:53:31 +03:00
|
|
|
|
if let Some(ref fill) = self.gpu_face3d_fill {
|
2026-07-16 21:32:20 -07:00
|
|
|
|
if !fill.chunks_3d.is_empty() || !fill.chunks_2d.is_empty() {
|
2026-04-28 21:53:31 +03:00
|
|
|
|
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, &[]);
|
2026-07-16 21:32:20 -07:00
|
|
|
|
if !fill.chunks_3d.is_empty() {
|
2026-05-27 05:40:39 +03:00
|
|
|
|
if hidden_line {
|
|
|
|
|
|
pass.set_pipeline(&self.face3d_depth_pipeline);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pass.set_pipeline(&self.face3d_pipeline);
|
|
|
|
|
|
}
|
2026-07-16 21:32:20 -07:00
|
|
|
|
for c in &fill.chunks_3d {
|
|
|
|
|
|
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
|
|
|
|
|
pass.draw(0..c.vertex_count, 0..1);
|
|
|
|
|
|
}
|
2026-05-27 05:40:39 +03:00
|
|
|
|
}
|
2026-07-16 21:32:20 -07:00
|
|
|
|
if !fill.chunks_2d.is_empty() {
|
2026-05-27 05:40:39 +03:00
|
|
|
|
pass.set_pipeline(&self.face3d_pipeline);
|
2026-07-16 21:32:20 -07:00
|
|
|
|
for c in &fill.chunks_2d {
|
|
|
|
|
|
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
|
|
|
|
|
|
pass.draw(0..c.vertex_count, 0..1);
|
|
|
|
|
|
}
|
2026-05-27 05:40:39 +03:00
|
|
|
|
}
|
2026-04-28 21:53:31 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 20:58:44 +03:00
|
|
|
|
// ── Pass 5b: 3DFACE edges (batched, possibly multiple chunks) ────
|
2026-05-27 05:57:19 +03:00
|
|
|
|
// 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() {
|
2026-04-29 20:58:44 +03:00
|
|
|
|
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,
|
2026-04-28 21:53:31 +03:00
|
|
|
|
}),
|
2026-04-29 20:58:44 +03:00
|
|
|
|
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 {
|
2026-05-23 09:52:23 +03:00
|
|
|
|
if edges.instance_count > 0 {
|
2026-06-26 10:39:23 +03:00
|
|
|
|
if let Some(bg) = &edges.const_bind_group {
|
|
|
|
|
|
pass.set_bind_group(1, bg.as_ref(), &[]);
|
|
|
|
|
|
}
|
2026-05-23 09:52:23 +03:00
|
|
|
|
pass.set_vertex_buffer(0, edges.instance_buffer.slice(..));
|
|
|
|
|
|
pass.draw(0..6, 0..edges.instance_count);
|
2026-04-29 20:58:44 +03:00
|
|
|
|
}
|
2026-04-28 21:53:31 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-06 23:19:08 +03:00
|
|
|
|
// ── 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 {
|
2026-04-08 00:52:38 +03:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-04-08 12:20:47 +03:00
|
|
|
|
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, &[]);
|
2026-07-04 18:28:27 +03:00
|
|
|
|
// 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;
|
2026-05-06 07:12:05 +03:00
|
|
|
|
let mut scissor_active = false;
|
2026-07-04 18:28:27 +03:00
|
|
|
|
let mut black_active = false;
|
2026-05-06 07:12:05 +03:00
|
|
|
|
for (i, wire) in self.gpu_wires.iter().enumerate() {
|
2026-05-23 09:52:23 +03:00
|
|
|
|
if wire.instance_count == 0 {
|
2026-05-06 07:12:05 +03:00
|
|
|
|
continue;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
2026-05-27 06:36:59 +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;
|
|
|
|
|
|
}
|
2026-07-04 18:28:27 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-06 07:12:05 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
2026-06-26 10:39:23 +03:00
|
|
|
|
if let Some(bg) = &wire.const_bind_group {
|
|
|
|
|
|
pass.set_bind_group(1, bg.as_ref(), &[]);
|
|
|
|
|
|
}
|
2026-05-23 09:52:23 +03:00
|
|
|
|
pass.set_vertex_buffer(0, wire.instance_buffer.slice(..));
|
|
|
|
|
|
pass.draw(0..6, 0..wire.instance_count);
|
2026-05-06 07:12:05 +03:00
|
|
|
|
}
|
|
|
|
|
|
if scissor_active {
|
|
|
|
|
|
pass.set_scissor_rect(0, 0, vp.width, vp.height);
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
2026-06-26 07:29:28 +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 {
|
2026-06-26 10:39:23 +03:00
|
|
|
|
if let Some(bg) = &pw.const_bind_group {
|
|
|
|
|
|
pass.set_bind_group(1, bg.as_ref(), &[]);
|
|
|
|
|
|
}
|
2026-06-26 07:29:28 +03:00
|
|
|
|
pass.set_vertex_buffer(0, pw.instance_buffer.slice(..));
|
|
|
|
|
|
pass.draw(0..6, 0..pw.instance_count);
|
|
|
|
|
|
}
|
2026-06-05 10:57:38 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
2026-04-06 23:44:36 +03:00
|
|
|
|
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
// ── Pass 5c: SDF text quads (drawn over wires) ────────────────────
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
// 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;
|
2026-07-10 10:39:44 +03:00
|
|
|
|
let have_preview =
|
|
|
|
|
|
self.text_preview_vbuf.is_some() && self.text_preview_vcount > 0;
|
|
|
|
|
|
if have_base || have_hl || have_preview {
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
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, &[]);
|
refactor(text): per-entity SDF text — all text kinds, selection/hover highlight
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:01:36 +03:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-10 10:39:44 +03:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(text): render TEXT as SDF quads behind OCS_TEXT_SDF flag
Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:02:34 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-06 23:44:36 +03:00
|
|
|
|
// ── 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 {
|
2026-04-08 00:52:38 +03:00
|
|
|
|
view: msaa,
|
2026-04-06 23:44:36 +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,
|
|
|
|
|
|
});
|
2026-04-08 12:20:47 +03:00
|
|
|
|
pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0);
|
2026-07-09 10:45:40 +03:00
|
|
|
|
pass.set_pipeline(&self.wipeout_pipeline);
|
2026-04-06 23:44:36 +03:00
|
|
|
|
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
2026-05-15 23:13:16 +03:00
|
|
|
|
let mut scissor_active = false;
|
|
|
|
|
|
for (i, wipeout) in self.gpu_wipeouts.iter().enumerate() {
|
2026-05-17 15:02:07 +03:00
|
|
|
|
if self.wipeout_skip_flags.get(i).copied().unwrap_or(false) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-05-15 23:13:16 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
2026-04-06 23:44:36 +03:00
|
|
|
|
pass.set_bind_group(1, &wipeout.bind_group, &[]);
|
|
|
|
|
|
pass.set_vertex_buffer(0, wipeout.vertex_buffer.slice(..));
|
|
|
|
|
|
pass.draw(0..6, 0..1);
|
|
|
|
|
|
}
|
2026-05-15 23:13:16 +03:00
|
|
|
|
if scissor_active {
|
|
|
|
|
|
pass.set_scissor_rect(0, 0, vp.width, vp.height);
|
|
|
|
|
|
}
|
2026-04-06 23:44:36 +03:00
|
|
|
|
}
|
2026-04-08 00:52:38 +03:00
|
|
|
|
|
2026-04-27 03:04:31 +03:00
|
|
|
|
// ── 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.
|
2026-04-27 03:01:42 +03:00
|
|
|
|
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 {
|
2026-05-23 09:52:23 +03:00
|
|
|
|
if wire.instance_count > 0 {
|
2026-06-26 10:39:23 +03:00
|
|
|
|
if let Some(bg) = &wire.const_bind_group {
|
|
|
|
|
|
pass.set_bind_group(1, bg.as_ref(), &[]);
|
|
|
|
|
|
}
|
2026-05-23 09:52:23 +03:00
|
|
|
|
pass.set_vertex_buffer(0, wire.instance_buffer.slice(..));
|
|
|
|
|
|
pass.draw(0..6, 0..wire.instance_count);
|
2026-04-27 03:01:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 18:20:27 +03:00
|
|
|
|
// ── 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).
|
2026-04-08 00:52:38 +03:00
|
|
|
|
{
|
|
|
|
|
|
let _resolve = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
|
|
|
|
|
label: Some("msaa.resolve_pass"),
|
|
|
|
|
|
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
|
|
|
|
|
view: msaa,
|
|
|
|
|
|
depth_slice: None,
|
2026-04-08 12:20:47 +03:00
|
|
|
|
resolve_target: Some(&self.resolve_view),
|
2026-04-08 00:52:38 +03:00
|
|
|
|
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`
|
2026-04-08 12:20:47 +03:00
|
|
|
|
|
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 {
|
2026-04-08 12:20:47 +03:00
|
|
|
|
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(
|
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,
|
2026-04-08 12:20:47 +03:00
|
|
|
|
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>) {
|
2026-07-08 18:20:27 +03:00
|
|
|
|
// 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;
|
2026-04-08 00:52:38 +03:00
|
|
|
|
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());
|
2026-04-08 12:20:47 +03:00
|
|
|
|
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),
|
|
|
|
|
|
},
|
2026-05-28 12:15:00 +03:00
|
|
|
|
wgpu::BindGroupEntry {
|
|
|
|
|
|
binding: 2,
|
|
|
|
|
|
resource: self.blit_uniform_buffer.as_entire_binding(),
|
|
|
|
|
|
},
|
2026-04-08 12:20:47 +03:00
|
|
|
|
],
|
|
|
|
|
|
});
|
|
|
|
|
|
self.resolve_view = resolve_view;
|
2026-07-08 18:20:27 +03:00
|
|
|
|
self.alloc_size = alloc;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 18:20: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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
2026-06-24 20:14:41 +03:00
|
|
|
|
view_rot: glam::Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-05-15 23:30:13 +03:00
|
|
|
|
clip_w: u32,
|
|
|
|
|
|
clip_h: u32,
|
|
|
|
|
|
) -> usize {
|
2026-06-24 20:14:41 +03:00
|
|
|
|
let diag_px = aabb_diagonal_pixels(mesh.world_aabb, view_rot, eye, clip_w, clip_h);
|
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],
|
2026-06-24 20:14:41 +03:00
|
|
|
|
view_rot: glam::Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
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 = [
|
2026-06-24 20:14:41 +03:00
|
|
|
|
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()),
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-17 14:54:17 +03:00
|
|
|
|
/// `true` when the world-XY AABB projects entirely outside the
|
2026-05-17 15:36:15 +03:00
|
|
|
|
/// 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
|
2026-07-09 10:45:40 +03:00
|
|
|
|
/// subtracted) that `view_proj` expects. `WipeoutGpu.world_aabb` rebuilds
|
2026-05-17 15:36:15 +03:00
|
|
|
|
/// the absolute local-space rect from `model.world_origin + boundary
|
|
|
|
|
|
/// extents` for this reason; meshes already store an absolute rect.
|
2026-05-17 14:54:17 +03:00
|
|
|
|
fn aabb_offscreen(
|
|
|
|
|
|
aabb: [f32; 4],
|
2026-06-24 20:14:41 +03:00
|
|
|
|
view_rot: glam::Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-05-17 14:54:17 +03:00
|
|
|
|
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 = [
|
2026-06-24 20:14:41 +03:00
|
|
|
|
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()),
|
2026-05-17 14:54:17 +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; }
|
|
|
|
|
|
}
|
2026-05-17 15:36:15 +03:00
|
|
|
|
// 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
|
2026-05-17 14:54:17 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 23:18:21 +03:00
|
|
|
|
/// 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],
|
2026-06-24 20:14:41 +03:00
|
|
|
|
view_rot: glam::Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-05-15 23:18:21 +03:00
|
|
|
|
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 = [
|
2026-06-24 20:14:41 +03:00
|
|
|
|
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()),
|
2026-05-15 23:18:21 +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; }
|
|
|
|
|
|
}
|
|
|
|
|
|
(max_px - min_px).max(max_py - min_py) < threshold_px
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 23:13:16 +03:00
|
|
|
|
/// 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]>,
|
2026-06-24 20:14:41 +03:00
|
|
|
|
view_rot: glam::Mat4,
|
|
|
|
|
|
eye: glam::DVec3,
|
2026-05-15 23:13:16 +03:00
|
|
|
|
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 = [
|
2026-06-24 20:14:41 +03:00
|
|
|
|
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()),
|
2026-05-15 23:13:16 +03:00
|
|
|
|
];
|
|
|
|
|
|
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,
|
2026-04-08 00:52:38 +03:00
|
|
|
|
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: &[],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 12:20:47 +03:00
|
|
|
|
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: &[],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 00:52:38 +03:00
|
|
|
|
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: &[],
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 19:47:42 +03:00
|
|
|
|
/// 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,
|
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>>>,
|
|
|
|
|
|
),
|
|
|
|
|
|
>,
|
2026-05-27 19:47:42 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl MultiPipeline {
|
2026-06-27 11:50:11 +03:00
|
|
|
|
/// 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.
|
2026-05-27 19:47:42 +03:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(render): install the GPU error handler once per device, throttled
The uncaptured-error handler from #404 was armed in `Pipeline::new`, which
`MultiPipeline::ensure_len` runs again for every pane added — so the slot was
re-registered on every new pane. Move it to `MultiPipeline::new`, which runs
once per device; if iced ever rebuilds the device it builds a new
`MultiPipeline` too, so the fresh device stays covered — where a `Once` guard
would have left it on wgpu's panicking default.
Throttle the message. The handler slot is per-device and single, so it also
catches iced's own draw errors and is the only thing that can report them — it
has to stay loud. But a validation error normally repeats on every frame, and
an unthrottled print floods stderr at refresh rate and buries the first, most
useful message. Log the 1st, 2nd, 4th, 8th … occurrence instead: the error is
never hidden, the running count shows it is ongoing, and the output stays
finite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:38:53 +03:00
|
|
|
|
/// Send wgpu's uncaptured validation errors to stderr instead of the default
|
|
|
|
|
|
/// handler, which panics — and inside iced's main-thread redraw that panic
|
|
|
|
|
|
/// aborts the process, taking the user's unsaved work with it (#358). A bad
|
|
|
|
|
|
/// frame must degrade, not end the session.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The handler slot is per-device and single: this also catches iced's own draw
|
|
|
|
|
|
/// errors, and nothing else can report them, so it must stay loud enough to be
|
|
|
|
|
|
/// noticed. A validation error normally repeats on every frame, though, so a
|
|
|
|
|
|
/// plain print would flood stderr at refresh rate and bury the first (most
|
|
|
|
|
|
/// useful) message. Log the 1st, 2nd, 4th, 8th … occurrence instead: the error
|
|
|
|
|
|
/// is never hidden, the count shows it is ongoing, and the output stays finite.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Installed from `MultiPipeline::new`, which runs once per device — NOT from
|
|
|
|
|
|
/// `Pipeline::new`, which runs again for every pane `ensure_len` adds. If iced
|
|
|
|
|
|
/// ever rebuilds the device it builds a new `MultiPipeline` too, so the fresh
|
|
|
|
|
|
/// device is covered (and the throttle restarts with it).
|
|
|
|
|
|
fn install_gpu_error_handler(device: &wgpu::Device) {
|
|
|
|
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
|
|
static SEEN: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
|
|
SEEN.store(0, Ordering::Relaxed);
|
|
|
|
|
|
device.on_uncaptured_error(std::sync::Arc::new(|e: wgpu::Error| {
|
|
|
|
|
|
let n = SEEN.fetch_add(1, Ordering::Relaxed) + 1;
|
|
|
|
|
|
if n.is_power_of_two() {
|
|
|
|
|
|
eprintln!("[gpu] uncaptured wgpu error #{n} (frame degraded, session kept alive): {e}");
|
|
|
|
|
|
}
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 19:47:42 +03:00
|
|
|
|
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 {
|
fix(render): install the GPU error handler once per device, throttled
The uncaptured-error handler from #404 was armed in `Pipeline::new`, which
`MultiPipeline::ensure_len` runs again for every pane added — so the slot was
re-registered on every new pane. Move it to `MultiPipeline::new`, which runs
once per device; if iced ever rebuilds the device it builds a new
`MultiPipeline` too, so the fresh device stays covered — where a `Once` guard
would have left it on wgpu's panicking default.
Throttle the message. The handler slot is per-device and single, so it also
catches iced's own draw errors and is the only thing that can report them — it
has to stay loud. But a validation error normally repeats on every frame, and
an unthrottled print floods stderr at refresh rate and buries the first, most
useful message. Log the 1st, 2nd, 4th, 8th … occurrence instead: the error is
never hidden, the running count shows it is ongoing, and the output stays
finite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:38:53 +03:00
|
|
|
|
install_gpu_error_handler(device);
|
2026-05-27 19:47:42 +03:00
|
|
|
|
Self {
|
|
|
|
|
|
inners: vec![Pipeline::new(device, queue, format)],
|
|
|
|
|
|
format,
|
2026-07-14 21:19:18 +03:00
|
|
|
|
wire_buffer_cache: rustc_hash::FxHashMap::default(),
|
2026-05-27 19:47:42 +03:00
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:02:56 +03:00
|
|
|
|
|
|
|
|
|
|
/// The two silhouette angles of a cone/cylinder face for a view direction,
|
|
|
|
|
|
/// expressed in the face's `(u, v, axis)` frame via the view's components on
|
|
|
|
|
|
/// each: `du = view·u`, `dv = view·v`, `da = view·axis`. `tan_a` is the cone
|
|
|
|
|
|
/// taper (0 for a cylinder).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The outward normal is edge-on to the view where `du·cosθ + dv·sinθ =
|
|
|
|
|
|
/// -tanα·da`, i.e. `θ = φ ± acos(-tanα·da / |view⊥|)` with `φ = atan2(dv, du)`.
|
|
|
|
|
|
/// `None` when the view runs down the axis (no outline) or the whole cone faces
|
|
|
|
|
|
/// toward/away (`|arg| > 1`).
|
|
|
|
|
|
fn silhouette_thetas(du: f64, dv: f64, da: f64, tan_a: f64) -> Option<(f64, f64)> {
|
|
|
|
|
|
let r_perp = (du * du + dv * dv).sqrt();
|
|
|
|
|
|
if r_perp < 1e-6 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let arg = -tan_a * da / r_perp;
|
|
|
|
|
|
if arg.abs() > 1.0 {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let phi = dv.atan2(du);
|
|
|
|
|
|
let delta = arg.acos();
|
|
|
|
|
|
Some((phi + delta, phi - delta))
|
|
|
|
|
|
}
|