Wassim SAMAD c3199e7540
editor: green/red placement box for shelf + tool sync for presets (#366)
* feat(editor): shelf placement validity box + sync tool for positioned presets

Give the generic move tool a green/red footprint box for shelf placement,
matching the GLB item cursor, and drop the vertical-arrow CursorSphere for
shelves. Box colour comes from canPlaceOnFloor; an invalid (red) drop is
refused unless Shift forces it, and R/T play the rotate sfx. Re-sync the box
transform on node change so a re-armed clone isn't left at the previous
rotation/position.

Extract the box wireframe geometry helpers into a shared
placement-box-geometry module reused by the item coordinator and the new
declarative PlacementBox component. Export the Tool type so host apps can set
the active tool for a positioned preset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: apply biome formatting + ignore generated next-env.d.ts

Format files that predate the current biome config (lineWidth 100, single
quotes, semicolons as-needed) so they stop showing as dirty on every checkout.
Formatting only — no behavior change.

Also exclude **/next-env.d.ts from biome: Next regenerates it (double quotes +
semicolon) on every build, so biome kept reformatting it into a perpetual dirty
diff. Ignoring it lets Next own the file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:52:57 -04:00

294 lines
9.6 KiB
TypeScript

import type {
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
RoofNode,
RoofSegmentNode,
} from '@pascal-app/core'
import { unionPolygons } from '@pascal-app/viewer'
import { getRoofSegmentPlanLinework } from '../roof-segment/floorplan'
type Pt = [number, number]
type Seg = [Pt, Pt]
function signedArea(ring: readonly Pt[]): number {
let a = 0
const n = ring.length
for (let i = 0; i < n; i++) {
const p = ring[i] as Pt
const q = ring[(i + 1) % n] as Pt
a += p[0] * q[1] - q[0] * p[1]
}
return a / 2
}
/** Distance `t >= 0` from `V` along unit dir `(dx,dz)` to where the ray first
* meets segment `A→B`, or null. (Used to terminate valleys at ridges.) */
function rayHitT(
vx: number,
vz: number,
dx: number,
dz: number,
ax: number,
az: number,
bx: number,
bz: number,
): number | null {
const ex = bx - ax
const ez = bz - az
const denom = dx * ez - dz * ex
if (Math.abs(denom) < 1e-9) return null
const wx = ax - vx
const wz = az - vz
const t = (wx * ez - wz * ex) / denom
const s = (wx * dz - wz * dx) / denom
if (t < 0) return null
if (s < -1e-6 || s > 1 + 1e-6) return null
return t
}
function pointInPolygon(px: number, pz: number, poly: readonly Pt[]): boolean {
let inside = false
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const pi = poly[i] as Pt
const pj = poly[j] as Pt
if (
pi[1] > pz !== pj[1] > pz &&
px < ((pj[0] - pi[0]) * (pz - pi[1])) / (pj[1] - pi[1]) + pi[0]
) {
inside = !inside
}
}
return inside
}
/** Parametric `t` in (0,1) along `p1→p2` where it crosses segment `a→b`, else null. */
function segCrossT(p1: Pt, p2: Pt, a: Pt, b: Pt): number | null {
const rx = p2[0] - p1[0]
const rz = p2[1] - p1[1]
const ex = b[0] - a[0]
const ez = b[1] - a[1]
const denom = rx * ez - rz * ex
if (Math.abs(denom) < 1e-12) return null
const wx = a[0] - p1[0]
const wz = a[1] - p1[1]
const t = (wx * ez - wz * ex) / denom
const s = (wx * rz - wz * rx) / denom
if (t <= 1e-4 || t >= 1 - 1e-9) return null
if (s < -1e-6 || s > 1 + 1e-6) return null
return t
}
type SegPlan = {
footprint: Pt[]
ridges: Seg[]
hips: Seg[]
breaks: Seg[]
slope: { tail: Pt; head: Pt } | null
}
/** A segment's footprint + ridge/hip/break/slope linework, in world plan coords. */
function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan {
const cosRoof = Math.cos(-roof.rotation)
const sinRoof = Math.sin(-roof.rotation)
const segCx = roof.position[0] + seg.position[0] * cosRoof - seg.position[2] * sinRoof
const segCz = roof.position[2] + seg.position[0] * sinRoof + seg.position[2] * cosRoof
const rot = -(roof.rotation + seg.rotation)
const cos = Math.cos(rot)
const sin = Math.sin(rot)
const tp = (lx: number, lz: number): Pt => [
segCx + lx * cos - lz * sin,
segCz + lx * sin + lz * cos,
]
const hw = Math.max(seg.width, 0.01) / 2
const hd = Math.max(seg.depth, 0.01) / 2
const lw = getRoofSegmentPlanLinework(seg)
const mapSeg = (s: readonly [readonly [number, number], readonly [number, number]]): Seg => [
tp(s[0][0], s[0][1]),
tp(s[1][0], s[1][1]),
]
return {
footprint: [tp(-hw, -hd), tp(hw, -hd), tp(hw, hd), tp(-hw, hd)],
ridges: lw.ridges.map(mapSeg),
hips: lw.hips.map(mapSeg),
breaks: lw.breaks.map(mapSeg),
slope: lw.slope
? {
tail: tp(lw.slope.tail[0], lw.slope.tail[1]),
head: tp(lw.slope.head[0], lw.slope.head[1]),
}
: null,
}
}
/**
* Roof-level floor-plan builder. Draws the whole merged-roof plan: the
* unioned silhouette, the valley diagonals at concave junctions, and every
* segment's ridge/hip/break linework — clipped so a line stops at the valley
* where its segment overlaps a neighbour, instead of running on at the
* segment's full length into the cut-away part.
*
* Drawing all the linework here (rather than per-segment) is what lets the
* clip work: the valleys and the neighbouring footprints are all in hand, so
* each line can be trimmed to the actual merged geometry. The segment
* builder keeps only its hit-target / selection chrome.
*
* Composition uses the floor plan's negated-rotation convention
* (segment-local → roof-local → plan). `unionPolygons` returns one ring per
* disjoint group, so non-touching segments each keep their own outline. The
* group is decorative (`pointerEvents: 'none'`) — clicks fall through to the
* segment hit-targets.
*/
export function buildRoofFloorplan(node: RoofNode, ctx: GeometryContext): FloorplanGeometry | null {
const segments = ctx.children.filter((c): c is RoofSegmentNode => c.type === 'roof-segment')
if (segments.length === 0) return null
const plans = segments.map((s) => buildSegPlan(node, s))
const rings = unionPolygons(plans.map((p) => p.footprint)) as Pt[][]
if (rings.length === 0) return null
// Valleys at concave (reflex) corners of the merged outline. Each runs
// along the interior angle bisector and terminates at the nearest segment
// ridge — the diagonal where two merged slopes meet.
const allRidges: Seg[] = plans.flatMap((p) => p.ridges)
const valleys: Seg[] = []
for (const ring of rings) {
const n = ring.length
if (n < 3) continue
const orient = signedArea(ring) > 0 ? 1 : -1
for (let i = 0; i < n; i++) {
const prev = ring[(i - 1 + n) % n] as Pt
const V = ring[i] as Pt
const next = ring[(i + 1) % n] as Pt
const ax = prev[0] - V[0]
const az = prev[1] - V[1]
const bx = next[0] - V[0]
const bz = next[1] - V[1]
if ((ax * bz - az * bx) * orient <= 0) continue // not reflex
const la = Math.hypot(ax, az) || 1
const lb = Math.hypot(bx, bz) || 1
let dx = -(ax / la + bx / lb)
let dz = -(az / la + bz / lb)
const dl = Math.hypot(dx, dz)
if (dl < 1e-6) continue
dx /= dl
dz /= dl
let bestT = Number.POSITIVE_INFINITY
for (const [A, B] of allRidges) {
const t = rayHitT(V[0], V[1], dx, dz, A[0], A[1], B[0], B[1])
if (t !== null && t > 1e-4 && t < bestT) bestT = t
}
if (!Number.isFinite(bestT)) continue
valleys.push([
[V[0], V[1]],
[V[0] + dx * bestT, V[1] + dz * bestT],
])
}
}
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected ?? false) || (view?.highlighted ?? false)
const ink = showSelectedChrome && palette ? palette.selectedStroke : '#111111'
const eaveWidth = showSelectedChrome ? 0.04 : 0.03
const ridgeWidth = showSelectedChrome ? 0.05 : 0.038
const hipWidth = showSelectedChrome ? 0.04 : 0.026
const children: FloorplanGeometry[] = []
const pushLine = (a: Pt, b: Pt, width: number) => {
children.push({
kind: 'line',
x1: a[0],
y1: a[1],
x2: b[0],
y2: b[1],
stroke: ink,
strokeWidth: width,
strokeLinecap: 'round',
pointerEvents: 'none',
})
}
// Merged outline (eaves).
for (const ring of rings) {
if (ring.length < 3) continue
children.push({
kind: 'polygon',
points: ring.map(([x, z]) => [x, z] as FloorplanPoint),
fill: 'none',
stroke: ink,
strokeWidth: eaveWidth,
strokeLinejoin: 'miter',
pointerEvents: 'none',
})
}
// Valley diagonals.
for (const v of valleys) pushLine(v[0], v[1], hipWidth)
// Per-segment ridge / hip / break linework, clipped to the merged geometry:
// an endpoint that overshoots into another segment is pulled back to the
// valley it crosses (the junction), so a ridge stops at the diagonal.
const footprints = plans.map((p) => p.footprint)
const clipEnd = (pt: Pt, other: Pt, ownIndex: number): Pt => {
let inOther = false
for (let i = 0; i < footprints.length; i++) {
if (i === ownIndex) continue
if (pointInPolygon(pt[0], pt[1], footprints[i] as Pt[])) {
inOther = true
break
}
}
if (!inOther) return pt
let bestT = Number.POSITIVE_INFINITY // nearest valley crossing to the overshoot
for (const v of valleys) {
const t = segCrossT(pt, other, v[0], v[1])
if (t !== null && t < bestT) bestT = t
}
if (!Number.isFinite(bestT)) return pt // overshoots but no valley to stop at
return [pt[0] + (other[0] - pt[0]) * bestT, pt[1] + (other[1] - pt[1]) * bestT]
}
const clipPush = (line: Seg, width: number, ownIndex: number) => {
const a = clipEnd(line[0], line[1], ownIndex)
const b = clipEnd(line[1], a, ownIndex)
const dx = a[0] - b[0]
const dz = a[1] - b[1]
if (dx * dx + dz * dz < 1e-8) return
pushLine(a, b, width)
}
plans.forEach((p, idx) => {
for (const s of p.breaks) clipPush(s, hipWidth, idx)
for (const s of p.hips) clipPush(s, hipWidth, idx)
for (const s of p.ridges) clipPush(s, ridgeWidth, idx)
// Shed downslope arrow (no overshoot to clip).
if (p.slope) {
const { tail, head } = p.slope
const dx = head[0] - tail[0]
const dz = head[1] - tail[1]
const len = Math.hypot(dx, dz) || 1
const ux = dx / len
const uz = dz / len
const headLen = Math.min(0.22, len * 0.4)
const wing = headLen * 0.6
pushLine(tail, head, hipWidth)
children.push({
kind: 'polyline',
points: [
[head[0] - headLen * ux - wing * uz, head[1] - headLen * uz + wing * ux],
[head[0], head[1]],
[head[0] - headLen * ux + wing * uz, head[1] - headLen * uz - wing * ux],
],
stroke: ink,
strokeWidth: hipWidth,
strokeLinecap: 'round',
strokeLinejoin: 'round',
pointerEvents: 'none',
})
}
})
return children.length > 0 ? { kind: 'group', children } : null
}