* 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>
294 lines
9.6 KiB
TypeScript
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
|
|
}
|