2026-04-28 21:53:31 +03:00
|
|
|
|
pub mod face3d_gpu;
|
2026-03-22 14:56:27 +03:00
|
|
|
|
pub mod hatch_gpu;
|
2026-07-24 17:05:34 +03:00
|
|
|
|
/// Texture-backed compatibility hatch renderer. Used on WebGL2 and on native
|
|
|
|
|
|
/// adapters that cannot support the storage-buffer batch.
|
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 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,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
/// Stamps clip-boundary polygons into the stencil buffer (viewports + XCLIP).
|
|
|
|
|
|
clip_mask_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 /
|
2026-07-24 17:05:34 +03:00
|
|
|
|
/// xray pipelines). `Some` only on the fast native path; `None` in packed
|
|
|
|
|
|
/// compatibility mode. Passed to `WireGpu::from_run` / `from_batch`.
|
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-07-24 17:05:34 +03:00
|
|
|
|
/// hatch in the frame. `None` in compatibility mode.
|
2026-07-09 10:45:40 +03:00
|
|
|
|
hatch_pipeline: Option<wgpu::RenderPipeline>,
|
2026-07-24 17:05:34 +03:00
|
|
|
|
/// Texture-backed compatibility hatch pipeline + its group-1 layout.
|
|
|
|
|
|
/// Present whenever the storage-buffer batch is unavailable or forced off.
|
|
|
|
|
|
hatch_compat_bgl1: Option<wgpu::BindGroupLayout>,
|
|
|
|
|
|
hatch_compat_pipeline: Option<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
|
2026-07-24 17:05:34 +03:00
|
|
|
|
/// for instances / boundary / families / dashes). `None` in compatibility
|
|
|
|
|
|
/// mode, where hatches render through `hatch_web_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
|
|
|
|
#[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,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
/// This content viewport's non-rectangular clip boundary as a triangle-fan
|
|
|
|
|
|
/// vertex buffer in the render target's normalized device coords (`None` =
|
|
|
|
|
|
/// rectangular / unclipped, where the viewport's own render rectangle does
|
|
|
|
|
|
/// the clipping). Stamped into the stencil once per frame; every content
|
|
|
|
|
|
/// pass then draws with stencil reference 1 so only the interior survives.
|
|
|
|
|
|
clip_boundary: Option<(wgpu::Buffer, u32)>,
|
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
|
2026-07-24 17:05:34 +03:00
|
|
|
|
/// families are uploaded (no cap). `None` in compatibility mode.
|
2026-07-09 10:45:40 +03:00
|
|
|
|
gpu_hatch: Option<hatch_gpu::HatchGpu>,
|
2026-07-24 17:05:34 +03:00
|
|
|
|
/// Compatibility hatch fills — one texture-backed GPU object per hatch.
|
|
|
|
|
|
gpu_hatches_compat: 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-04-06 23:19:08 +03:00
|
|
|
|
gpu_images: Vec<ImageGpu>,
|
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-07-24 17:05:34 +03:00
|
|
|
|
// Select once per device. The fast native path hoists shared constants
|
|
|
|
|
|
// into storage; compatibility mode keeps them in 10 packed attributes.
|
|
|
|
|
|
let wire_mode = wire_gpu::WirePipelineMode::select(device);
|
|
|
|
|
|
let renderer_mode_name =
|
|
|
|
|
|
if wire_mode.uses_storage() { "fast-storage" } else { "packed-compat" };
|
2026-06-26 10:39:23 +03:00
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
2026-07-24 17:05:34 +03:00
|
|
|
|
if std::env::var_os("RUST_LOG").is_some() {
|
|
|
|
|
|
eprintln!(
|
|
|
|
|
|
"renderer pipeline: {} (storage buffers/stage: {})",
|
|
|
|
|
|
renderer_mode_name,
|
|
|
|
|
|
device.limits().max_storage_buffers_per_shader_stage
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
|
|
|
|
log::info!(
|
|
|
|
|
|
"renderer pipeline: {} (storage buffers/stage: {})",
|
|
|
|
|
|
renderer_mode_name,
|
|
|
|
|
|
device.limits().max_storage_buffers_per_shader_stage
|
|
|
|
|
|
);
|
2026-06-26 10:39:23 +03:00
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
2026-07-24 17:05:34 +03:00
|
|
|
|
let wire_const_bgl = wire_mode
|
|
|
|
|
|
.uses_storage()
|
|
|
|
|
|
.then(|| wire_gpu::WireConst::bind_group_layout(device));
|
2026-06-26 10:39:23 +03:00
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
2026-07-24 17:05:34 +03:00
|
|
|
|
let wire_const_bgl: Option<wgpu::BindGroupLayout> = None;
|
|
|
|
|
|
let mut wire_bgls: Vec<&wgpu::BindGroupLayout> = vec![&frame_bgl];
|
|
|
|
|
|
if let Some(bgl) = &wire_const_bgl {
|
|
|
|
|
|
wire_bgls.push(bgl);
|
|
|
|
|
|
}
|
2026-03-22 14:56:27 +03:00
|
|
|
|
let wire_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
|
|
|
|
|
label: Some("wire.pipeline_layout"),
|
2026-07-24 17:05:34 +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-07-24 17:05:34 +03:00
|
|
|
|
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(match wire_mode {
|
|
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
|
|
|
|
wire_gpu::WirePipelineMode::IndexedStorage => {
|
|
|
|
|
|
include_str!("../../shaders/wire_indexed.wgsl")
|
|
|
|
|
|
}
|
|
|
|
|
|
wire_gpu::WirePipelineMode::Packed => include_str!("../../shaders/wire.wgsl"),
|
|
|
|
|
|
})),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
// Stencil test shared by every paper content pipeline: draw only where
|
|
|
|
|
|
// the stencil equals the bound reference. Non-clipped content binds
|
|
|
|
|
|
// reference 0 (matching the frame's stencil, cleared to 0); a clip
|
|
|
|
|
|
// region's content binds reference 1 after its boundary has been stamped
|
|
|
|
|
|
// into the stencil by the clip-mask pipeline. In Model space nothing
|
|
|
|
|
|
// masks the stencil, so it stays 0 and reference 0 always passes.
|
|
|
|
|
|
let content_stencil_face = wgpu::StencilFaceState {
|
|
|
|
|
|
compare: wgpu::CompareFunction::Equal,
|
|
|
|
|
|
fail_op: wgpu::StencilOperation::Keep,
|
|
|
|
|
|
depth_fail_op: wgpu::StencilOperation::Keep,
|
|
|
|
|
|
pass_op: wgpu::StencilOperation::Keep,
|
|
|
|
|
|
};
|
|
|
|
|
|
let content_stencil = wgpu::StencilState {
|
|
|
|
|
|
front: content_stencil_face,
|
|
|
|
|
|
back: content_stencil_face,
|
|
|
|
|
|
read_mask: 0xff,
|
|
|
|
|
|
write_mask: 0x00,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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-07-24 17:05:34 +03:00
|
|
|
|
buffers: &[wire_mode.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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
// ── Clip-mask pipeline ─────────────────────────────────────────────
|
|
|
|
|
|
// Stamps a viewport / XCLIP boundary polygon into the stencil buffer:
|
|
|
|
|
|
// no colour, no depth, stencil `Invert` (even-odd fill → interior marked
|
|
|
|
|
|
// for any polygon, convex or not). The boundary is drawn as a triangle
|
|
|
|
|
|
// fan in paper coordinates and transformed exactly like wires.
|
|
|
|
|
|
let clip_mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
|
|
|
|
|
label: Some("clip_mask.shader"),
|
|
|
|
|
|
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
|
|
|
|
|
"../../shaders/clip_mask.wgsl"
|
|
|
|
|
|
))),
|
|
|
|
|
|
});
|
|
|
|
|
|
let clip_mask_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
|
|
|
|
|
label: Some("clip_mask.pipeline_layout"),
|
|
|
|
|
|
bind_group_layouts: &[],
|
|
|
|
|
|
push_constant_ranges: &[],
|
|
|
|
|
|
});
|
|
|
|
|
|
let clip_mask_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
|
|
|
|
|
label: Some("clip_mask.pipeline"),
|
|
|
|
|
|
layout: Some(&clip_mask_layout),
|
|
|
|
|
|
vertex: wgpu::VertexState {
|
|
|
|
|
|
module: &clip_mask_shader,
|
|
|
|
|
|
entry_point: Some("vs_main"),
|
|
|
|
|
|
buffers: &[wgpu::VertexBufferLayout {
|
|
|
|
|
|
array_stride: 8,
|
|
|
|
|
|
step_mode: wgpu::VertexStepMode::Vertex,
|
|
|
|
|
|
attributes: &[wgpu::VertexAttribute {
|
|
|
|
|
|
offset: 0,
|
|
|
|
|
|
shader_location: 0,
|
|
|
|
|
|
format: wgpu::VertexFormat::Float32x2,
|
|
|
|
|
|
}],
|
|
|
|
|
|
}],
|
|
|
|
|
|
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::Depth24PlusStencil8,
|
|
|
|
|
|
depth_write_enabled: false,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::Always,
|
|
|
|
|
|
stencil: wgpu::StencilState {
|
|
|
|
|
|
front: wgpu::StencilFaceState {
|
|
|
|
|
|
compare: wgpu::CompareFunction::Always,
|
|
|
|
|
|
fail_op: wgpu::StencilOperation::Keep,
|
|
|
|
|
|
depth_fail_op: wgpu::StencilOperation::Keep,
|
|
|
|
|
|
pass_op: wgpu::StencilOperation::Invert,
|
|
|
|
|
|
},
|
|
|
|
|
|
back: wgpu::StencilFaceState {
|
|
|
|
|
|
compare: wgpu::CompareFunction::Always,
|
|
|
|
|
|
fail_op: wgpu::StencilOperation::Keep,
|
|
|
|
|
|
depth_fail_op: wgpu::StencilOperation::Keep,
|
|
|
|
|
|
pass_op: wgpu::StencilOperation::Invert,
|
|
|
|
|
|
},
|
|
|
|
|
|
read_mask: 0xff,
|
|
|
|
|
|
write_mask: 0xff,
|
|
|
|
|
|
},
|
|
|
|
|
|
bias: wgpu::DepthBiasState::default(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
multisample: wgpu::MultisampleState {
|
|
|
|
|
|
count: MSAA_SAMPLES,
|
|
|
|
|
|
mask: !0,
|
|
|
|
|
|
alpha_to_coverage_enabled: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
fragment: Some(wgpu::FragmentState {
|
|
|
|
|
|
module: &clip_mask_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-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"),
|
2026-07-24 17:05:34 +03:00
|
|
|
|
buffers: &[wire_mode.layout()],
|
2026-07-04 18:28: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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-07-04 18:28:27 +03:00
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
2026-07-04 18:28:27 +03:00
|
|
|
|
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-07-24 17:05:34 +03:00
|
|
|
|
buffers: &[wire_mode.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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-04-27 03:01:42 +03:00
|
|
|
|
depth_write_enabled: false,
|
2026-04-27 03:04:31 +03:00
|
|
|
|
depth_compare: wgpu::CompareFunction::Always,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
2026-04-27 03:01:42 +03:00
|
|
|
|
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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
2026-07-23 23:22:55 +03:00
|
|
|
|
// Bias TOWARD the camera: a wipeout must win against geometry at
|
|
|
|
|
|
// its own depth (a block's wipeout + shapes are coincident at
|
|
|
|
|
|
// Z=0). A positive bias pushed the mask behind, so LessEqual
|
|
|
|
|
|
// rejected it and the geometry showed through. Tiny enough that
|
|
|
|
|
|
// meaningfully-nearer geometry still occludes the mask.
|
2026-06-25 18:17:01 +01:00
|
|
|
|
bias: wgpu::DepthBiasState {
|
2026-07-23 23:22:55 +03:00
|
|
|
|
constant: -8,
|
|
|
|
|
|
slope_scale: -1.0,
|
2026-06-25 18:17:01 +01:00
|
|
|
|
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-07-24 17:05:34 +03:00
|
|
|
|
// ── Hatch pipelines ────────────────────────────────────────────────
|
|
|
|
|
|
// Fast mode batches every hatch through five storage buffers. Compat
|
|
|
|
|
|
// mode uses one data texture per hatch and therefore needs no storage.
|
|
|
|
|
|
let (hatch_bgl1, hatch_pipeline) = if wire_mode.uses_storage() {
|
|
|
|
|
|
let hatch_bgl1 = hatch_gpu::HatchGpu::bind_group_layout(device);
|
|
|
|
|
|
let hatch_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
|
|
|
|
|
label: Some("hatch.pipeline_layout"),
|
|
|
|
|
|
bind_group_layouts: &[&frame_bgl, &hatch_bgl1],
|
|
|
|
|
|
push_constant_ranges: &[],
|
|
|
|
|
|
});
|
|
|
|
|
|
let hatch_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
|
|
|
|
|
label: Some("hatch.shader"),
|
|
|
|
|
|
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
|
|
|
|
|
"../../shaders/hatch.wgsl"
|
|
|
|
|
|
))),
|
|
|
|
|
|
});
|
|
|
|
|
|
let hatch_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
|
|
|
|
|
label: Some("hatch.pipeline"),
|
|
|
|
|
|
layout: Some(&hatch_layout),
|
|
|
|
|
|
vertex: wgpu::VertexState {
|
|
|
|
|
|
module: &hatch_shader,
|
|
|
|
|
|
entry_point: Some("vs_main"),
|
|
|
|
|
|
buffers: &[hatch_gpu::HatchVertex::layout()],
|
|
|
|
|
|
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
2026-06-25 18:17:01 +01:00
|
|
|
|
},
|
2026-07-24 17:05:34 +03:00
|
|
|
|
primitive: wgpu::PrimitiveState {
|
|
|
|
|
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
|
|
|
|
|
cull_mode: None,
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
depth_stencil: Some(wgpu::DepthStencilState {
|
|
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
|
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
|
|
|
|
|
stencil: content_stencil.clone(),
|
|
|
|
|
|
bias: wgpu::DepthBiasState {
|
|
|
|
|
|
constant: 1,
|
|
|
|
|
|
slope_scale: 1.0,
|
|
|
|
|
|
clamp: 0.0,
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
multisample: wgpu::MultisampleState {
|
|
|
|
|
|
count: MSAA_SAMPLES,
|
|
|
|
|
|
mask: !0,
|
|
|
|
|
|
alpha_to_coverage_enabled: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
fragment: Some(wgpu::FragmentState {
|
|
|
|
|
|
module: &hatch_shader,
|
|
|
|
|
|
entry_point: Some("fs_main"),
|
|
|
|
|
|
targets: &[Some(wgpu::ColorTargetState {
|
|
|
|
|
|
format,
|
|
|
|
|
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
|
|
|
|
|
write_mask: wgpu::ColorWrites::ALL,
|
|
|
|
|
|
})],
|
|
|
|
|
|
compilation_options: wgpu::PipelineCompilationOptions::default(),
|
|
|
|
|
|
}),
|
|
|
|
|
|
multiview: None,
|
|
|
|
|
|
cache: None,
|
|
|
|
|
|
});
|
|
|
|
|
|
(Some(hatch_bgl1), Some(hatch_pipeline))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(None, None)
|
2026-06-16 17:53:56 +03:00
|
|
|
|
};
|
2026-05-17 19:52:28 +03:00
|
|
|
|
|
2026-07-24 17:05:34 +03:00
|
|
|
|
let (hatch_compat_bgl1, hatch_compat_pipeline) = if !wire_mode.uses_storage() {
|
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
|
|
|
|
let bgl1 = hatch_web_gpu::HatchWebGpu::bind_group_layout(device);
|
|
|
|
|
|
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
2026-07-24 17:05:34 +03:00
|
|
|
|
label: Some("hatch_compat.pipeline_layout"),
|
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
|
|
|
|
bind_group_layouts: &[&frame_bgl, &bgl1],
|
|
|
|
|
|
push_constant_ranges: &[],
|
|
|
|
|
|
});
|
|
|
|
|
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
2026-07-24 17:05:34 +03:00
|
|
|
|
label: Some("hatch_compat.shader"),
|
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
|
|
|
|
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
|
|
|
|
|
|
"../../shaders/hatch_web.wgsl"
|
|
|
|
|
|
))),
|
|
|
|
|
|
});
|
|
|
|
|
|
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
2026-07-24 17:05:34 +03:00
|
|
|
|
label: Some("hatch_compat.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
|
|
|
|
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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
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
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
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
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-07-24 17:05:34 +03:00
|
|
|
|
(Some(bgl1), Some(pipeline))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
(None, 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
|
|
|
|
};
|
|
|
|
|
|
|
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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-07-04 16:27:59 +03:00
|
|
|
|
depth_write_enabled: false,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
2026-07-04 16:27:59 +03:00
|
|
|
|
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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-06-30 22:46:29 +03:00
|
|
|
|
depth_write_enabled: false,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::Always,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
2026-06-30 22:46:29 +03:00
|
|
|
|
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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-05-27 05:28:20 +03:00
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
2026-05-27 05:28:20 +03:00
|
|
|
|
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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-04-28 21:53:31 +03:00
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-05-27 05:40:39 +03:00
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
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 {
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-04-06 23:19:08 +03:00
|
|
|
|
depth_write_enabled: true,
|
|
|
|
|
|
depth_compare: wgpu::CompareFunction::LessEqual,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil: content_stencil.clone(),
|
2026-04-06 23:19:08 +03:00
|
|
|
|
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,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
clip_mask_pipeline,
|
2026-07-04 18:28:27 +03:00
|
|
|
|
wire_black_pipeline,
|
2026-04-27 03:01:42 +03:00
|
|
|
|
wire_xray_pipeline,
|
2026-07-24 17:05:34 +03:00
|
|
|
|
wire_const_bgl,
|
2026-07-09 10:45:40 +03:00
|
|
|
|
wipeout_pipeline,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
hatch_pipeline,
|
2026-07-24 17:05:34 +03:00
|
|
|
|
hatch_compat_bgl1,
|
|
|
|
|
|
hatch_compat_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,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
clip_boundary: None,
|
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,
|
2026-07-24 17:05:34 +03:00
|
|
|
|
gpu_hatches_compat: 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-04-06 23:19:08 +03:00
|
|
|
|
gpu_images: 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-07-21 15:53:28 +03:00
|
|
|
|
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
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() {
|
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;
|
refactor(viewport): unify content-viewport clipping on the stencil path
Rectangular paper viewports now go through the same GPU stencil clip as
non-rectangular ones: viewport_clip_boundary_ndc returns the four rect
corners (a full-target mask) instead of nothing, so the render path has
no rect/non-rect special case — every content viewport carries a clip
boundary and draws with stencil reference 0xFF.
With a single clip path, the old per-wire / -image / -wipeout scissor
mechanism is fully dead and removed: compute_*_scissors, the
*_pixel_scissors buffers, the project_scissor helper, the per-pass
set_scissor_rect blocks, the vp_scissor field threaded through
WireModel / ImageModel / HatchModel and their GPU mirrors (WireGpu /
ImageGpu / WipeoutGpu / HatchWebGpu), from_run's scissor parameter, the
scissor grouping in build_wire_buffers, and the is_arena_eligible gate.
Net -245 lines across 54 files.
Rectangular viewports still clip exactly via their own render rectangle;
the stencil mask is a redundant full-target no-op there, kept only so
the clip code path stays uniform. XCLIP keeps its CPU wire clip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:06:28 +03:00
|
|
|
|
while j < wires.len() && is_mesh_edge(&wires[j]) == mesh_edge {
|
2026-06-05 09:35:18 +03:00
|
|
|
|
j += 1;
|
|
|
|
|
|
}
|
refactor(viewport): unify content-viewport clipping on the stencil path
Rectangular paper viewports now go through the same GPU stencil clip as
non-rectangular ones: viewport_clip_boundary_ndc returns the four rect
corners (a full-target mask) instead of nothing, so the render path has
no rect/non-rect special case — every content viewport carries a clip
boundary and draws with stencil reference 0xFF.
With a single clip path, the old per-wire / -image / -wipeout scissor
mechanism is fully dead and removed: compute_*_scissors, the
*_pixel_scissors buffers, the project_scissor helper, the per-pass
set_scissor_rect blocks, the vp_scissor field threaded through
WireModel / ImageModel / HatchModel and their GPU mirrors (WireGpu /
ImageGpu / WipeoutGpu / HatchWebGpu), from_run's scissor parameter, the
scissor grouping in build_wire_buffers, and the is_arena_eligible gate.
Net -245 lines across 54 files.
Rectangular viewports still clip exactly via their own render rectangle;
the stencil mask is a redundant full-target no-op there, kept only so
the clip code path stays uniform. XCLIP keeps its CPU wire clip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:06:28 +03:00
|
|
|
|
batches.extend(WireGpu::from_run(device, &wires[i..j], depth_map, 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-07-21 15:53:28 +03:00
|
|
|
|
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
2026-06-05 10:04:22 +03:00
|
|
|
|
) {
|
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 =
|
refactor(viewport): unify content-viewport clipping on the stencil path
Rectangular paper viewports now go through the same GPU stencil clip as
non-rectangular ones: viewport_clip_boundary_ndc returns the four rect
corners (a full-target mask) instead of nothing, so the render path has
no rect/non-rect special case — every content viewport carries a clip
boundary and draws with stencil reference 0xFF.
With a single clip path, the old per-wire / -image / -wipeout scissor
mechanism is fully dead and removed: compute_*_scissors, the
*_pixel_scissors buffers, the project_scissor helper, the per-pass
set_scissor_rect blocks, the vp_scissor field threaded through
WireModel / ImageModel / HatchModel and their GPU mirrors (WireGpu /
ImageGpu / WipeoutGpu / HatchWebGpu), from_run's scissor parameter, the
scissor grouping in build_wire_buffers, and the is_arena_eligible gate.
Net -245 lines across 54 files.
Rectangular viewports still clip exactly via their own render rectangle;
the stencil mask is a redundant full-target no-op there, kept only so
the clip code path stays uniform. XCLIP keeps its CPU wire clip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:06:28 +03:00
|
|
|
|
WireGpu::from_run(device, &out, depth_map, 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],
|
2026-07-21 15:53:28 +03:00
|
|
|
|
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
2026-06-05 10:57:38 +03:00
|
|
|
|
) {
|
|
|
|
|
|
self.gpu_preview_wires = if wires.is_empty() {
|
|
|
|
|
|
vec![]
|
|
|
|
|
|
} else {
|
refactor(viewport): unify content-viewport clipping on the stencil path
Rectangular paper viewports now go through the same GPU stencil clip as
non-rectangular ones: viewport_clip_boundary_ndc returns the four rect
corners (a full-target mask) instead of nothing, so the render path has
no rect/non-rect special case — every content viewport carries a clip
boundary and draws with stencil reference 0xFF.
With a single clip path, the old per-wire / -image / -wipeout scissor
mechanism is fully dead and removed: compute_*_scissors, the
*_pixel_scissors buffers, the project_scissor helper, the per-pass
set_scissor_rect blocks, the vp_scissor field threaded through
WireModel / ImageModel / HatchModel and their GPU mirrors (WireGpu /
ImageGpu / WipeoutGpu / HatchWebGpu), from_run's scissor parameter, the
scissor grouping in build_wire_buffers, and the is_arena_eligible gate.
Net -245 lines across 54 files.
Rectangular viewports still clip exactly via their own render rectangle;
the stencil mask is a redundant full-target no-op there, kept only so
the clip code path stays uniform. XCLIP keeps its CPU wire clip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:06:28 +03:00
|
|
|
|
WireGpu::from_run(device, wires, depth_map, 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
/// Upload this content viewport's non-rectangular clip boundary as a
|
|
|
|
|
|
/// triangle-fan vertex buffer in render-target NDC. The boundary is already
|
|
|
|
|
|
/// projected (paper shape → the same visible-sub-rect crop as the content),
|
|
|
|
|
|
/// so the mask pipeline stamps it straight into the stencil with `Invert`
|
|
|
|
|
|
/// (even-odd fill → interior marked, any convexity). Empty input clears the
|
|
|
|
|
|
/// boundary so the viewport renders unclipped (its render rectangle clips).
|
|
|
|
|
|
pub fn upload_clip_boundary(&mut self, device: &wgpu::Device, boundary_ndc: &[[f32; 2]]) {
|
|
|
|
|
|
use wgpu::util::DeviceExt;
|
|
|
|
|
|
if boundary_ndc.len() < 3 {
|
|
|
|
|
|
self.clip_boundary = None;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Triangle fan (p0, pi, pi+1).
|
|
|
|
|
|
let mut verts: Vec<f32> = Vec::with_capacity((boundary_ndc.len() - 2) * 6);
|
|
|
|
|
|
for i in 1..boundary_ndc.len() - 1 {
|
|
|
|
|
|
for &p in &[boundary_ndc[0], boundary_ndc[i], boundary_ndc[i + 1]] {
|
|
|
|
|
|
verts.extend_from_slice(&[p[0], p[1]]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if verts.is_empty() {
|
|
|
|
|
|
self.clip_boundary = None;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
|
|
|
|
label: Some("clip_boundary.vbuf"),
|
|
|
|
|
|
contents: bytemuck::cast_slice(&verts),
|
|
|
|
|
|
usage: wgpu::BufferUsages::VERTEX,
|
|
|
|
|
|
});
|
|
|
|
|
|
self.clip_boundary = Some((vbuf, (verts.len() / 2) as u32));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-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-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-07-21 15:53:28 +03:00
|
|
|
|
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
|
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],
|
|
|
|
|
|
) {
|
2026-07-24 17:05:34 +03:00
|
|
|
|
if let Some(bgl1) = &self.hatch_compat_bgl1 {
|
|
|
|
|
|
self.gpu_hatches_compat = hatches
|
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
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|h| h.boundary.len() >= 3)
|
2026-07-24 17:05:34 +03:00
|
|
|
|
.map(|h| hatch_web_gpu::HatchWebGpu::new(device, queue, h, bgl1))
|
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
|
|
|
|
.collect();
|
2026-07-24 17:05:34 +03:00
|
|
|
|
self.gpu_hatch = None;
|
|
|
|
|
|
return;
|
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
|
|
|
|
}
|
2026-07-24 17:05:34 +03:00
|
|
|
|
|
|
|
|
|
|
self.gpu_hatches_compat.clear();
|
|
|
|
|
|
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,
|
|
|
|
|
|
};
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
// Non-rectangular viewport clip: the boundary is stamped into the
|
|
|
|
|
|
// just-cleared (0x00) stencil with `Invert`, so an odd (interior)
|
|
|
|
|
|
// coverage becomes 0xFF. Every content pass then draws with reference
|
|
|
|
|
|
// 0xFF so only the interior survives. Rectangular / unclipped viewports
|
|
|
|
|
|
// leave the stencil at 0 and draw with reference 0 (the viewport's own
|
|
|
|
|
|
// render rectangle does the clipping).
|
|
|
|
|
|
let stencil_ref: u32 = if self.clip_boundary.is_some() { 0xFF } else { 0 };
|
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,
|
|
|
|
|
|
}),
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
// Clip stencil starts at 0 (= "unclipped", passes content
|
|
|
|
|
|
// bound to reference 0); clip masks stamp 1 into interiors.
|
|
|
|
|
|
stencil_ops: Some(wgpu::Operations {
|
|
|
|
|
|
load: wgpu::LoadOp::Clear(0),
|
|
|
|
|
|
store: wgpu::StoreOp::Store,
|
|
|
|
|
|
}),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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);
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
// Stamp the viewport clip boundary into the just-cleared stencil
|
|
|
|
|
|
// (interior → 1) before any content draws, so every pass below can
|
|
|
|
|
|
// clip to the shape with reference 1.
|
|
|
|
|
|
if let Some((vbuf, vcount)) = &self.clip_boundary {
|
|
|
|
|
|
pass.set_pipeline(&self.clip_mask_pipeline);
|
|
|
|
|
|
pass.set_vertex_buffer(0, vbuf.slice(..));
|
|
|
|
|
|
pass.draw(0..*vcount, 0..1);
|
|
|
|
|
|
}
|
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);
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
pass.set_stencil_reference(stencil_ref);
|
2026-07-05 09:48:49 +02:00
|
|
|
|
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
|
|
|
|
|
2026-07-24 17:05:34 +03:00
|
|
|
|
// Storage-free compatibility path. Draw before wires so outlines
|
|
|
|
|
|
// remain on top, matching the batched path.
|
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
|
|
|
|
if !self.skip_hatch_frame {
|
2026-07-24 17:05:34 +03:00
|
|
|
|
if let Some(pipeline) = &self.hatch_compat_pipeline {
|
|
|
|
|
|
pass.set_pipeline(pipeline);
|
|
|
|
|
|
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
|
|
|
|
|
|
pass.set_stencil_reference(stencil_ref);
|
|
|
|
|
|
for hatch in &self.gpu_hatches_compat {
|
|
|
|
|
|
pass.set_bind_group(1, &hatch.bind_group, &[]);
|
|
|
|
|
|
pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..));
|
|
|
|
|
|
pass.draw(0..6, 0..1);
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
|
|
|
|
|
}
|
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,
|
|
|
|
|
|
}),
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
|
2026-04-06 23:19:08 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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, &[]);
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
pass.set_stencil_reference(stencil_ref);
|
refactor(viewport): unify content-viewport clipping on the stencil path
Rectangular paper viewports now go through the same GPU stencil clip as
non-rectangular ones: viewport_clip_boundary_ndc returns the four rect
corners (a full-target mask) instead of nothing, so the render path has
no rect/non-rect special case — every content viewport carries a clip
boundary and draws with stencil reference 0xFF.
With a single clip path, the old per-wire / -image / -wipeout scissor
mechanism is fully dead and removed: compute_*_scissors, the
*_pixel_scissors buffers, the project_scissor helper, the per-pass
set_scissor_rect blocks, the vp_scissor field threaded through
WireModel / ImageModel / HatchModel and their GPU mirrors (WireGpu /
ImageGpu / WipeoutGpu / HatchWebGpu), from_run's scissor parameter, the
scissor grouping in build_wire_buffers, and the is_arena_eligible gate.
Net -245 lines across 54 files.
Rectangular viewports still clip exactly via their own render rectangle;
the stencil mask is a redundant full-target no-op there, kept only so
the clip code path stays uniform. XCLIP keeps its CPU wire clip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:06:28 +03:00
|
|
|
|
for img in self.gpu_images.iter() {
|
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-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,
|
|
|
|
|
|
}),
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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, &[]);
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
pass.set_stencil_reference(stencil_ref);
|
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,
|
|
|
|
|
|
}),
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
|
2026-04-28 21:53:31 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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, &[]);
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
pass.set_stencil_reference(stencil_ref);
|
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
|
|
|
|
}),
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
|
2026-04-29 20:58:44 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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, &[]);
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
pass.set_stencil_reference(stencil_ref);
|
2026-04-29 20:58:44 +03:00
|
|
|
|
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,
|
|
|
|
|
|
}),
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
|
2026-03-22 14:56:27 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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;
|
|
|
|
|
|
let mut black_active = false;
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
pass.set_stencil_reference(stencil_ref);
|
refactor(viewport): unify content-viewport clipping on the stencil path
Rectangular paper viewports now go through the same GPU stencil clip as
non-rectangular ones: viewport_clip_boundary_ndc returns the four rect
corners (a full-target mask) instead of nothing, so the render path has
no rect/non-rect special case — every content viewport carries a clip
boundary and draws with stencil reference 0xFF.
With a single clip path, the old per-wire / -image / -wipeout scissor
mechanism is fully dead and removed: compute_*_scissors, the
*_pixel_scissors buffers, the project_scissor helper, the per-pass
set_scissor_rect blocks, the vp_scissor field threaded through
WireModel / ImageModel / HatchModel and their GPU mirrors (WireGpu /
ImageGpu / WipeoutGpu / HatchWebGpu), from_run's scissor parameter, the
scissor grouping in build_wire_buffers, and the is_arena_eligible gate.
Net -245 lines across 54 files.
Rectangular viewports still clip exactly via their own render rectangle;
the stencil mask is a redundant full-target no-op there, kept only so
the clip code path stays uniform. XCLIP keeps its CPU wire clip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:06:28 +03:00
|
|
|
|
for wire in self.gpu_wires.iter() {
|
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;
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
|
2026-07-04 18:28:27 +03:00
|
|
|
|
if use_black != black_active {
|
|
|
|
|
|
pass.set_pipeline(if use_black {
|
|
|
|
|
|
&self.wire_black_pipeline
|
|
|
|
|
|
} else {
|
|
|
|
|
|
&self.wire_pipeline
|
|
|
|
|
|
});
|
|
|
|
|
|
black_active = use_black;
|
|
|
|
|
|
}
|
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
|
|
|
|
}
|
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,
|
|
|
|
|
|
}),
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
|
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
|
|
|
|
}),
|
|
|
|
|
|
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, &[]);
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
pass.set_stencil_reference(stencil_ref);
|
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.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,
|
|
|
|
|
|
}),
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
|
2026-04-06 23:44:36 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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, &[]);
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
pass.set_stencil_reference(stencil_ref);
|
2026-05-15 23:13:16 +03:00
|
|
|
|
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-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-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,
|
|
|
|
|
|
}),
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store }),
|
2026-04-27 03:01:42 +03:00
|
|
|
|
}),
|
|
|
|
|
|
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, &[]);
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
pass.set_stencil_reference(stencil_ref);
|
2026-04-27 03:01:42 +03:00
|
|
|
|
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-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,
|
fix(viewport): clip non-rectangular paper viewports to their boundary shape
Paper content viewports with a circle / spline / ellipse clip boundary
drew their model content to the full bounding rectangle instead of the
shape. Each paper content viewport is its own render into its own screen
rectangle, so clipping is done per viewport: the boundary polygon is
projected into that viewport's render-target NDC (paper shape mapped
through the same visible-sub-rect crop as the content, so it stays fixed
to the viewport frame) and stamped into the stencil buffer with an
even-odd Invert, which marks the interior of any shape, convex or not,
without triangulation. Every content pass then draws only where the
stencil is set, so hatch, wire, mesh, 3DFACE, text, image, wipeout and
selection are all clipped uniformly.
Because Invert turns the cleared 0x00 into 0xFF, clipped content draws
with stencil reference 0xFF (unclipped content keeps reference 0). The
shared depth buffer moves to Depth24PlusStencil8 across every pipeline.
Rectangular viewports keep clipping via their own render rectangle and
XCLIP keeps its existing CPU wire clip; neither needs the stencil path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:02 +03:00
|
|
|
|
format: wgpu::TextureFormat::Depth24PlusStencil8,
|
2026-03-22 14:56:27 +03:00
|
|
|
|
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))
|
|
|
|
|
|
}
|