/** * Clip areas (#260): an object of type `clip` cuts everything below it, at any depth, to its own * rectangle – drawn and hit alike. Areas above one another intersect. A rectangle that turns, * grows or mirrors with its parent stays convex, so the region is always one convex polygon. */ import type { AssetType } from '../model/AssetTypes'; import type { Mat } from '../model/matrix'; import type { PropertyBag, Vec2 } from '../model/Properties'; import { footprintCorners } from './bounds'; /** A convex polygon in world units; empty when nothing of the object is left to see. */ export type ClipRegion = readonly Vec2[]; export interface ClipNode { type: AssetType; props: PropertyBag; m: Mat; } /** The area's own rectangle in world units. */ function clipCorners(props: PropertyBag, m: Mat): Vec2[] { return footprintCorners('clip', props, m); } function signedArea(polygon: readonly Vec2[]): number { let area = 0; for (let i = 0; i < polygon.length; i++) { const a = polygon[i]!; const b = polygon[(i + 1) % polygon.length]!; area += a.x * b.y - b.x * a.y; } return area / 2; } /** Which side of the edge a→b the point is on, turned so that inside is positive whatever the winding. */ function side(a: Vec2, b: Vec2, p: Vec2, winding: number): number { return ((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) * winding; } /** Sutherland–Hodgman: `subject` cut by every edge of the convex `clip`. */ export function intersectConvex(subject: readonly Vec2[], clip: readonly Vec2[]): Vec2[] { const winding = Math.sign(signedArea(clip)); if (winding === 0) return []; let out: Vec2[] = [...subject]; for (let i = 0; i < clip.length && out.length > 0; i++) { const a = clip[i]!; const b = clip[(i + 1) % clip.length]!; const input = out; out = []; for (let j = 0; j < input.length; j++) { const p = input[j]!; const q = input[(j + 1) % input.length]!; const sp = side(a, b, p, winding); const sq = side(a, b, q, winding); if (sp >= 0) out.push(p); if (sp >= 0 !== sq >= 0) { const t = sp / (sp - sq); out.push({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t }); } } } return out.length >= 3 && signedArea(out) !== 0 ? out : []; } /** Whether a world point lies inside the region, edge included. */ export function regionContains(region: ClipRegion, p: Vec2): boolean { if (region.length < 3) return false; const winding = Math.sign(signedArea(region)); for (let i = 0; i < region.length; i++) if (side(region[i]!, region[(i + 1) % region.length]!, p, winding) < 0) return false; return true; } /** * The region each object is cut to: null for one with no clip area above it. The area itself is * not cut by its own rectangle, only by those above it. */ export function clipRegions( items: readonly T[], parentOf: (item: T) => T | null | undefined, ): Map { const regions = new Map(); const regionOf = (item: T): ClipRegion | null => { if (regions.has(item)) return regions.get(item)!; // Set first, so a broken hierarchy that loops ends here instead of overflowing the stack. regions.set(item, null); const parent = parentOf(item); let region: ClipRegion | null = null; if (parent) { const above = regionOf(parent); if (parent.type !== 'clip') region = above; else { const own = clipCorners(parent.props, parent.m); region = above ? intersectConvex(own, above) : own; } } regions.set(item, region); return region; }; for (const item of items) regionOf(item); return regions; } /** * Whether the scene view opens the clip areas above `item` (#260): when it, or an object above it, * is selected. Selecting the area opens all it cuts, selecting one row of a list opens that row. */ export function opensClip( item: T, selected: readonly string[], parentOf: (item: T) => T | undefined, ): boolean { const seen = new Set(); for (let at: T | undefined = item; at && !seen.has(at); at = parentOf(at)) { if (selected.includes(at.id)) return true; seen.add(at); } return false; }