feat: face-frame hosting for roof wall children + items on roof walls
Wall-mounted items join doors/windows on roof-segment wall faces, and the storage model moves to FACE-LOCAL coordinates so hosted children track segment edits live. - Children store roofFace + position [u, v, z-from-mid-plane] with rotation 0 in-frame — the exact wall-child conventions (the wall volume's mid-plane lands on the nominal footprint). A shared <RoofFaceHostFrame> derives segment pose + face frame from the live-override-merged segment: children follow resize handle drags in real time and never jump on commit. No re-anchor cascade needed — position is authoritative, the frame is derived. migrateNodes converts branch-era segment-local data. - Items: roofWallStrategy + roof:* handlers in the placement coordinator (surface 'roof-wall'), Shift free-place normalized with walls, ItemSystem wall-side push extended to segment hosts, correct 2D plan glyphs via face→segment→roof pose composition. The roof hit resolver + overlap guard moved to @pascal-app/editor (the coordinator lives there; nodes already depends on editor). - Cuts: subtractAccessoryCuts extracted and applied in BOTH the merged-shell and per-segment CSG paths (full edit mode / painted segments used to lose every hole), built from the CURRENT host geometry and live-effective children so holes follow segment and opening drags. - Handle rig: the grandparent portal now maps the node's world pose into the portal frame instead of composing parent+node registry poses — correct for any nesting (the face-frame group broke the old assumption), identical for walls. - Host-field hygiene: useDraftNode.commit/adopt and the window panel duplicate forward roofSegmentId/roofFace/wallId; every roof↔wall re-anchor clears and every revert restores them. Codex-reviewed (design consultation, adversarial rounds on the replaced cascade and on this refactor); frame conventions locked by unit tests. Record: private-editor plans/editor-roof-wall-openings.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5fd9be05df
commit
7879b83df3
@@ -38,6 +38,7 @@ 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'
|
||||
@@ -54,6 +55,9 @@ import {
|
||||
NO_RAYCAST,
|
||||
} 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,
|
||||
@@ -276,14 +280,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
|
||||
|
||||
@@ -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: segment-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,
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
checkCanPlace,
|
||||
floorStrategy,
|
||||
itemSurfaceStrategy,
|
||||
roofWallStrategy,
|
||||
shelfSurfaceStrategy,
|
||||
wallStrategy,
|
||||
} from './placement-strategies'
|
||||
@@ -213,6 +215,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
config.initialState ?? {
|
||||
surface: 'floor',
|
||||
wallId: null,
|
||||
roofSegmentId: null,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
@@ -413,6 +416,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
placementState.current = configRef.current.initialState ?? {
|
||||
surface: 'floor',
|
||||
wallId: null,
|
||||
roofSegmentId: null,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
@@ -987,6 +991,146 @@ 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) => {
|
||||
@@ -1499,6 +1643,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
|
||||
@@ -1673,6 +1821,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)
|
||||
@@ -1704,6 +1856,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)
|
||||
|
||||
@@ -231,6 +231,10 @@ 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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user