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>
This commit is contained in:
Hakan Seven 2026-07-20 10:02:02 +03:00
commit c97c2a8250
6 changed files with 378 additions and 41 deletions

View file

@ -347,6 +347,127 @@ impl Scene {
result
}
/// Clip regions for every on content viewport in `paper_block`: the paper
/// bounding rect (matching the wires' `vp_scissor`) plus the boundary
/// polygon whose interior the GPU keeps via the stencil buffer. A plain
/// rectangular viewport contributes its four corners; a viewport with a
/// non-rectangular clip (`clip_boundary_handle`) contributes that entity's
/// tessellated outline (circle / ellipse / spline / polyline), reusing the
/// entity's own `to_truck` tessellation.
/// A content viewport's non-rectangular clip boundary, projected into that
/// viewport's render-target normalized device coords. Returns an empty vec
/// for a rectangular viewport (no `clip_boundary_handle`) — those clip via
/// their own render rectangle and need no stencil mask.
///
/// `uo/vo/us/vs` are the visible-sub-rect crop the content camera applies
/// (see `crop_view_proj`); the same crop is applied here so the boundary
/// lines up with the content even when the viewport hangs off-canvas.
pub(super) fn viewport_clip_boundary_ndc(
&self,
vp_handle: Handle,
uo: f32,
vo: f32,
us: f32,
vs: f32,
) -> Vec<[f32; 2]> {
use acadrust::entities::Viewport;
let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else {
return vec![];
};
let vp: &Viewport = vp;
if vp.clip_boundary_handle.is_null() {
return vec![];
}
let pcx = vp.center.x as f32;
let pcy = vp.center.y as f32;
let z = vp.center.z as f32;
let hw = (vp.width / 2.0) as f32;
let hh = (vp.height / 2.0) as f32;
if hw.abs() < 1e-6 || hh.abs() < 1e-6 {
return vec![];
}
let poly = self.clip_boundary_polygon(vp.clip_boundary_handle, z);
if poly.len() < 3 {
return vec![];
}
// Paper point → full-rectangle NDC ([-1, 1] across the viewport rect,
// Y up, matching the content camera + `viewport_screen_rect`), then the
// same crop the content camera uses to map its visible sub-rect back to
// NDC [-1, 1].
let us = us.max(1e-6);
let vs = vs.max(1e-6);
let sx = 1.0 / us;
let sy = 1.0 / vs;
let tx = (1.0 - 2.0 * uo - us) / us;
let ty = -(1.0 - 2.0 * vo - vs) / vs;
poly.iter()
.map(|p| {
let nx = (p[0] - pcx) / hw;
let ny = (p[1] - pcy) / hh;
[sx * nx + tx, sy * ny + ty]
})
.collect()
}
/// Tessellated outline of a non-rectangular viewport / XCLIP clip-boundary
/// entity, in paper coordinates, by reusing the entity's own `to_truck`
/// tessellation (Lines). NaN segment breaks are dropped so the result is a
/// single ordered ring suitable for a stencil triangle-fan.
pub(super) fn clip_boundary_polygon(&self, handle: Handle, z: f32) -> Vec<[f32; 3]> {
use std::f64::consts::TAU;
let Some(entity) = self
.document
.entities()
.find(|e| e.common().handle == handle)
else {
return vec![];
};
// Circles and ellipses tessellate directly — their `to_truck` returns a
// parametric TruckObject (not `Lines`), so extracting a polygon there
// would come back empty. Everything else (splines, polylines, …) reuses
// the entity's own `Lines` tessellation.
const N: usize = 64;
match entity {
EntityType::Circle(c) => (0..N)
.map(|i| {
let a = i as f64 * TAU / N as f64;
[
(c.center.x + a.cos() * c.radius) as f32,
(c.center.y + a.sin() * c.radius) as f32,
z,
]
})
.collect(),
EntityType::Ellipse(el) => {
// major_axis = center → major endpoint; minor = perp(major) × ratio.
let (mx, my) = (el.major_axis.x, el.major_axis.y);
let r = el.minor_axis_ratio;
(0..N)
.map(|i| {
let t = i as f64 * TAU / N as f64;
let px = mx * t.cos() - my * r * t.sin();
let py = my * t.cos() + mx * r * t.sin();
[(el.center.x + px) as f32, (el.center.y + py) as f32, z]
})
.collect()
}
_ => {
use crate::entities::traits::EntityTypeOps;
let Some(te) = entity.to_truck_entity(&self.document) else {
return vec![];
};
if let crate::scene::convert::acad_to_truck::TruckObject::Lines(pts) = te.object {
pts.into_iter()
.filter(|p| p[0].is_finite() && p[1].is_finite())
.map(|p| [p[0] as f32, p[1] as f32, z])
.collect()
} else {
vec![]
}
}
}
}
}
// ── Paper boundary wire ────────────────────────────────────────────────────