Hatch fills drew the full per-family pattern loop every frame regardless
of zoom. Two LOD tiers, neither of which existed:
* **Shader-side solid substitute.** When the densest line family's
spacing × scale projects to less than 2 px, individual lines blur
into a solid color and the per-fragment family loop is wasted ALU
(and produces moiré). Pass `world_per_pixel` through Uniforms; the
hatch fragment shader now returns `h.color` directly in that case.
* **CPU-side sub-pixel skip.** When the entire hatch AABB projects
to less than 2 px, drop the draw call. Adds `world_aabb` to
HatchGpu plus a per-frame `compute_hatch_lod` pass (mirrors the
existing scissor pass) and `hatch_skip_flags`. The render pass
skips flagged hatches.
Factored a shared `aabb_below_pixel` helper for future LOD callers.
The Uniforms layout is unchanged in size (96 B) — the trailing
`_pad: vec2<f32>` becomes `world_per_pixel: f32, _pad: f32`, so wgsl
modules that don't read the new field keep working with their old
`_pad: vec2<f32>` declarations.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wire pass already uses `WireModel.vp_scissor` to clip viewport-projected
geometry to its paper-space viewport rect; hatch / image / wipeout
passes drew across the whole canvas. When a future paper-space path
projects fills/images into a viewport, they would bleed past the
viewport frame.
Add `vp_scissor: Option<[f32; 4]>` to HatchModel and ImageModel
(mirroring WireModel), forward it through HatchGpu / ImageGpu, and add
per-pass `compute_*_scissors` / `set_scissor_rect` machinery. Factored
the wire-side scissor-projection math into a shared `project_scissor`
helper. Field defaults to None at every constructor site — no producer
sets it yet, so this is plumbing for the paper-viewport fill projection
that Phase 1.4 unblocks without changing current visual output.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>