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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
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(node), center.z)
|
||||
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
|
||||
|
||||
+369
-52
@@ -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,6 +511,7 @@ export const SiteBoundaryEditor: React.FC = () => {
|
||||
if (!showSiteHandles) return null
|
||||
|
||||
return (
|
||||
<group ref={handlesRootRef}>
|
||||
<PolygonEditor
|
||||
color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
|
||||
minVertices={3}
|
||||
@@ -490,5 +532,6 @@ export const SiteBoundaryEditor: React.FC = () => {
|
||||
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,11 +123,7 @@ 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
|
||||
isActive
|
||||
? 'opacity-100 grayscale-0'
|
||||
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user