Merge pull request #395 from pascalorg/feat/editor-ux-rendering-placement

editor: roof wall openings — doors/windows/items on roof-segment wall faces
This commit is contained in:
Aymeric Rabot
2026-06-10 14:39:37 -04:00
committed by GitHub
81 changed files with 4777 additions and 571 deletions
+94 -4
View File
@@ -1,8 +1,9 @@
'use client'
import { nodeRegistry } from '@pascal-app/core'
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
import Image from 'next/image'
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Tooltip,
TooltipContent,
@@ -79,6 +80,28 @@ function activatePaintMode(): void {
ed.setMode('material-paint')
}
type RoofFeature = { kind: string; label: string; iconSrc: string }
const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.png'
/**
* Roof accessories surfaced under the Roof tile (a "Features" group). Unlike
* the community editor these aren't DB presets — each is a registry kind with
* `capabilities.roofAccessory`, enumerated from the registry at render time
* (it is populated by the app bootstrap — a module-scope const would race it)
* and activated like any structure tool (the kind's tool attaches it to the
* roof segment under the cursor). Label + icon come from the registry's
* `presentation`; non-url icons fall back to the roof icon.
*/
function activateRoofFeatureTool(kind: string): void {
const ed = useEditor.getState()
ed.setPhase('structure')
ed.setStructureLayer('elements')
ed.setCatalogCategory(null)
ed.setMode('build')
ed.setTool(kind as Parameters<typeof ed.setTool>[0])
}
/**
* Build tab for the open-source standalone editor — a preset-less replica of
* the community Build sidebar. Clicking a type activates its raw tool, drawn
@@ -88,11 +111,32 @@ function activatePaintMode(): void {
export function BuildTab() {
const activeTool = useEditor((s) => s.tool)
const mode = useEditor((s) => s.mode)
// Which build tile's panel is showing. Roof is the only tile with a panel
// (its Features group); others arm a tool and show nothing below.
const [selectedTypeId, setSelectedTypeId] = useState<string | null>(null)
// Read at render time (not module scope): the registry is populated by the
// app bootstrap, so enumerating earlier would race it and see no kinds.
const roofFeatures = useMemo<RoofFeature[]>(() => {
const features: RoofFeature[] = []
for (const [kind, def] of nodeRegistry.entries()) {
if (def.capabilities.roofAccessory === undefined) continue
// Door / window declare `roofAccessory` for the wall-face cut but
// already have their own Build tiles — listing them here too
// would duplicate the entry under Roof → Features.
if (def.capabilities.wallOpeningPlacement) continue
const icon = def.presentation?.icon
features.push({
kind,
label: def.presentation?.label ?? kind,
iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON,
})
}
return features
}, [])
const isTypeActive = (type: BuildType) =>
type.mode === 'material-paint'
? mode === 'material-paint'
: mode === 'build' && activeTool === type.kind
type.mode === 'material-paint' ? mode === 'material-paint' : selectedTypeId === type.id
const handleTypeClick = useCallback((type: BuildType) => {
if (type.mode === 'material-paint') {
@@ -100,6 +144,7 @@ export function BuildTab() {
} else if (type.kind) {
activateBuildTool(type.kind)
}
setSelectedTypeId(type.id)
}, [])
// On open, land on the first build tool — parity with the community Build
@@ -160,6 +205,51 @@ export function BuildTab() {
<div className="min-h-0 flex-1 overflow-y-auto">
<MaterialPaintPanel />
</div>
) : selectedTypeId === 'roof' && roofFeatures.length > 0 ? (
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
<div className="px-0.5 pt-1 font-medium text-muted-foreground text-xs">Features</div>
<TooltipProvider delayDuration={0} disableHoverableContent>
<div
className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
>
{roofFeatures.map((feature) => {
const active = mode === 'build' && activeTool === feature.kind
return (
<Tooltip key={feature.kind}>
<TooltipTrigger asChild>
<button
className={cn(
'group relative flex aspect-square items-center justify-center rounded-xl p-1 transition-all duration-200',
active
? 'bg-primary/10 ring-1 ring-primary/50'
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
activateRoofFeatureTool(feature.kind)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<Image
alt={feature.label}
className="size-full object-contain transition-transform duration-200 group-hover:scale-110"
height={48}
src={feature.iconSrc}
width={48}
/>
</button>
</TooltipTrigger>
<TooltipContent className="pointer-events-none" side="top">
{feature.label}
</TooltipContent>
</Tooltip>
)
})}
</div>
</TooltipProvider>
</div>
) : null}
</div>
)
+29 -1
View File
@@ -40,6 +40,7 @@ import {
} from 'lucide-react'
import Image from 'next/image'
import { type ReactNode, useCallback } from 'react'
import { flushSync } from 'react-dom'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from './toolbar-tooltip'
@@ -49,6 +50,24 @@ const TOOLBAR_CONTAINER =
const TOOLBAR_BTN =
'flex w-8 items-center justify-center text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90'
function requestWalkthroughPointerLock() {
const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
if (!canvas) return
if (!canvas.hasAttribute('tabindex')) {
canvas.tabIndex = -1
}
canvas.focus({ preventScroll: true })
if (document.pointerLockElement === canvas) return
try {
canvas.requestPointerLock?.()
} catch {
return
}
}
function ToolbarTooltip({ children, label }: { children: ReactNode; label: string }) {
return (
<Tooltip>
@@ -441,6 +460,15 @@ function DisplayMenu() {
function WalkthroughButton() {
const isFirstPersonMode = useEditor((state) => state.isFirstPersonMode)
const setFirstPersonMode = useEditor((state) => state.setFirstPersonMode)
const handleClick = useCallback(() => {
if (isFirstPersonMode) {
setFirstPersonMode(false)
return
}
flushSync(() => setFirstPersonMode(true))
requestWalkthroughPointerLock()
}, [isFirstPersonMode, setFirstPersonMode])
return (
<ToolbarTooltip label="Walkthrough">
@@ -449,7 +477,7 @@ function WalkthroughButton() {
TOOLBAR_BTN,
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
)}
onClick={() => setFirstPersonMode(!isFirstPersonMode)}
onClick={handleClick}
type="button"
>
<Footprints className="h-4 w-4" />
+17
View File
@@ -1205,6 +1205,23 @@ export type PaintEffectiveMaterialArgs = {
*/
export type RoofAccessoryConfig = {
buildCut?: (node: AnyNode, hostSegment: AnyNode) => BufferGeometry | null
/**
* Which segment brushes `buildCut` subtracts from. Wall-face openings
* (door / window) cut only the wall brush — subtracting the same box
* from the shin / deck slabs is pointless work and creates tangential
* / coplanar CSG cases near the gable and shed slopes. Defaults to
* all three (skylight / dormer genuinely poke through the deck).
*/
cutScope?: 'all' | 'wall'
/**
* The kind's own dirty-driven geometry system consumes its dirty
* marks (door / window via DoorSystem / WindowSystem, which already
* cascade to the host segment through `parentId`). The roof-merge
* loop must then leave those marks alone — consuming them would
* starve that system whenever it defers a rebuild (mesh not mounted
* yet, per-frame rebuild budget exhausted).
*/
dirtyHandledByOwnSystem?: boolean
}
/**
+11
View File
@@ -108,6 +108,17 @@ export {
RoofSegmentNode,
RoofType,
} from './nodes/roof-segment'
export type { RoofSegmentWallFace, RoofWallFaceId } from './nodes/roof-segment-walls'
export {
clampRectToRoofWallFace,
getMaxRoofRectHeightFromAnchor,
getMaxRoofRectWidthFromAnchor,
getRoofSegmentWallFace,
getRoofSegmentWallFaces,
getRoofWallFaceFrame,
roofFacePointToSegment,
segmentPointToRoofWallFace,
} from './nodes/roof-segment-walls'
export { ScanNode } from './nodes/scan'
export { ShelfNode } from './nodes/shelf'
export { SiteNode } from './nodes/site'
+8
View File
@@ -46,6 +46,14 @@ export const DoorNode = BaseNode.extend({
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
side: z.enum(['front', 'back']).optional(),
wallId: z.string().optional(),
// Alternative host: a roof-segment's generated wall face (base wall
// under the roof or a coplanar gable end). When set, `position` is
// FACE-LOCAL — [u along the face, v height, z from the wall mid-plane]
// — exactly the wall-child convention; the renderer mounts the node
// inside the face frame (`getRoofWallFaceFrame`), which is what makes
// hosted children track segment resizes live.
roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Overall dimensions
width: z.number().default(0.9),
+7
View File
@@ -135,6 +135,13 @@ export const ItemNode = BaseNode.extend({
// Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
wallId: z.string().optional(),
wallT: z.number().optional(), // 0-1 parametric position along wall
// Alternative wall host: a roof-segment's generated wall face. When
// set, `position` is FACE-LOCAL — [u along the face, v = bottom edge,
// z from the wall mid-plane] — exactly the wall-child convention
// (ItemSystem's wall-side push applies the same way); the renderer
// mounts the node inside the face frame (`getRoofWallFaceFrame`).
roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Denormalized references to collections this node belongs to
collectionIds: z.array(z.custom<CollectionId>()).optional(),
@@ -0,0 +1,78 @@
import { describe, expect, test } from 'bun:test'
import { RoofSegmentNode } from './roof-segment'
import {
getRoofSegmentWallFace,
getRoofWallFaceFrame,
roofFacePointToSegment,
segmentPointToRoofWallFace,
} from './roof-segment-walls'
function segment(overrides: Partial<RoofSegmentNode> = {}): RoofSegmentNode {
return RoofSegmentNode.parse({
id: 'rseg_test',
type: 'roof-segment',
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 2.6,
wallThickness: 0.1,
pitch: 40,
...overrides,
})
}
describe('roof wall face frames', () => {
test('frame z = 0 lands on the nominal footprint (wall mid-plane)', () => {
const seg = segment()
// front face, u at the face middle, v = 1, mid-plane.
const point = roofFacePointToSegment(seg, 'front', [(8 + 0.1) / 2, 1, 0])
expect(point[0]).toBeCloseTo(0)
expect(point[1]).toBeCloseTo(1)
expect(point[2]).toBeCloseTo(3) // depth / 2 — the footprint plane
})
test('frame +z is the outward normal on every face', () => {
const seg = segment()
for (const [faceId, axis, sign] of [
['front', 2, 1],
['back', 2, -1],
['right', 0, 1],
['left', 0, -1],
] as const) {
const onPlane = roofFacePointToSegment(seg, faceId, [1, 1, 0])
const pushed = roofFacePointToSegment(seg, faceId, [1, 1, 0.5])
expect(pushed[axis] - onPlane[axis]).toBeCloseTo(0.5 * sign)
// The other horizontal axis is unaffected by the push.
const other = axis === 2 ? 0 : 2
expect(pushed[other] - onPlane[other]).toBeCloseTo(0)
}
})
test('face frame agrees with the hit resolver coordinates', () => {
const seg = segment()
// A point on the outer surface (z = +thickness/2 off the mid-plane)
// must read back with the same u/v and dist ≈ 0 off the outer plane.
const segLocal = roofFacePointToSegment(seg, 'right', [2.5, 1.25, 0.05])
const { u, v, dist } = segmentPointToRoofWallFace(seg, 'right', segLocal)
expect(u).toBeCloseTo(2.5)
expect(v).toBeCloseTo(1.25)
expect(dist).toBeCloseTo(0)
})
test('resizing the segment moves the frame, not the stored coords', () => {
// The core live-tracking property: the same face-local point maps to
// the new plane after a depth change — children follow by re-render.
const before = roofFacePointToSegment(segment(), 'front', [2, 1, 0])
const after = roofFacePointToSegment(segment({ depth: 8 }), 'front', [2, 1, 0])
expect(before[2]).toBeCloseTo(3)
expect(after[2]).toBeCloseTo(4)
expect(after[1]).toBeCloseTo(before[1])
})
test('frame yaw matches the face descriptor yaw', () => {
const seg = segment()
for (const faceId of ['front', 'back', 'right', 'left'] as const) {
expect(getRoofWallFaceFrame(seg, faceId).yaw).toBe(getRoofSegmentWallFace(seg, faceId).yaw)
}
})
})
@@ -0,0 +1,429 @@
import type { RoofSegmentNode } from './roof-segment'
import { getSegmentSlopeFrame } from './roof-segment'
/**
* Wall-face math for roof segments — the vertical surfaces a wall-mounted
* opening (door / window) can attach to. A segment's generated volume has
* four vertical faces; on gable-family roofs the end faces extend past the
* eave line into the gable (rect + triangle/pentagon, coplanar with the
* base wall). These helpers describe each face as a 2D frame
* (`u` along the face, `v` height above the segment base) plus the
* placeable profile polygon, so placement tools, renderers, and CSG cut
* builders all share one definition of "the wall under the roof".
*
* The numbers MUST mirror the outer wall volume built by
* `getRoofSegmentBrushes` in the viewer's roof system
* (`getVol(wallThickness / 2, 0, 0, …)`): the volume is the segment
* footprint extended outward by `wallThickness / 2`, which drops the eave
* line by `(wallThickness / 2) · tanθ` and raises the ridge by the same
* amount so the apex stays at `wallHeight + activeRh`.
*/
export type RoofWallFaceId = 'front' | 'back' | 'right' | 'left'
export type RoofSegmentWallFace = {
id: RoofWallFaceId
/** Outward normal in segment-local space. */
normal: [number, number, number]
/**
* Yaw (radians, rotation-y) mapping opening-local +Z to the outward
* normal and opening-local +X to the face's +U direction — the same
* frame a wall-hosted door/window uses relative to its wall mesh.
*/
yaw: number
/** Face length along U. */
length: number
/**
* Placeable region, CCW polygon in face coords. `u ∈ [0, length]`,
* `v` is height above the segment base (segment-local Y).
*/
profile: [number, number][]
}
type SegmentWallInputs = Pick<
RoofSegmentNode,
'roofType' | 'width' | 'depth' | 'wallHeight' | 'wallThickness' | 'pitch'
> &
Partial<
Pick<
RoofSegmentNode,
| 'gambrelLowerWidthRatio'
| 'gambrelLowerHeightRatio'
| 'mansardSteepWidthRatio'
| 'mansardSteepHeightRatio'
| 'dutchHipWidthRatio'
| 'dutchHipHeightRatio'
>
>
type WallVolumeFrame = {
/** Outer wall plane extents (footprint + wallThickness). */
wV: number
dV: number
/** Eave height of the outer volume. */
eaveY: number
/** Ridge/peak height of the outer volume. */
peakY: number
/** tan(pitch) of the primary slope. */
tanTheta: number
hasSlope: boolean
}
function getWallVolumeFrame(node: SegmentWallInputs): WallVolumeFrame {
const { activeRh, tanTheta } = getSegmentSlopeFrame(node)
const wallThickness = node.wallThickness ?? 0.1
const autoDrop = (wallThickness / 2) * tanTheta
const wV = Math.max(0.01, node.width + wallThickness)
const dV = Math.max(0.01, node.depth + wallThickness)
const eaveY = Math.max(0.01, node.wallHeight - autoDrop)
let rh = activeRh
if (activeRh > 0) {
rh = activeRh + autoDrop
if (node.roofType === 'shed') rh = activeRh + 2 * autoDrop
}
return {
wV,
dV,
eaveY,
peakY: eaveY + Math.max(0.001, rh),
tanTheta,
hasSlope: activeRh > 0,
}
}
const FACE_NORMALS: Record<RoofWallFaceId, [number, number, number]> = {
front: [0, 0, 1],
back: [0, 0, -1],
right: [1, 0, 0],
left: [-1, 0, 0],
}
const FACE_YAWS: Record<RoofWallFaceId, number> = {
front: 0,
back: Math.PI,
right: Math.PI / 2,
left: -Math.PI / 2,
}
function rectProfile(length: number, top: number): [number, number][] {
return [
[0, 0],
[length, 0],
[length, top],
[0, top],
]
}
function buildFaceProfile(
node: SegmentWallInputs,
frame: WallVolumeFrame,
id: RoofWallFaceId,
): [number, number][] {
const { wV, dV, eaveY, peakY, tanTheta, hasSlope } = frame
const isEnd = id === 'right' || id === 'left'
const length = isEnd ? dV : wV
if (!hasSlope) return rectProfile(length, eaveY)
switch (node.roofType) {
case 'gable': {
if (!isEnd) return rectProfile(length, eaveY)
return [
[0, 0],
[length, 0],
[length, eaveY],
[length / 2, peakY],
[0, eaveY],
]
}
case 'gambrel': {
if (!isEnd) return rectProfile(length, eaveY)
// Kink ring sits at z = ±mz on the nominal footprint (see
// getModuleFaces); both end faces are symmetric about u = length/2.
const ratio = node.gambrelLowerWidthRatio ?? 0.5
const mz = Math.min((node.depth / 2) * ratio, length / 2)
const kinkY = eaveY + (length / 2 - mz) * tanTheta
return [
[0, 0],
[length, 0],
[length, eaveY],
[length / 2 + mz, kinkY],
[length / 2, peakY],
[length / 2 - mz, kinkY],
[0, eaveY],
]
}
case 'shed': {
// Slope falls toward +Z: 'back' is the full-height wall, the end
// faces are right trapezoids rising toward the back edge.
if (id === 'front') return rectProfile(length, eaveY)
if (id === 'back') return rectProfile(length, peakY)
if (id === 'right') {
return [
[0, 0],
[length, 0],
[length, peakY],
[0, eaveY],
]
}
return [
[0, 0],
[length, 0],
[length, eaveY],
[0, peakY],
]
}
// hip / mansard / dutch slope on every side (dutch gablets are
// recessed from the wall plane), so only the base rect is placeable.
default:
return rectProfile(length, eaveY)
}
}
export function getRoofSegmentWallFace(
node: SegmentWallInputs,
id: RoofWallFaceId,
): RoofSegmentWallFace {
const frame = getWallVolumeFrame(node)
const isEnd = id === 'right' || id === 'left'
return {
id,
normal: FACE_NORMALS[id],
yaw: FACE_YAWS[id],
length: isEnd ? frame.dV : frame.wV,
profile: buildFaceProfile(node, frame, id),
}
}
export function getRoofSegmentWallFaces(node: SegmentWallInputs): RoofSegmentWallFace[] {
const frame = getWallVolumeFrame(node)
return (['front', 'back', 'right', 'left'] as const).map((id) => ({
id,
normal: FACE_NORMALS[id],
yaw: FACE_YAWS[id],
length: id === 'right' || id === 'left' ? frame.dV : frame.wV,
profile: buildFaceProfile(node, frame, id),
}))
}
/**
* Segment-local point → face coords. `dist` is the signed offset off the
* outer wall plane along the face normal (0 = on the plane, positive =
* outside the volume).
*/
export function segmentPointToRoofWallFace(
node: SegmentWallInputs,
id: RoofWallFaceId,
point: [number, number, number],
): { u: number; v: number; dist: number } {
const { wV, dV } = getWallVolumeFrame(node)
const [x, y, z] = point
switch (id) {
case 'front':
return { u: x + wV / 2, v: y, dist: z - dV / 2 }
case 'back':
return { u: wV / 2 - x, v: y, dist: -z - dV / 2 }
case 'right':
return { u: dV / 2 - z, v: y, dist: x - wV / 2 }
case 'left':
return { u: z + dV / 2, v: y, dist: -x - wV / 2 }
}
}
type FaceConstraint = {
nu: number
nv: number
c: number
}
/**
* Inward half-plane constraints of the raw profile polygon (CCW →
* interior is to the left of each edge): a point p is inside when
* `nu·p.u + nv·p.v ≥ c` for every constraint.
*/
function getProfileConstraints(face: RoofSegmentWallFace): FaceConstraint[] {
const constraints: FaceConstraint[] = []
const pts = face.profile
for (let i = 0; i < pts.length; i++) {
const a = pts[i]!
const b = pts[(i + 1) % pts.length]!
const du = b[0] - a[0]
const dv = b[1] - a[1]
const len = Math.hypot(du, dv)
if (len < 1e-9) continue
const nu = -dv / len
const nv = du / len
constraints.push({ nu, nv, c: nu * a[0] + nv * a[1] })
}
return constraints
}
/**
* Half-plane constraints for the CENTER of a `width × height` rect that
* must fit inside the face profile — the raw constraints eroded by the
* rect's half-extents projected on each edge normal.
*/
function getRectCenterConstraints(
face: RoofSegmentWallFace,
width: number,
height: number,
): FaceConstraint[] {
return getProfileConstraints(face).map(({ nu, nv, c }) => ({
nu,
nv,
c: c + (Math.abs(nu) * width) / 2 + (Math.abs(nv) * height) / 2,
}))
}
/**
* The face's render frame in segment-local space: a group placed at
* `origin` and yawed by `yaw` maps face coords to segment space —
* frame X = U (along the face), frame Y = V (height), frame Z = the
* outward normal, with z = 0 on the WALL MID-PLANE. The mid-plane of
* the generated wall volume lands exactly on the nominal footprint
* (`±width/2` / `±depth/2`), so hosted children use the same position
* conventions as wall children (openings at z = 0, wall-side items
* pushed +thickness/2 at render time). Renderers derive this from the
* live-override-merged segment, which is what makes hosted children
* track segment edits live instead of jumping on commit.
*/
export function getRoofWallFaceFrame(
node: SegmentWallInputs,
id: RoofWallFaceId,
): { origin: [number, number, number]; yaw: number } {
const { wV, dV } = getWallVolumeFrame(node)
switch (id) {
case 'front':
return { origin: [-wV / 2, 0, node.depth / 2], yaw: FACE_YAWS.front }
case 'back':
return { origin: [wV / 2, 0, -node.depth / 2], yaw: FACE_YAWS.back }
case 'right':
return { origin: [node.width / 2, 0, dV / 2], yaw: FACE_YAWS.right }
case 'left':
return { origin: [-node.width / 2, 0, -dV / 2], yaw: FACE_YAWS.left }
}
}
/** Face-frame point ([u, v, z-from-mid-plane]) → segment-local point. */
export function roofFacePointToSegment(
node: SegmentWallInputs,
id: RoofWallFaceId,
point: [number, number, number],
): [number, number, number] {
const { origin, yaw } = getRoofWallFaceFrame(node, id)
const cos = Math.cos(yaw)
const sin = Math.sin(yaw)
const [u, v, z] = point
// rotation-y: +x → (cos, 0, -sin), +z → (sin, 0, cos)
return [origin[0] + u * cos + z * sin, origin[1] + v, origin[2] - u * sin + z * cos]
}
/**
* Max width of a rect growing from an anchored vertical edge (`anchorU`)
* in direction `growSign` (±1 along U) while staying inside the face
* profile at the fixed vertical center `vCenter`. Resize-handle limit:
* the anchored-edge model matches the handles' apply math (opposite
* edge stays put, center re-derives).
*/
export function getMaxRoofRectWidthFromAnchor(
face: RoofSegmentWallFace,
anchorU: number,
growSign: number,
vCenter: number,
height: number,
): number {
let max = Number.POSITIVE_INFINITY
for (const { nu, nv, c } of getProfileConstraints(face)) {
// Center at anchorU + growSign·w/2, eroded by |nu|·w/2 + |nv|·h/2:
// base + k·w ≥ 0 with k ≤ 0 only when growth approaches the edge.
const k = (nu * growSign - Math.abs(nu)) / 2
if (k >= -1e-9) continue
const base = nu * anchorU + nv * vCenter - c - (Math.abs(nv) * height) / 2
max = Math.min(max, Math.max(0, base / -k))
}
return max
}
/**
* Max height of a rect growing from an anchored horizontal edge
* (`anchorV`) in direction `growSign` (+1 = bottom anchored, grows up)
* while staying inside the face profile at the fixed horizontal center
* `uCenter`.
*/
export function getMaxRoofRectHeightFromAnchor(
face: RoofSegmentWallFace,
uCenter: number,
width: number,
anchorV: number,
growSign: number,
): number {
let max = Number.POSITIVE_INFINITY
for (const { nu, nv, c } of getProfileConstraints(face)) {
const k = (nv * growSign - Math.abs(nv)) / 2
if (k >= -1e-9) continue
const base = nu * uCenter + nv * anchorV - c - (Math.abs(nu) * width) / 2
max = Math.min(max, Math.max(0, base / -k))
}
return max
}
const CLAMP_EPSILON = 1e-4
/**
* Clamp a rect center so the rect fits inside the face profile.
*
* - `lockV: true` (doors): `v` is fixed; only `u` slides. Returns null
* when no `u` keeps the rect inside at that height.
* - otherwise (windows): the center is projected into the eroded convex
* region (cyclic projection — profiles are convex by construction).
*
* Returns null when the rect cannot fit anywhere on the face.
*/
export function clampRectToRoofWallFace(
face: RoofSegmentWallFace,
u: number,
v: number,
width: number,
height: number,
opts?: { lockV?: boolean },
): { u: number; v: number } | null {
const constraints = getRectCenterConstraints(face, width, height)
if (constraints.length < 3) return null
if (opts?.lockV) {
let lo = Number.NEGATIVE_INFINITY
let hi = Number.POSITIVE_INFINITY
for (const { nu, nv, c } of constraints) {
const rhs = c - nv * v
if (Math.abs(nu) < 1e-9) {
if (rhs > CLAMP_EPSILON) return null
continue
}
if (nu > 0) lo = Math.max(lo, rhs / nu)
else hi = Math.min(hi, rhs / nu)
}
if (lo > hi + CLAMP_EPSILON) return null
return { u: Math.min(Math.max(u, lo), hi), v }
}
let pu = u
let pv = v
for (let iter = 0; iter < 32; iter++) {
let worst: FaceConstraint | null = null
let worstViolation = CLAMP_EPSILON
for (const constraint of constraints) {
const violation = constraint.c - (constraint.nu * pu + constraint.nv * pv)
if (violation > worstViolation) {
worstViolation = violation
worst = constraint
}
}
if (!worst) return { u: pu, v: pv }
pu += worst.nu * worstViolation
pv += worst.nv * worstViolation
}
for (const { nu, nv, c } of constraints) {
if (nu * pu + nv * pv < c - 1e-3) return null
}
return { u: pu, v: pv }
}
+8
View File
@@ -28,6 +28,14 @@ export const WindowNode = BaseNode.extend({
// Wall reference
wallId: z.string().optional(),
// Alternative host: a roof-segment's generated wall face (base wall
// under the roof or a coplanar gable end). When set, `position` is
// FACE-LOCAL — [u along the face, v height, z from the wall mid-plane]
// — exactly the wall-child convention; the renderer mounts the node
// inside the face frame (`getRoofWallFaceFrame`), which is what makes
// hosted children track segment resizes live.
roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Overall dimensions
width: z.number().default(1.5),
+50
View File
@@ -14,6 +14,7 @@ import {
type RoofSegmentNode,
type RoofType,
} from '../schema/nodes/roof-segment'
import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls'
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
@@ -539,6 +540,55 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
patchedNodes[id] = { ...node, children: [] } as AnyNode
}
// Roof-hosted wall children (door / window / item) originally stored
// SEGMENT-LOCAL positions with the face yaw in rotation[1]; the
// format moved to explicit `roofFace` + FACE-LOCAL coords so the
// renderer's face frame can track segment edits live. Convert in
// place: face from the old cardinal yaw, u/v from the outer-plane
// projection, z re-based from the outer plane to the wall mid-plane.
if (
(node.type === 'door' || node.type === 'window' || node.type === 'item') &&
typeof (node as { roofSegmentId?: unknown }).roofSegmentId === 'string' &&
(node as { roofFace?: unknown }).roofFace === undefined
) {
const current = patchedNodes[id] as AnyNode & {
roofSegmentId: string
position: [number, number, number]
rotation: [number, number, number]
}
const segment = patchedNodes[current.roofSegmentId] as
| (AnyNode & { wallThickness?: number })
| undefined
if (segment?.type === 'roof-segment') {
const tau = Math.PI * 2
const yaw = (((current.rotation?.[1] ?? 0) % tau) + tau) % tau
const eps = 1e-3
const face =
yaw < eps || tau - yaw < eps
? ('front' as const)
: Math.abs(yaw - Math.PI) < eps
? ('back' as const)
: Math.abs(yaw - Math.PI / 2) < eps
? ('right' as const)
: Math.abs(yaw - (3 * Math.PI) / 2) < eps
? ('left' as const)
: null
if (face) {
const { u, v, dist } = segmentPointToRoofWallFace(
segment as never,
face,
current.position,
)
patchedNodes[id] = {
...current,
roofFace: face,
position: [u, v, dist + (segment.wallThickness ?? 0.1) / 2],
rotation: [0, 0, 0],
} as AnyNode
}
}
}
if (node.type === 'roof') {
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
}
@@ -76,6 +76,13 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
| undefined
}
// Remap roofSegmentId (doors/windows/items hosted on roof wall faces)
if ('roofSegmentId' in clonedNode && typeof clonedNode.roofSegmentId === 'string') {
;(clonedNode as Record<string, unknown>).roofSegmentId = idMap.get(
clonedNode.roofSegmentId,
) as string | undefined
}
clonedNodes[newId] = clonedNode
}
@@ -220,6 +227,12 @@ export function cloneLevelSubtree(
;(cloned as Record<string, unknown>).wallId = idMap.get(cloned.wallId) ?? cloned.wallId
}
// Remap roofSegmentId (doors/windows/items hosted on roof wall faces)
if ('roofSegmentId' in cloned && typeof cloned.roofSegmentId === 'string') {
;(cloned as Record<string, unknown>).roofSegmentId =
idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId
}
clonedNodes.push(cloned)
}
@@ -19,7 +19,6 @@ import {
useLiveTransforms,
useScene,
} from '@pascal-app/core'
import { useAlignmentGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import {
memo,
@@ -31,7 +30,9 @@ import {
useState,
} from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useEditor from '../../../store/use-editor'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
import { useFloorplanRender } from '../floorplan-render-context'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
@@ -493,6 +494,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
event.preventDefault()
event.stopPropagation()
suppressBoxSelectForPointer(event)
const session = handler.start({
node,
@@ -603,6 +605,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
}
drag.session.commit()
sfxEmitter.emit('sfx:structure-build')
clearSurfacePlanSnapFeedback()
dragRef.current = null
setActiveDragId(null)
setRotationOverlay(null)
@@ -654,6 +657,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
for (const id of drag.session.affectedIds) overrides.clear(id)
}
clearSurfacePlanSnapFeedback()
dragRef.current = null
setActiveDragId(null)
setRotationOverlay(null)
@@ -672,7 +676,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// Affordances that publish Figma alignment guides during `apply`
// (fence endpoint) leave them in the store on cancel — `canCommit`
// (the pointer-up clear) never runs on a cancel.
useAlignmentGuides.getState().clear()
clearSurfacePlanSnapFeedback()
// Drop any live overrides the session may have published. No-op
// for affordances whose `apply()` writes straight to scene; the
// override-routed sessions (wall endpoint, wall curve) rely on
@@ -707,7 +711,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
dragRef.current = null
}
// Clear any alignment guide a session left behind on mid-drag unmount.
useAlignmentGuides.getState().clear()
clearSurfacePlanSnapFeedback()
}
}, [])
@@ -767,6 +771,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (!node) return
event.preventDefault()
event.stopPropagation()
suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never)
}}
@@ -64,7 +64,7 @@ import {
type FirstPersonColliderWorld,
type FirstPersonSpawn,
} from './first-person/build-collider-world'
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl'
import BVHEcctrl from './first-person/bvh-ecctrl'
const CAMERA_EYE_OFFSET = 0.45
@@ -78,7 +78,11 @@ const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
const keyboardMap = [
const VOID_FALL_RESPAWN_DEPTH = 12
type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
@@ -86,6 +90,36 @@ const keyboardMap = [
{ name: 'jump', keys: ['Space'] },
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
]
const keyboardMap = movementKeyboardBindings
const movementKeyToName = new Map<string, MovementKeyName>(
movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)),
)
const inactiveMovementInput: MovementInput = {
backward: false,
forward: false,
jump: false,
leftward: false,
rightward: false,
run: false,
}
function getMovementInputForKey(code: string, active: boolean): MovementInput | null {
const name = movementKeyToName.get(code)
return name ? ({ [name]: active } as MovementInput) : null
}
function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) {
activeElement.blur()
}
if (!canvas.hasAttribute('tabindex')) {
canvas.tabIndex = -1
}
canvas.focus({ preventScroll: true })
}
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
@@ -535,6 +569,8 @@ export const FirstPersonControls = () => {
const selectedLevelId = useViewer((state) => state.selection.levelId)
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
const controllerRef = useRef<BVHEcctrlApi | null>(null)
const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
const hadPointerLockRef = useRef(false)
const yawRef = useRef(0)
const pitchRef = useRef(0)
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
@@ -577,6 +613,13 @@ export const FirstPersonControls = () => {
setIsElevatorRideLocked(locked)
}, [])
const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
controllerRef.current = api
if (api) {
api.setMovement(movementInputRef.current)
}
}, [])
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
const nodes = useScene.getState().nodes
camera.updateMatrixWorld(true)
@@ -915,6 +958,14 @@ export const FirstPersonControls = () => {
})
}, [camera, controllerStart, placedSpawn, world])
useEffect(() => {
const canvas = gl.domElement
focusFirstPersonCanvas(canvas)
const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas))
return () => window.cancelAnimationFrame(frame)
}, [gl])
useEffect(() => {
const canvas = gl.domElement
const handleMouseMove = (e: MouseEvent) => {
@@ -945,14 +996,29 @@ export const FirstPersonControls = () => {
toggleInteractableTarget()
}
const handlePointerLockChange = () => {
const isLocked = document.pointerLockElement === canvas
if (isLocked) {
hadPointerLockRef.current = true
return
}
if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
useEditor.getState().setFirstPersonMode(false)
}
}
handlePointerLockChange()
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('click', handleClick)
document.addEventListener('mousedown', handleMouseDown, true)
document.addEventListener('pointerlockchange', handlePointerLockChange)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('click', handleClick)
document.removeEventListener('mousedown', handleMouseDown, true)
document.removeEventListener('pointerlockchange', handlePointerLockChange)
if (document.pointerLockElement === canvas) {
document.exitPointerLock()
}
@@ -962,7 +1028,24 @@ export const FirstPersonControls = () => {
useEffect(() => {
const canvas = gl.domElement
const applyMovementKey = (event: KeyboardEvent, active: boolean) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return false
}
const movement = getMovementInputForKey(event.code, active)
if (!movement) return false
event.preventDefault()
Object.assign(movementInputRef.current, movement)
controllerRef.current?.setMovement(movement)
return true
}
const handleKeyDown = (event: KeyboardEvent) => {
const handledMovement = applyMovementKey(event, true)
if (handledMovement) return
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
@@ -985,9 +1068,15 @@ export const FirstPersonControls = () => {
}
}
const handleKeyUp = (event: KeyboardEvent) => {
applyMovementKey(event, false)
}
document.addEventListener('keydown', handleKeyDown, true)
document.addEventListener('keyup', handleKeyUp, true)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
document.removeEventListener('keyup', handleKeyUp, true)
}
}, [closeInteractableTarget, gl, toggleInteractableTarget])
@@ -1217,6 +1306,29 @@ export const FirstPersonControls = () => {
if (!controllerRef.current?.group) return
const group = controllerRef.current.group
// The site ground collider is effectively unbounded, but scenes without a
// site node only have finite fallback floors — if the controller still ends
// up below every collider it can never land, so put it back at the spawn.
// Prefer the live spawn node over the mount-time start position so a spawn
// moved mid-walkthrough doesn't respawn the player at stale coordinates.
const worldBounds = worldRef.current?.bounds
if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) {
const respawnPosition = placedSpawn
? [
placedSpawn.position[0],
placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE,
placedSpawn.position[2],
]
: controllerStart?.position
if (respawnPosition) {
group.position.set(respawnPosition[0]!, respawnPosition[1]!, respawnPosition[2]!)
controllerRef.current.resetLinVel()
ridingElevatorRef.current = null
setElevatorRideLocked(false)
}
}
group.rotation.y = 0
camera.position.copy(group.position).add(cameraOffset)
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
@@ -1284,7 +1396,7 @@ export const FirstPersonControls = () => {
maxWalkSpeed={4}
paused={isElevatorRideLocked}
position={controllerStart.position}
ref={controllerRef}
ref={setControllerApi}
/>
</KeyboardControls>
)}
@@ -105,6 +105,36 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
world?.dispose()
})
test('skips meshes hidden by an invisible ancestor (stale roof segment CSG)', () => {
registerColliderDefinition('column', ColumnNode, 'structure')
// Mirror the roof's segments-wrapper shape: the registered mesh's own
// visible flag stays true while a hidden wrapper hides it at render
// time. The collider must match the render, not the own-flag.
const column = ColumnNode.parse({ id: 'column_test' })
const visibleColumn = ColumnNode.parse({ id: 'column_visible', position: [3, 0, 0] })
setSceneNodes([column, visibleColumn])
const wrapper = new Group()
wrapper.visible = false
const hiddenMesh = new Mesh(new BoxGeometry(10, 2, 10), new MeshBasicMaterial())
wrapper.add(hiddenMesh)
wrapper.updateMatrixWorld(true)
sceneRegistry.nodes.set(column.id, hiddenMesh)
sceneRegistry.byType[column.type]!.add(column.id)
mountNode(visibleColumn, [1, 2, 1], [3, 1, 0])
const world = buildFirstPersonColliderWorldFromRegistry()
expect(world).not.toBeNull()
// Bounds reflect only the visible 1×1 column at x = 3; the 10×10 mesh
// under the hidden wrapper contributed no geometry.
expect(world?.bounds?.min.x).toBeCloseTo(2.5)
expect(world?.bounds?.max.x).toBeCloseTo(3.5)
world?.dispose()
})
test('leaves elevators to their dedicated dynamic collider meshes', () => {
registerColliderDefinition('elevator', ElevatorNode, 'structure')
@@ -141,9 +171,12 @@ describe('buildFirstPersonColliderWorldFromRegistry', () => {
// Ground slab sits just below the site ground plane (y = 0).
expect(world?.bounds?.min.y).toBeCloseTo(-0.08)
expect(world?.bounds?.max.y).toBeCloseTo(0)
// Default site footprint falls back to the 30 m minimum size.
expect(world?.bounds?.min.x).toBeCloseTo(-15)
expect(world?.bounds?.max.x).toBeCloseTo(15)
// The ground collider extends far past the site polygon so stepping out of
// the site boundary never drops the player below the ground plane.
expect(world?.bounds?.min.x).toBeCloseTo(-1000)
expect(world?.bounds?.max.x).toBeCloseTo(1000)
expect(world?.bounds?.min.z).toBeCloseTo(-1000)
expect(world?.bounds?.max.z).toBeCloseTo(1000)
world?.dispose()
})
})
@@ -27,6 +27,7 @@ const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
const LEVEL_FALLBACK_FLOOR_THICKNESS = 0.08
const LEVEL_FALLBACK_FLOOR_PADDING = 2
const LEVEL_FALLBACK_FLOOR_MIN_SIZE = 30
const SITE_GROUND_COLLIDER_MIN_SIZE = 2000
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
@@ -49,6 +50,22 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh {
return 'isMesh' in object && (object as THREE.Mesh).isMesh
}
// Renderer-effective visibility: an invisible ancestor hides the whole
// subtree at render time even when the object's own flag is true. The
// collider world must match what's rendered — the roof keeps stale,
// UNCUT per-segment CSG inside its hidden `segments-wrapper` (full-edit
// exit hides the wrapper without stripping geometry), and cloning those
// meshes would block the walkthrough player at openings the visible
// merged shell has cut through.
function isEffectivelyVisible(object: THREE.Object3D) {
let current: THREE.Object3D | null = object
while (current) {
if (!current.visible) return false
current = current.parent
}
return true
}
function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
}
@@ -129,8 +146,10 @@ function collectLevelFallbackFloorGeometries(nodes: SceneNodes) {
// a dedicated collider, a spawn on the bare ground (no slab, or not parented to
// a level that triggers the per-level fallback) has no floor to stand on and the
// walkthrough player falls through. Derive a thin ground slab from node data (not
// the rendered mesh) so it exists regardless of geometry-mount timing, sized to
// cover the whole scene footprint at the site's ground plane.
// the rendered mesh) so it exists regardless of geometry-mount timing. The slab
// is effectively unbounded (not sized to the site polygon): the ground plane must
// keep holding the player up even after they step past the site boundary,
// otherwise they fall below the ground plane into the void.
function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
if (site.visible === false) return null
@@ -142,11 +161,11 @@ function createSiteGroundColliderGeometry(site: SiteNode, nodes: SceneNodes) {
const [boundsWidth, boundsDepth] = bounds?.size ?? [0, 0]
const width = Math.max(
boundsWidth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
SITE_GROUND_COLLIDER_MIN_SIZE,
)
const depth = Math.max(
boundsDepth + LEVEL_FALLBACK_FLOOR_PADDING * 2,
LEVEL_FALLBACK_FLOOR_MIN_SIZE,
SITE_GROUND_COLLIDER_MIN_SIZE,
)
const geometry = createBoxColliderGeometry(width, LEVEL_FALLBACK_FLOOR_THICKNESS, depth)
@@ -316,9 +335,12 @@ function collectColliderGeometriesFromNode(
if (visitedMeshes.has(object)) return
visitedMeshes.add(object)
// Prune hidden subtrees — children of an invisible group never render,
// so they must not collide either (see isEffectivelyVisible).
if (!object.visible) return
if (
isMesh(object) &&
object.visible &&
isColliderMaterialVisible(object.material) &&
!SKIPPED_MESH_NAMES.has(object.name)
) {
@@ -361,6 +383,11 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
const root = sceneRegistry.nodes.get(nodeId)
if (!root) continue
// Registered objects can sit inside a hidden wrapper (roof segments
// under `segments-wrapper`) — the per-node traversal starts AT the
// object, so the ancestor chain must be checked here.
if (!isEffectivelyVisible(root)) continue
if (node.type === 'door') {
const doorGeometry = createDoorLeafColliderGeometry(root, node)
if (doorGeometry) {
@@ -10,6 +10,8 @@ import {
ElevatorNode,
FenceNode,
generateId,
getActiveRoofHeight,
getEffectiveNode,
getWallCurveLength,
getWallThickness,
ItemNode,
@@ -111,6 +113,49 @@ function getMenuYOffset(node: AnyNode | null): number {
return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
}
function getAttributeVersion(
attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute | null | undefined,
): number {
return attribute && 'version' in attribute && typeof attribute.version === 'number'
? attribute.version
: 0
}
function getObjectGeometryKey(object: THREE.Object3D): string {
const parts: string[] = []
object.traverse((child) => {
const geometry = (child as Partial<THREE.Mesh>).geometry
if (!geometry) return
parts.push(
[
geometry.id,
getAttributeVersion(geometry.getAttribute('position')),
getAttributeVersion(geometry.getIndex()),
].join(':'),
)
})
return parts.join('|')
}
function setNodeDerivedMenuAnchor(
node: AnyNode,
object: THREE.Object3D,
target: THREE.Vector3,
): boolean {
if (node.type !== 'roof-segment') return false
const visualTop =
node.wallHeight +
getActiveRoofHeight(node) +
Math.max(0, node.deckThickness ?? 0) +
Math.max(0, node.shingleThickness ?? 0)
target.set(0, visualTop, 0).applyMatrix4(object.matrixWorld)
target.y += getMenuYOffset(node)
return true
}
// Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the
// pill reads sensibly before an explicit height / thickness is set.
const FENCE_DEFAULT_HEIGHT = 1.8
@@ -171,9 +216,14 @@ export function FloatingActionMenu() {
const anchorRef = useRef(new THREE.Vector3())
const hasAnchorRef = useRef(false)
const lastMatrixRef = useRef(new THREE.Matrix4())
const lastAnchorKeyRef = useRef<{ id: string | null; node: AnyNode | null }>({
const lastAnchorKeyRef = useRef<{
id: string | null
node: AnyNode | null
geometryKey: string | null
}>({
id: null,
node: null,
geometryKey: null,
})
// Only show for single selection of specific types
@@ -218,7 +268,7 @@ export function FloatingActionMenu() {
})
useFrame((state) => {
if (!(selectedId && isValidType && groupRef.current)) return
if (!(selectedId && node && isValidType && groupRef.current)) return
// Scale the HTML menu with camera zoom (ortho) or inverse distance
// (perspective) so it feels anchored to the world, clamped on both ends
@@ -253,6 +303,8 @@ export function FloatingActionMenu() {
const obj = sceneRegistry.nodes.get(selectedId)
if (obj) {
obj.updateWorldMatrix(true, false)
// Recompute the anchor only when the object genuinely changes —
// reselected, moved (its own world matrix changed), or resized
// (a fresh store node on commit, or a live override / handle drag
@@ -261,21 +313,28 @@ export function FloatingActionMenu() {
// holds still.
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
const dragActive = activeHandleDrag?.nodeId === selectedId
const effectiveNode = getEffectiveNode(node)
const geometryKey = getObjectGeometryKey(obj)
const selectionChanged =
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
const geometryChanged = lastAnchorKeyRef.current.geometryKey !== geometryKey
if (selectionChanged || matrixChanged || overrideActive || dragActive) {
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
// Position above the object. Per-type offsets clear each kind's
// in-world chrome (height-resize arrows, measurement labels).
anchorRef.current.set(center.x, box.max.y + getMenuYOffset(node), center.z)
if (selectionChanged || matrixChanged || geometryChanged || overrideActive || dragActive) {
if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
// Position above the object. Per-type offsets clear each kind's
// in-world chrome (height-resize arrows, measurement labels).
anchorRef.current.set(center.x, box.max.y + getMenuYOffset(effectiveNode), center.z)
hasAnchorRef.current = true
}
} else {
hasAnchorRef.current = true
}
lastMatrixRef.current.copy(obj.matrixWorld)
lastAnchorKeyRef.current = { id: selectedId, node }
lastAnchorKeyRef.current = { id: selectedId, node, geometryKey }
}
if (hasAnchorRef.current) {
@@ -64,6 +64,7 @@ import {
import { createPortal } from 'react-dom'
import { Vector3 } from 'three'
import { useShallow } from 'zustand/react/shallow'
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import {
alignFloorplanDraftPoint,
buildFloorplanItemEntry,
@@ -77,6 +78,7 @@ import { guideEmitter } from '../../lib/guide-events'
import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measurements'
import { sfxEmitter } from '../../lib/sfx-bus'
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import { cn } from '../../lib/utils'
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
@@ -8429,17 +8431,22 @@ export function FloorplanPanel({
if (isCeilingBuildActive) {
// Polygon vertex: grid (snapToHalf) + optional 45° angle snap from
// the previous vertex. Alignment runs only when angle snap is OFF
// (first vertex, or Shift held) — when the angle is being locked,
// pulling the vertex sideways would break it.
// the previous vertex. Wall magnetic snap may still win, while
// generic alignment runs only when angle snap is OFF (first vertex,
// or Shift held) so it does not pull a locked angle sideways.
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap,
})
if (angleSnap) useAlignmentGuides.getState().clear()
else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
const snappedPoint = resolveCeilingPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) =>
@@ -8505,13 +8512,25 @@ export function FloorplanPanel({
// moves (the catch-all would otherwise swallow the move event).
if (isPolygonBuildActive) {
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap,
})
if (angleSnap) useAlignmentGuides.getState().clear()
else snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
let snappedPoint = fallbackPoint
if (isSlabBuildActive) {
snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
} else if (angleSnap) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { bypass: event.altKey })
}
// Emit `grid:move` so the registry-driven slab tool also tracks
// the cursor (its 3D preview needs it).
@@ -8679,7 +8698,9 @@ export function FloorplanPanel({
isOpeningPlacementActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive,
levelId,
publishFloorplanNavigationPose,
smoothFloorplanNavigationView,
referenceScaleDraft,
@@ -8936,8 +8957,10 @@ export function FloorplanPanel({
isOpeningPlacementActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive,
isZoneBuildActive,
levelId,
roofDraftStart,
setCursorPoint,
setFenceDraftEnd,
@@ -9114,24 +9137,39 @@ export function FloorplanPanel({
return
}
const snappedPoint = snapPolygonDraftPoint({
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
angleSnap,
})
if (isCeilingBuildActive) {
emitFloorplanGridEvent('double-click', planPoint, event)
const snappedPoint = resolveCeilingPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
emitFloorplanGridEvent('double-click', snappedPoint, event)
handleCeilingPlacementConfirm(snappedPoint)
return
}
if (isZoneBuildActive) {
handleZonePlacementConfirm(snappedPoint)
handleZonePlacementConfirm(fallbackPoint)
} else {
const snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
// Slab is registry-driven: forward the double-click so the 3D tool
// commits the node (zone has no registry tool, so it commits locally).
emitFloorplanGridEvent('double-click', planPoint, event)
emitFloorplanGridEvent('double-click', snappedPoint, event)
handleSlabPlacementConfirm(snappedPoint)
}
},
@@ -9146,6 +9184,7 @@ export function FloorplanPanel({
isPolygonDraftBuildActive,
isRoofBuildActive,
isZoneBuildActive,
levelId,
shiftPressed,
],
)
@@ -72,6 +72,7 @@ const PAINT_CURSOR_BADGE_COLOR = '#818cf8'
const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
const PAINT_CURSOR_BADGE_OFFSET_X = 14
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
const SCENE_READY_FALLBACK_MS = 8000
const EDITOR_HOVER_STYLES: HoverStyles = {
default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
@@ -895,6 +896,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
<div
className="relative min-w-0 flex-1 overflow-hidden"
data-pascal-viewer-3d
ref={viewer3dRef}
style={{ display: show3d ? undefined : 'none' }}
>
@@ -1068,6 +1070,19 @@ export default function Editor({
setIsViewerSceneReady(ready)
}, [])
useEffect(() => {
if (isLoading || isSceneLoading || !hasLoadedInitialScene || isViewerSceneReady) return
const timer = window.setTimeout(() => {
console.warn('[editor] viewer scene readiness timed out; showing editor shell anyway', {
sceneReadyKey,
})
setIsViewerSceneReady(true)
}, SCENE_READY_FALLBACK_MS)
return () => window.clearTimeout(timer)
}, [hasLoadedInitialScene, isLoading, isSceneLoading, isViewerSceneReady, sceneReadyKey])
const showLoader = isLoading || isSceneLoading || !hasLoadedInitialScene || !isViewerSceneReady
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
@@ -38,11 +38,13 @@ import {
Vector3,
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { formatAngleRadians } from '../tools/shared/segment-angle'
import {
ARROW_COLOR,
@@ -54,6 +56,10 @@ import {
} from './handles/handle-arrow'
import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
// Pooled scratch for the handle rig's world-relative pose mapping.
const _rigRelative = new Matrix4()
const _rigScratchScale = new Vector3()
export {
ARROW_COLOR,
ARROW_HOVER_COLOR,
@@ -275,14 +281,32 @@ function NodeArrowHandlesForNode({
// exclusion the wall arrow also goes without.
useFrame(() => {
if (innerRef.current && innerRide && portalObject) {
// Grandparent mode: pose the rig by mapping the node's WORLD pose
// into the portal target's frame. Copying the parent + node
// registry poses (the previous approach) assumed the node mesh is
// a DIRECT child of the parent's registered object — roof-hosted
// openings break that with an intermediate face-frame group, which
// the world-relative mapping absorbs for free. For wall children
// the result is identical (portal⁻¹ ∘ node = wall.local ∘ node.local).
if (outerRef.current) {
outerRef.current.position.set(0, 0, 0)
outerRef.current.quaternion.identity()
}
portalObject.updateWorldMatrix(true, false)
innerRide.updateWorldMatrix(true, false)
_rigRelative.copy(portalObject.matrixWorld).invert().multiply(innerRide.matrixWorld)
_rigRelative.decompose(
innerRef.current.position,
innerRef.current.quaternion,
_rigScratchScale,
)
return
}
if (outerRef.current && outerRide) {
outerRef.current.position.copy(outerRide.position)
outerRef.current.quaternion.copy(outerRide.quaternion)
}
if (innerRef.current && innerRide) {
innerRef.current.position.copy(innerRide.position)
innerRef.current.quaternion.copy(innerRide.quaternion)
}
})
// Active-drag tracking. When a handle starts dragging, it claims its
@@ -1169,6 +1193,7 @@ function TranslateArrow({
// 3D translate gizmo and the floating Move button behave identically.
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
suppressBoxSelectForPointer(event)
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(node as never)
useViewer.getState().setSelection({ selectedIds: [] })
@@ -2,7 +2,9 @@
import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import {
WALL_FINE_GRID_STEP,
@@ -49,8 +51,10 @@ type UseFloorplanBackgroundPlacementArgs = {
isOpeningPlacementActive: boolean
isPolygonBuildActive: boolean
isRoofBuildActive: boolean
isSlabBuildActive: boolean
isWallBuildActive: boolean
isZoneBuildActive: boolean
levelId: string | null
roofDraftStart: WallPlanPoint | null
setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
@@ -105,8 +109,10 @@ export function useFloorplanBackgroundPlacement({
isOpeningPlacementActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive,
isZoneBuildActive,
levelId,
roofDraftStart,
setCursorPoint,
setFenceDraftEnd,
@@ -149,17 +155,22 @@ export function useFloorplanBackgroundPlacement({
if (isCeilingBuildActive) {
// Align the committed vertex the same way the move-preview did, so
// the placed point matches what the user saw. Skip when angle snap
// owns the vertex (matches the move branch).
// the placed point matches what the user saw. Wall magnetic snap may
// still win; generic alignment is skipped when angle snap owns the
// vertex (matches the move branch).
const angleSnap = ceilingDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap,
})
if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
}
const snappedPoint = resolveCeilingPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
emitFloorplanGridEvent('click', snappedPoint, event)
handleCeilingPlacementPoint(snappedPoint)
@@ -225,13 +236,22 @@ export function useFloorplanBackgroundPlacement({
// the 2D draft polygon invisible while the 3D tool builds fine).
if (isPolygonBuildActive) {
const angleSnap = activePolygonDraftPoints.length > 0 && !shiftPressed
let snappedPoint = snapPolygonDraftPoint({
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap,
})
if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(snappedPoint, { bypass: event.altKey })
let snappedPoint = fallbackPoint
if (isSlabBuildActive) {
snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
} else if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { bypass: event.altKey })
}
// Emit the grid event so the registry-driven slab tool also
@@ -320,8 +340,10 @@ export function useFloorplanBackgroundPlacement({
isOpeningPlacementActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive,
isZoneBuildActive,
levelId,
roofDraftStart,
setCursorPoint,
setFenceDraftEnd,
@@ -1,10 +1,23 @@
'use client'
import { sceneRegistry } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
DEFAULT_WALL_HEIGHT,
getWallCurveFrameAt,
getWallCurveLength,
getWallThickness,
isCurvedWall,
resolveLevelId,
sceneRegistry,
spatialGridManager,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useWallSnapIndicator, type WallSnapKind } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { memo, useRef } from 'react'
import { memo, useMemo, useRef } from 'react'
import { BoxGeometry, CircleGeometry, CylinderGeometry, type Group } from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
@@ -34,6 +47,14 @@ const BEACON_HEIGHT = 2.5 // world-meter height of the pillar
const BEACON_RADIUS = 0.018 // world-meter radius of the pillar
const MARKER = 0.13 // world-meter base size of the floor glyph
const FLOOR_LIFT = 0.012 // tiny lift so the marker reads above the floor grid
const WALL_TOP_HIGHLIGHT_LIFT = 0.035
const WALL_TOP_HIGHLIGHT_HEIGHT = 0.018
const WALL_TOP_HIGHLIGHT_OVERHANG = 0.14
const WALL_TOP_GLOW_HEIGHT = 0.026
const WALL_TOP_GLOW_OVERHANG = 0.36
const WALL_TOP_END_OVERHANG = 0.08
const CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH = 0.45
const NO_RAYCAST = () => null
// Shared resources — one material + unit geometries, so snap churn during a
// drag doesn't rebuild GPU buffers (mirrors the alignment guide layer).
@@ -45,17 +66,41 @@ const beaconMaterial = new MeshBasicNodeMaterial({
transparent: true,
opacity: 0.9,
})
const wallTopHighlightMaterial = new MeshBasicNodeMaterial({
color: BEACON_COLOR,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
opacity: 0.88,
})
const wallTopHighlightGlowMaterial = new MeshBasicNodeMaterial({
color: BEACON_COLOR,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
opacity: 0.26,
})
const PILLAR_GEOMETRY = new CylinderGeometry(BEACON_RADIUS, BEACON_RADIUS, BEACON_HEIGHT, 8)
// Flat unit geometries scaled per marker. Boxes are 0.002 tall so they read as
// a flat plate; circles/triangles lie flat via an X rotation at the mesh.
const FLAT_BOX_GEOMETRY = new BoxGeometry(1, 0.002, 1)
const WALL_TOP_HIGHLIGHT_GEOMETRY = new BoxGeometry(1, 1, 1)
const TRIANGLE_GEOMETRY = new CircleGeometry(1, 3)
const CIRCLE_GEOMETRY = new CircleGeometry(1, 28)
export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
const point = useWallSnapIndicator((s) => s.point)
const levelId = useViewer((s) => s.selection.levelId)
const nodes = useScene((s) => s.nodes)
const groupRef = useRef<Group>(null)
const highlightedWalls = useMemo(() => {
if (!point?.wallIds?.length) return []
return point.wallIds
.map((wallId) => nodes[wallId as AnyNodeId])
.filter((node): node is WallNode => node?.type === 'wall' && node.visible !== false)
}, [nodes, point?.wallIds])
// Track the active level's building-local Y each frame so the beacon stands
// on the floor being edited, not the building base — same source the
@@ -70,6 +115,9 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
if (!point) return null
return (
<group ref={groupRef}>
{highlightedWalls.map((wall) => (
<WallTopHighlight key={wall.id} nodes={nodes} wall={wall} />
))}
<mesh
geometry={PILLAR_GEOMETRY}
layers={EDITOR_LAYER}
@@ -82,6 +130,103 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() {
)
})
type WallTopHighlightSegment = {
angle: number
center: [number, number]
length: number
}
function getWallTopY(wall: WallNode, nodes: Readonly<Record<string, AnyNode>>) {
const levelId = resolveLevelId(wall, nodes as Record<string, AnyNode>)
const slabElevation = spatialGridManager.getSlabElevationForWall(
levelId,
wall.start,
wall.end,
wall.curveOffset ?? 0,
wall.thickness,
)
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT
}
function buildHighlightSegment(start: [number, number], end: [number, number]) {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const length = Math.hypot(dx, dz)
if (length < 1e-6) return null
return {
angle: -Math.atan2(dz, dx),
center: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] as [number, number],
length,
}
}
function buildWallTopHighlightSegments(wall: WallNode): WallTopHighlightSegment[] {
if (!isCurvedWall(wall)) {
const segment = buildHighlightSegment(wall.start, wall.end)
return segment ? [segment] : []
}
const sampleCount = Math.max(
8,
Math.ceil(getWallCurveLength(wall) / CURVED_WALL_HIGHLIGHT_SEGMENT_LENGTH),
)
const segments: WallTopHighlightSegment[] = []
let previous = getWallCurveFrameAt(wall, 0).point
for (let index = 1; index <= sampleCount; index += 1) {
const current = getWallCurveFrameAt(wall, index / sampleCount).point
const segment = buildHighlightSegment([previous.x, previous.y], [current.x, current.y])
if (segment) segments.push(segment)
previous = current
}
return segments
}
function WallTopHighlight({
nodes,
wall,
}: {
nodes: Readonly<Record<string, AnyNode>>
wall: WallNode
}) {
const segments = useMemo(() => buildWallTopHighlightSegments(wall), [wall])
const y = getWallTopY(wall, nodes)
const width = Math.max(getWallThickness(wall) + WALL_TOP_HIGHLIGHT_OVERHANG, 0.24)
const glowWidth = Math.max(getWallThickness(wall) + WALL_TOP_GLOW_OVERHANG, 0.42)
return (
<>
{segments.map((segment, index) => (
<group key={`${wall.id}:${index}`}>
<mesh
frustumCulled={false}
geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
layers={EDITOR_LAYER}
material={wallTopHighlightGlowMaterial}
position={[segment.center[0], y - 0.003, segment.center[1]]}
raycast={NO_RAYCAST}
renderOrder={1003}
rotation={[0, segment.angle, 0]}
scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_GLOW_HEIGHT, glowWidth]}
/>
<mesh
frustumCulled={false}
geometry={WALL_TOP_HIGHLIGHT_GEOMETRY}
layers={EDITOR_LAYER}
material={wallTopHighlightMaterial}
position={[segment.center[0], y + 0.002, segment.center[1]]}
raycast={NO_RAYCAST}
renderOrder={1004}
rotation={[0, segment.angle, 0]}
scale={[segment.length + WALL_TOP_END_OVERHANG, WALL_TOP_HIGHLIGHT_HEIGHT, width]}
/>
</group>
))}
</>
)
}
/** Floor glyph whose shape encodes which kind of geometry the point snapped to. */
function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number }) {
const y = FLOOR_LIFT
@@ -5,31 +5,90 @@ import {
emitter,
resolveLevelId,
sceneRegistry,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react'
import type { Object3D } from 'three'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { useShallow } from 'zustand/react/shallow'
import {
clearCeilingSnapFeedback,
resolveCeilingPlanPointSnap,
} from '../../../lib/ceiling-plan-snap'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapToHalf } from '../../tools/item/placement-math'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
const BRACKET_THICKNESS = 0.04
const BRACKET_HEIGHT = 0.04
const BRACKET_Y_OFFSET = 0.035
const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
// Draw the corner handles after everything else and with depth testing
// off (see materials below) so they stay visible — and clickable — even
// when a wall, roof, or the ceiling itself would otherwise occlude them.
const HANDLE_COLOR = '#d4d4d4'
const HANDLE_HOVER_COLOR = '#818cf8'
const HANDLE_OPACITY = 0.72
const HANDLE_HOVER_OPACITY = 0.92
const HANDLE_DRAG_THRESHOLD_PX = 4
const SHARED_HANDLE_BOX_GEOMETRY = new BoxGeometry(1, 1, 1)
// Draw the corner handles after the ceiling surface so they read cleanly
// when unobstructed, while material depth testing still lets other scene
// geometry hide them.
const CORNER_RENDER_ORDER = 1000
type CornerBracketData = {
corner: [number, number]
index: number
incomingEdgeIndex: number
incomingDirection: [number, number]
outgoingEdgeIndex: number
outgoingDirection: [number, number]
incomingLength: number
outgoingLength: number
cornerStrength: number
}
type CornerDragState = {
ceilingId: CeilingNode['id']
cornerIndex: number
didDrag: boolean
initialPolygon: Array<[number, number]>
inputDraggingSet: boolean
pointerId: number
previewPolygon: Array<[number, number]> | null
previousSnappedPosition: [number, number] | null
previousInputDragging: boolean
startClientX: number
startClientY: number
startPlanePosition: [number, number]
}
function stopHandlePointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation()
suppressBoxSelectForPointer(event, { markHandled: false })
}
function suppressNextClick() {
const suppressClick = (clickEvent: MouseEvent) => {
clickEvent.stopImmediatePropagation()
clickEvent.preventDefault()
window.removeEventListener('click', suppressClick, true)
}
window.addEventListener('click', suppressClick, true)
requestAnimationFrame(() => {
window.removeEventListener('click', suppressClick, true)
})
}
function clearCornerDragPreview(drag: CornerDragState) {
if (drag.didDrag) {
useLiveNodeOverrides.getState().clear(drag.ceilingId)
useScene.getState().markDirty(drag.ceilingId)
}
if (drag.inputDraggingSet) {
useViewer.getState().setInputDragging(drag.previousInputDragging)
}
clearCeilingSnapFeedback()
}
export const CeilingSelectionAffordanceSystem = () => {
@@ -79,11 +138,239 @@ const CeilingSelectionAffordance = ({
ceiling: CeilingNode
levelId: string
}) => {
const { camera, gl } = useThree()
const liveOverride = useLiveNodeOverrides(
(state) => state.overrides.get(ceiling.id) as Partial<CeilingNode> | undefined,
)
const effectiveCeiling = useMemo(
() => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
[ceiling, liveOverride],
)
const [levelObject, setLevelObject] = useState<Object3D | null>(
() => sceneRegistry.nodes.get(levelId) ?? null,
)
const [hoveredCornerIndex, setHoveredCornerIndex] = useState<number | null>(null)
const [draggedCornerIndex, setDraggedCornerIndex] = useState<number | null>(null)
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
const dragRef = useRef<CornerDragState | null>(null)
const raycasterRef = useRef(new Raycaster())
const ndcRef = useRef(new Vector2())
const planeRef = useRef(new Plane())
const planePointRef = useRef(new Vector3())
const planeNormalRef = useRef(new Vector3())
const planeOriginRef = useRef(new Vector3())
const intersectionRef = useRef(new Vector3())
const localIntersectionRef = useRef(new Vector3())
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
const displayPolygon = previewPolygon ?? effectiveCeiling.polygon
const activeCornerIndex = draggedCornerIndex ?? hoveredCornerIndex
const corners = useMemo(() => buildCornerBrackets(displayPolygon), [displayPolygon])
const highlightedEdgeIndices = useMemo(() => {
const next = new Set<number>()
if (activeCornerIndex === null || displayPolygon.length < 2) return next
next.add(activeCornerIndex)
next.add((activeCornerIndex - 1 + displayPolygon.length) % displayPolygon.length)
return next
}, [activeCornerIndex, displayPolygon.length])
const highlightedCornerIndices = useMemo(() => {
const next = new Set<number>()
if (activeCornerIndex === null || displayPolygon.length < 2) return next
next.add(activeCornerIndex)
next.add((activeCornerIndex - 1 + displayPolygon.length) % displayPolygon.length)
next.add((activeCornerIndex + 1) % displayPolygon.length)
return next
}, [activeCornerIndex, displayPolygon.length])
useEffect(() => {
if (activeCornerIndex === null) return
useViewer.getState().setHoveredId(effectiveCeiling.id)
return () => {
if (useViewer.getState().hoveredId === effectiveCeiling.id) {
useViewer.getState().setHoveredId(null)
}
}
}, [activeCornerIndex, effectiveCeiling.id])
const selectCeilingForEdit = useCallback(() => {
const editor = useEditor.getState()
editor.setMovingNode(null)
editor.setMovingWallEndpoint(null)
editor.setCurvingWall(null)
editor.setEditingHole(null)
editor.setMode('select')
useViewer.getState().setSelection({ selectedIds: [effectiveCeiling.id] })
}, [effectiveCeiling.id])
const getHandlePlanePoint = useCallback(
(event: MouseEvent | PointerEvent): [number, number] | null => {
if (!levelObject) return null
const rect = gl.domElement.getBoundingClientRect()
ndcRef.current.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
)
raycasterRef.current.setFromCamera(ndcRef.current, camera)
planePointRef.current.set(0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0)
levelObject.localToWorld(planePointRef.current)
planeOriginRef.current.set(0, 0, 0)
levelObject.localToWorld(planeOriginRef.current)
planeNormalRef.current.set(0, 1, 0)
levelObject.localToWorld(planeNormalRef.current)
planeNormalRef.current.sub(planeOriginRef.current).normalize()
planeRef.current.setFromNormalAndCoplanarPoint(planeNormalRef.current, planePointRef.current)
const hit = raycasterRef.current.ray.intersectPlane(planeRef.current, intersectionRef.current)
if (!hit) return null
localIntersectionRef.current.copy(intersectionRef.current)
levelObject.worldToLocal(localIntersectionRef.current)
return [localIntersectionRef.current.x, localIntersectionRef.current.z]
},
[camera, effectiveCeiling.height, gl.domElement, levelObject],
)
const handleCornerPointerDown = useCallback(
(corner: CornerBracketData, event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0) return
stopHandlePointerDown(event)
const startPlanePosition = getHandlePlanePoint(event.nativeEvent)
if (!startPlanePosition) return
const initialCorner = effectiveCeiling.polygon[corner.index]
if (!initialCorner) return
dragRef.current = {
ceilingId: effectiveCeiling.id,
cornerIndex: corner.index,
didDrag: false,
initialPolygon: effectiveCeiling.polygon.map(([x, z]) => [x, z] as [number, number]),
inputDraggingSet: false,
pointerId: event.pointerId,
previewPolygon: null,
previousSnappedPosition: [initialCorner[0], initialCorner[1]],
previousInputDragging: useViewer.getState().inputDragging,
startClientX: event.nativeEvent.clientX,
startClientY: event.nativeEvent.clientY,
startPlanePosition,
}
},
[effectiveCeiling.id, effectiveCeiling.polygon, getHandlePlanePoint],
)
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag || drag.ceilingId !== effectiveCeiling.id) return
if (event.pointerId !== drag.pointerId) return
const dragDistance = Math.hypot(
event.clientX - drag.startClientX,
event.clientY - drag.startClientY,
)
const planePosition = getHandlePlanePoint(event)
if (!planePosition) return
if (!drag.didDrag) {
if (dragDistance < HANDLE_DRAG_THRESHOLD_PX) return
drag.didDrag = true
drag.inputDraggingSet = true
useViewer.getState().setInputDragging(true)
setDraggedCornerIndex(drag.cornerIndex)
selectCeilingForEdit()
sfxEmitter.emit('sfx:item-pick')
}
const initialCorner = drag.initialPolygon[drag.cornerIndex]
if (!initialCorner) return
const rawNextPosition: [number, number] = [
initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]),
initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]),
]
const gridNextPosition: [number, number] = [
initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]),
initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]),
]
const nextPosition = resolveCeilingPlanPointSnap({
rawPoint: rawNextPosition,
fallbackPoint: gridNextPosition,
levelId,
excludeId: drag.ceilingId,
altKey: event.altKey,
}).point
if (
drag.previousSnappedPosition &&
(nextPosition[0] !== drag.previousSnappedPosition[0] ||
nextPosition[1] !== drag.previousSnappedPosition[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
drag.previousSnappedPosition = nextPosition
const nextPolygon = drag.initialPolygon.map((polygonPoint, index) =>
index === drag.cornerIndex ? nextPosition : polygonPoint,
)
drag.previewPolygon = nextPolygon
setPreviewPolygon(nextPolygon)
useLiveNodeOverrides.getState().set(drag.ceilingId, { polygon: nextPolygon })
useScene.getState().markDirty(drag.ceilingId)
}
const finishDrag = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag || event.pointerId !== drag.pointerId) return
dragRef.current = null
setDraggedCornerIndex(null)
setPreviewPolygon(null)
if (drag.didDrag) {
event.preventDefault()
suppressNextClick()
if (drag.previewPolygon) {
useScene.getState().updateNode(drag.ceilingId, { polygon: drag.previewPolygon })
useViewer.getState().setSelection({ selectedIds: [drag.ceilingId] })
}
sfxEmitter.emit('sfx:item-place')
}
clearCornerDragPreview(drag)
}
const cancelDrag = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag || event.pointerId !== drag.pointerId) return
dragRef.current = null
setDraggedCornerIndex(null)
setPreviewPolygon(null)
clearCornerDragPreview(drag)
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', finishDrag, true)
window.addEventListener('pointercancel', cancelDrag, true)
return () => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', finishDrag, true)
window.removeEventListener('pointercancel', cancelDrag, true)
const drag = dragRef.current
if (!drag || drag.ceilingId !== effectiveCeiling.id) return
dragRef.current = null
clearCornerDragPreview(drag)
}
}, [effectiveCeiling.id, getHandlePlanePoint, levelId, selectCeilingForEdit])
useEffect(() => {
let frameId = 0
@@ -114,9 +401,28 @@ const CeilingSelectionAffordance = ({
if (!levelObject || corners.length === 0) return null
return createPortal(
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
<group position={[0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
{corners.map((corner, index) => (
<CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
<CornerBracket
ceiling={effectiveCeiling}
corner={corner}
highlightIncoming={highlightedEdgeIndices.has(corner.incomingEdgeIndex)}
highlightOutgoing={highlightedEdgeIndices.has(corner.outgoingEdgeIndex)}
isHovered={activeCornerIndex === corner.index}
isLinkedHovered={
activeCornerIndex !== null &&
activeCornerIndex !== corner.index &&
highlightedCornerIndices.has(corner.index)
}
key={`${ceiling.id}-corner-${index}`}
onHoverChange={(hovered) => {
setHoveredCornerIndex((current) => {
if (hovered) return corner.index
return current === corner.index ? null : current
})
}}
onPointerDown={(event) => handleCornerPointerDown(corner, event)}
/>
))}
</group>,
levelObject,
@@ -126,21 +432,29 @@ const CeilingSelectionAffordance = ({
const CornerBracket = ({
ceiling,
corner,
highlightIncoming,
highlightOutgoing,
isHovered,
isLinkedHovered,
onHoverChange,
onPointerDown,
}: {
ceiling: CeilingNode
corner: CornerBracketData
highlightIncoming: boolean
highlightOutgoing: boolean
isHovered: boolean
isLinkedHovered: boolean
onHoverChange: (hovered: boolean) => void
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
}) => {
const [isHovered, setIsHovered] = useState(false)
const color = '#d4d4d4'
const opacity = 0.72
const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
const cubeOpacity = isHovered ? 0.92 : 0.72
const cubeHighlighted = isHovered || isLinkedHovered
const cubeColor = cubeHighlighted ? HANDLE_HOVER_COLOR : HANDLE_COLOR
const cubeOpacity = cubeHighlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY
const handleClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation()
const nodes = useScene.getState().nodes
useEditor.getState().setMovingNode(null)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setCurvingWall(null)
@@ -160,36 +474,42 @@ const CornerBracket = ({
return (
<group position={[corner.corner[0], 0, corner.corner[1]]}>
<BracketLeg
color={color}
color={highlightIncoming ? HANDLE_HOVER_COLOR : HANDLE_COLOR}
direction={corner.incomingDirection}
highlighted={highlightIncoming}
length={corner.incomingLength}
onClick={handleClick}
opacity={opacity}
onHoverChange={onHoverChange}
onPointerDown={onPointerDown}
/>
<BracketLeg
color={color}
color={highlightOutgoing ? HANDLE_HOVER_COLOR : HANDLE_COLOR}
direction={corner.outgoingDirection}
highlighted={highlightOutgoing}
length={corner.outgoingLength}
onClick={handleClick}
opacity={opacity}
onHoverChange={onHoverChange}
onPointerDown={onPointerDown}
/>
<mesh
geometry={SHARED_HANDLE_BOX_GEOMETRY}
onClick={handleClick}
onPointerDown={onPointerDown}
onPointerEnter={(e) => {
e.stopPropagation()
setIsHovered(true)
onHoverChange(true)
}}
onPointerLeave={(e) => {
e.stopPropagation()
setIsHovered(false)
onHoverChange(false)
}}
renderOrder={CORNER_RENDER_ORDER}
scale={HIT_BOX_SIZE}
>
<boxGeometry args={HIT_BOX_SIZE} />
<meshBasicMaterial
color={cubeColor}
depthTest={false}
depthTest
depthWrite={false}
opacity={cubeOpacity}
transparent
@@ -203,16 +523,20 @@ const BracketLeg = ({
direction,
length,
color,
highlighted,
onClick,
opacity,
onHoverChange,
onPointerDown,
}: {
direction: [number, number]
length: number
color: string
highlighted: boolean
onClick: (e: ThreeEvent<MouseEvent>) => void
opacity: number
onHoverChange: (hovered: boolean) => void
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
}) => {
const angle = Math.atan2(direction[1], direction[0])
const angle = -Math.atan2(direction[1], direction[0])
const position: [number, number, number] = [
direction[0] * (length / 2),
0,
@@ -221,17 +545,27 @@ const BracketLeg = ({
return (
<mesh
geometry={SHARED_HANDLE_BOX_GEOMETRY}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerEnter={(e) => {
e.stopPropagation()
onHoverChange(true)
}}
onPointerLeave={(e) => {
e.stopPropagation()
onHoverChange(false)
}}
position={position}
renderOrder={CORNER_RENDER_ORDER}
rotation={[0, angle, 0]}
scale={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]}
>
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
<meshBasicMaterial
color={color}
depthTest={false}
depthTest
depthWrite={false}
opacity={opacity}
opacity={highlighted ? HANDLE_HOVER_OPACITY : HANDLE_OPACITY}
transparent
/>
</mesh>
@@ -241,7 +575,7 @@ const BracketLeg = ({
function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
if (polygon.length < 3) return []
const allCorners = polygon.map((corner, index) => {
return polygon.map((corner, index) => {
const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
const next = polygon[(index + 1) % polygon.length]!
const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
@@ -251,35 +585,18 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
const cornerStrength =
1 -
Math.abs(
incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1],
)
return {
corner,
index,
incomingEdgeIndex: (index - 1 + polygon.length) % polygon.length,
incomingDirection,
outgoingEdgeIndex: index,
outgoingDirection,
incomingLength: getBracketLength(incomingLength),
outgoingLength: getBracketLength(outgoingLength),
cornerStrength,
}
})
if (allCorners.length <= 4) {
return allCorners
}
const selectedIndices = new Set(
allCorners
.map((corner, index) => ({ index, strength: corner.cornerStrength }))
.sort((a, b) => b.strength - a.strength)
.slice(0, 4)
.map(({ index }) => index),
)
return allCorners.filter((_, index) => selectedIndices.has(index))
}
function normalize2D(vector: [number, number]): [number, number] {
@@ -1,17 +1,89 @@
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { Color, type Material, type Mesh } from 'three'
import useEditor from '../../../store/use-editor'
const CEILING_GRID_HIGHLIGHT_COLOR = '#ffffff'
const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial'
const CEILING_GRID_HIGHLIGHT_MATERIAL_KEY = '__pascalCeilingGridHighlightMaterial'
type CeilingGridUserData = {
[CEILING_GRID_BASE_MATERIAL_KEY]?: Material | Material[]
[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]?: Material | Material[]
}
type HighlightableMaterial = Material & {
color?: Color
depthWrite?: boolean
needsUpdate?: boolean
opacity?: number
transparent?: boolean
}
function cloneCeilingGridHighlightMaterial(material: Material | Material[]): Material | Material[] {
const cloneOne = (entry: Material): Material => {
const clone = entry.clone() as HighlightableMaterial
if (clone.color instanceof Color) {
clone.color.set(CEILING_GRID_HIGHLIGHT_COLOR)
}
clone.depthWrite = false
clone.opacity = 1
clone.transparent = true
clone.needsUpdate = true
return clone
}
return Array.isArray(material) ? material.map(cloneOne) : cloneOne(material)
}
function disposeMaterial(material: Material | Material[] | undefined) {
if (!material) return
const materials = Array.isArray(material) ? material : [material]
for (const entry of materials) {
entry.dispose()
}
}
function setCeilingGridHighlighted(ceilingGrid: Mesh, highlighted: boolean) {
const userData = ceilingGrid.userData as CeilingGridUserData
if (highlighted) {
if (!userData[CEILING_GRID_BASE_MATERIAL_KEY]) {
userData[CEILING_GRID_BASE_MATERIAL_KEY] = ceilingGrid.material
userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY] = cloneCeilingGridHighlightMaterial(
ceilingGrid.material,
)
}
const highlightMaterial = userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]
if (highlightMaterial) {
ceilingGrid.material = highlightMaterial
}
return
}
const baseMaterial = userData[CEILING_GRID_BASE_MATERIAL_KEY]
if (baseMaterial) {
ceilingGrid.material = baseMaterial
}
disposeMaterial(userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY])
delete userData[CEILING_GRID_BASE_MATERIAL_KEY]
delete userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]
}
export const CeilingSystem = () => {
const tool = useEditor((state) => state.tool)
const selectedItem = useEditor((state) => state.selectedItem)
const movingNode = useEditor((state) => state.movingNode)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const activeLevelId = useViewer((state) => state.selection.levelId)
const hoveredId = useViewer((state) => state.hoveredId)
useEffect(() => {
const nodes = useScene.getState().nodes
const hoveredNode = hoveredId ? nodes[hoveredId as AnyNodeId] : null
const hoveredCeilingId = hoveredNode?.type === 'ceiling' ? hoveredNode.id : null
const levelsToShowCeilings = new Set<string>()
@@ -54,7 +126,7 @@ export const CeilingSystem = () => {
ceilings.forEach((ceiling) => {
const mesh = sceneRegistry.nodes.get(ceiling)
if (mesh) {
const ceilingGrid = mesh.getObjectByName('ceiling-grid')
const ceilingGrid = mesh.getObjectByName('ceiling-grid') as Mesh | undefined
if (ceilingGrid) {
let belongsToVisibleLevel = false
let currentId: string | null = ceiling
@@ -68,14 +140,18 @@ export const CeilingSystem = () => {
currentId = node?.parentId as string | null
}
const shouldHighlightGrid = ceiling === hoveredCeilingId
const shouldShowGrid =
belongsToVisibleLevel || (levelsToShowCeilings.size === 0 && isCeilingToolActive)
shouldHighlightGrid ||
belongsToVisibleLevel ||
(levelsToShowCeilings.size === 0 && isCeilingToolActive)
setCeilingGridHighlighted(ceilingGrid, shouldHighlightGrid)
ceilingGrid.visible = shouldShowGrid
ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden
}
}
})
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId])
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId, hoveredId])
return null
}
@@ -18,6 +18,8 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry {
// meshes are drawn. An empty position (count 0) leaves WebGPU vertex buffer
// slot 0 unbound and the draw is rejected, poisoning the command encoder.
g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
g.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
g.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
// Match the four material slots the roof-segment renderer's material
// array expects (0=top, 1=side, 2=interior, 3=shingle). Without these
// groups, mesh.material is a single-material lookup that mismatches
@@ -6,19 +6,27 @@ import type {
GridEvent,
ItemEvent,
ItemNode,
RoofEvent,
RoofNode,
RoofSegmentNode,
RoofWallFaceId,
ShelfEvent,
ShelfNode,
WallEvent,
WallNode,
} from '@pascal-app/core'
import {
clampRectToRoofWallFace,
getRoofSegmentWallFace,
getScaledDimensions,
isLowProfileItemSurface,
nodeRegistry,
roofFacePointToSegment,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
import {
calculateCursorRotation,
@@ -211,10 +219,13 @@ export const wallStrategy = {
const adjustedY = validation.adjustedY ?? y
return {
stateUpdate: { surface: 'wall', wallId: event.node.id },
stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
nodeUpdate: {
position: [x, adjustedY, z],
parentId: event.node.id,
// The draft may arrive from a roof-segment wall face.
roofSegmentId: undefined,
roofFace: undefined,
side,
rotation: [0, itemRotation, 0],
},
@@ -313,6 +324,8 @@ export const wallStrategy = {
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: event.node.id,
roofSegmentId: undefined,
roofFace: undefined,
side: ctx.draftItem.side,
rotation: ctx.draftItem.rotation,
metadata: stripTransient(ctx.draftItem.metadata),
@@ -342,6 +355,223 @@ export const wallStrategy = {
},
}
// ============================================================================
// ROOF WALL STRATEGY
// ============================================================================
type RoofWallTarget = {
segment: RoofSegmentNode
faceId: RoofWallFaceId
faceYaw: number
/** Stored node position: FACE-LOCAL, y = bottom edge. */
position: [number, number, number]
/** Face-coord center of the placed rect (for the overlap guard). */
centerU: number
centerV: number
width: number
height: number
cursorPosition: [number, number, number]
cursorRotationY: number
}
/**
* Resolve a roof pointer event to an item placement on a segment wall
* face. Items snap u / bottom-v to the 0.5m grid, then the rect is
* clamped inside the face profile (sliding under the gable slopes).
* Position frame matches wall hosting: y anchors the BOTTOM edge;
* `wall-side` items mount on the outer surface, `wall` items center in
* the wall thickness.
*
* `shiftFree` mirrors the wall flow's Shift override (stubbed
* validators): the profile clamp is skipped, so the rect may overhang
* the face edges — placement follows the snapped cursor as-is.
*/
function resolveRoofWallTarget(
ctx: PlacementContext,
event: RoofEvent,
shiftFree = false,
): RoofWallTarget | null {
const attachTo = ctx.asset.attachTo
if (attachTo !== 'wall' && attachTo !== 'wall-side') return null
const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
if (!hit) return null
const rawDims = ctx.draftItem
? getScaledDimensions(ctx.draftItem)
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
const dims = getGridAlignedDimensions(rawDims, attachTo)
const [width, height] = dims
const u = snapToHalf(hit.u)
const centerV = snapToHalf(hit.v) + height / 2
const fitted = shiftFree ? null : clampRectToRoofWallFace(hit.face, u, centerV, width, height)
if (!fitted && !shiftFree) return null
const finalU = fitted?.u ?? u
const finalV = fitted?.v ?? centerV
// FACE-LOCAL storage (z = 0 → wall mid-plane; ItemSystem pushes
// wall-side items to the outer surface, exactly like wall hosting).
// The renderer mounts the node inside the live face frame, so items
// track segment resizes without any re-anchoring.
const position: [number, number, number] = [finalU, finalV - height / 2, 0]
const segObj = sceneRegistry.nodes.get(hit.segment.id)
if (!segObj) return null
segObj.updateWorldMatrix(true, false)
const segLocal = roofFacePointToSegment(hit.segment, hit.face.id, position)
const worldPos = segObj.localToWorld(new Vector3(segLocal[0], segLocal[1], segLocal[2]))
const nodes = useScene.getState().nodes
const roof = hit.segment.parentId
? (nodes[hit.segment.parentId as AnyNodeId] as RoofNode | undefined)
: undefined
return {
segment: hit.segment,
faceId: hit.face.id,
faceYaw: hit.face.yaw,
position,
centerU: finalU,
centerV: finalV,
width,
height,
cursorPosition: [worldPos.x, worldPos.y, worldPos.z],
cursorRotationY: (roof?.rotation ?? 0) + (hit.segment.rotation ?? 0) + hit.face.yaw,
}
}
/** Validation half of `checkCanPlace` for the roof-wall surface. */
function canPlaceOnRoofWall(ctx: PlacementContext): boolean {
const segmentId = ctx.state.roofSegmentId
if (!(segmentId && ctx.draftItem)) return false
const segment = useScene.getState().nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
if (segment?.type !== 'roof-segment') return false
const faceId = ctx.draftItem.roofFace
if (!faceId) return false
const face = getRoofSegmentWallFace(segment, faceId)
const dims = getGridAlignedDimensions(
getScaledDimensions(ctx.draftItem),
ctx.draftItem.asset.attachTo,
)
const [width, height] = dims
// gridPosition carries the stored FACE-LOCAL coords (u, bottom-v, z).
const u = ctx.gridPosition.x
const centerV = ctx.gridPosition.y + height / 2
const clamped = clampRectToRoofWallFace(face, u, centerV, width, height)
if (!clamped || Math.abs(clamped.u - u) > 1e-3 || Math.abs(clamped.v - centerV) > 1e-3) {
return false
}
return !hasRoofFaceChildOverlap(segment, faceId, u, centerV, width, height, ctx.draftItem.id)
}
export const roofWallStrategy = {
/**
* Handle roof:enter / first hover — transition onto a segment wall
* face. Returns null when the item doesn't wall-attach or the pointer
* isn't over a placeable face.
*/
enter(ctx: PlacementContext, event: RoofEvent, shiftFree = false): TransitionResult | null {
const target = resolveRoofWallTarget(ctx, event, shiftFree)
if (!target) return null
return {
stateUpdate: { surface: 'roof-wall', roofSegmentId: target.segment.id, wallId: null },
nodeUpdate: {
position: target.position,
parentId: target.segment.id,
roofSegmentId: target.segment.id,
roofFace: target.faceId,
wallId: undefined,
side: 'front',
rotation: [0, 0, 0],
},
cursorRotationY: target.cursorRotationY,
gridPosition: target.position,
cursorPosition: target.cursorPosition,
stopPropagation: true,
}
},
/**
* Handle roof:move while on a segment wall face. Returns null when the
* pointer resolves to a DIFFERENT segment (the coordinator re-enters —
* segment transitions inside one roof never re-fire roof:enter) or to
* no placeable face.
*/
move(ctx: PlacementContext, event: RoofEvent, shiftFree = false): PlacementResult | null {
if (ctx.state.surface !== 'roof-wall') return null
if (!ctx.draftItem) return null
const target = resolveRoofWallTarget(ctx, event, shiftFree)
if (!target) return null
if (target.segment.id !== ctx.state.roofSegmentId) return null
return {
gridPosition: target.position,
cursorPosition: target.cursorPosition,
cursorRotationY: target.cursorRotationY,
nodeUpdate: {
position: target.position,
side: 'front',
rotation: [0, 0, 0],
roofFace: target.faceId,
},
stopPropagation: true,
// Items don't cut the roof — no geometry rebuild needed.
dirtyNodeId: null,
}
},
/**
* Handle roof:click — commit placement on the segment wall face.
*/
click(ctx: PlacementContext, _event: RoofEvent, shiftFree = false): CommitResult | null {
if (ctx.state.surface !== 'roof-wall') return null
if (!(ctx.draftItem && ctx.state.roofSegmentId)) return null
// Shift mirrors the wall flow's stubbed validators: skip profile-fit
// and overlap checks entirely.
if (!shiftFree && !canPlaceOnRoofWall(ctx)) return null
return {
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.state.roofSegmentId,
roofSegmentId: ctx.state.roofSegmentId,
roofFace: ctx.draftItem.roofFace,
wallId: undefined,
side: 'front',
rotation: [0, 0, 0],
metadata: stripTransient(ctx.draftItem.metadata),
},
stopPropagation: true,
dirtyNodeId: null,
}
},
/**
* Handle roof:leave — transition back to floor surface.
*/
leave(ctx: PlacementContext): TransitionResult | null {
if (ctx.state.surface !== 'roof-wall') return null
return {
stateUpdate: { surface: 'floor', roofSegmentId: null },
nodeUpdate: {
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
parentId: ctx.levelId,
roofSegmentId: undefined,
roofFace: undefined,
},
cursorRotationY: 0,
gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
stopPropagation: true,
}
},
}
// ============================================================================
// CEILING STRATEGY
// ============================================================================
@@ -794,6 +1024,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
}
if (attachTo === 'wall' || attachTo === 'wall-side') {
if (ctx.state.surface === 'roof-wall') {
return canPlaceOnRoofWall(ctx)
}
if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false
return validators.canPlaceOnWall(
ctx.levelId,
@@ -12,7 +12,13 @@ import type { Vector3 } from 'three'
// PLACEMENT STATE
// ============================================================================
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface'
export type SurfaceType =
| 'floor'
| 'wall'
| 'roof-wall'
| 'ceiling'
| 'item-surface'
| 'shelf-surface'
/**
* Tracks which surface the draft item is currently on.
@@ -21,6 +27,12 @@ export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf
export interface PlacementState {
surface: SurfaceType
wallId: string | null
/**
* Active roof-segment when `surface === 'roof-wall'` — wall-attach
* items also host on the vertical wall faces a roof segment generates
* (base walls + coplanar gable ends).
*/
roofSegmentId: string | null
ceilingId: string | null
surfaceItemId: string | null
/**
@@ -15,6 +15,10 @@ interface OriginalState {
rotation: [number, number, number]
side: ItemNode['side']
parentId: string | null
// Roof-segment wall hosting — cleared/changed by surface transitions
// mid-move, so reverts must restore it alongside parentId.
roofSegmentId: ItemNode['roofSegmentId']
roofFace: ItemNode['roofFace']
metadata: ItemNode['metadata']
}
@@ -92,6 +96,8 @@ export function useDraftNode(): DraftNodeHandle {
rotation: [...node.rotation] as [number, number, number],
side: node.side,
parentId: node.parentId,
roofSegmentId: node.roofSegmentId,
roofFace: node.roofFace,
metadata: node.metadata,
}
@@ -121,6 +127,8 @@ export function useDraftNode(): DraftNodeHandle {
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
@@ -133,6 +141,15 @@ export function useDraftNode(): DraftNodeHandle {
side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string,
// Forward the roof host explicitly: strategies set it on every
// commit (segment id on a roof face, undefined elsewhere), and
// dropping it here strands the item in the roof frame without
// the segment transform.
roofSegmentId: updateProps.roofSegmentId,
roofFace: updateProps.roofFace,
// Only when the strategy decided about wallId (roof commits clear
// it) — floor/ceiling commits never managed the field.
...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
})
useScene.temporal.getState().pause()
@@ -163,6 +180,11 @@ export function useDraftNode(): DraftNodeHandle {
rotation: updateProps.rotation ?? draft.rotation,
scale: updateProps.scale ?? draft.scale,
side: updateProps.side ?? draft.side,
// Roof host — see the move-mode commit above for why this must be
// forwarded explicitly.
roofSegmentId: updateProps.roofSegmentId,
roofFace: updateProps.roofFace,
...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}),
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
})
useScene.getState().createNode(finalNode, parentId)
@@ -207,6 +229,8 @@ export function useDraftNode(): DraftNodeHandle {
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
@@ -10,6 +10,7 @@ import {
getScaledDimensions,
type ItemEvent,
movingFootprintAnchors,
type RoofEvent,
resolveLevelId,
type ShelfEvent,
sceneRegistry,
@@ -57,6 +58,7 @@ import {
checkCanPlace,
floorStrategy,
itemSurfaceStrategy,
roofWallStrategy,
shelfSurfaceStrategy,
wallStrategy,
} from './placement-strategies'
@@ -203,6 +205,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
config.initialState ?? {
surface: 'floor',
wallId: null,
roofSegmentId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
@@ -403,6 +406,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
placementState.current = configRef.current.initialState ?? {
surface: 'floor',
wallId: null,
roofSegmentId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
@@ -977,6 +981,145 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
}
// ---- Roof Wall Handlers ----
// Wall-attach items also host on the vertical wall faces a roof
// segment generates (base walls + coplanar gable ends). Unlike walls,
// crossing between segments inside ONE roof never re-fires
// `roof:enter` (events come from the roof group), so the move handler
// re-enters whenever the strategy reports a segment change.
const enterRoofWall = (event: RoofEvent): boolean => {
const result = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
if (!result) return false
event.stopPropagation()
applyTransition(result)
if (!draftNode.current) {
ensureDraft(result)
} else if (result.nodeUpdate.parentId) {
// Existing draft (move mode): reparent to the segment
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
}
return true
}
const onRoofWallEnter = (event: RoofEvent) => {
has3DPointerDrivenMoveRef.current = true
enterRoofWall(event)
}
const onRoofWallMove = (event: RoofEvent) => {
releaseCommit = () => onRoofWallClick(event)
has3DPointerDrivenMoveRef.current = true
if (!cursorGroupRef.current) return
const ctx = getContext()
if (ctx.state.surface !== 'roof-wall' || !draftNode.current) {
enterRoofWall(event)
return
}
const result = roofWallStrategy.move(ctx, event, shiftFreeRef.current)
if (!result) {
// Different segment under the pointer (or no placeable face) —
// try a fresh enter; a null resolve leaves the draft where it is.
enterRoofWall(event)
return
}
event.stopPropagation()
const posChanged =
gridPosition.current.x !== result.gridPosition[0] ||
gridPosition.current.y !== result.gridPosition[1] ||
gridPosition.current.z !== result.gridPosition[2]
if (posChanged) {
sfxEmitter.emit('sfx:grid-snap')
}
gridPosition.current.set(...result.gridPosition)
const wc = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(wc.x, wc.y, wc.z)
cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current
if (draft && result.nodeUpdate) {
if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side
if ('rotation' in result.nodeUpdate)
draft.rotation = result.nodeUpdate.rotation as [number, number, number]
}
const placeable = revalidate()
if (draft && placeable) {
draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) {
mesh.position.copy(gridPosition.current)
// Wall-side items sit on the outer surface: mirror ItemSystem's
// push (z = thickness/2 off the face frame's mid-plane) so the
// drag preview doesn't sink into the wall until commit.
if (asset.attachTo === 'wall-side' && placementState.current.roofSegmentId) {
const segment =
useScene.getState().nodes[placementState.current.roofSegmentId as AnyNodeId]
if (segment?.type === 'roof-segment') {
mesh.position.z = (segment.wallThickness ?? 0.1) / 2
}
}
const rot = result.nodeUpdate?.rotation
if (rot) mesh.rotation.y = rot[1]
}
// The 2D floor-plan live frame is wall-local; a segment-local
// value would render garbage — clear instead of publishing.
useLiveTransforms.getState().clear(draft.id)
}
}
const onRoofWallClick = (event: RoofEvent) => {
const result = roofWallStrategy.click(getContext(), event, shiftFreeRef.current)
if (!result) return
event.stopPropagation()
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) {
const enterResult = roofWallStrategy.enter(getContext(), event, shiftFreeRef.current)
if (enterResult) {
applyTransition(enterResult)
} else {
revalidate()
}
}
}
const onRoofWallLeave = (event: RoofEvent) => {
const result = roofWallStrategy.leave(getContext())
if (!result) return
event.stopPropagation()
if (draftNode.isAdopted) {
// Move mode: keep draft alive, reparent to level
applyTransition(result)
const draft = draftNode.current
if (draft) {
useScene.getState().updateNode(draft.id, {
parentId: result.nodeUpdate.parentId as string,
roofSegmentId: undefined,
})
}
} else {
// Create mode: destroy transient and reset state
draftNode.destroy()
Object.assign(placementState.current, result.stateUpdate)
}
}
// ---- Item Surface Handlers ----
const detachItemSurfaceToFloor = (event: ItemEvent) => {
@@ -1489,6 +1632,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const draft = draftNode.current
if (!draft) return
// Roof-wall drafts live flat in the host face frame (yaw 0) —
// manual rotation would skew them off the wall plane.
if (placementState.current.surface === 'roof-wall') return
let rotationDelta = 0
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
rotationDelta = ROTATION_STEP
@@ -1663,6 +1810,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('roof:enter', onRoofWallEnter)
emitter.on('roof:move', onRoofWallMove)
emitter.on('roof:click', onRoofWallClick)
emitter.on('roof:leave', onRoofWallLeave)
emitter.on('ceiling:enter', onCeilingEnter)
emitter.on('ceiling:move', onCeilingMove)
emitter.on('ceiling:click', onCeilingClick)
@@ -1694,6 +1845,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('roof:enter', onRoofWallEnter)
emitter.off('roof:move', onRoofWallMove)
emitter.off('roof:click', onRoofWallClick)
emitter.off('roof:leave', onRoofWallLeave)
emitter.off('ceiling:enter', onCeilingEnter)
emitter.off('ceiling:move', onCeilingMove)
emitter.off('ceiling:click', onCeilingClick)
@@ -9,6 +9,10 @@ type PointerEventLike = {
nativeEvent?: PointerEvent | PointerEventLike
}
type SuppressBoxSelectOptions = {
markHandled?: boolean
}
function pointerIdFor(event: PointerEvent | PointerEventLike): number | null {
if ('pointerId' in event && typeof event.pointerId === 'number') {
return event.pointerId
@@ -28,8 +32,12 @@ export function markBoxSelectHandled() {
}, 50)
}
export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLike) {
markBoxSelectHandled()
export function suppressBoxSelectForPointer(
event: PointerEvent | PointerEventLike,
options: SuppressBoxSelectOptions = {},
) {
const markHandled = options.markHandled ?? true
if (markHandled) markBoxSelectHandled()
const pointerId = pointerIdFor(event)
if (pointerId === null || suppressedPointerIds.has(pointerId)) return
@@ -38,7 +46,7 @@ export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLi
const clear = (releaseEvent?: PointerEvent) => {
if (releaseEvent && releaseEvent.pointerId !== pointerId) return
markBoxSelectHandled()
if (markHandled) markBoxSelectHandled()
suppressedPointerIds.delete(pointerId)
const cleanup = suppressionCleanups.get(pointerId)
suppressionCleanups.delete(pointerId)
@@ -48,15 +56,18 @@ export function suppressBoxSelectForPointer(event: PointerEvent | PointerEventLi
const onPointerUp = (releaseEvent: PointerEvent) => clear(releaseEvent)
const onPointerCancel = (releaseEvent: PointerEvent) => clear(releaseEvent)
const onBlur = () => clear()
// Click-preserving handle interactions need suppression cleared before
// canvas-level pointerup handlers decide whether to block the follow-up click.
const releaseListenerOptions = markHandled ? undefined : { capture: true }
const cleanup = () => {
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerCancel)
window.removeEventListener('pointerup', onPointerUp, releaseListenerOptions)
window.removeEventListener('pointercancel', onPointerCancel, releaseListenerOptions)
window.removeEventListener('blur', onBlur)
}
suppressionCleanups.set(pointerId, cleanup)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerCancel)
window.addEventListener('pointerup', onPointerUp, releaseListenerOptions)
window.addEventListener('pointercancel', onPointerCancel, releaseListenerOptions)
window.addEventListener('blur', onBlur)
}
@@ -3,9 +3,11 @@ import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
BoxGeometry,
BufferGeometry,
Color,
CylinderGeometry,
DoubleSide,
ExtrudeGeometry,
Float32BufferAttribute,
type Line,
@@ -20,10 +22,10 @@ import {
ARROW_COLOR as EDGE_ARROW_COLOR,
ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR,
ARROW_SCALE as EDGE_ARROW_SCALE,
useArrowMaterial,
useInvisibleHitAreaMaterial,
} from '../../editor/node-arrow-handles'
import { snapToHalf } from '../item/placement-math'
import { suppressBoxSelectForPointer } from '../select/box-select-state'
const Y_OFFSET = 0.02
// Per-side resize arrows: indigo chevrons that match the registry arrow
@@ -77,6 +79,17 @@ type DragState = {
pointerId: number
}
export type PolygonEditorPlanPointSnapContext = {
rawPoint: [number, number]
gridPoint: [number, number]
mode: DragState['mode']
vertexIndex: number | null
edgeIndex?: number
initialPosition: [number, number]
initialPolygon: Array<[number, number]>
nativeEvent?: GridEvent['nativeEvent']
}
export interface PolygonEditorProps {
polygon: Array<[number, number]>
color?: string
@@ -104,6 +117,8 @@ export interface PolygonEditorProps {
onVertexHoverChange?: (vertexIndex: number | null) => void
/** Called when a midpoint add-vertex handle enters or leaves hover. */
onMidpointHoverChange?: (edgeIndex: number | null) => void
/** Called when an edge move handle enters or leaves hover. */
onEdgeHoverChange?: (edgeIndex: number | null) => void
/** Called when any polygon drag starts or ends. */
onDragStateChange?: (isDragging: boolean) => void
/** Called once when a polygon drag starts. */
@@ -114,6 +129,10 @@ export interface PolygonEditorProps {
showBorderLine?: boolean
/** Whether midpoint handles can add new vertices. */
showMidpointHandles?: boolean
/** Whether hovering a handle should also tint its connected edges and endpoint handles. */
highlightConnectedHandles?: boolean
/** Optional host-owned point snapper. Defaults to the existing half-grid snap. */
resolvePlanPoint?: (context: PolygonEditorPlanPointSnapContext) => [number, number]
/** Optional vertex handle renderer for host-specific affordances. */
renderVertexHandle?: PolygonVertexHandleRenderer
/** Optional midpoint handle renderer for host-specific add-vertex affordances. */
@@ -127,6 +146,7 @@ export interface PolygonEditorProps {
const MIN_HANDLE_HEIGHT = 0.15
const EDGE_HANDLE_HEIGHT = 0.06
const EDGE_HANDLE_THICKNESS = 0.12
const EDGE_HANDLE_GEOMETRY = new BoxGeometry(1, 1, 1)
function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
const dx = end[0] - start[0]
@@ -137,6 +157,11 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number,
return [-dz / length, dx / length]
}
function stopHandlePointerDown(event: ThreeEvent<PointerEvent>) {
event.stopPropagation()
suppressBoxSelectForPointer(event, { markHandled: false })
}
type HandleClickHandler = (event: ThreeEvent<MouseEvent>) => void
type HandlePointerHandler = (event: ThreeEvent<PointerEvent>) => void
@@ -181,7 +206,7 @@ function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMateri
() =>
new MeshBasicNodeMaterial({
color: new Color('#ffffff'),
depthTest: false,
depthTest: true,
depthWrite: true,
opacity: 1,
transparent: true,
@@ -198,11 +223,24 @@ function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMateri
return material
}
function usePolygonArrowMaterial(): MeshBasicNodeMaterial {
return useMemo(
() =>
new MeshBasicNodeMaterial({
color: new Color(EDGE_ARROW_COLOR),
depthTest: true,
depthWrite: true,
opacity: 1,
side: DoubleSide,
transparent: true,
}),
[],
)
}
// One mesh per handle: lives on SCENE_LAYER with a node material so the
// post-processing ink-edge pass outlines it, and carries the pointer handlers
// directly so it stays grabbable — matching the registry arrow gizmos in
// node-arrow-handles.tsx. No paired hit mesh is needed; the R3F event
// raycaster picks SCENE_LAYER meshes too.
// post-processing ink-edge pass outlines it. The visual material still
// depth-tests, so walls/items in front can occlude it.
function OutlinedCylinderHandle({
radius,
height,
@@ -290,7 +328,7 @@ function OutlinedEdgeArrowHandle({
rotationY: number
scale: number
} & PolygonHandleHandlers) {
const material = useArrowMaterial()
const material = usePolygonArrowMaterial()
useEffect(() => {
material.color.set(color)
}, [color, material])
@@ -311,6 +349,47 @@ function OutlinedEdgeArrowHandle({
)
}
function HighlightedEdgeSegment({
end,
start,
y,
}: {
end: [number, number]
start: [number, number]
y: number
}) {
const geometry = useMemo(() => {
const nextGeometry = new BufferGeometry()
nextGeometry.setAttribute(
'position',
new Float32BufferAttribute([start[0], y, start[1], end[0], y, end[1]], 3),
)
return nextGeometry
}, [end, start, y])
useEffect(() => () => geometry.dispose(), [geometry])
return (
<line
// @ts-expect-error R3F <line> element conflicts with SVG <line> type
frustumCulled={false}
geometry={geometry}
layers={EDITOR_LAYER}
raycast={NO_RAYCAST}
renderOrder={12}
>
<lineBasicNodeMaterial
color={EDGE_ARROW_HOVER_COLOR}
depthTest
depthWrite={false}
linewidth={4}
opacity={0.95}
transparent
/>
</line>
)
}
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
polygon,
color = '#3b82f6',
@@ -324,11 +403,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
onBeforeVertexDrag,
onVertexHoverChange,
onMidpointHoverChange,
onEdgeHoverChange,
onDragStateChange,
onDragStart,
onDragCommit,
showBorderLine = true,
showMidpointHandles = true,
highlightConnectedHandles = false,
resolvePlanPoint,
renderMidpointHandle,
renderVertexHandle,
}) => {
@@ -429,6 +511,12 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange])
useEffect(() => {
onEdgeHoverChange?.(hoveredEdge)
}, [hoveredEdge, onEdgeHoverChange])
useEffect(() => () => onEdgeHoverChange?.(null), [onEdgeHoverChange])
const lineRef = useRef<Line>(null!)
const previousPositionRef = useRef<[number, number] | null>(null)
@@ -556,6 +644,47 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
})
}, [displayPolygon])
const activeVertexIndex = dragState?.mode === 'vertex' ? dragState.vertexIndex : hoveredVertex
const activeEdgeIndex = dragState?.mode === 'edge' ? dragState.edgeIndex : hoveredEdge
const highlightedEdgeIndices = useMemo(() => {
const next = new Set<number>()
const edgeCount = displayPolygon.length
if (!highlightConnectedHandles || edgeCount < 2) return next
if (activeVertexIndex !== null && activeVertexIndex !== undefined) {
next.add(activeVertexIndex)
next.add((activeVertexIndex - 1 + edgeCount) % edgeCount)
}
if (hoveredMidpoint !== null) {
next.add(hoveredMidpoint)
}
if (activeEdgeIndex !== null && activeEdgeIndex !== undefined) {
next.add(activeEdgeIndex)
}
return next
}, [
activeEdgeIndex,
activeVertexIndex,
displayPolygon.length,
highlightConnectedHandles,
hoveredMidpoint,
])
const isVertexLinkedHighlighted = useCallback(
(index: number) => {
if (!highlightConnectedHandles || highlightedEdgeIndices.size === 0) return false
const edgeCount = displayPolygon.length
if (edgeCount < 2) return false
return (
highlightedEdgeIndices.has(index) ||
highlightedEdgeIndices.has((index - 1 + edgeCount) % edgeCount)
)
},
[displayPolygon.length, highlightConnectedHandles, highlightedEdgeIndices],
)
const arrowGeometry = useMemo(() => createEdgeArrowGeometry(), [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
@@ -616,9 +745,21 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
useEffect(() => {
const onGridMove = (event: GridEvent) => {
const point = levelNode ? event.localPosition : event.position
const gridX = snapToHalf(point[0])
const gridZ = snapToHalf(point[2])
const newPosition: [number, number] = [gridX, gridZ]
const rawPoint: [number, number] = [point[0], point[2]]
const gridPoint: [number, number] = [snapToHalf(rawPoint[0]), snapToHalf(rawPoint[1])]
const newPosition =
dragState?.isDragging && resolvePlanPoint
? resolvePlanPoint({
rawPoint,
gridPoint,
mode: dragState.mode,
vertexIndex: dragState.vertexIndex,
edgeIndex: dragState.edgeIndex,
initialPosition: dragState.initialPosition,
initialPolygon: dragState.initialPolygon,
nativeEvent: event.nativeEvent,
})
: gridPoint
// Play snap sound when cursor moves to a new grid cell during drag
if (
@@ -673,7 +814,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return () => {
emitter.off('grid:move', onGridMove)
}
}, [dragState, handleVertexDrag, levelNode, updatePreviewPolygon])
}, [dragState, handleVertexDrag, levelNode, resolvePlanPoint, updatePreviewPolygon])
// Set up pointer up listener for ending drag
useEffect(() => {
@@ -741,9 +882,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02)
const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2
// Interactive handles are single SCENE_LAYER node-material meshes (like the
// registry arrow gizmos) so the ink-edge pass outlines them while they stay
// grabbable. The edge BAR and border line stay on EDITOR_LAYER, visual-only
// Interactive handles are SCENE_LAYER node-material meshes so the ink-edge
// pass outlines them while normal scene depth can hide them. The edge BAR and
// border line stay on EDITOR_LAYER, visual-only
// (raycast disabled) so they never steal clicks from the vertex/midpoint
// handles overlapping them — edge dragging starts from the chevron arrow
// outside the polygon edge.
@@ -771,10 +912,28 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
</line>
)}
{highlightConnectedHandles &&
highlightedEdgeIndices.size > 0 &&
Array.from(highlightedEdgeIndices).map((edgeIndex) => {
const start = displayPolygon[edgeIndex]
const end = displayPolygon[(edgeIndex + 1) % displayPolygon.length]
if (!(start && end)) return null
return (
<HighlightedEdgeSegment
end={end}
key={`highlight-edge-${edgeIndex}`}
start={start}
y={edgeHandleY}
/>
)
})}
{/* Vertex handles - blue cylinders that match surface height */}
{displayPolygon.map(([x, z], index) => {
const isHovered = hoveredVertex === index
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
const isLinkedHighlighted = isVertexLinkedHighlighted(index)
const isHighlighted = isDragging || isHovered || isLinkedHighlighted
const radius = 0.1
const height = handleHeight
const point: [number, number] = [x!, z!]
@@ -793,7 +952,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
},
onPointerDown: (e) => {
if (e.button !== 0) return
e.stopPropagation()
stopHandlePointerDown(e)
setHoveredEdge(null)
onBeforeVertexDrag?.(index, point)
startDrag({
@@ -835,7 +994,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return (
<OutlinedCylinderHandle
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
height={height}
key={`vertex-${index}`}
{...handleProps}
@@ -854,7 +1013,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
stopHandlePointerDown(e)
setHoveredEdge(null)
startDrag({
isDragging: true,
@@ -873,6 +1032,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
edgeHandles.map(({ index, length, midpoint, rotationY, outwardNormal, outwardAngle }) => {
const isHovered = hoveredEdge === index
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
const isLinkedHighlighted = highlightedEdgeIndices.has(index)
const isHighlighted = isDragging || isHovered || isLinkedHighlighted
const arrowX = midpoint[0] + outwardNormal[0] * EDGE_ARROW_OFFSET
const arrowZ = midpoint[1] + outwardNormal[1] * EDGE_ARROW_OFFSET
@@ -906,15 +1067,16 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
which sits outside the polygon and never overlaps a
vertex/midpoint handle. */}
<mesh
geometry={EDGE_HANDLE_GEOMETRY}
layers={EDITOR_LAYER}
position={[midpoint[0], edgeHandleY, midpoint[1]]}
raycast={NO_RAYCAST}
rotation={[0, rotationY, 0]}
scale={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]}
>
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
<meshBasicMaterial
color={isDragging ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
opacity={isDragging ? 0.5 : isHighlighted ? 0.38 : 0.14}
transparent
/>
</mesh>
@@ -922,7 +1084,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
Points outward from the edge; dragging it translates only this
edge's two vertices along the outward normal. */}
<OutlinedEdgeArrowHandle
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
geometry={arrowGeometry}
onClick={(e) => {
if (e.button !== 0) return
@@ -930,7 +1092,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
}}
onPointerDown={(e) => {
if (e.button !== 0) return
e.stopPropagation()
stopHandlePointerDown(e)
beginEdgeDrag(e)
}}
onPointerEnter={(e) => {
@@ -954,6 +1116,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
!dragState &&
midpoints.map(([x, z], index) => {
const isHovered = hoveredMidpoint === index
const isLinkedHighlighted = highlightedEdgeIndices.has(index)
const isHighlighted = isHovered || isLinkedHighlighted
const radius = 0.06
const height = handleHeight
const point: [number, number] = [x!, z!]
@@ -965,7 +1129,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
},
onPointerDown: (e) => {
if (e.button !== 0) return
e.stopPropagation()
stopHandlePointerDown(e)
onBeforeVertexDrag?.(index + 1, point)
const insertedVertex = handleAddVertex(index, point)
if (insertedVertex.vertexIndex >= 0) {
@@ -1008,11 +1172,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
return (
<OutlinedCylinderHandle
color={isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
color={isHighlighted ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
height={height}
key={`midpoint-${index}`}
{...handleProps}
opacity={isHovered ? 1 : 0.7}
opacity={isHighlighted ? 1 : 0.7}
position={position}
radius={radius}
/>
@@ -3,7 +3,15 @@ import { SCENE_LAYER } from '@pascal-app/viewer'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { useFrame } from '@react-three/fiber'
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Color, CylinderGeometry, DoubleSide, type Mesh, type Object3D, RingGeometry } from 'three'
import {
Color,
CylinderGeometry,
DoubleSide,
type Group,
type Mesh,
type Object3D,
RingGeometry,
} from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -38,6 +46,7 @@ const SITE_FLAG_HALO_COLOR = '#6366f1'
type TintableMaterial = {
color?: Color
depthTest: boolean
depthWrite: boolean
opacity: number
needsUpdate: boolean
@@ -69,10 +78,9 @@ function SiteFlagModel({
mesh.frustumCulled = false
mesh.raycast = NO_RAYCAST
mesh.receiveShadow = false
mesh.renderOrder = 1010
mesh.material = new MeshBasicNodeMaterial({
color: new Color(ARROW_COLOR),
depthTest: false,
depthTest: true,
depthWrite: opacity >= 0.999,
opacity,
transparent: opacity < 0.999,
@@ -93,6 +101,7 @@ function SiteFlagModel({
for (const material of materials as Array<MeshBasicNodeMaterial & TintableMaterial>) {
material.color?.copy(color)
material.depthTest = true
material.opacity = opacity
material.transparent = opacity < 0.999
material.depthWrite = opacity >= 0.999
@@ -217,11 +226,23 @@ function SiteFlagFallback({
<group position={[0, SITE_FLAG_BASE_Y + (active ? SITE_FLAG_ACTIVE_LIFT : 0), 0]}>
<mesh layers={SCENE_LAYER} position={[0, 0.16, 0]} raycast={NO_RAYCAST}>
<cylinderGeometry args={[0.11, 0.16, 0.32, 24]} />
<meshBasicMaterial color={color} depthTest={false} opacity={opacity} transparent />
<meshBasicMaterial
color={color}
depthTest
depthWrite={opacity >= 0.999}
opacity={opacity}
transparent={opacity < 0.999}
/>
</mesh>
<mesh layers={SCENE_LAYER} position={[0, 0.34, 0]} raycast={NO_RAYCAST}>
<cylinderGeometry args={[0.04, 0.11, 0.14, 24]} />
<meshBasicMaterial color={color} depthTest={false} opacity={opacity} transparent />
<meshBasicMaterial
color={color}
depthTest
depthWrite={opacity >= 0.999}
opacity={opacity}
transparent={opacity < 0.999}
/>
</mesh>
</group>
)
@@ -394,6 +415,26 @@ export const SiteBoundaryEditor: React.FC = () => {
}
}, [isSiteEditing, siteId])
// The flag models render on SCENE_LAYER (scene-depth occlusion), so unlike
// EDITOR_LAYER affordances the thumbnail camera can't filter them — hide
// them around captures (preset/snapshot/auto-save thumbnails), same as
// `handle-arrow.tsx`.
const handlesRootRef = useRef<Group>(null)
useEffect(() => {
const hideForCapture = () => {
if (handlesRootRef.current) handlesRootRef.current.visible = false
}
const restoreAfterCapture = () => {
if (handlesRootRef.current) handlesRootRef.current.visible = true
}
emitter.on('thumbnail:before-capture', hideForCapture)
emitter.on('thumbnail:after-capture', restoreAfterCapture)
return () => {
emitter.off('thumbnail:before-capture', hideForCapture)
emitter.off('thumbnail:after-capture', restoreAfterCapture)
}
}, [])
const activateSiteEditing = useCallback(() => {
isDraggingSiteBoundaryRef.current = true
setIsDraggingSiteBoundary(true)
@@ -470,25 +511,27 @@ export const SiteBoundaryEditor: React.FC = () => {
if (!showSiteHandles) return null
return (
<PolygonEditor
color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
minVertices={3}
onBeforeVertexDrag={activateSiteEditing}
onDragCommit={() => {
sfxEmitter.emit('sfx:item-place')
exitSiteEditing()
}}
onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
onDragStateChange={handleSiteBoundaryDragChange}
onMidpointHoverChange={setHoveredMidpoint}
onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
onVertexHoverChange={setHoveredVertex}
polygon={displayPolygon}
renderMidpointHandle={renderSiteFlagMidpoint}
renderVertexHandle={renderSiteFlagVertex}
showBorderLine={isSiteBoundaryHighlighted}
showMidpointHandles={showSiteHandles}
/>
<group ref={handlesRootRef}>
<PolygonEditor
color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
minVertices={3}
onBeforeVertexDrag={activateSiteEditing}
onDragCommit={() => {
sfxEmitter.emit('sfx:item-place')
exitSiteEditing()
}}
onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
onDragStateChange={handleSiteBoundaryDragChange}
onMidpointHoverChange={setHoveredMidpoint}
onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
onVertexHoverChange={setHoveredVertex}
polygon={displayPolygon}
renderMidpointHandle={renderSiteFlagMidpoint}
renderVertexHandle={renderSiteFlagVertex}
showBorderLine={isSiteBoundaryHighlighted}
showMidpointHandles={showSiteHandles}
/>
</group>
)
}
@@ -20,6 +20,7 @@ import {
WALL_JOIN_SNAP_RADIUS,
type WallDraftSnapResult,
type WallPlanPoint,
type WallSnapRadii,
} from './wall-snap-geometry'
// The pure snap geometry lives in `./wall-snap-geometry`; re-exported here so
@@ -30,6 +31,7 @@ export {
type WallDraftSnapKind,
type WallDraftSnapResult,
type WallPlanPoint,
type WallSnapRadii,
} from './wall-snap-geometry'
export const WALL_GRID_STEP = 0.5
@@ -345,6 +347,8 @@ type SnapWallDraftArgs = {
* local-axis grid at `step`.
*/
gridSnap?: (point: WallPlanPoint) => WallPlanPoint
/** Optional magnetic snap radii. Omitted means wall tools keep their defaults. */
snapRadii?: WallSnapRadii
}
export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSnapResult {
@@ -357,13 +361,14 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
step: overrideStep,
magnetic = true,
gridSnap,
snapRadii,
} = args
// Discrete special points (corner / midpoint / crossing) are taken from the
// raw cursor so an interim grid snap can't mask them. A corner always wins,
// then the nearer of midpoint / crossing — see `findWallSpecialPointSnap`.
if (magnetic) {
const special = findWallSpecialPointSnap(point, walls, ignoreWallIds)
const special = findWallSpecialPointSnap(point, walls, ignoreWallIds, snapRadii)
if (special) return special
}
@@ -377,7 +382,10 @@ export function snapWallDraftPointDetailed(args: SnapWallDraftArgs): WallDraftSn
: snapPointToGrid(point, step)
if (magnetic) {
const wallSnap = findWallSnapTarget(basePoint, walls, { ignoreWallIds })
const wallSnap = findWallSnapTarget(basePoint, walls, {
ignoreWallIds,
radius: snapRadii?.wall,
})
if (wallSnap) return { point: wallSnap, snap: 'wall' }
}
@@ -62,6 +62,13 @@ describe('findWallSpecialPointSnap', () => {
// handled separately by findWallSnapTarget, not a special point.
expect(findWallSpecialPointSnap([1.2, 0.1], walls)).toBeNull()
})
test('honors tighter per-call radii without changing defaults', () => {
const walls = [makeWall([0, 0], [4, 0])]
expect(findWallSpecialPointSnap([0.34, 0], walls)?.snap).toBe('endpoint')
expect(findWallSpecialPointSnap([0.34, 0], walls, undefined, { endpoint: 0.3 })).toBeNull()
})
})
describe('findWallSnapTarget (edge / along-wall)', () => {
@@ -76,4 +83,10 @@ describe('findWallSnapTarget (edge / along-wall)', () => {
const walls = [makeWall([0, 0], [4, 0])]
expect(findWallSnapTarget([1.2, 2], walls)).toBeNull()
})
test('honors a tighter wall-body radius', () => {
const walls = [makeWall([0, 0], [4, 0])]
expect(findWallSnapTarget([1.2, 0.1], walls, { radius: 0.08 })).toBeNull()
})
})
@@ -15,6 +15,8 @@ export type WallPlanPoint = [number, number]
/** Which kind of existing-geometry snap produced a drafted point. */
export type WallDraftSnapKind = 'endpoint' | 'midpoint' | 'intersection' | 'wall'
export type WallSnapRadii = Partial<Record<WallDraftSnapKind, number>>
export type WallDraftSnapResult = {
point: WallPlanPoint
/**
@@ -119,9 +121,10 @@ export function findWallEndpointFromRaw(
point: WallPlanPoint,
walls: WallNode[],
ignoreWallIds?: string[],
radius = WALL_ENDPOINT_SNAP_RADIUS,
): WallPlanPoint | null {
const ignored = new Set(ignoreWallIds ?? [])
const radiusSquared = WALL_ENDPOINT_SNAP_RADIUS ** 2
const radiusSquared = radius ** 2
let best: WallPlanPoint | null = null
let bestDistSq = Number.POSITIVE_INFINITY
@@ -152,9 +155,10 @@ export function findWallMidpointFromRaw(
point: WallPlanPoint,
walls: WallNode[],
ignoreWallIds?: string[],
radius = WALL_MIDPOINT_SNAP_RADIUS,
): WallPlanPoint | null {
const ignored = new Set(ignoreWallIds ?? [])
const radiusSquared = WALL_MIDPOINT_SNAP_RADIUS ** 2
const radiusSquared = radius ** 2
let best: WallPlanPoint | null = null
let bestDistSq = Number.POSITIVE_INFINITY
@@ -202,10 +206,11 @@ export function findWallIntersectionFromRaw(
point: WallPlanPoint,
walls: WallNode[],
ignoreWallIds?: string[],
radius = WALL_INTERSECTION_SNAP_RADIUS,
): WallPlanPoint | null {
const ignored = new Set(ignoreWallIds ?? [])
const straight = walls.filter((wall) => !ignored.has(wall.id) && !isCurvedWall(wall))
const radiusSquared = WALL_INTERSECTION_SNAP_RADIUS ** 2
const radiusSquared = radius ** 2
let best: WallPlanPoint | null = null
let bestDistSq = Number.POSITIVE_INFINITY
@@ -257,12 +262,13 @@ export function findWallSpecialPointSnap(
point: WallPlanPoint,
walls: WallNode[],
ignoreWallIds?: string[],
radii?: WallSnapRadii,
): WallDraftSnapResult | null {
const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds)
const endpoint = findWallEndpointFromRaw(point, walls, ignoreWallIds, radii?.endpoint)
if (endpoint) return { point: endpoint, snap: 'endpoint' }
const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds)
const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds)
const midpoint = findWallMidpointFromRaw(point, walls, ignoreWallIds, radii?.midpoint)
const intersection = findWallIntersectionFromRaw(point, walls, ignoreWallIds, radii?.intersection)
return nearestCandidate(point, [
midpoint && { point: midpoint, snap: 'midpoint' },
intersection && { point: intersection, snap: 'intersection' },
@@ -1,15 +1,13 @@
'use client'
import { Icon } from '@iconify/react'
import { type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { type LucideIcon, Trash2 } from 'lucide-react'
import Image from 'next/image'
import { cn } from './../../../lib/utils'
import useEditor, { selectSiteFloorplanContext } from './../../../store/use-editor'
import useEditor from './../../../store/use-editor'
import { ActionButton } from './action-button'
type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete'
type ControlId = 'select' | 'box-select' | 'zone' | 'delete'
type ControlConfig = {
id: ControlId
@@ -32,13 +30,6 @@ const controls: ControlConfig[] = [
color: 'hover:bg-blue-500/20 hover:text-blue-400',
activeColor: 'bg-blue-500/20 text-blue-400',
},
{
id: 'site-edit',
imageSrc: '/icons/site-flag.png',
label: 'Edit site',
color: 'hover:bg-white/5',
activeColor: 'bg-white/10 hover:bg-white/10',
},
{
id: 'zone',
imageSrc: '/icons/zone.png',
@@ -65,47 +56,20 @@ export function ControlModes() {
const setPhase = useEditor((state) => state.setPhase)
const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
const levelId = useViewer((s) => s.selection.levelId)
// Only subscribe to the primitive `level` number — when walls are added to
// this level the object ref changes but this number doesn't, so Object.is
// dedupes and we avoid a re-render.
const levelIndex = useScene((state) => {
if (!levelId) return null
const node = state.nodes[levelId]
return node?.type === 'level' ? (node as LevelNode).level : null
})
const isSiteEditing = phase === 'site'
const isGroundFloor = levelIndex === 0
const canEnterSiteEdit = isGroundFloor || isSiteEditing
const structureLayer = useEditor((state) => state.structureLayer)
const getIsActive = (id: ControlId): boolean => {
if (isSiteEditing) return id === 'site-edit'
if (id === 'select') return mode === 'select' && selectionTool === 'click'
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
if (id === 'site-edit') return false
if (id === 'zone')
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
return mode === id
}
const handleClick = (id: ControlId) => {
if (id === 'site-edit') {
if (isSiteEditing) {
// Toggle off → back to structure/select
setPhase('structure')
setMode('select')
setStructureLayer('elements')
} else if (isGroundFloor) {
useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
selectSiteFloorplanContext()
}
return
}
// Exit site editing first if needed
if (isSiteEditing) {
setPhase('structure')
@@ -136,36 +100,19 @@ export function ControlModes() {
{controls.map((c) => {
const ModeIcon = c.icon
const isImageMode = Boolean(c.imageSrc)
const isSiteButton = c.id === 'site-edit'
const isActive = getIsActive(c.id)
const isDisabled = isSiteButton && !canEnterSiteEdit
return (
<ActionButton
className={cn(
'group text-muted-foreground',
isSiteButton
? isActive
? c.activeColor
: canEnterSiteEdit
? 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0'
: 'cursor-not-allowed opacity-35 grayscale'
: !(isImageMode || isActive) && c.color,
!(isSiteButton || isImageMode) && isActive && c.activeColor,
!isSiteButton && isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
!isSiteButton && isImageMode && !isActive && 'hover:bg-white/5',
!(isImageMode || isActive) && c.color,
!isImageMode && isActive && c.activeColor,
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
isImageMode && !isActive && 'hover:bg-white/5',
)}
disabled={isDisabled}
key={c.id}
label={
isSiteButton
? isActive
? 'Exit site editing'
: canEnterSiteEdit
? 'Edit site'
: 'Site editing (ground level only)'
: c.label
}
label={c.label}
onClick={() => handleClick(c.id)}
shortcut={c.shortcut}
size="icon"
@@ -176,13 +123,9 @@ export function ControlModes() {
alt={c.label}
className={cn(
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
isSiteButton
? isActive
? 'opacity-100 grayscale-0'
: ''
: isActive
? 'opacity-100 grayscale-0'
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
isActive
? 'opacity-100 grayscale-0'
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
)}
height={28}
src={c.imageSrc}
@@ -26,6 +26,8 @@ import { ActionButton } from './action-button'
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
const REFERENCES_EMPTY_TEXT =
'Upload GLB meshes as scan references or blueprint images as guide references.'
function formatGridSnapStep(step: GridSnapStep) {
return step.toFixed(2)
@@ -342,7 +344,7 @@ function GuidesControl() {
</div>
) : (
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
No guide images on this level yet.
{REFERENCES_EMPTY_TEXT}
</div>
)}
</div>
@@ -581,7 +583,7 @@ function ScansControl() {
</div>
) : (
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
No scans on this level yet.
{REFERENCES_EMPTY_TEXT}
</div>
)}
</div>
@@ -805,7 +807,7 @@ function ReferencesControl() {
</div>
)}
<ReferenceListSection
emptyText="No scans on this level yet."
emptyText={REFERENCES_EMPTY_TEXT}
iconSrc="/icons/mesh.png"
nodes={scans}
noun="scan"
@@ -816,7 +818,7 @@ function ReferencesControl() {
/>
<div className="h-px bg-border/45" />
<ReferenceListSection
emptyText="No guide images on this level yet."
emptyText={REFERENCES_EMPTY_TEXT}
iconSrc="/icons/floorplan.png"
nodes={guides}
noun="guide image"
+27
View File
@@ -65,6 +65,7 @@ export { useFreshPlacementVisibility } from './components/tools/shared/fresh-pla
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
export {
PolygonEditor,
type PolygonEditorPlanPointSnapContext,
type PolygonEditorProps,
} from './components/tools/shared/polygon-editor'
export {
@@ -108,6 +109,7 @@ export {
type WallDraftSnapKind,
type WallDraftSnapResult,
type WallPlanPoint,
type WallSnapRadii,
} from './components/tools/wall/wall-drafting'
// `ToolbarLeft` / `ToolbarRight` are the headless-spec aliases for the
// existing `ViewerToolbarLeft` / `ViewerToolbarRight` exports — the
@@ -172,6 +174,13 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
export { markToolCancelConsumed } from './hooks/use-keyboard'
export { type Selection, useSelection } from './hooks/use-selection'
export {
CEILING_ALIGNMENT_THRESHOLD_M,
type CeilingPlanSnapInput,
type CeilingPlanSnapResult,
clearCeilingSnapFeedback,
resolveCeilingPlanPointSnap,
} from './lib/ceiling-plan-snap'
export { EDITOR_LAYER } from './lib/constants'
// Helper libs used by the kind-owned roof / stair / elevator panels.
export {
@@ -230,9 +239,20 @@ export {
resolvePlanarCursorPosition,
} from './lib/planar-cursor-placement'
export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication'
// Roof wall-face hit resolution + overlap guard — shared by the
// kind-owned door / window tools in `@pascal-app/nodes` and the item
// placement coordinator's roof-wall strategy.
export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from './lib/roof-wall-hit'
export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus'
export {
clearSlabSnapFeedback,
resolveSlabPlanPointSnap,
SLAB_ALIGNMENT_THRESHOLD_M,
type SlabPlanSnapInput,
type SlabPlanSnapResult,
} from './lib/slab-plan-snap'
export { duplicateStairSubtree } from './lib/stair-duplication'
export {
getBuildingLevelsForLevel,
@@ -242,6 +262,13 @@ export {
resolveStairPlacementLevelId,
resolveStairToLevelId,
} from './lib/stair-levels'
export {
clearSurfacePlanSnapFeedback,
resolveSurfacePlanPointSnap,
SURFACE_ALIGNMENT_THRESHOLD_M,
type SurfacePlanSnapInput,
type SurfacePlanSnapResult,
} from './lib/surface-plan-snap'
// `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/
// nodes` so they don't need their own copy / their own tailwind-merge
// dependency.
@@ -0,0 +1,24 @@
import {
clearSurfacePlanSnapFeedback,
resolveSurfacePlanPointSnap,
SURFACE_ALIGNMENT_THRESHOLD_M,
type SurfacePlanSnapInput,
type SurfacePlanSnapResult,
} from './surface-plan-snap'
const CEILING_SNAP_MOVING_ID = '__ceiling_snap__'
export const CEILING_ALIGNMENT_THRESHOLD_M = SURFACE_ALIGNMENT_THRESHOLD_M
export type CeilingPlanSnapInput = SurfacePlanSnapInput
export type CeilingPlanSnapResult = SurfacePlanSnapResult
export function clearCeilingSnapFeedback() {
clearSurfacePlanSnapFeedback()
}
export function resolveCeilingPlanPointSnap(input: CeilingPlanSnapInput): CeilingPlanSnapResult {
return resolveSurfacePlanPointSnap({
...input,
movingId: input.movingId ?? CEILING_SNAP_MOVING_ID,
})
}
+164
View File
@@ -0,0 +1,164 @@
import {
type AnyNodeId,
getRoofSegmentWallFaces,
getScaledDimensions,
type ItemNode,
type RoofNode,
type RoofSegmentNode,
type RoofSegmentWallFace,
type RoofWallFaceId,
sceneRegistry,
segmentPointToRoofWallFace,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three'
const worldPoint = new THREE.Vector3()
const worldNormal = new THREE.Vector3()
const localPoint = new THREE.Vector3()
const localNormal = new THREE.Vector3()
const inverseMatrix = new THREE.Matrix4()
export type RoofWallHit = {
segment: RoofSegmentNode
face: RoofSegmentWallFace
/** Face coords of the hit (u along the face, v above the segment base). */
u: number
v: number
}
/** Pointer hits more than this far off the wall plane are not wall hits. */
const PLANE_TOLERANCE = 0.06
/** Reject faces whose normal disagrees with the hit normal (slope / soffit). */
const NORMAL_ALIGNMENT = 0.7
/** A wall face is vertical; slope faces on low pitches have |ny| ≫ 0. */
const MAX_NORMAL_Y = 0.4
/**
* Resolve a pointer hit on a roof to one of its segments' vertical wall
* faces (base walls under the roof + the coplanar gable/shed/gambrel end
* faces). Counterpart of `resolveRoofSegmentHit`, which resolves to the
* sloped top surface instead.
*
* `normal` must be the raw `NodeEvent.normal` (hit-object-local) together
* with the `object` it came from — roof events can originate from the
* merged-roof mesh (roof-local frame) or a painted segment mesh
* (segment-local frame), so the normal is normalised through world space
* here instead of trusting the event frame.
*
* Lives in `@pascal-app/editor` because both the kind-owned door/window
* tools (in `@pascal-app/nodes`, which depends on editor) and the item
* placement coordinator (in editor itself) consume it.
*/
export function resolveRoofWallHit(
roof: RoofNode,
position: [number, number, number],
normal: [number, number, number] | undefined,
object: THREE.Object3D | undefined,
): RoofWallHit | null {
if (!normal || !object) return null
worldPoint.set(position[0], position[1], position[2])
worldNormal.set(normal[0], normal[1], normal[2])
object.updateWorldMatrix(true, false)
worldNormal.transformDirection(object.matrixWorld)
const state = useScene.getState()
let best: { hit: RoofWallHit; score: number } | null = null
for (const childId of roof.children ?? []) {
const segment = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
if (segment?.type !== 'roof-segment') continue
const segObj = sceneRegistry.nodes.get(segment.id)
if (!segObj) continue
segObj.updateWorldMatrix(true, false)
localPoint.copy(worldPoint)
segObj.worldToLocal(localPoint)
inverseMatrix.copy(segObj.matrixWorld).invert()
localNormal.copy(worldNormal).transformDirection(inverseMatrix)
if (Math.abs(localNormal.y) > MAX_NORMAL_Y) continue
for (const face of getRoofSegmentWallFaces(segment)) {
const alignment =
localNormal.x * face.normal[0] +
localNormal.y * face.normal[1] +
localNormal.z * face.normal[2]
if (alignment < NORMAL_ALIGNMENT) continue
const { u, v, dist } = segmentPointToRoofWallFace(segment, face.id, [
localPoint.x,
localPoint.y,
localPoint.z,
])
if (Math.abs(dist) > PLANE_TOLERANCE) continue
if (u < -PLANE_TOLERANCE || u > face.length + PLANE_TOLERANCE) continue
if (v < -PLANE_TOLERANCE) continue
const score = Math.abs(dist)
if (!best || score < best.score) {
best = { hit: { segment, face, u, v }, score }
}
}
}
return best?.hit ?? null
}
/**
* Overlap guard for nodes sharing a roof-segment wall face — the
* roof-host analogue of `hasWallChildOverlap`. Hosted children store
* FACE-LOCAL coords + an explicit `roofFace`, so siblings compare
* directly: doors/windows are center-anchored in v, wall items
* bottom-anchored.
*/
export function hasRoofFaceChildOverlap(
segment: RoofSegmentNode,
faceId: RoofWallFaceId,
u: number,
v: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const newLeft = u - width / 2
const newRight = u + width / 2
const newBottom = v - height / 2
const newTop = v + height / 2
for (const childId of segment.children ?? []) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child) continue
if ((child as { roofFace?: RoofWallFaceId }).roofFace !== faceId) continue
const position = (child as { position?: [number, number, number] }).position
if (!position) continue
let childW: number
let childBottom: number
let childTop: number
if (child.type === 'door' || child.type === 'window') {
const opening = child as { width: number; height: number }
childW = opening.width
childBottom = position[1] - opening.height / 2
childTop = position[1] + opening.height / 2
} else if (child.type === 'item') {
const item = child as ItemNode
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
const [w, h] = getScaledDimensions(item)
childW = w
// Items anchor position[1] at their bottom edge.
childBottom = position[1]
childTop = position[1] + h
} else {
continue
}
const xOverlap = newLeft < position[0] + childW / 2 && newRight > position[0] - childW / 2
const yOverlap = newBottom < childTop && newTop > childBottom
if (xOverlap && yOverlap) return true
}
return false
}
+25
View File
@@ -0,0 +1,25 @@
import {
clearSurfacePlanSnapFeedback,
resolveSurfacePlanPointSnap,
SURFACE_ALIGNMENT_THRESHOLD_M,
type SurfacePlanSnapInput,
type SurfacePlanSnapResult,
} from './surface-plan-snap'
const SLAB_SNAP_MOVING_ID = '__slab_snap__'
export const SLAB_ALIGNMENT_THRESHOLD_M = SURFACE_ALIGNMENT_THRESHOLD_M
export type SlabPlanSnapInput = SurfacePlanSnapInput
export type SlabPlanSnapResult = SurfacePlanSnapResult
export function clearSlabSnapFeedback() {
clearSurfacePlanSnapFeedback()
}
export function resolveSlabPlanPointSnap(input: SlabPlanSnapInput): SlabPlanSnapResult {
return resolveSurfacePlanPointSnap({
...input,
highlightWalls: input.highlightWalls ?? false,
movingId: input.movingId ?? SLAB_SNAP_MOVING_ID,
})
}
@@ -0,0 +1,239 @@
import {
type AlignmentAnchor,
type AlignmentGuide,
type AnyNode,
collectAlignmentAnchors,
getWallCurveFrameAt,
getWallCurveLength,
isCurvedWall,
resolveAlignment,
resolveLevelId,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
getSegmentGridStep,
snapWallDraftPointDetailed,
type WallDraftSnapKind,
type WallPlanPoint,
type WallSnapRadii,
} from '../components/tools/wall/wall-drafting'
import useAlignmentGuides from '../store/use-alignment-guides'
import useEditor from '../store/use-editor'
import useWallSnapIndicator from '../store/use-wall-snap-indicator'
const SURFACE_SNAP_MOVING_ID = '__surface_snap__'
export const SURFACE_ALIGNMENT_THRESHOLD_M = 0.08
const SURFACE_WALL_SNAP_RADII = {
endpoint: 0.38,
midpoint: 0.28,
intersection: 0.28,
wall: 0.18,
} satisfies WallSnapRadii
const WALL_SOURCE_MATCH_EPSILON = 0.035
export type SurfacePlanSnapInput = {
rawPoint: WallPlanPoint
fallbackPoint?: WallPlanPoint
levelId?: string | null
excludeId?: string | null
movingId?: string
nodes?: Readonly<Record<string, AnyNode>>
walls?: readonly WallNode[]
candidates?: readonly AlignmentAnchor[]
threshold?: number
altKey?: boolean
magnetic?: boolean
align?: boolean
highlightWalls?: boolean
step?: number
snapRadii?: WallSnapRadii
}
export type SurfacePlanSnapResult = {
point: WallPlanPoint
wallSnap: WallDraftSnapKind | null
guides: AlignmentGuide[]
wallIds: string[]
}
function getLevelWalls(
nodes: Readonly<Record<string, AnyNode>>,
levelId: string | null | undefined,
walls?: readonly WallNode[],
): WallNode[] {
const source =
walls ?? Object.values(nodes).filter((node): node is WallNode => node.type === 'wall')
if (!levelId) return source.filter((wall) => wall.visible !== false)
return source.filter(
(wall) =>
wall.visible !== false && resolveLevelId(wall, nodes as Record<string, AnyNode>) === levelId,
)
}
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint) {
const dx = a[0] - b[0]
const dz = a[1] - b[1]
return dx * dx + dz * dz
}
function wallMidpoint(wall: WallNode): WallPlanPoint {
if (isCurvedWall(wall)) {
const frame = getWallCurveFrameAt(wall, 0.5)
return [frame.point.x, frame.point.y]
}
return [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2]
}
function distanceToSegmentSquared(point: WallPlanPoint, start: WallPlanPoint, end: WallPlanPoint) {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-9) return distanceSquared(point, start)
const t = Math.max(
0,
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
)
const projected: WallPlanPoint = [start[0] + dx * t, start[1] + dz * t]
return distanceSquared(point, projected)
}
function distanceToWallSquared(point: WallPlanPoint, wall: WallNode) {
if (!isCurvedWall(wall)) {
return distanceToSegmentSquared(point, wall.start, wall.end)
}
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(wall) / 0.3))
let bestDistanceSquared = Number.POSITIVE_INFINITY
let previous = getWallCurveFrameAt(wall, 0).point
for (let index = 1; index <= sampleCount; index += 1) {
const current = getWallCurveFrameAt(wall, index / sampleCount).point
const distance = distanceToSegmentSquared(
point,
[previous.x, previous.y],
[current.x, current.y],
)
bestDistanceSquared = Math.min(bestDistanceSquared, distance)
previous = current
}
return bestDistanceSquared
}
function closestWallIds(point: WallPlanPoint, walls: readonly WallNode[], count: number) {
return walls
.map((wall) => ({ id: wall.id, distance: distanceToWallSquared(point, wall) }))
.sort((a, b) => a.distance - b.distance)
.slice(0, count)
.map(({ id }) => id)
}
function findSnapSourceWallIds(
point: WallPlanPoint,
kind: WallDraftSnapKind,
walls: readonly WallNode[],
): string[] {
const epsilonSquared = WALL_SOURCE_MATCH_EPSILON ** 2
if (kind === 'endpoint') {
const endpointMatches = walls.filter(
(wall) =>
distanceSquared(point, wall.start) <= epsilonSquared ||
distanceSquared(point, wall.end) <= epsilonSquared,
)
if (endpointMatches.length > 0) return endpointMatches.map((wall) => wall.id)
return closestWallIds(point, walls, 1)
}
if (kind === 'midpoint') {
const midpointMatches = walls.filter(
(wall) => distanceSquared(point, wallMidpoint(wall)) <= epsilonSquared,
)
if (midpointMatches.length > 0) return midpointMatches.map((wall) => wall.id)
return closestWallIds(point, walls, 1)
}
if (kind === 'intersection') {
const crossingMatches = walls.filter(
(wall) => distanceToWallSquared(point, wall) <= epsilonSquared,
)
if (crossingMatches.length > 0) return crossingMatches.map((wall) => wall.id).slice(0, 2)
return closestWallIds(point, walls, 2)
}
return closestWallIds(point, walls, 1)
}
export function clearSurfacePlanSnapFeedback() {
useAlignmentGuides.getState().clear()
useWallSnapIndicator.getState().clear()
}
export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult {
const nodes = input.nodes ?? useScene.getState().nodes
const walls = getLevelWalls(nodes, input.levelId, input.walls)
const fallbackPoint = input.fallbackPoint
const magnetic = input.magnetic ?? useEditor.getState().magneticSnap
const wallSnap = snapWallDraftPointDetailed({
point: input.rawPoint,
walls,
step: input.step ?? getSegmentGridStep(),
magnetic,
snapRadii: input.snapRadii ?? SURFACE_WALL_SNAP_RADII,
gridSnap: fallbackPoint ? () => fallbackPoint : undefined,
})
if (wallSnap.snap) {
const wallIds =
input.highlightWalls === false
? []
: findSnapSourceWallIds(wallSnap.point, wallSnap.snap, walls)
useWallSnapIndicator.getState().set({
x: wallSnap.point[0],
z: wallSnap.point[1],
kind: wallSnap.snap,
...(wallIds.length > 0 ? { wallIds } : {}),
})
useAlignmentGuides.getState().clear()
return { point: wallSnap.point, wallSnap: wallSnap.snap, guides: [], wallIds }
}
useWallSnapIndicator.getState().clear()
const basePoint = fallbackPoint ?? wallSnap.point
if (input.align === false || input.altKey) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
const movingId = input.movingId ?? SURFACE_SNAP_MOVING_ID
const candidates =
input.candidates ??
collectAlignmentAnchors(nodes, input.excludeId ?? movingId, input.levelId ?? null)
if (candidates.length === 0) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
const alignment = resolveAlignment({
moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }],
candidates,
threshold: input.threshold ?? SURFACE_ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(alignment.guides)
if (!alignment.snap) {
return { point: basePoint, wallSnap: null, guides: alignment.guides, wallIds: [] }
}
return {
point: [basePoint[0] + alignment.snap.dx, basePoint[1] + alignment.snap.dz],
wallSnap: null,
guides: alignment.guides,
wallIds: [],
}
}
@@ -15,6 +15,8 @@ export type WallSnapPoint = {
x: number
z: number
kind: WallSnapKind
/** Optional wall ids whose geometry produced this snap. */
wallIds?: string[]
}
type WallSnapIndicatorState = {
+103 -6
View File
@@ -1,9 +1,15 @@
'use client'
import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { PolygonEditor } from '@pascal-app/editor'
import {
clearCeilingSnapFeedback,
PolygonEditor,
type PolygonEditorPlanPointSnapContext,
resolveCeilingPlanPointSnap,
triggerSFX,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
/**
* Phase 5 Stage D — ceiling boundary editor (registry-driven).
@@ -23,11 +29,26 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
const updateNode = useScene((s) => s.updateNode)
const markDirty = useScene((s) => s.markDirty)
const setSelection = useViewer((s) => s.setSelection)
const setHoveredId = useViewer((s) => s.setHoveredId)
const ownsCeilingHoverRef = useRef(false)
const ownsPolygonPreviewRef = useRef(false)
const liveOverride = useLiveNodeOverrides((state) => {
if (ownsPolygonPreviewRef.current) return null
return state.overrides.get(ceilingId) as Partial<CeilingNode> | undefined
})
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
const effectiveCeiling = useMemo(
() => (ceiling && liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling),
[ceiling, liveOverride],
)
const ceilingLevelId = effectiveCeiling
? resolveLevelId(effectiveCeiling, useScene.getState().nodes)
: null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
clearCeilingSnapFeedback()
updateNode(ceilingId, { polygon: newPolygon })
setSelection({ selectedIds: [ceilingId] })
},
@@ -37,36 +58,112 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
const handlePolygonPreview = useCallback(
(preview: ReadonlyArray<readonly [number, number]> | null) => {
if (preview) {
ownsPolygonPreviewRef.current = true
useLiveNodeOverrides.getState().set(ceilingId, {
polygon: preview.map(([x, z]) => [x, z] as [number, number]),
})
} else {
useLiveNodeOverrides.getState().clear(ceilingId)
ownsPolygonPreviewRef.current = false
}
markDirty(ceilingId)
},
[ceilingId, markDirty],
)
const setCeilingHandleHover = useCallback(
(active: boolean) => {
if (active) {
ownsCeilingHoverRef.current = true
setHoveredId(ceilingId)
return
}
if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) {
setHoveredId(null)
}
ownsCeilingHoverRef.current = false
},
[ceilingId, setHoveredId],
)
const handleHandleHoverChange = useCallback(
(index: number | null) => {
setCeilingHandleHover(index !== null)
},
[setCeilingHandleHover],
)
const handleDragStateChange = useCallback(
(isDragging: boolean) => {
if (!isDragging) {
ownsPolygonPreviewRef.current = false
clearCeilingSnapFeedback()
}
setCeilingHandleHover(isDragging)
},
[setCeilingHandleHover],
)
const handlePolygonEditorDragCommit = useCallback(() => {
triggerSFX('sfx:item-place')
clearCeilingSnapFeedback()
}, [])
const handlePolygonEditorDragStart = useCallback(() => {
ownsPolygonPreviewRef.current = true
triggerSFX('sfx:item-pick')
}, [])
const handlePolygonEditorBeforeVertexDrag = useCallback(() => {
ownsPolygonPreviewRef.current = true
}, [])
const resolvePolygonEditorPlanPoint = useCallback(
(context: PolygonEditorPlanPointSnapContext) =>
resolveCeilingPlanPointSnap({
rawPoint: context.rawPoint,
fallbackPoint: context.gridPoint,
levelId: ceilingLevelId,
excludeId: ceilingId,
altKey: context.nativeEvent?.altKey === true,
}).point,
[ceilingId, ceilingLevelId],
)
useEffect(() => {
return () => {
clearCeilingSnapFeedback()
useLiveNodeOverrides.getState().clear(ceilingId)
useScene.getState().markDirty(ceilingId)
ownsPolygonPreviewRef.current = false
if (ownsCeilingHoverRef.current && useViewer.getState().hoveredId === ceilingId) {
useViewer.getState().setHoveredId(null)
}
ownsCeilingHoverRef.current = false
}
}, [ceilingId])
if (!ceiling?.polygon || ceiling.polygon.length < 3) return null
if (!effectiveCeiling?.polygon || effectiveCeiling.polygon.length < 3) return null
return (
<PolygonEditor
allowEdgeMove
color="#d4d4d4"
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
highlightConnectedHandles
levelId={ceilingLevelId ?? undefined}
minVertices={3}
onBeforeVertexDrag={handlePolygonEditorBeforeVertexDrag}
onDragCommit={handlePolygonEditorDragCommit}
onDragStart={handlePolygonEditorDragStart}
onDragStateChange={handleDragStateChange}
onEdgeHoverChange={handleHandleHoverChange}
onMidpointHoverChange={handleHandleHoverChange}
onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
polygon={ceiling.polygon}
surfaceHeight={ceiling.height ?? 2.5}
onVertexHoverChange={handleHandleHoverChange}
polygon={effectiveCeiling.polygon}
resolvePlanPoint={resolvePolygonEditorPlanPoint}
surfaceHeight={effectiveCeiling.height ?? 2.5}
/>
)
}
@@ -1,8 +1,10 @@
import type { CeilingNode } from '@pascal-app/core'
import { type AnyNode, type CeilingNode, resolveLevelId } from '@pascal-app/core'
import { resolveCeilingPlanPointSnap } from '@pascal-app/editor'
import {
createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance,
type PolygonAffordanceSnapContext,
} from '../shared/polygon-vertex-affordance'
/**
@@ -11,6 +13,35 @@ import {
* optional `holeIndex`. See `slab/floorplan-affordances.ts` for the
* full contract.
*/
export const ceilingMoveVertexAffordance = createPolygonVertexAffordance<CeilingNode>('ceiling')
export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance<CeilingNode>('ceiling')
export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<CeilingNode>('ceiling')
const ceilingSnapOptions = {
resolvePlanPoint({
node,
nodes,
rawPoint,
fallbackPoint,
modifiers,
}: PolygonAffordanceSnapContext<CeilingNode>) {
const sceneNodes = nodes as Record<string, AnyNode>
return resolveCeilingPlanPointSnap({
rawPoint,
fallbackPoint,
levelId: resolveLevelId(node, sceneNodes),
excludeId: node.id,
nodes: sceneNodes,
altKey: modifiers.altKey,
}).point
},
}
export const ceilingMoveVertexAffordance = createPolygonVertexAffordance<CeilingNode>(
'ceiling',
ceilingSnapOptions,
)
export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance<CeilingNode>(
'ceiling',
ceilingSnapOptions,
)
export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<CeilingNode>(
'ceiling',
ceilingSnapOptions,
)
+13 -57
View File
@@ -1,19 +1,13 @@
'use client'
import {
collectAlignmentAnchors,
emitter,
type GridEvent,
type LevelNode,
resolveAlignment,
useScene,
} from '@pascal-app/core'
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import {
CursorSphere,
clearCeilingSnapFeedback,
EDITOR_LAYER,
markToolCancelConsumed,
resolveCeilingPlanPointSnap,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
@@ -33,8 +27,6 @@ import { CeilingNode } from './schema'
const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function calculateSnapPoint(
lastPoint: [number, number],
@@ -93,10 +85,7 @@ export const CeilingTool: React.FC = () => {
// draw isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), [])
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs
// on every cursor move (cursorPosition is in its deps), so clearing guides
// in its cleanup would wipe the guide the instant after each move sets it.
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
useEffect(() => () => clearCeilingSnapFeedback(), [])
const verticalGeo = useMemo(
() =>
@@ -115,44 +104,6 @@ export const CeilingTool: React.FC = () => {
useEffect(() => {
if (!currentLevelId) return
// Alignment candidates — anchors of every OTHER alignable object. The
// ceiling's own in-progress vertices are intentionally excluded (no
// self-alignment while drawing).
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// Snap the drafted vertex onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid/ortho snap. Alt
// bypasses.
const alignPoint = (
fallback: [number, number],
raw: [number, number],
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
const ar = resolveAlignment({
moving: [{ nodeId: '__ceiling-draft__', kind: 'corner', x: raw[0], z: raw[1] }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
useAlignmentGuides.getState().set(ar.guides)
let [x, z] = fallback
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
}
return [x, z]
}
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && gridCursorRef.current)) return
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
@@ -168,7 +119,12 @@ export const CeilingTool: React.FC = () => {
shiftPressed.current || !lastPoint
? gridPosition
: calculateSnapPoint(lastPoint, gridPosition)
const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true)
const displayPoint = resolveCeilingPlanPointSnap({
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true,
}).point
setSnappedCursorPosition(displayPoint)
if (
points.length > 0 &&
@@ -199,7 +155,7 @@ export const CeilingTool: React.FC = () => {
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
useAlignmentGuides.getState().clear()
clearCeilingSnapFeedback()
} else {
// Every non-closing vertex is a "start" tick; the closing click above
// fires the structure-build (end) cue.
@@ -214,14 +170,14 @@ export const CeilingTool: React.FC = () => {
const ceilingId = commitCeilingDrawing(currentLevelId, points)
setSelection({ selectedIds: [ceilingId] })
setPoints([])
useAlignmentGuides.getState().clear()
clearCeilingSnapFeedback()
}
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
useAlignmentGuides.getState().clear()
clearCeilingSnapFeedback()
}
const onKeyDown = (e: KeyboardEvent) => {
+28 -5
View File
@@ -3,8 +3,11 @@ import type {
DoorNode as DoorNodeType,
HandleDescriptor,
NodeDefinition,
RoofSegmentNode,
WallNode,
} from '@pascal-app/core'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { scaleHandleHeight } from './door-math'
import { buildDoorFloorplan } from './floorplan'
import { doorWidthAffordance } from './floorplan-affordances'
@@ -42,7 +45,13 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor<DoorNodeType>
// 'max' = +X edge anchored (left arrow grows the -X edge outward).
anchor: side === 'right' ? 'min' : 'max',
min: MIN_DOOR_WIDTH,
max: (n, scene) => readWallLength(n, scene),
max: (n, scene) => {
// Roof-hosted doors clamp against the face profile (the wall-based
// limits read Infinity when wallId is unset).
const roofMax = readRoofFaceWidthMax(n, scene, sign)
if (roofMax !== null) return Math.max(MIN_DOOR_WIDTH, roofMax)
return readWallLength(n, scene)
},
currentValue: (n) => n.width,
apply: (initial, newWidth) => {
// Anchored edge stays fixed in wall-local coords. Door rotation is
@@ -80,6 +89,8 @@ function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
anchor: 'min', // bottom anchored at wall-local Y = position[1] - height/2
min: MIN_DOOR_HEIGHT,
max: (n, scene) => {
const roofMax = readRoofFaceHeightMax(n, scene, 1)
if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax)
const bottom = n.position[1] - n.height / 2
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
},
@@ -147,10 +158,22 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
duplicable: true,
deletable: true,
wallOpeningPlacement: true,
// `wallId` ties the door to its host wall and is re-derived from
// the wall under the cursor when a preset is placed. Host apps
// strip this at preset-save time via `getHostRefFields(def)`.
hostRefFields: ['wallId'],
// Doors also host on roof-segment wall faces (base walls under the
// roof, gable ends). `buildCut` punches the opening into the
// segment's wall brush; `dirtyHandledByOwnSystem` keeps the roof-merge
// loop from consuming door dirty marks (DoorSystem owns them and
// already cascades to the host via parentId).
roofAccessory: {
buildCut: (node, hostSegment) =>
buildRoofWallOpeningCut(node as DoorNodeType, hostSegment as RoofSegmentNode),
cutScope: 'wall',
dirtyHandledByOwnSystem: true,
},
// `wallId` / `roofSegmentId` tie the door to its host and are
// re-derived from the surface under the cursor when a preset is
// placed. Host apps strip these at preset-save time via
// `getHostRefFields(def)`.
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
},
parametrics: doorParametrics,
+18 -6
View File
@@ -8,6 +8,10 @@ import {
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
getRoofHostedOpeningLevelId,
getRoofHostedOpeningPlanPoint,
} from '../shared/roof-opening-host'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
@@ -35,11 +39,13 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
// Snapshot of the door's "valid" state at move-start — used by
// canCommit to decide whether the current snapped position is OK.
const startLevelId = (() => {
// Walk up via parentId until we hit a node whose type isn't 'wall'
// — that's the level (or null). The door is wall-hosted, so the
// wall's parent is the level. Cached at start because the parent
// chain doesn't change during a move.
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
// Wall-hosted: the wall's parent is the level. Roof-hosted: walk
// segment → roof → level. Cached at start because the parent chain
// doesn't change during a move.
const nodes = useScene.getState().nodes
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
if (roofLevelId) return roofLevelId
const wall = nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null
})()
const originalWall = node.parentId
@@ -49,7 +55,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
original:
originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0])
: [node.position[0], 0],
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
metadata: node.metadata,
})
@@ -62,6 +68,8 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
side: DoorNode['side']
parentId: string
wallId: string
roofSegmentId: undefined
roofFace: undefined
} | null = null
const session: FloorplanMoveTargetSession = {
@@ -94,6 +102,10 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
side: hit.side,
parentId: hit.wall.id,
wallId: hit.wall.id,
// Re-anchoring to a wall ends any roof-segment hosting; the
// overlay's snapshot restores it if the move is reverted.
roofSegmentId: undefined,
roofFace: undefined,
}
// Build the updates atomically — position + rotation + side +
+195 -24
View File
@@ -4,6 +4,8 @@ import {
DoorNode,
emitter,
isCurvedWall,
type RoofEvent,
type RoofNode,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
@@ -25,6 +27,11 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
@@ -57,6 +64,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: movingDoorNode.side,
parentId: movingDoorNode.parentId,
wallId: movingDoorNode.wallId,
// Doors can be hosted on a roof-segment wall face. Moving onto a
// wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
// must restore the roof host.
roofSegmentId: movingDoorNode.roofSegmentId,
roofFace: movingDoorNode.roofFace,
metadata: movingDoorNode.metadata,
}
@@ -66,7 +78,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
})
}
let currentWallId: string | null = movingDoorNode.parentId
let currentHostId: string | null = movingDoorNode.parentId
let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null
let lastTarget: {
wallNode: WallEvent['node']
@@ -80,18 +92,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
event: WallEvent
} | null = null
const markWallDirty = (wallId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
const markHostDirty = (hostId: string | null) => {
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
}
const lastWallDirtyAt = new Map<string, number>()
const markWallDirtyThrottled = (wallId: string | null) => {
if (!wallId) return
const lastHostDirtyAt = new Map<string, number>()
const markHostDirtyThrottled = (hostId: string | null) => {
if (!hostId) return
const now = globalThis.performance?.now?.() ?? Date.now()
const last = lastWallDirtyAt.get(wallId) ?? 0
const last = lastHostDirtyAt.get(hostId) ?? 0
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
if (now - last > 120) {
lastWallDirtyAt.set(wallId, now)
markWallDirty(wallId)
lastHostDirtyAt.set(hostId, now)
markHostDirty(hostId)
}
}
@@ -200,16 +212,18 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
if (currentWallId !== target.wallId) {
if (currentHostId !== target.wallId) {
useScene.getState().updateNode(movingDoorNode.id, {
position: [target.clampedX, target.clampedY, 0],
rotation: [0, target.itemRotation, 0],
side: target.side,
parentId: target.wallId,
wallId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
markWallDirty(currentWallId)
currentWallId = target.wallId
markHostDirty(currentHostId)
currentHostId = target.wallId
} else {
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
if (doorMesh) {
@@ -222,7 +236,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
position: [target.clampedX, target.clampedY, 0],
rotation: target.itemRotation,
})
markWallDirtyThrottled(target.wallId)
markHostDirtyThrottled(target.wallId)
updateCursor(
wallLocalToWorld(
@@ -284,6 +298,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: target.side,
wallId: target.wallId,
parentId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -294,6 +310,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
@@ -304,16 +322,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: target.side,
parentId: target.wallId,
wallId: target.wallId,
roofSegmentId: undefined,
metadata: {},
})
if (original.parentId && original.parentId !== target.wallId) {
markWallDirty(original.parentId)
markHostDirty(original.parentId)
}
placedId = movingDoorNode.id
}
markWallDirty(target.wallId)
markHostDirty(target.wallId)
useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause()
@@ -330,25 +349,97 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
dragAnchor = null
lastTarget = null
if (isNew) return
if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId)
if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId)
}
currentWallId = original.parentId
currentHostId = original.parentId
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markWallDirty(original.parentId)
if (original.parentId) markHostDirty(original.parentId)
}
const onCancel = () => {
// ── Roof-segment wall faces ─────────────────────────────────────
// Mirrors the wall flow for the segments' vertical wall faces (base
// walls under the roof + coplanar gable ends). This is also the
// placement path preset tiles take (`metadata.isNew` clones).
const resolveRoofMoveTarget = (event: RoofEvent) =>
resolveRoofWallOpeningTarget({
event,
width: movingDoorNode.width,
height: movingDoorNode.height,
ignoreId: movingDoorNode.id,
vertical: { kind: 'bottom-locked' },
})
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const pose = getRoofWallOpeningCursorPose(target, roof)
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
}
const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event)
if (!target) return
// Wall-frame drag anchor / live transform don't apply on a roof face.
dragAnchor = null
lastTarget = null
useLiveTransforms.getState().clear(movingDoorNode.id)
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
rotation: [0, 0, 0],
side: 'front',
parentId: target.segment.id,
wallId: undefined,
roofSegmentId: target.segment.id,
roofFace: target.face.id,
})
markHostDirty(currentHostId)
currentHostId = target.segment.id
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
rotation: [0, 0, 0],
roofFace: target.face.id,
})
}
updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation()
}
const onRoofClick = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event)
if (!target?.valid) return
const segmentId = target.segment.id
let placedId: string
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId)
useScene.temporal.getState().resume()
const cloned = structuredClone(movingDoorNode) as any
delete cloned.id
cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
const node = DoorNode.parse({
...cloned,
position: target.position,
rotation: [0, 0, 0],
side: 'front',
wallId: undefined,
roofSegmentId: segmentId,
roofFace: target.face.id,
parentId: segmentId,
})
useScene.getState().createNode(node, segmentId as AnyNodeId)
placedId = node.id
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
@@ -356,9 +447,79 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
rotation: [0, 0, 0],
side: 'front',
parentId: segmentId,
wallId: undefined,
roofSegmentId: segmentId,
roofFace: target.face.id,
metadata: {},
})
if (original.parentId && original.parentId !== segmentId) {
markHostDirty(original.parentId)
}
placedId = movingDoorNode.id
}
markHostDirty(segmentId)
useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode()
event.stopPropagation()
}
const onRoofLeave = () => {
hideCursor()
useLiveTransforms.getState().clear(movingDoorNode.id)
dragAnchor = null
lastTarget = null
if (isNew) return
if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId)
}
currentHostId = original.parentId
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markHostDirty(original.parentId)
}
const onCancel = () => {
useLiveTransforms.getState().clear(movingDoorNode.id)
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
if (currentHostId) markHostDirty(currentHostId)
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markHostDirty(original.parentId)
}
useScene.temporal.getState().resume()
hideCursor()
@@ -369,6 +530,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('roof:enter', onRoofHover)
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('tool:cancel', onCancel)
return () => {
@@ -379,7 +544,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (currentMeta?.isTransient) {
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId)
if (currentHostId) markHostDirty(currentHostId)
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
@@ -387,9 +552,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
if (original.parentId) markHostDirty(original.parentId)
}
}
useLiveTransforms.getState().clear(movingDoorNode.id)
@@ -399,6 +566,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('roof:enter', onRoofHover)
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('tool:cancel', onCancel)
}
}, [movingDoorNode, exitMoveMode])
+9 -1
View File
@@ -4,6 +4,7 @@ import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useNodeEvents } from '@pascal-app/viewer'
import { useLayoutEffect, useRef } from 'react'
import { type Mesh, MeshBasicMaterial } from 'three'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
@@ -17,7 +18,7 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
return (
const mesh = (
<mesh
castShadow
material={doorHitboxMaterial}
@@ -31,6 +32,13 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
if (!node.roofSegmentId) return mesh
return (
<RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
{mesh}
</RoofFaceHostFrame>
)
}
export default DoorRenderer
+146 -6
View File
@@ -4,6 +4,8 @@ import {
DoorNode,
emitter,
isCurvedWall,
type RoofEvent,
type RoofNode,
sceneRegistry,
spatialGridManager,
useScene,
@@ -22,6 +24,11 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
@@ -33,8 +40,10 @@ const edgeMaterial = new LineBasicNodeMaterial({
})
/**
* Door tool — places DoorNodes on walls only.
* Doors always sit at floor level (clampedY = height/2).
* Door tool — places DoorNodes on walls and on roof-segment wall faces
* (the generated base walls under a roof, including coplanar gable ends).
* Doors always sit at floor level (clampedY = height/2 — segment base for
* roof-hosted doors).
*/
const DoorTool: React.FC = () => {
const draftRef = useRef<DoorNode | null>(null)
@@ -56,8 +65,8 @@ const DoorTool: React.FC = () => {
wallEvent.node.end,
)
const markWallDirty = (wallId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
const markHostDirty = (hostId: string) => {
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
}
const destroyDraft = () => {
@@ -65,7 +74,7 @@ const DoorTool: React.FC = () => {
const wallId = draftRef.current.parentId
useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null
if (wallId) markWallDirty(wallId)
if (wallId) markHostDirty(wallId)
}
const hideCursor = () => {
@@ -207,7 +216,7 @@ const DoorTool: React.FC = () => {
rotation: [0, itemRotation, 0],
side,
})
markWallDirty(event.node.id)
markHostDirty(event.node.id)
} else {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
@@ -215,6 +224,9 @@ const DoorTool: React.FC = () => {
side,
parentId: event.node.id,
wallId: event.node.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
}
@@ -335,6 +347,126 @@ const DoorTool: React.FC = () => {
hideCursor()
}
// ── Roof-segment wall faces ─────────────────────────────────────
// The merged roof mesh emits `roof:*`; hits are resolved against the
// segments' vertical wall faces (base walls + coplanar gable ends).
const resolveRoofTarget = (event: RoofEvent) =>
resolveRoofWallOpeningTarget({
event,
width: draftRef.current?.width ?? 0.9,
height: draftRef.current?.height ?? 2.1,
ignoreId: draftRef.current?.id,
vertical: { kind: 'bottom-locked' },
})
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const pose = getRoofWallOpeningCursorPose(target, roof)
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
}
const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofTarget(event)
if (!target) {
// On the roof but not over a placeable wall face (slope, soffit,
// or a face the door cannot fit on).
if (draftRef.current?.roofSegmentId) {
destroyDraft()
hideCursor()
}
return
}
const { segment, face, position } = target
if (draftRef.current && draftRef.current.parentId !== segment.id) destroyDraft()
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position,
rotation: [0, 0, 0],
roofFace: face.id,
})
} else {
const node = DoorNode.parse({
position,
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: segment.id,
roofFace: face.id,
parentId: segment.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, segment.id as AnyNodeId)
draftRef.current = node
}
updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation()
}
const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
const { segment, face, position } = target
const draft = draftRef.current
draftRef.current = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
const state = useScene.getState()
const doorCount = Object.values(state.nodes).filter(
(n) => n.type === 'door' && (n as DoorNode).roofSegmentId !== undefined,
).length
const node = DoorNode.parse({
name: `Door ${doorCount + 1}`,
position,
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: segment.id,
roofFace: face.id,
parentId: segment.id,
width: draft.width,
height: draft.height,
doorCategory: draft.doorCategory,
doorType: draft.doorType,
leafCount: draft.leafCount,
operationState: draft.operationState,
slideDirection: draft.slideDirection,
trackStyle: draft.trackStyle,
garagePanelCount: draft.garagePanelCount,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
threshold: draft.threshold,
thresholdHeight: draft.thresholdHeight,
hingesSide: draft.hingesSide,
swingDirection: draft.swingDirection,
segments: draft.segments,
handle: draft.handle,
handleHeight: draft.handleHeight,
handleSide: draft.handleSide,
doorCloser: draft.doorCloser,
panicBar: draft.panicBar,
panicBarHeight: draft.panicBarHeight,
})
useScene.getState().createNode(node, segment.id as AnyNodeId)
// Rebuild the segment (and the merged roof) so the wall brush
// picks up the new opening cut.
useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
event.stopPropagation()
}
const onRoofLeave = () => {
if (!draftRef.current?.roofSegmentId) return
destroyDraft()
hideCursor()
}
const onCancel = () => {
destroyDraft()
hideCursor()
@@ -344,6 +476,10 @@ const DoorTool: React.FC = () => {
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('roof:enter', onRoofHover)
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('tool:cancel', onCancel)
return () => {
@@ -355,6 +491,10 @@ const DoorTool: React.FC = () => {
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('roof:enter', onRoofHover)
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('tool:cancel', onCancel)
}
}, [])
+6 -5
View File
@@ -206,12 +206,13 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
// siblings of GLB items inside the unified `items` table.
//
// Items can be hosted on walls (assets with `attachTo: 'wall'`)
// via `wallId` + `wallT`. When a composition that includes a
// wall-hosted item is saved as a preset (a sconce, a hanging
// shelf, etc.), the host app strips these via `getHostRefFields(def)`
// so the descendant re-attaches against the new wall geometry at
// via `wallId` + `wallT`, or on a roof-segment wall face via
// `roofSegmentId`. When a composition that includes a wall-hosted
// item is saved as a preset (a sconce, a hanging shelf, etc.), the
// host app strips these via `getHostRefFields(def)` so the
// descendant re-attaches against the new host geometry at
// placement time.
hostRefFields: ['wallId', 'wallT'],
hostRefFields: ['wallId', 'wallT', 'roofSegmentId', 'roofFace'],
// Floor items get lifted by slabs underneath via the generic
// `<FloorElevationSystem>`. Wall- / ceiling-attached items live in
// their parent's local frame and skip the lift via `applies`.
+32
View File
@@ -5,9 +5,12 @@ import {
collectAlignmentAnchors,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
getRoofWallFaceFrame,
getScaledDimensions,
type ItemNode,
movingFootprintAnchors,
type RoofSegmentNode,
roofFacePointToSegment,
useScene,
} from '@pascal-app/core'
import { applyFloorplanAlignment, useEditor, type WallPlanPoint } from '@pascal-app/editor'
@@ -95,6 +98,31 @@ function resolveItemPlanTransform(
point: [parentTransform.point[0] + offsetX, parentTransform.point[1] + offsetZ],
rotation: parentTransform.rotation + localRotation,
}
} else if (parent?.type === 'roof-segment') {
// Roof-hosted wall item: FACE-LOCAL position mapped through the face
// frame, then composed through the segment's and roof's yaw +
// position into level-local plan coords — without this the drag seed
// jumps off the roof at move start.
const segment = parent as RoofSegmentNode
const roof = segment.parentId
? (nodes[segment.parentId as AnyNodeId] as
| (AnyNode & { position: [number, number, number]; rotation: number })
| undefined)
: undefined
if (roof?.type === 'roof' && item.roofFace) {
const frame = getRoofWallFaceFrame(segment, item.roofFace)
const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position)
const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0)
const [rx, rz] = rotateVec(
sx + segment.position[0],
sz + segment.position[2],
roof.rotation ?? 0,
)
result = {
point: [rx + roof.position[0], rz + roof.position[2]],
rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + localRotation,
}
}
}
cache.set(item.id as AnyNodeId, result)
@@ -208,6 +236,10 @@ function buildWallItemSession(
rotation: [0, hit.itemRotation, 0],
side: hit.side,
parentId: hit.wall.id,
// Re-anchoring to a wall ends any roof-segment hosting; the
// overlay's snapshot restores it if the move is reverted.
roofSegmentId: undefined,
roofFace: undefined,
},
},
])
+28
View File
@@ -4,8 +4,11 @@ import {
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
getRoofWallFaceFrame,
getScaledDimensions,
type ItemNode,
type RoofSegmentNode,
roofFacePointToSegment,
useLiveTransforms,
} from '@pascal-app/core'
@@ -112,6 +115,31 @@ function resolveItemTransform(
y: shelfZ + offsetY,
rotation: shelfRotationY + localRotation,
}
} else if (parentNode?.type === 'roof-segment') {
// Roof-hosted wall item: FACE-LOCAL position mapped through the face
// frame, then composed through the segment's and parent roof's poses
// into level-local plan coords.
const segment = parentNode as RoofSegmentNode
const roof = segment.parentId
? (ctx.resolve(segment.parentId as AnyNodeId) as
| (AnyNode & { position: [number, number, number]; rotation: number })
| undefined)
: undefined
if (roof?.type === 'roof' && item.roofFace) {
const frame = getRoofWallFaceFrame(segment, item.roofFace)
const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position)
const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0)
const [rx, rz] = rotateVec(
sx + segment.position[0],
sz + segment.position[2],
roof.rotation ?? 0,
)
result = {
x: rx + roof.position[0],
y: rz + roof.position[2],
rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + localRotation,
}
}
} else {
// Level / slab / ceiling parent — item.position is level-local.
result = {
+14
View File
@@ -38,9 +38,20 @@ import { Vector3 } from 'three'
function getInitialState(node: ItemNode): PlacementState {
const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
if (node.roofSegmentId) {
return {
surface: 'roof-wall',
wallId: null,
roofSegmentId: node.roofSegmentId,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
}
}
return {
surface: 'wall',
wallId: node.parentId,
roofSegmentId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
@@ -50,6 +61,7 @@ function getInitialState(node: ItemNode): PlacementState {
return {
surface: 'ceiling',
wallId: null,
roofSegmentId: null,
ceilingId: node.parentId,
surfaceItemId: null,
shelfId: null,
@@ -58,6 +70,7 @@ function getInitialState(node: ItemNode): PlacementState {
return {
surface: 'floor',
wallId: null,
roofSegmentId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
@@ -81,6 +94,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) {
? {
surface: 'floor',
wallId: null,
roofSegmentId: null,
ceilingId: null,
surfaceItemId: null,
shelfId: null,
+9 -1
View File
@@ -34,6 +34,7 @@ import { Suspense, useEffect, useMemo, useRef } from 'react'
import type { AnimationAction, Group, Material, Mesh } from 'three'
import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
type MutableMaterial = Material & {
depthTest?: boolean
@@ -95,7 +96,7 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
const roomClearPreview =
(node as ItemNode & { roomClearPreview?: unknown }).roomClearPreview === true
return (
const content = (
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
{roomClearPreview ? (
<ClearPreviewModel node={node} />
@@ -113,6 +114,13 @@ export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
)}
</group>
)
if (!node.roofSegmentId) return content
return (
<RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
{content}
</RoofFaceHostFrame>
)
}
const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract())
@@ -13,13 +13,17 @@ import { BufferGeometry, Float32BufferAttribute } from 'three'
* (count 0) makes three.js create no GPU buffer for it, so vertex buffer slot 0
* is never bound and WebGPU rejects the draw with "Vertex buffer slot 0 … was
* not set", which poisons the whole command encoder (cascading into "Invalid
* CommandBuffer" on every queue submit). Three real vertices give it a bound
* buffer; the `groupCount` count-0 groups keep nothing drawn while matching the
* mesh's material-array length so raycasts / BVH never index past the materials.
* CommandBuffer" on every queue submit). The zero normals and UVs keep lit
* node-material pipelines from compiling additional required-but-unbound
* vertex buffers. Three real vertices give it bound buffers; the `groupCount`
* count-0 groups keep nothing drawn while matching the mesh's material-array
* length so raycasts / BVH never index past the materials.
*/
export function createPlaceholderGeometry(groupCount = 0): BufferGeometry {
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(9), 3))
geometry.setAttribute('normal', new Float32BufferAttribute(new Float32Array(9), 3))
geometry.setAttribute('uv', new Float32BufferAttribute(new Float32Array(6), 2))
for (let group = 0; group < groupCount; group++) {
geometry.addGroup(0, 0, group)
}
@@ -1,6 +1,8 @@
import {
type AnyNode,
type AnyNodeId,
type FloorplanAffordance,
type FloorplanAffordanceModifiers,
type FloorplanAffordanceSession,
useScene,
} from '@pascal-app/core'
@@ -41,6 +43,22 @@ export type EdgeDragPayload = {
edgeIndex: number
}
type PolygonAffordanceMode = 'move-vertex' | 'add-vertex' | 'move-edge'
export type PolygonAffordanceSnapContext<N extends PolygonShape & { id: AnyNodeId }> = {
node: N
nodes: Record<AnyNodeId, AnyNode>
rawPoint: WallPlanPoint
fallbackPoint: WallPlanPoint
modifiers: FloorplanAffordanceModifiers
holeIndex?: number
mode: PolygonAffordanceMode
}
type PolygonAffordanceOptions<N extends PolygonShape & { id: AnyNodeId }> = {
resolvePlanPoint?: (context: PolygonAffordanceSnapContext<N>) => WallPlanPoint
}
type PolygonShape = {
polygon: ReadonlyArray<readonly [number, number]>
holes?: ReadonlyArray<ReadonlyArray<readonly [number, number]>>
@@ -76,11 +94,19 @@ function buildRingPatch(
return { holes: nextHoles }
}
function resolveAffordancePlanPoint<N extends PolygonShape & { id: AnyNodeId }>(
options: PolygonAffordanceOptions<N> | undefined,
context: PolygonAffordanceSnapContext<N>,
): WallPlanPoint {
return options?.resolvePlanPoint?.(context) ?? context.fallbackPoint
}
export function createPolygonVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string,
options?: PolygonAffordanceOptions<N>,
): FloorplanAffordance<N> {
return {
start({ node, payload }): FloorplanAffordanceSession {
start({ node, payload, nodes }): FloorplanAffordanceSession {
const { vertexIndex, holeIndex } = payload as PolygonVertexPayload
const originalRing = getRing(node, holeIndex)
if (!originalRing) {
@@ -96,9 +122,17 @@ export function createPolygonVertexAffordance<N extends PolygonShape & { id: Any
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? (planPoint as WallPlanPoint)
: snapPointToGrid(planPoint as WallPlanPoint)
const rawPoint: WallPlanPoint = [planPoint[0], planPoint[1]]
const fallbackPoint = modifiers.shiftKey ? rawPoint : snapPointToGrid(rawPoint)
const snapped = resolveAffordancePlanPoint(options, {
node,
nodes,
rawPoint,
fallbackPoint,
modifiers,
holeIndex,
mode: 'move-vertex',
})
const nextRing: [number, number][] = originalRing.map((p, i) =>
i === vertexIndex ? [snapped[0], snapped[1]] : p,
)
@@ -128,9 +162,10 @@ export function createPolygonVertexAffordance<N extends PolygonShape & { id: Any
*/
export function createPolygonAddVertexAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string,
options?: PolygonAffordanceOptions<N>,
): FloorplanAffordance<N> {
return {
start({ node, payload }): FloorplanAffordanceSession {
start({ node, payload, nodes }): FloorplanAffordanceSession {
const { edgeIndex, holeIndex } = payload as AddVertexPayload
const originalRing = getRing(node, holeIndex)
if (!originalRing) {
@@ -171,9 +206,17 @@ export function createPolygonAddVertexAffordance<N extends PolygonShape & { id:
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
const snapped: WallPlanPoint = modifiers.shiftKey
? (planPoint as WallPlanPoint)
: snapPointToGrid(planPoint as WallPlanPoint)
const rawPoint: WallPlanPoint = [planPoint[0], planPoint[1]]
const fallbackPoint = modifiers.shiftKey ? rawPoint : snapPointToGrid(rawPoint)
const snapped = resolveAffordancePlanPoint(options, {
node,
nodes,
rawPoint,
fallbackPoint,
modifiers,
holeIndex,
mode: 'add-vertex',
})
const nextRing: [number, number][] = initialRing.map((p, i) =>
i === newVertexIndex ? [snapped[0], snapped[1]] : p,
)
@@ -204,9 +247,10 @@ export function createPolygonAddVertexAffordance<N extends PolygonShape & { id:
*/
export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: AnyNodeId }>(
kind: string,
options?: PolygonAffordanceOptions<N>,
): FloorplanAffordance<N> {
return {
start({ node, payload, initialPlanPoint }): FloorplanAffordanceSession {
start({ node, payload, initialPlanPoint, nodes }): FloorplanAffordanceSession {
const { edgeIndex, holeIndex } = payload as EdgeDragPayload
const originalRing = getRing(node, holeIndex)
if (!originalRing) {
@@ -254,17 +298,33 @@ export function createPolygonMoveEdgeAffordance<N extends PolygonShape & { id: A
apply({ planPoint, modifiers }) {
// Project the pointer delta onto the edge normal — that's the
// signed perpendicular distance the edge should travel.
const deltaX = planPoint[0] - startX
const deltaY = planPoint[1] - startY
const rawPoint: WallPlanPoint = [planPoint[0], planPoint[1]]
const deltaX = rawPoint[0] - startX
const deltaY = rawPoint[1] - startY
let projection = deltaX * normalX + deltaY * normalY
if (!modifiers.shiftKey) {
// Snap the projection scalar to a 0.5m grid (legacy uses the
// same half-meter snap for slab edges).
projection = Math.round(projection * 2) / 2
}
const fallbackPoint: WallPlanPoint = [
startX + normalX * projection,
startY + normalY * projection,
]
const snappedPoint = resolveAffordancePlanPoint(options, {
node,
nodes,
rawPoint,
fallbackPoint,
modifiers,
holeIndex,
mode: 'move-edge',
})
const normalDistance =
(snappedPoint[0] - startX) * normalX + (snappedPoint[1] - startY) * normalY
const nextRing: [number, number][] = originalRing.map((p, i) => {
if (i === edgeStartIndex || i === edgeEndIndex) {
return [p[0] + normalX * projection, p[1] + normalY * projection]
return [p[0] + normalX * normalDistance, p[1] + normalY * normalDistance]
}
return [p[0], p[1]] as [number, number]
})
@@ -0,0 +1,53 @@
'use client'
import {
type AnyNodeId,
getRoofWallFaceFrame,
type RoofSegmentNode,
type RoofWallFaceId,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { type ReactNode, useMemo } from 'react'
/**
* Mounts a roof-hosted wall child inside its host face frame. Children
* of roof segments render under the roof's `roof-elements` group (roof
* frame); this wrapper applies the segment transform plus the face
* frame, both derived from the LIVE-override-merged segment — hosted
* nodes therefore track segment handle drags in real time instead of
* jumping to their new spot on commit. Inside the frame, children use
* plain wall-child position conventions ([u, v, z-from-mid-plane]).
*/
export function RoofFaceHostFrame({
roofSegmentId,
roofFace,
children,
}: {
roofSegmentId: string
roofFace: RoofWallFaceId | undefined
children: ReactNode
}) {
const storeSegment = useScene(
(state) => state.nodes[roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined,
)
const liveOverride = useLiveNodeOverrides((s) => s.get(roofSegmentId as AnyNodeId))
const segment = useMemo(
() =>
storeSegment && liveOverride
? ({ ...storeSegment, ...liveOverride } as RoofSegmentNode)
: storeSegment,
[storeSegment, liveOverride],
)
if (!segment || segment.type !== 'roof-segment' || !roofFace) return null
const frame = getRoofWallFaceFrame(segment, roofFace)
return (
<group position={segment.position} rotation-y={segment.rotation}>
<group position={frame.origin} rotation-y={frame.yaw}>
{children}
</group>
</group>
)
}
@@ -0,0 +1,120 @@
import type {
AnyNode,
AnyNodeId,
RoofNode,
RoofSegmentNode,
RoofWallFaceId,
} from '@pascal-app/core'
import {
getMaxRoofRectHeightFromAnchor,
getMaxRoofRectWidthFromAnchor,
getRoofSegmentWallFace,
roofFacePointToSegment,
} from '@pascal-app/core'
/**
* Host-side helpers for openings (door / window) hosted on a roof-segment
* wall face: resize-handle limits derived from the face profile, and the
* plan-space anchors the 2D floor-plan move path needs. Hosted children
* store FACE-LOCAL coords ([u, v, z-from-mid-plane]) + `roofFace`.
*/
type RoofHostedOpening = {
roofSegmentId?: string
roofFace?: RoofWallFaceId
parentId: string | null
position: [number, number, number]
width: number
height: number
}
type SceneReader = { get: (id: AnyNodeId) => unknown }
function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) {
if (!(node.roofSegmentId && node.roofFace)) return null
const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined
if (!segment || segment.type !== 'roof-segment') return null
return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) }
}
/**
* Resize-handle width limit for a roof-hosted opening: the opposite edge
* is anchored, `growSign` (+1 = door-local +X arrow) is the direction
* the dragged edge moves. Null when the node is not roof-hosted.
*/
export function readRoofFaceWidthMax(
node: RoofHostedOpening,
scene: SceneReader,
growSign: number,
): number | null {
const host = resolveHostFace(node, scene)
if (!host) return null
const anchorU = node.position[0] - (growSign * node.width) / 2
return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, node.position[1], node.height)
}
/**
* Resize-handle height limit for a roof-hosted opening. `growSign` +1 =
* bottom edge anchored, top grows up; -1 = top anchored, bottom grows
* down. Null when the node is not roof-hosted.
*/
export function readRoofFaceHeightMax(
node: RoofHostedOpening,
scene: SceneReader,
growSign: number,
): number | null {
const host = resolveHostFace(node, scene)
if (!host) return null
const anchorV = node.position[1] - (growSign * node.height) / 2
return getMaxRoofRectHeightFromAnchor(host.face, node.position[0], node.width, anchorV, growSign)
}
/**
* Level hosting a roof-hosted opening's roof (opening → segment → roof →
* level). Null when the parent chain isn't roof-shaped.
*/
export function getRoofHostedOpeningLevelId(
node: { parentId: string | null },
nodes: Record<string, AnyNode | undefined>,
): AnyNodeId | null {
const segment = node.parentId ? nodes[node.parentId] : undefined
if (segment?.type !== 'roof-segment') return null
const roof = segment.parentId ? nodes[segment.parentId] : undefined
if (roof?.type !== 'roof') return null
return (roof.parentId as AnyNodeId | null) ?? null
}
/**
* Level-plan [x, z] of a roof-hosted node — its face-local center mapped
* through the face frame, then composed through the segment's and roof's
* yaw + position.
*/
export function getRoofHostedOpeningPlanPoint(
node: {
parentId: string | null
roofFace?: RoofWallFaceId
position: [number, number, number]
},
nodes: Record<string, AnyNode | undefined>,
): [number, number] | null {
const segment = node.parentId ? (nodes[node.parentId] as RoofSegmentNode | undefined) : undefined
if (segment?.type !== 'roof-segment' || !node.roofFace) return null
const roof = segment.parentId ? (nodes[segment.parentId] as RoofNode | undefined) : undefined
if (roof?.type !== 'roof') return null
const rotate = (x: number, z: number, yaw: number): [number, number] => [
x * Math.cos(yaw) + z * Math.sin(yaw),
-x * Math.sin(yaw) + z * Math.cos(yaw),
]
const segLocal = roofFacePointToSegment(segment, node.roofFace, [
node.position[0],
node.position[1],
node.position[2],
])
const [sx, sz] = rotate(segLocal[0], segLocal[2], segment.rotation ?? 0)
const segX = sx + segment.position[0]
const segZ = sz + segment.position[2]
const [rx, rz] = rotate(segX, segZ, roof.rotation ?? 0)
return [rx + roof.position[0], rz + roof.position[2]]
}
@@ -0,0 +1,50 @@
import type { RoofSegmentNode, RoofWallFaceId } from '@pascal-app/core'
import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core'
import * as THREE from 'three'
type RoofWallOpening = {
roofSegmentId?: string
roofFace?: RoofWallFaceId
position: [number, number, number]
width: number
height: number
}
/**
* CSG cut for a door / window hosted on a roof-segment wall face
* (`capabilities.roofAccessory.buildCut`). A box through the wall
* mid-plane, derived from the CURRENT host geometry (the opening stores
* face-local coords), so the hole follows segment resizes for free.
*
* Returns null for wall-hosted openings: their cut is handled by the
* wall system's own cutout pipeline.
*/
export function buildRoofWallOpeningCut(
node: RoofWallOpening,
hostSegment: RoofSegmentNode,
): THREE.BufferGeometry | null {
if (!node.roofSegmentId || !node.roofFace) return null
const wallThickness = hostSegment.wallThickness ?? 0.1
// Through the wall both ways, but well short of the rake/eave overhang
// so the cut never nicks the soffit or fascia bands.
const depth = wallThickness * 2 + 0.04
// A door's cut bottom is coplanar with the wall brush base — extend it
// slightly downward so three-bvh-csg never has to clip coplanar faces.
const bottom = node.position[1] - node.height / 2
const bottomPad = bottom < 0.005 ? 0.02 : 0
const center = roofFacePointToSegment(hostSegment, node.roofFace, [
node.position[0],
node.position[1],
0,
])
const { yaw } = getRoofWallFaceFrame(hostSegment, node.roofFace)
const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth)
geo.translate(0, -bottomPad / 2, 0)
geo.rotateY(yaw)
geo.translate(center[0], center[1], center[2])
return geo
}
@@ -0,0 +1,113 @@
import {
type AnyNodeId,
clampRectToRoofWallFace,
type RoofEvent,
type RoofNode,
type RoofSegmentNode,
type RoofSegmentWallFace,
roofFacePointToSegment,
sceneRegistry,
} from '@pascal-app/core'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Vector3 } from 'three'
/**
* Stateless target/cursor math shared by the door and window placement
* + move tools' roof flows. The tools keep ownership of everything
* stateful (draft lifecycle, undo/temporal sequencing, commit field
* lists, SFX/selection) — only the settled geometry lives here.
*/
export type RoofWallOpeningTarget = {
segment: RoofSegmentNode
face: RoofSegmentWallFace
/** FACE-LOCAL stored position: [u, v-center, 0] on the wall mid-plane. */
position: [number, number, number]
/** False when the rect overlaps a sibling on the same face. */
valid: boolean
}
export type RoofWallOpeningVertical =
/** Doors: bottom on the segment base, only `u` slides. */
| { kind: 'bottom-locked' }
/** Windows: free height, optionally grid-snapped before the clamp. */
| { kind: 'free'; snap?: (v: number) => number }
/**
* Resolve a roof pointer event to an opening placement on a segment
* wall face: hit → vertical policy → profile clamp → overlap check.
* Null when the pointer isn't over a placeable face or the rect cannot
* fit at that spot.
*/
export function resolveRoofWallOpeningTarget(args: {
event: RoofEvent
width: number
height: number
ignoreId?: string
vertical: RoofWallOpeningVertical
}): RoofWallOpeningTarget | null {
const { event, width, height, ignoreId, vertical } = args
const hit = resolveRoofWallHit(event.node as RoofNode, event.position, event.normal, event.object)
if (!hit) return null
const centerV = vertical.kind === 'bottom-locked' ? height / 2 : (vertical.snap?.(hit.v) ?? hit.v)
const clamped = clampRectToRoofWallFace(
hit.face,
hit.u,
centerV,
width,
height,
vertical.kind === 'bottom-locked' ? { lockV: true } : undefined,
)
if (!clamped) return null
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
width,
height,
ignoreId,
)
return {
segment: hit.segment,
face: hit.face,
position: [clamped.u, clamped.v, 0],
valid,
}
}
const cursorPoint = new Vector3()
/**
* World → building-local. Tool cursor groups render inside the
* building's frame (same conversion as the roof accessory tools).
*/
export function worldToSelectedBuildingLocal(point: Vector3): [number, number, number] {
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
if (buildingObj) buildingObj.worldToLocal(point)
return [point.x, point.y, point.z]
}
/**
* Cursor pose for a resolved target: building-local position of the
* opening center + total yaw (roof ∘ segment ∘ face).
*/
export function getRoofWallOpeningCursorPose(
target: RoofWallOpeningTarget,
roof: RoofNode,
): { position: [number, number, number]; rotationY: number } | null {
const segObj = sceneRegistry.nodes.get(target.segment.id as AnyNodeId)
if (!segObj) return null
segObj.updateWorldMatrix(true, false)
const segLocal = roofFacePointToSegment(target.segment, target.face.id, target.position)
cursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(cursorPoint)
return {
position: worldToSelectedBuildingLocal(cursorPoint),
rotationY: (roof.rotation ?? 0) + (target.segment.rotation ?? 0) + target.face.yaw,
}
}
+19 -6
View File
@@ -15,8 +15,15 @@ import {
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
import { useEffect, useMemo, useRef } from 'react'
import {
BufferGeometry,
Float32BufferAttribute,
type Group,
Path,
Shape,
ShapeGeometry,
} from 'three'
import { MeshLambertNodeMaterial } from 'three/webgpu'
const Y_OFFSET = 0.01
@@ -134,6 +141,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
if (!polygonPoints || polygonPoints.length < 2) return null
return createBoundaryLineGeometry(polygonPoints)
}, [polygonPoints])
useEffect(() => () => lineGeometry?.dispose(), [lineGeometry])
const groundGeometry = useMemo(() => {
if (!groundShape) return null
return new ShapeGeometry(groundShape)
}, [groundShape])
useEffect(() => () => groundGeometry?.dispose(), [groundGeometry])
const handlers = useNodeEvents(node, 'site')
@@ -149,15 +163,14 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
))}
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
{groundShape && (
{groundGeometry && (
<mesh
geometry={groundGeometry}
material={groundMaterial}
position={[0, -0.05, 0]}
receiveShadow
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[groundShape]} />
</mesh>
/>
)}
{/* Simple boundary line */}
+29 -2
View File
@@ -1,7 +1,12 @@
'use client'
import { resolveLevelId, type SlabNode, useLiveNodeOverrides, useScene } from '@pascal-app/core'
import { PolygonEditor } from '@pascal-app/editor'
import {
clearSlabSnapFeedback,
PolygonEditor,
type PolygonEditorPlanPointSnapContext,
resolveSlabPlanPointSnap,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect } from 'react'
@@ -30,9 +35,11 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
const setSelection = useViewer((s) => s.setSelection)
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
const slabLevelId = slab ? resolveLevelId(slab, useScene.getState().nodes) : null
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
clearSlabSnapFeedback()
updateNode(slabId, { polygon: newPolygon })
setSelection({ selectedIds: [slabId] })
},
@@ -46,6 +53,7 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
polygon: preview.map(([x, z]) => [x, z] as [number, number]),
})
} else {
clearSlabSnapFeedback()
useLiveNodeOverrides.getState().clear(slabId)
}
markDirty(slabId)
@@ -53,11 +61,28 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
[slabId, markDirty],
)
const handleDragCommit = useCallback(() => {
clearSlabSnapFeedback()
}, [])
const resolvePolygonEditorPlanPoint = useCallback(
(context: PolygonEditorPlanPointSnapContext) =>
resolveSlabPlanPointSnap({
rawPoint: context.rawPoint,
fallbackPoint: context.gridPoint,
levelId: slabLevelId,
excludeId: slabId,
altKey: context.nativeEvent?.altKey === true,
}).point,
[slabId, slabLevelId],
)
// Guarantee the override clears if the editor unmounts mid-drag
// (selection change, mode switch) so the slab mesh doesn't get stuck
// on a stale polygon.
useEffect(() => {
return () => {
clearSlabSnapFeedback()
useLiveNodeOverrides.getState().clear(slabId)
useScene.getState().markDirty(slabId)
}
@@ -69,11 +94,13 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
<PolygonEditor
allowEdgeMove
color="#a3a3a3"
levelId={resolveLevelId(slab, useScene.getState().nodes)}
levelId={slabLevelId ?? undefined}
minVertices={3}
onDragCommit={handleDragCommit}
onPolygonChange={handlePolygonChange}
onPolygonPreview={handlePolygonPreview}
polygon={slab.polygon}
resolvePlanPoint={resolvePolygonEditorPlanPoint}
surfaceHeight={slab.elevation ?? 0.05}
/>
)
@@ -1,8 +1,10 @@
import type { SlabNode } from '@pascal-app/core'
import { type AnyNode, resolveLevelId, type SlabNode } from '@pascal-app/core'
import { resolveSlabPlanPointSnap } from '@pascal-app/editor'
import {
createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance,
type PolygonAffordanceSnapContext,
} from '../shared/polygon-vertex-affordance'
/**
@@ -19,6 +21,35 @@ import {
* the slab is selected, every hole's handles appear at the same time.
* Simpler model, no UX downside in practice.
*/
export const slabMoveVertexAffordance = createPolygonVertexAffordance<SlabNode>('slab')
export const slabAddVertexAffordance = createPolygonAddVertexAffordance<SlabNode>('slab')
export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance<SlabNode>('slab')
const slabSnapOptions = {
resolvePlanPoint({
node,
nodes,
rawPoint,
fallbackPoint,
modifiers,
}: PolygonAffordanceSnapContext<SlabNode>) {
const sceneNodes = nodes as Record<string, AnyNode>
return resolveSlabPlanPointSnap({
rawPoint,
fallbackPoint,
levelId: resolveLevelId(node, sceneNodes),
excludeId: node.id,
nodes: sceneNodes,
altKey: modifiers.altKey,
}).point
},
}
export const slabMoveVertexAffordance = createPolygonVertexAffordance<SlabNode>(
'slab',
slabSnapOptions,
)
export const slabAddVertexAffordance = createPolygonAddVertexAffordance<SlabNode>(
'slab',
slabSnapOptions,
)
export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance<SlabNode>(
'slab',
slabSnapOptions,
)
+13 -57
View File
@@ -1,19 +1,13 @@
'use client'
import {
collectAlignmentAnchors,
emitter,
type GridEvent,
type LevelNode,
resolveAlignment,
useScene,
} from '@pascal-app/core'
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import {
CursorSphere,
clearSlabSnapFeedback,
EDITOR_LAYER,
markToolCancelConsumed,
resolveSlabPlanPointSnap,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
@@ -34,8 +28,6 @@ import { SlabNode } from './schema'
*/
const Y_OFFSET = 0.02
/** Figma-style alignment-snap threshold (meters), matching the move tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
function calculateSnapPoint(
lastPoint: [number, number],
@@ -90,52 +82,11 @@ export const SlabTool: React.FC = () => {
// isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), [])
// Clear alignment guides on unmount ONLY. The main drawing effect re-runs
// on every cursor move (cursorPosition is in its deps), so clearing guides
// in its cleanup would wipe the guide the instant after each move sets it.
useEffect(() => () => useAlignmentGuides.getState().clear(), [])
useEffect(() => () => clearSlabSnapFeedback(), [])
useEffect(() => {
if (!currentLevelId) return
// Alignment candidates — anchors of every OTHER alignable object. The
// slab's own in-progress vertices are intentionally excluded (no
// self-alignment while drawing).
const alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
// Snap the drafted vertex onto another object's nearest real anchor and
// publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped
// point: resolving against the grid point would only ever catch anchors
// that happen to sit on a grid line, so off-grid items (furniture, angled
// walls) would never surface a guide. The matched axis locks exactly to the
// candidate's coordinate; the other axis keeps its grid/ortho snap. Alt
// bypasses.
const alignPoint = (
fallback: [number, number],
raw: [number, number],
bypass: boolean,
): [number, number] => {
if (bypass || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
const ar = resolveAlignment({
moving: [{ nodeId: '__slab-draft__', kind: 'corner', x: raw[0], z: raw[1] }],
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
if (ar.guides.length === 0) {
useAlignmentGuides.getState().clear()
return fallback
}
useAlignmentGuides.getState().set(ar.guides)
let [x, z] = fallback
for (const guide of ar.guides) {
if (guide.axis === 'x') x = guide.coord
else z = guide.coord
}
return [x, z]
}
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]]
@@ -149,7 +100,12 @@ export const SlabTool: React.FC = () => {
shiftPressed.current || !lastPoint
? gridPosition
: calculateSnapPoint(lastPoint, gridPosition)
const displayPoint = alignPoint(orthoPoint, rawPoint, event.nativeEvent?.altKey === true)
const displayPoint = resolveSlabPlanPointSnap({
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true,
}).point
setSnappedCursorPosition(displayPoint)
if (
points.length > 0 &&
@@ -176,7 +132,7 @@ export const SlabTool: React.FC = () => {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
useAlignmentGuides.getState().clear()
clearSlabSnapFeedback()
} else {
// Every non-closing vertex is a "start" tick; the closing click above
// fires the structure-build (end) cue.
@@ -191,14 +147,14 @@ export const SlabTool: React.FC = () => {
const slabId = commitSlabDrawing(currentLevelId, points)
setSelection({ selectedIds: [slabId] })
setPoints([])
useAlignmentGuides.getState().clear()
clearSlabSnapFeedback()
}
}
const onCancel = () => {
if (points.length > 0) markToolCancelConsumed()
setPoints([])
useAlignmentGuides.getState().clear()
clearSlabSnapFeedback()
}
const onKeyDown = (e: KeyboardEvent) => {
@@ -31,6 +31,7 @@ describe('spawn definition', () => {
expect(spawnDefinition.schemaVersion).toBe(1)
expect(spawnDefinition.category).toBe('site')
expect(spawnDefinition.schema).toBe(SpawnNode)
expect(typeof spawnDefinition.floorplanMoveTarget).toBe('function')
})
test('defaults() returns a value that the schema accepts', () => {
@@ -111,6 +112,7 @@ describe('spawn definition', () => {
const flat = flattenFloorplan(geometry)
expect(flat.some((entry) => entry.kind === 'path' && entry.stroke === '#818cf8')).toBe(true)
expect(flat.some((entry) => entry.kind === 'move-handle')).toBe(true)
expect(flat.some((entry) => entry.kind === 'rotate-arrow')).toBe(true)
})
+2
View File
@@ -1,6 +1,7 @@
import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core'
import { buildSpawnFloorplan } from './floorplan'
import { spawnRotateAffordance } from './floorplan-affordances'
import { spawnFloorplanMoveTarget } from './floorplan-move'
import { spawnParametrics } from './parametrics'
import { SpawnNode } from './schema'
@@ -92,6 +93,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
// delete. Legacy spawn click handlers in FloorplanNodeLayer become
// dead code once Phase 6 cleanup removes the [] entries path.
floorplan: buildSpawnFloorplan,
floorplanMoveTarget: spawnFloorplanMoveTarget,
floorplanAffordances: {
'spawn-rotate': spawnRotateAffordance,
},
@@ -0,0 +1,39 @@
import {
type AnyNodeId,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
type SpawnNode,
snapScalar,
useScene,
} from '@pascal-app/core'
import { getSegmentGridStep } from '@pascal-app/editor'
export const spawnFloorplanMoveTarget: FloorplanMoveTarget<SpawnNode> = ({ node }) => {
const spawnId = node.id as AnyNodeId
const startY = node.position[1]
const originalPosition: [number, number, number] = [...node.position]
let lastPosition: [number, number, number] | null = null
const session: FloorplanMoveTargetSession = {
affectedIds: [spawnId],
apply({ planPoint, modifiers }) {
const step = getSegmentGridStep()
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
const next: [number, number, number] = [snap(planPoint[0]), startY, snap(planPoint[1])]
if (lastPosition && lastPosition[0] === next[0] && lastPosition[2] === next[2]) return
lastPosition = next
useScene.getState().updateNodes([{ id: spawnId, data: { position: next } }])
},
canCommit() {
if (!lastPosition) return false
return lastPosition[0] !== originalPosition[0] || lastPosition[2] !== originalPosition[2]
},
commit() {
if (!lastPosition) return
useScene.getState().updateNodes([{ id: spawnId, data: { position: lastPosition } }])
},
}
return session
}
+5
View File
@@ -113,6 +113,11 @@ export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): Floo
]
if (isSelected) {
children.push({
kind: 'move-handle',
point: [px, pz],
})
const cornerLocalX = 0.34 + ROTATE_ARROW_CORNER_OFFSET
const cornerLocalZ = 0.34 + ROTATE_ARROW_CORNER_OFFSET
const [cornerX, cornerZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, planRotation)
+24 -4
View File
@@ -2,9 +2,12 @@ import type {
AnyNodeId,
HandleDescriptor,
NodeDefinition,
RoofSegmentNode,
WallNode,
WindowNode as WindowNodeType,
} from '@pascal-app/core'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { buildWindowFloorplan } from './floorplan'
import { windowWidthAffordance } from './floorplan-affordances'
import { windowFloorplanMoveTarget } from './floorplan-move'
@@ -36,7 +39,13 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor<WindowNodeT
axis: 'x',
anchor: side === 'right' ? 'min' : 'max',
min: MIN_WINDOW_WIDTH,
max: (n, scene) => readWallLength(n, scene),
max: (n, scene) => {
// Roof-hosted windows clamp against the face profile (the
// wall-based limits read Infinity when wallId is unset).
const roofMax = readRoofFaceWidthMax(n, scene, sign)
if (roofMax !== null) return Math.max(MIN_WINDOW_WIDTH, roofMax)
return readWallLength(n, scene)
},
currentValue: (n) => n.width,
apply: (initial, newWidth) => {
const rotY = initial.rotation[1]
@@ -73,6 +82,8 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNode
anchor: edge === 'top' ? 'min' : 'max',
min: MIN_WINDOW_HEIGHT,
max: (n, scene) => {
const roofMax = readRoofFaceHeightMax(n, scene, sign)
if (roofMax !== null) return Math.max(MIN_WINDOW_HEIGHT, roofMax)
// Maximum: distance from the anchored edge to the wall's allowed Y
// bounds. Top arrow caps at wall.height - bottom; bottom arrow caps
// at top (positive Y room above the floor).
@@ -139,9 +150,18 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
duplicable: true,
deletable: true,
wallOpeningPlacement: true,
// `wallId` is re-derived from the wall under the cursor at preset
// placement time — see the door capability for the same pattern.
hostRefFields: ['wallId'],
// Windows also host on roof-segment wall faces (base walls under the
// roof, gable ends) — same wiring as door; see the door capability
// for why `dirtyHandledByOwnSystem` is required.
roofAccessory: {
buildCut: (node, hostSegment) =>
buildRoofWallOpeningCut(node as WindowNodeType, hostSegment as RoofSegmentNode),
cutScope: 'wall',
dirtyHandledByOwnSystem: true,
},
// `wallId` / `roofSegmentId` are re-derived from the surface under
// the cursor at preset placement time — see door for the pattern.
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
},
parametrics: windowParametrics,
+17 -2
View File
@@ -8,6 +8,10 @@ import {
} from '@pascal-app/core'
import { snapToHalf } from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
import {
getRoofHostedOpeningLevelId,
getRoofHostedOpeningPlanPoint,
} from '../shared/roof-opening-host'
import {
findClosestWallInPlan,
projectWallLocalPointToPlan,
@@ -29,7 +33,12 @@ import { clampToWall, hasWallChildOverlap } from './window-math'
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
const startLevelId = (() => {
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
// Wall-hosted: the wall's parent is the level. Roof-hosted: walk
// segment → roof → level.
const nodes = useScene.getState().nodes
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
if (roofLevelId) return roofLevelId
const wall = nodes[node.parentId as AnyNodeId]
return wall ? (wall.parentId as AnyNodeId | null) : null
})()
const originalWall = node.parentId
@@ -39,7 +48,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
original:
originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0])
: [node.position[0], 0],
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
metadata: node.metadata,
})
@@ -56,6 +65,8 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
side: WindowNode['side']
parentId: string
wallId: string
roofSegmentId: undefined
roofFace: undefined
} | null = null
const session: FloorplanMoveTargetSession = {
@@ -93,6 +104,10 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
side: hit.side,
parentId: hit.wall.id,
wallId: hit.wall.id,
// Re-anchoring to a wall ends any roof-segment hosting; the
// overlay's snapshot restores it if the move is reverted.
roofSegmentId: undefined,
roofFace: undefined,
}
useScene.getState().updateNodes([
+199 -28
View File
@@ -3,6 +3,8 @@ import {
collectAlignmentAnchors,
emitter,
isCurvedWall,
type RoofEvent,
type RoofNode,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
@@ -17,6 +19,7 @@ import {
getSideFromNormal,
isValidWallSideFace,
snapToHalf,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
useEditor,
@@ -25,6 +28,11 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -70,6 +78,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
side: movingWindowNode.side,
parentId: movingWindowNode.parentId,
wallId: movingWindowNode.wallId,
// Windows can be hosted on a roof-segment wall face. Moving onto a
// wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
// must restore the roof host.
roofSegmentId: movingWindowNode.roofSegmentId,
roofFace: movingWindowNode.roofFace,
metadata: movingWindowNode.metadata,
}
@@ -85,7 +98,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
})
}
let currentWallId: string | null = movingWindowNode.parentId
let currentHostId: string | null = movingWindowNode.parentId
let dragAnchor: {
wallId: string
rawX: number
@@ -105,18 +118,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
event: WallEvent
} | null = null
const markWallDirty = (wallId: string | null) => {
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
const markHostDirty = (hostId: string | null) => {
if (hostId) useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
}
const lastWallDirtyAt = new Map<string, number>()
const markWallDirtyThrottled = (wallId: string | null) => {
if (!wallId) return
const lastHostDirtyAt = new Map<string, number>()
const markHostDirtyThrottled = (hostId: string | null) => {
if (!hostId) return
const now = globalThis.performance?.now?.() ?? Date.now()
const last = lastWallDirtyAt.get(wallId) ?? 0
const last = lastHostDirtyAt.get(hostId) ?? 0
// Wall rebuilds can trigger expensive CSG; throttle live previews to avoid FPS collapse.
if (now - last > 120) {
lastWallDirtyAt.set(wallId, now)
markWallDirty(wallId)
lastHostDirtyAt.set(hostId, now)
markHostDirty(hostId)
}
}
@@ -223,16 +236,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
const applyPreview = (target: NonNullable<typeof lastTarget>) => {
if (currentWallId !== target.wallId) {
if (currentHostId !== target.wallId) {
useScene.getState().updateNode(movingWindowNode.id, {
position: [target.clampedX, target.clampedY, 0],
rotation: [0, target.itemRotation, 0],
side: target.side,
parentId: target.wallId,
wallId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
markWallDirty(currentWallId)
currentWallId = target.wallId
markHostDirty(currentHostId)
currentHostId = target.wallId
} else {
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
if (windowMesh) {
@@ -245,7 +260,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
position: [target.clampedX, target.clampedY, 0],
rotation: target.itemRotation,
})
markWallDirtyThrottled(target.wallId)
markHostDirtyThrottled(target.wallId)
updateCursor(
wallLocalToWorld(
@@ -303,10 +318,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const cloned = structuredClone(movingWindowNode) as any
delete cloned.id
if (cloned.metadata && typeof cloned.metadata === 'object') {
delete cloned.metadata.isNew
delete cloned.metadata.isTransient
}
cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
const node = WindowNode.parse({
...cloned,
@@ -315,6 +327,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
side: target.side,
wallId: target.wallId,
parentId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -327,6 +341,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
@@ -337,16 +353,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
side: target.side,
parentId: target.wallId,
wallId: target.wallId,
roofSegmentId: undefined,
metadata: {},
})
if (original.parentId && original.parentId !== target.wallId) {
markWallDirty(original.parentId)
markHostDirty(original.parentId)
}
placedId = movingWindowNode.id
}
markWallDirty(target.wallId)
markHostDirty(target.wallId)
useLiveTransforms.getState().clear(movingWindowNode.id)
useScene.temporal.getState().pause()
@@ -364,25 +381,99 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
lastTarget = null
if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall
if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId)
if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId)
}
currentWallId = original.parentId
currentHostId = original.parentId
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markWallDirty(original.parentId)
if (original.parentId) markHostDirty(original.parentId)
}
const onCancel = () => {
// ── Roof-segment wall faces ─────────────────────────────────────
// Mirrors the wall flow for the segments' vertical wall faces (base
// walls under the roof + coplanar gable ends — a window can sit in
// the gable pediment). This is also the placement path preset tiles
// take (`metadata.isNew` clones).
const resolveRoofMoveTarget = (event: RoofEvent) =>
resolveRoofWallOpeningTarget({
event,
width: movingWindowNode.width,
height: movingWindowNode.height,
ignoreId: movingWindowNode.id,
vertical: { kind: 'free', snap: snapToHalf },
})
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const pose = getRoofWallOpeningCursorPose(target, roof)
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
}
const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event)
if (!target) return
// Wall-frame drag anchor / live transform don't apply on a roof face.
dragAnchor = null
lastTarget = null
useLiveTransforms.getState().clear(movingWindowNode.id)
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
rotation: [0, 0, 0],
side: 'front',
parentId: target.segment.id,
wallId: undefined,
roofSegmentId: target.segment.id,
roofFace: target.face.id,
})
markHostDirty(currentHostId)
currentHostId = target.segment.id
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
rotation: [0, 0, 0],
roofFace: target.face.id,
})
}
updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation()
}
const onRoofClick = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event)
if (!target?.valid) return
const segmentId = target.segment.id
let placedId: string
if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id)
if (currentWallId) markWallDirty(currentWallId)
useScene.temporal.getState().resume()
const cloned = structuredClone(movingWindowNode) as any
delete cloned.id
cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
const node = WindowNode.parse({
...cloned,
position: target.position,
rotation: [0, 0, 0],
side: 'front',
wallId: undefined,
roofSegmentId: segmentId,
roofFace: target.face.id,
parentId: segmentId,
})
useScene.getState().createNode(node, segmentId as AnyNodeId)
placedId = node.id
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
@@ -390,9 +481,79 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
rotation: [0, 0, 0],
side: 'front',
parentId: segmentId,
wallId: undefined,
roofSegmentId: segmentId,
roofFace: target.face.id,
metadata: {},
})
if (original.parentId && original.parentId !== segmentId) {
markHostDirty(original.parentId)
}
placedId = movingWindowNode.id
}
markHostDirty(segmentId)
useLiveTransforms.getState().clear(movingWindowNode.id)
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode()
event.stopPropagation()
}
const onRoofLeave = () => {
hideCursor()
useLiveTransforms.getState().clear(movingWindowNode.id)
dragAnchor = null
lastTarget = null
if (isNew) return
if (currentHostId && currentHostId !== original.parentId) {
markHostDirty(currentHostId)
}
currentHostId = original.parentId
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markHostDirty(original.parentId)
}
const onCancel = () => {
useLiveTransforms.getState().clear(movingWindowNode.id)
if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id)
if (currentHostId) markHostDirty(currentHostId)
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markHostDirty(original.parentId)
}
useScene.temporal.getState().resume()
hideCursor()
@@ -403,6 +564,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('roof:enter', onRoofHover)
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('tool:cancel', onCancel)
return () => {
@@ -414,7 +579,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
if (currentMeta?.isTransient) {
if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id)
if (currentWallId) markWallDirty(currentWallId)
if (currentHostId) markHostDirty(currentHostId)
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: original.position,
@@ -422,9 +587,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
if (original.parentId) markHostDirty(original.parentId)
}
}
useLiveTransforms.getState().clear(movingWindowNode.id)
@@ -434,6 +601,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('roof:enter', onRoofHover)
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('tool:cancel', onCancel)
}
}, [movingWindowNode, exitMoveMode])
+2
View File
@@ -215,6 +215,8 @@ export default function WindowPanel() {
rotation: [...node.rotation] as [number, number, number],
side: node.side,
wallId: node.wallId,
roofSegmentId: node.roofSegmentId,
roofFace: node.roofFace,
parentId: node.parentId,
width: node.width,
height: node.height,
+9 -1
View File
@@ -9,6 +9,7 @@ import {
} from '@pascal-app/viewer'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
const ref = useRef<Mesh>(null!)
@@ -33,7 +34,7 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
node.material?.texture,
])
return (
const mesh = (
<mesh
material={material}
position={node.position}
@@ -45,6 +46,13 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
if (!node.roofSegmentId) return mesh
return (
<RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
{mesh}
</RoofFaceHostFrame>
)
}
export default WindowRenderer
+141 -5
View File
@@ -3,6 +3,8 @@ import {
collectAlignmentAnchors,
emitter,
isCurvedWall,
type RoofEvent,
type RoofNode,
sceneRegistry,
spatialGridManager,
useScene,
@@ -23,6 +25,11 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
resolveRoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -35,7 +42,9 @@ const edgeMaterial = new LineBasicNodeMaterial({
})
/**
* Window tool — places WindowNodes on walls only.
* Window tool — places WindowNodes on walls and on roof-segment wall
* faces (the generated base walls under a roof, including coplanar gable
* ends — a window can sit in the gable pediment).
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
*/
const WindowTool: React.FC = () => {
@@ -58,8 +67,8 @@ const WindowTool: React.FC = () => {
wallEvent.node.end,
)
const markWallDirty = (wallId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
const markHostDirty = (hostId: string) => {
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
}
const destroyDraft = () => {
@@ -68,7 +77,7 @@ const WindowTool: React.FC = () => {
useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null
// Rebuild wall so it removes the cutout from the deleted draft
if (wallId) markWallDirty(wallId)
if (wallId) markHostDirty(wallId)
}
const hideCursor = () => {
@@ -215,7 +224,7 @@ const WindowTool: React.FC = () => {
rotation: [0, itemRotation, 0],
side,
})
markWallDirty(event.node.id)
markHostDirty(event.node.id)
} else {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
@@ -223,6 +232,9 @@ const WindowTool: React.FC = () => {
side,
parentId: event.node.id,
wallId: event.node.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
}
@@ -343,6 +355,122 @@ const WindowTool: React.FC = () => {
hideCursor()
}
// ── Roof-segment wall faces ─────────────────────────────────────
// The merged roof mesh emits `roof:*`; hits are resolved against the
// segments' vertical wall faces (base walls + coplanar gable ends),
// so a window can sit anywhere inside the face profile — including
// the gable pediment triangle.
const resolveRoofTarget = (event: RoofEvent) =>
resolveRoofWallOpeningTarget({
event,
width: draftRef.current?.width ?? 1.5,
height: draftRef.current?.height ?? 1.5,
ignoreId: draftRef.current?.id,
vertical: { kind: 'free', snap: snapToHalf },
})
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const pose = getRoofWallOpeningCursorPose(target, roof)
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
}
const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofTarget(event)
if (!target) {
// On the roof but not over a placeable wall face (slope, soffit,
// or a face the window cannot fit on).
if (draftRef.current?.roofSegmentId) {
destroyDraft()
hideCursor()
}
return
}
const { segment, face, position } = target
if (draftRef.current && draftRef.current.parentId !== segment.id) destroyDraft()
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position,
rotation: [0, 0, 0],
roofFace: face.id,
})
} else {
const node = WindowNode.parse({
position,
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: segment.id,
roofFace: face.id,
parentId: segment.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, segment.id as AnyNodeId)
draftRef.current = node
}
updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation()
}
const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
const { segment, face, position } = target
const draft = draftRef.current
draftRef.current = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
const state = useScene.getState()
const windowCount = Object.values(state.nodes).filter(
(n) => n.type === 'window' && (n as WindowNode).roofSegmentId !== undefined,
).length
const node = WindowNode.parse({
name: `Window ${windowCount + 1}`,
position,
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: segment.id,
roofFace: face.id,
parentId: segment.id,
width: draft.width,
height: draft.height,
windowType: draft.windowType,
operationState: draft.operationState,
awningDirection: draft.awningDirection,
casementStyle: draft.casementStyle,
hingesSide: draft.hingesSide,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
columnRatios: draft.columnRatios,
rowRatios: draft.rowRatios,
columnDividerThickness: draft.columnDividerThickness,
rowDividerThickness: draft.rowDividerThickness,
sill: draft.sill,
sillDepth: draft.sillDepth,
sillThickness: draft.sillThickness,
})
useScene.getState().createNode(node, segment.id as AnyNodeId)
// Rebuild the segment (and the merged roof) so the wall brush
// picks up the new opening cut.
useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
event.stopPropagation()
}
const onRoofLeave = () => {
if (!draftRef.current?.roofSegmentId) return
destroyDraft()
hideCursor()
}
const onCancel = () => {
destroyDraft()
hideCursor()
@@ -352,6 +480,10 @@ const WindowTool: React.FC = () => {
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('roof:enter', onRoofHover)
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('tool:cancel', onCancel)
return () => {
@@ -363,6 +495,10 @@ const WindowTool: React.FC = () => {
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('roof:enter', onRoofHover)
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('tool:cancel', onCancel)
}
}, [])
+1 -1
View File
@@ -71,7 +71,7 @@ export function resolveSurfaceColor(
// The active scene theme may tint individual roles (e.g. Mediterranean's blue
// roof); fall back to the chosen colour preset's palette when it doesn't.
const tints = sceneThemeId ? getSceneTheme(sceneThemeId).clayTints : undefined
return tints?.[role] ?? PRESET_PALETTES[preset][role]
return tints?.[role] ?? (PRESET_PALETTES[preset] ?? CLAY_PALETTE)[role]
}
// DoubleSide on any NodeMaterial inside the MRT scenePass (SSGI's output /
+84
View File
@@ -7,6 +7,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { EdgeMode } from '../lib/edge-style'
import type { ColorPreset, RenderShading } from '../lib/materials'
import { SCENE_THEME_IDS } from '../lib/scene-themes'
export type RenderContext = 'editor' | 'viewer'
@@ -114,6 +115,85 @@ type ViewerState = {
setInputDragging: (dragging: boolean) => void
}
type PersistedViewerState = Partial<
Pick<
ViewerState,
| 'cameraMode'
| 'sceneTheme'
| 'shadingByContext'
| 'textures'
| 'colorPreset'
| 'edges'
| 'shadows'
| 'unit'
| 'levelMode'
| 'wallMode'
| 'projectPreferences'
>
>
const CAMERA_MODES = ['perspective', 'orthographic'] as const
const RENDER_SHADINGS = ['solid', 'rendered'] as const
const COLOR_PRESETS = ['clay', 'white', 'mono', 'blueprint'] as const
const EDGE_MODES = ['off', 'soft', 'strong'] as const
const UNITS = ['metric', 'imperial'] as const
const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const
const WALL_MODES = ['up', 'cutaway', 'down'] as const
function pickString<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback
}
function normalizeShadingByContext(value: unknown): ViewerState['shadingByContext'] {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const next: ViewerState['shadingByContext'] = {}
for (const [context, shading] of Object.entries(value)) {
if (context !== 'editor' && context !== 'viewer') continue
next[context] = pickString<RenderShading>(shading, RENDER_SHADINGS, 'rendered')
}
return next
}
function normalizeProjectPreferences(value: unknown): ViewerState['projectPreferences'] {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const next: ViewerState['projectPreferences'] = {}
for (const [projectId, preferences] of Object.entries(value)) {
if (!preferences || typeof preferences !== 'object' || Array.isArray(preferences)) continue
const record = preferences as Record<string, unknown>
next[projectId] = {
...(typeof record.showScans === 'boolean' ? { showScans: record.showScans } : {}),
...(typeof record.showGuides === 'boolean' ? { showGuides: record.showGuides } : {}),
...(typeof record.showGrid === 'boolean' ? { showGrid: record.showGrid } : {}),
}
}
return next
}
function normalizePersistedViewerState(value: unknown): PersistedViewerState {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const state = value as Record<string, unknown>
return {
cameraMode: pickString<ViewerState['cameraMode']>(
state.cameraMode,
CAMERA_MODES,
'perspective',
),
sceneTheme: pickString(state.sceneTheme, SCENE_THEME_IDS, 'studio'),
shadingByContext: normalizeShadingByContext(state.shadingByContext),
textures: typeof state.textures === 'boolean' ? state.textures : true,
colorPreset: pickString<ColorPreset>(state.colorPreset, COLOR_PRESETS, 'clay'),
edges: pickString<EdgeMode>(state.edges, EDGE_MODES, 'soft'),
shadows: typeof state.shadows === 'boolean' ? state.shadows : true,
unit: pickString<ViewerState['unit']>(state.unit, UNITS, 'metric'),
levelMode: pickString<ViewerState['levelMode']>(state.levelMode, LEVEL_MODES, 'stacked'),
wallMode: pickString<ViewerState['wallMode']>(state.wallMode, WALL_MODES, 'up'),
projectPreferences: normalizeProjectPreferences(state.projectPreferences),
}
}
const useViewer = create<ViewerState>()(
persist(
(set) => ({
@@ -267,6 +347,10 @@ const useViewer = create<ViewerState>()(
}),
{
name: 'viewer-preferences',
merge: (persistedState, currentState) => ({
...currentState,
...normalizePersistedViewerState(persistedState),
}),
partialize: (state) => ({
cameraMode: state.cameraMode,
sceneTheme: state.sceneTheme,
@@ -133,6 +133,8 @@ export function generateCeilingGeometry(
// the whole command encoder.
const degenerate = new THREE.BufferGeometry()
degenerate.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
degenerate.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
degenerate.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
return degenerate
}
@@ -36,12 +36,20 @@ export const ItemSystem = () => {
if (!mesh) return
if (item.asset.attachTo === 'wall-side') {
// Wall-attached item: offset Z by half the parent wall's thickness
const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
if (parentWall && parentWall.type === 'wall') {
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
// Wall-attached item: offset Z by half the host wall's thickness.
// Roof-segment wall faces share the convention — the face frame's
// z = 0 is the wall mid-plane, so the same push lands the item on
// the outer surface.
const parent = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
const thickness =
parent?.type === 'wall'
? ((parent as WallNode).thickness ?? 0.1)
: parent?.type === 'roof-segment'
? (parent.wallThickness ?? 0.1)
: undefined
if (thickness !== undefined) {
const side = item.side === 'front' ? 1 : -1
mesh.position.z = (wallThickness / 2) * side
mesh.position.z = (thickness / 2) * side
}
}
+126 -75
View File
@@ -110,7 +110,13 @@ export const RoofSystem = () => {
// previous cut shape (stale CSG) once the user exits segment
// edit mode. Registry-driven so the viewer stays kind-agnostic.
const def = nodeRegistry.get(node.type)
if (def?.capabilities?.roofAccessory) {
// Kinds with `dirtyHandledByOwnSystem` (door / window) reach the roof
// through their own geometry system's parentId cascade instead —
// their dirty marks belong to that system, not to this loop.
if (
def?.capabilities?.roofAccessory &&
!def.capabilities.roofAccessory.dirtyHandledByOwnSystem
) {
const segId = (node as { roofSegmentId?: string }).roofSegmentId
const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined
if (seg?.parentId) {
@@ -131,10 +137,20 @@ export const RoofSystem = () => {
// Only compute expensive individual CSG when the segment is actually rendered
// (its parent group is visible = the roof is selected for editing)
const isVisible = mesh.parent?.visible !== false
if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
updateRoofSegmentGeometry(effectiveSegment, mesh)
// Accessory-reveal mode (RoofEditSystem): the wrapper is shown so
// portaled handles render, but the merged shell stays visible and
// the segment meshes are stripped to empty placeholders. Rebuilding
// per-segment CSG here would draw UNCUT geometry on top of the
// merged shell — hiding a freshly cut opening (door / window /
// skylight) until the next deselect. Full edit mode hides the
// merged mesh, so gate the rebuild on its visibility.
const revealOnly =
mesh.parent?.name === 'segments-wrapper' &&
mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true
if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
updateRoofSegmentGeometry(effectiveSegment, mesh, nodes)
segmentsProcessed++
} else if (isVisible) {
} else if (isVisible && !revealOnly) {
return // Over budget — keep dirty, process next frame
} else {
// Just sync transform, skip CSG — the merged roof handles visuals.
@@ -152,6 +168,14 @@ export const RoofSystem = () => {
'position',
new THREE.Float32BufferAttribute(new Float32Array(9), 3),
)
placeholder.setAttribute(
'normal',
new THREE.Float32BufferAttribute(new Float32Array(9), 3),
)
placeholder.setAttribute(
'uv',
new THREE.Float32BufferAttribute(new Float32Array(6), 2),
)
computeGeometryBoundsTree(placeholder)
mesh.geometry = placeholder
}
@@ -210,8 +234,12 @@ export const RoofSystem = () => {
// GEOMETRY GENERATION
// ============================================================================
function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) {
const newGeo = generateRoofSegmentGeometry(node)
function updateRoofSegmentGeometry(
node: RoofSegmentNode,
mesh: THREE.Mesh,
nodes?: Record<string, AnyNode>,
) {
const newGeo = generateRoofSegmentGeometry(node, nodes)
mesh.geometry.dispose()
mesh.geometry = newGeo
@@ -221,6 +249,89 @@ function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) {
mesh.rotation.y = node.rotation
}
/**
* Subtract every hosted accessory cut (`capabilities.roofAccessory.
* buildCut`) from a segment's brushes, in SEGMENT-LOCAL space. Shared by
* the merged-shell path AND the per-segment path (full edit mode /
* painted segments) — without the latter, selecting a segment used to
* swap the merged shell for uncut per-segment meshes and every door /
* window / skylight hole vanished until deselect. Children are read
* live-effective so an in-flight handle drag carves the live hole.
* Registry-driven so the viewer never names a kind.
*/
function subtractAccessoryCuts(
brushes: { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush },
segment: RoofSegmentNode,
nodes: Record<string, AnyNode>,
) {
let workingShin = brushes.shinSlab
let workingDeck = brushes.deckSlab
let workingWall = brushes.wallBrush
for (const childElemId of segment.children ?? []) {
const storedChild = nodes[childElemId as AnyNodeId]
if (!storedChild) continue
const childElem = getEffectiveNode(storedChild)
const meta =
typeof childElem.metadata === 'object' && childElem.metadata !== null
? (childElem.metadata as Record<string, unknown>)
: undefined
if (meta?.isTransient) continue
const childDef = nodeRegistry.get(childElem.type)
const buildCut = childDef?.capabilities?.roofAccessory?.buildCut
if (!buildCut) continue
const cutGeo = buildCut(childElem, segment)
if (!cutGeo) continue
// Wrap the kind-emitted geometry in a Brush. Kinds return raw
// shapes; the viewer welds (mandatory after rotations leave
// duplicated verts), attaches a single material group, and
// builds the bounds tree — keeping kind code free of
// three-bvh-csg / three-mesh-bvh imports.
const welded = mergeVertices(cutGeo, 1e-4)
cutGeo.dispose()
const idxCount = welded.getIndex()?.count ?? 0
if (idxCount === 0) {
welded.dispose()
continue
}
welded.clearGroups()
welded.addGroup(0, idxCount, 0)
welded.computeVertexNormals()
computeGeometryBoundsTree(welded)
const cut = new Brush(welded, dummyMats[0])
cut.updateMatrixWorld()
const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all'
try {
if (cutScope !== 'wall') {
const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush
workingShin.geometry.dispose()
prepareBrushForCSG(nextShin)
workingShin = nextShin
const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush
workingDeck.geometry.dispose()
prepareBrushForCSG(nextDeck)
workingDeck = nextDeck
}
const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush
workingWall.geometry.dispose()
prepareBrushForCSG(nextWall)
workingWall = nextWall
} catch (e) {
console.error(`[${childElem.type}] cut CSG failed:`, e)
} finally {
cut.geometry.dispose()
}
}
brushes.shinSlab = workingShin
brushes.deckSlab = workingDeck
brushes.wallBrush = workingWall
}
function updateMergedRoofGeometry(
roofNode: RoofNode,
group: THREE.Group,
@@ -261,74 +372,7 @@ function updateMergedRoofGeometry(
const brushes = getRoofSegmentBrushes(child)
if (!brushes) continue
// Per-child cuts in SEGMENT-LOCAL space: subtract every accessory
// that contributes a cut (declares
// `capabilities.roofAccessory.buildCut`) from shin / deck / wall
// before we accumulate. Mirrors roof-system v1 — the cut is built
// in segment-local, then carved out before the segment transform
// stacks on. Registry-driven so the viewer never names a kind.
let workingShin = brushes.shinSlab
let workingDeck = brushes.deckSlab
let workingWall = brushes.wallBrush
for (const childElemId of child.children ?? []) {
const childElem = nodes[childElemId as AnyNodeId]
if (!childElem) continue
const meta =
typeof childElem.metadata === 'object' && childElem.metadata !== null
? (childElem.metadata as Record<string, unknown>)
: undefined
if (meta?.isTransient) continue
const childDef = nodeRegistry.get(childElem.type)
const buildCut = childDef?.capabilities?.roofAccessory?.buildCut
if (!buildCut) continue
const cutGeo = buildCut(childElem, child)
if (!cutGeo) continue
// Wrap the kind-emitted geometry in a Brush. Kinds return raw
// shapes; the viewer welds (mandatory after rotations leave
// duplicated verts), attaches a single material group, and
// builds the bounds tree — keeping kind code free of
// three-bvh-csg / three-mesh-bvh imports.
const welded = mergeVertices(cutGeo, 1e-4)
cutGeo.dispose()
const idxCount = welded.getIndex()?.count ?? 0
if (idxCount === 0) {
welded.dispose()
continue
}
welded.clearGroups()
welded.addGroup(0, idxCount, 0)
welded.computeVertexNormals()
computeGeometryBoundsTree(welded)
const cut = new Brush(welded, dummyMats[0])
cut.updateMatrixWorld()
try {
const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush
workingShin.geometry.dispose()
prepareBrushForCSG(nextShin)
workingShin = nextShin
const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush
workingDeck.geometry.dispose()
prepareBrushForCSG(nextDeck)
workingDeck = nextDeck
const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush
workingWall.geometry.dispose()
prepareBrushForCSG(nextWall)
workingWall = nextWall
} catch (e) {
console.error(`[${childElem.type}] cut CSG failed:`, e)
} finally {
cut.geometry.dispose()
}
}
brushes.shinSlab = workingShin
brushes.deckSlab = workingDeck
brushes.wallBrush = workingWall
subtractAccessoryCuts(brushes, child, nodes)
_matrix.compose(
_position.set(child.position[0], child.position[1], child.position[2]),
@@ -789,13 +833,20 @@ export function getRoofSegmentBrushes(
return null
}
export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.BufferGeometry {
export function generateRoofSegmentGeometry(
node: RoofSegmentNode,
nodes?: Record<string, AnyNode>,
): THREE.BufferGeometry {
const brushes = getRoofSegmentBrushes(node)
if (!brushes) {
// Fallback: simple box
return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth)
}
if (nodes) {
subtractAccessoryCuts(brushes, node, nodes)
}
const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes
let resultGeo = new THREE.BufferGeometry()
@@ -531,6 +531,8 @@ function createEmptyGeometry(): THREE.BufferGeometry {
// unbound and the draw is rejected ("Vertex buffer slot 0 … was not set"),
// poisoning the command encoder. The count-0 groups keep nothing drawn.
geometry.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2))
geometry.addGroup(0, 0, STAIR_TREAD_MATERIAL_INDEX)
geometry.addGroup(0, 0, STAIR_SIDE_MATERIAL_INDEX)
return geometry