Add roof drag controls to the 2D floorplan

This commit is contained in:
sudhir
2026-04-22 17:24:58 +05:30
parent a69591670d
commit 1492e5bd0c
9 changed files with 3016 additions and 494 deletions
+60 -1
View File
@@ -192,6 +192,58 @@ function appendUniquePlanPoint(points: Point2D[], point: Point2D | null) {
points.push(point) points.push(point)
} }
function getFloorplanArcPoint(center: Point2D, radius: number, angle: number): Point2D {
return {
x: center.x + Math.cos(angle) * radius,
y: center.y + Math.sin(angle) * radius,
}
}
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
const stairType = stair.stairType ?? 'straight'
const baseSweepAngle =
stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
}
return baseSweepAngle
}
function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
const stairType = stair.stairType ?? 'straight'
const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
const startAngle = stair.rotation - sweepAngle / 2
const endAngle = startAngle + sweepAngle
const center = {
x: stair.position[0],
y: stair.position[2],
}
const innerRadius = Math.max(
stairType === 'spiral' ? 0.05 : 0.2,
stair.innerRadius ?? (stairType === 'spiral' ? 0.2 : 0.9),
)
const outerRadius = innerRadius + stair.width
const outerArcLength = Math.abs(sweepAngle) * outerRadius
const segmentCount = Math.max(
24,
Math.ceil(Math.abs(sweepAngle) / (Math.PI / 24)),
Math.ceil(outerArcLength / 0.14),
)
const outerPoints: Point2D[] = []
const innerPoints: Point2D[] = []
for (let index = 0; index <= segmentCount; index += 1) {
const t = index / segmentCount
const angle = startAngle + (endAngle - startAngle) * t
outerPoints.push(getFloorplanArcPoint(center, outerRadius, angle))
innerPoints.push(getFloorplanArcPoint(center, innerRadius, angle))
}
return [...outerPoints, ...innerPoints.reverse()]
}
function buildFloorplanStairArrow( function buildFloorplanStairArrow(
segments: FloorplanStairSegmentEntry[], segments: FloorplanStairSegmentEntry[],
): FloorplanStairArrowEntry | null { ): FloorplanStairArrowEntry | null {
@@ -374,7 +426,9 @@ export function buildFloorplanStairEntry(
stair: StairNode, stair: StairNode,
segments: StairSegmentNode[], segments: StairSegmentNode[],
): FloorplanStairEntry | null { ): FloorplanStairEntry | null {
if (segments.length === 0) { const stairType = stair.stairType ?? 'straight'
if (segments.length === 0 && stairType === 'straight') {
return null return null
} }
@@ -394,9 +448,14 @@ export function buildFloorplanStairEntry(
treadThickness, treadThickness,
} }
}) })
const hitPolygons =
stairType === 'straight'
? segmentEntries.map(({ polygon }) => polygon)
: [getFloorplanCurvedStairHitPolygon(stair)]
return { return {
arrow: buildFloorplanStairArrow(segmentEntries), arrow: buildFloorplanStairArrow(segmentEntries),
hitPolygons,
stair, stair,
segments: segmentEntries, segments: segmentEntries,
} }
+1
View File
@@ -32,6 +32,7 @@ export type FloorplanStairArrowEntry = {
export type FloorplanStairEntry = { export type FloorplanStairEntry = {
arrow: FloorplanStairArrowEntry | null arrow: FloorplanStairArrowEntry | null
hitPolygons: Point2D[][]
stair: StairNode stair: StairNode
segments: FloorplanStairSegmentEntry[] segments: FloorplanStairSegmentEntry[]
} }
@@ -216,7 +216,7 @@ function generateStairSegmentGeometry(
extrudedGeometry.applyMatrix4(matrix) extrudedGeometry.applyMatrix4(matrix)
extrudedGeometry.computeVertexNormals() extrudedGeometry.computeVertexNormals()
const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry const geometry = extrudedGeometry.index ? extrudedGeometry.toNonIndexed() : extrudedGeometry
if (geometry !== extrudedGeometry) { if (geometry !== extrudedGeometry) {
extrudedGeometry.dispose() extrudedGeometry.dispose()
} }
@@ -21,32 +21,46 @@ export type FloorplanActionMenuEntry = {
type FloorplanActionMenuLayerProps = { type FloorplanActionMenuLayerProps = {
item: FloorplanActionMenuEntry item: FloorplanActionMenuEntry
wall: FloorplanActionMenuEntry wall: FloorplanActionMenuEntry
fence: FloorplanActionMenuEntry
slab: FloorplanActionMenuEntry slab: FloorplanActionMenuEntry
ceiling: FloorplanActionMenuEntry ceiling: FloorplanActionMenuEntry
opening: FloorplanActionMenuEntry opening: FloorplanActionMenuEntry
stair: FloorplanActionMenuEntry stair: FloorplanActionMenuEntry
roof: FloorplanActionMenuEntry
offsetY?: number offsetY?: number
} }
export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
item, item,
wall, wall,
fence,
slab, slab,
ceiling, ceiling,
opening, opening,
stair, stair,
roof,
offsetY = 10, offsetY = 10,
}: FloorplanActionMenuLayerProps) { }: FloorplanActionMenuLayerProps) {
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall) const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence) const curvingFence = useEditor((state) => state.curvingFence)
if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) { if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) {
return null return null
} }
const entries: FloorplanActionMenuEntry[] = [item, wall, slab, ceiling, opening, stair] const entries: FloorplanActionMenuEntry[] = [
item,
wall,
fence,
slab,
ceiling,
opening,
stair,
roof,
]
return ( return (
<> <>
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ import {
isCurvedWall, isCurvedWall,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveTransforms,
useScene, useScene,
type WallEvent, type WallEvent,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -127,6 +128,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
parentId: event.node.id, parentId: event.node.id,
wallId: event.node.id, wallId: event.node.id,
}) })
useLiveTransforms.getState().set(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: itemRotation,
})
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
markWallDirty(event.node.id) markWallDirty(event.node.id)
@@ -195,6 +200,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
doorMesh.updateMatrixWorld(true) doorMesh.updateMatrixWorld(true)
} }
} }
useLiveTransforms.getState().set(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: itemRotation,
})
markWallDirty(event.node.id) markWallDirty(event.node.id)
const valid = !hasWallChildOverlap( const valid = !hasWallChildOverlap(
@@ -291,6 +300,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
} }
markWallDirty(event.node.id) markWallDirty(event.node.id)
useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
@@ -302,6 +312,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallLeave = () => { const onWallLeave = () => {
hideCursor() hideCursor()
useLiveTransforms.getState().clear(movingDoorNode.id)
if (isNew) return if (isNew) return
if (currentWallId && currentWallId !== original.parentId) { if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId) markWallDirty(currentWallId)
@@ -318,6 +329,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
} }
const onCancel = () => { const onCancel = () => {
useLiveTransforms.getState().clear(movingDoorNode.id)
if (isNew) { if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id) useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId) if (currentWallId) markWallDirty(currentWallId)
@@ -364,6 +376,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markWallDirty(original.parentId)
} }
} }
useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter) emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove) emitter.off('wall:move', onWallMove)
@@ -1,17 +1,25 @@
'use client' 'use client'
import { type AnyNodeId, type FenceNode, emitter, type GridEvent, useScene } from '@pascal-app/core' import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
sceneRegistry,
useLiveTransforms,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import type * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from './fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function samePoint(a: [number, number], b: [number, number]) { function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1] return a[0] === b[0] && a[1] === b[1]
} }
@@ -24,10 +32,11 @@ type LinkedFenceSnapshot = {
function getLinkedFenceSnapshots(args: { function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id'] fenceId: FenceNode['id']
fenceParentId: string | null
originalStart: [number, number] originalStart: [number, number]
originalEnd: [number, number] originalEnd: [number, number]
}) { }) {
const { fenceId, originalStart, originalEnd } = args const { fenceId, fenceParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState() const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = [] const snapshots: LinkedFenceSnapshot[] = []
@@ -36,6 +45,10 @@ function getLinkedFenceSnapshots(args: {
continue continue
} }
if ((node.parentId ?? null) !== fenceParentId) {
continue
}
if ( if (
!samePoint(node.start, originalStart) && !samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) && !samePoint(node.start, originalEnd) &&
@@ -78,12 +91,14 @@ function getLinkedFenceUpdates(
} }
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number]) const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const linkedOriginalsRef = useRef( const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({ getLinkedFenceSnapshots({
fenceId: node.id, fenceId: node.id,
fenceParentId: node.parentId ?? null,
originalStart: node.start, originalStart: node.start,
originalEnd: node.end, originalEnd: node.end,
}), }),
@@ -106,10 +121,49 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const nodeId = nodeIdRef.current const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current const originalEnd = originalEndRef.current
const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is WallNode => child?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
let wasCommitted = false let wasCommitted = false
const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
if (!mesh) {
return
}
mesh.position.set(deltaX, 0, deltaZ)
}
const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
const originalCenterX = (fence.start[0] + fence.end[0]) / 2
const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
useLiveTransforms.getState().set(fence.id, {
position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
rotation: 0,
})
}
const clearPreviewState = () => {
setMeshOffset(nodeId, 0, 0)
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
setMeshOffset(linkedFence.id, 0, 0)
useLiveTransforms.getState().clear(linkedFence.id)
}
}
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => { const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
useScene.getState().updateNodes( useScene.getState().updateNodes(
updates.map((entry) => ({ updates.map((entry) => ({
@@ -127,21 +181,33 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const centerX = (nextStart[0] + nextEnd[0]) / 2 const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2 const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ]) setCursorLocalPos([centerX, 0, centerZ])
applyNodePreview([ const deltaX = nextStart[0] - originalStart[0]
{ id: nodeId, start: nextStart, end: nextEnd }, const deltaZ = nextStart[1] - originalStart[1]
...getLinkedFenceUpdates( setMeshOffset(nodeId, deltaX, deltaZ)
linkedOriginalsRef.current, setFenceLiveTransform(node, deltaX, deltaZ)
originalStart,
originalEnd, for (const linkedFence of linkedOriginalsRef.current) {
nextStart, setMeshOffset(linkedFence.id, deltaX, deltaZ)
nextEnd, setFenceLiveTransform(
), {
]) ...node,
id: linkedFence.id,
start: linkedFence.start,
end: linkedFence.end,
},
deltaX,
deltaZ,
)
}
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0]) const [localX, localZ] = snapFenceDraftPoint({
const localZ = snap(event.localPosition[2]) point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
ignoreFenceIds: [nodeId],
})
if ( if (
previousGridPosRef.current && previousGridPosRef.current &&
@@ -164,17 +230,15 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd } const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end }, { id: nodeId, start: preview.start, end: preview.end },
@@ -186,6 +250,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
preview.end, preview.end,
), ),
]) ])
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id)
}
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
@@ -195,10 +263,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
} }
const onCancel = () => { const onCancel = () => {
applyNodePreview([ clearPreviewState()
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
useViewer.getState().setSelection({ selectedIds: [nodeId] }) useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
markToolCancelConsumed() markToolCancelConsumed()
@@ -211,17 +276,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
return () => { return () => {
if (!wasCommitted) { if (!wasCommitted) {
applyNodePreview([ clearPreviewState()
{ id: nodeId, start: originalStart, end: originalEnd }, } else {
...linkedOriginalsRef.current, useLiveTransforms.getState().clear(nodeId)
]) for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id)
}
} }
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel) emitter.off('tool:cancel', onCancel)
} }
}, [exitMoveMode]) }, [exitMoveMode, node])
return ( return (
<group> <group>
@@ -1,4 +1,13 @@
import type { DoorNode, ItemNode, SlabNode, StairNode, WallNode, WindowNode } from '@pascal-app/core' import type {
DoorNode,
ItemNode,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { import {
doesPolygonIntersectSelectionBounds, doesPolygonIntersectSelectionBounds,
getDistanceToWallSegment, getDistanceToWallSegment,
@@ -21,6 +30,7 @@ type ItemEntry = {
} }
type StairEntry = { type StairEntry = {
hitPolygons: Point2D[][]
stair: StairNode stair: StairNode
segments: Array<{ polygon: Point2D[] }> segments: Array<{ polygon: Point2D[] }>
} }
@@ -36,6 +46,14 @@ type SlabEntry = {
holes: Point2D[][] holes: Point2D[][]
} }
type RoofEntry = {
roof: RoofNode
segments: Array<{
polygon: Point2D[]
segment: RoofSegmentNode
}>
}
type FloorplanSelectionToolContext = { type FloorplanSelectionToolContext = {
point: Point2D point: Point2D
phase: 'site' | 'structure' | 'furnish' phase: 'site' | 'structure' | 'furnish'
@@ -45,6 +63,7 @@ type FloorplanSelectionToolContext = {
stairs: StairEntry[] stairs: StairEntry[]
walls: WallEntry[] walls: WallEntry[]
slabs: SlabEntry[] slabs: SlabEntry[]
roofs: RoofEntry[]
openingHitTolerance: number openingHitTolerance: number
wallHitTolerance: number wallHitTolerance: number
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
@@ -59,6 +78,12 @@ function getItemHitId(context: FloorplanSelectionToolContext) {
return itemHit?.item.id ?? null return itemHit?.item.id ?? null
} }
function getStairHitPolygons(stair: StairEntry) {
return stair.hitPolygons.length > 0
? stair.hitPolygons
: stair.segments.map(({ polygon }) => polygon)
}
export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) { export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
if (context.phase === 'structure') { if (context.phase === 'structure') {
const openingHit = context.openings.find(({ polygon }) => { const openingHit = context.openings.find(({ polygon }) => {
@@ -83,8 +108,8 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
return openingHit.opening.id return openingHit.opening.id
} }
const stairHit = context.stairs.find(({ segments }) => const stairHit = context.stairs.find((stair) =>
segments.some(({ polygon }) => isPointInsidePolygon(context.point, polygon)), getStairHitPolygons(stair).some((polygon) => isPointInsidePolygon(context.point, polygon)),
) )
if (stairHit) { if (stairHit) {
return stairHit.stair.id return stairHit.stair.id
@@ -99,6 +124,13 @@ export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
return wallHit.wall.id return wallHit.wall.id
} }
const roofHit = context.roofs.find(({ segments }) =>
segments.some(({ polygon }) => isPointInsidePolygon(context.point, polygon)),
)
if (roofHit) {
return roofHit.roof.id
}
const slabHit = context.slabs.find(({ polygon, holes }) => const slabHit = context.slabs.find(({ polygon, holes }) =>
isPointInsidePolygonWithHoles(context.point, polygon, holes), isPointInsidePolygonWithHoles(context.point, polygon, holes),
) )
@@ -119,6 +151,7 @@ type FloorplanSelectionBoundsContext = {
openings: OpeningPolygonEntry[] openings: OpeningPolygonEntry[]
slabs: SlabEntry[] slabs: SlabEntry[]
stairs: StairEntry[] stairs: StairEntry[]
roofs: RoofEntry[]
} }
export function getFloorplanSelectionIdsInBounds({ export function getFloorplanSelectionIdsInBounds({
@@ -130,6 +163,7 @@ export function getFloorplanSelectionIdsInBounds({
openings, openings,
slabs, slabs,
stairs, stairs,
roofs,
}: FloorplanSelectionBoundsContext) { }: FloorplanSelectionBoundsContext) {
const itemIds = isItemContextActive const itemIds = isItemContextActive
? items ? items
@@ -151,10 +185,19 @@ export function getFloorplanSelectionIdsInBounds({
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)) .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ slab }) => slab.id) .map(({ slab }) => slab.id)
const stairIds = stairs const stairIds = stairs
.filter((stair) =>
getStairHitPolygons(stair).some((polygon) =>
doesPolygonIntersectSelectionBounds(polygon, bounds),
),
)
.map(({ stair }) => stair.id)
const roofIds = roofs
.filter(({ segments }) => .filter(({ segments }) =>
segments.some(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)), segments.some(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)),
) )
.map(({ stair }) => stair.id) .map(({ roof }) => roof.id)
return Array.from(new Set([...itemIds, ...wallIds, ...openingIds, ...slabIds, ...stairIds])) return Array.from(
new Set([...itemIds, ...wallIds, ...openingIds, ...slabIds, ...stairIds, ...roofIds]),
)
} }
@@ -4,6 +4,7 @@ import {
isCurvedWall, isCurvedWall,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveTransforms,
useScene, useScene,
type WallEvent, type WallEvent,
WindowNode, WindowNode,
@@ -144,6 +145,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
parentId: event.node.id, parentId: event.node.id,
wallId: event.node.id, wallId: event.node.id,
}) })
useLiveTransforms.getState().set(movingWindowNode.id, {
position: [clampedX, clampedY, 0],
rotation: itemRotation,
})
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
markWallDirty(event.node.id) markWallDirty(event.node.id)
@@ -215,6 +220,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
windowMesh.updateMatrixWorld(true) windowMesh.updateMatrixWorld(true)
} }
} }
useLiveTransforms.getState().set(movingWindowNode.id, {
position: [clampedX, clampedY, 0],
rotation: itemRotation,
})
markWallDirty(event.node.id) markWallDirty(event.node.id)
const valid = !hasWallChildOverlap( const valid = !hasWallChildOverlap(
@@ -326,6 +335,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
} }
markWallDirty(event.node.id) markWallDirty(event.node.id)
useLiveTransforms.getState().clear(movingWindowNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
@@ -337,6 +347,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallLeave = () => { const onWallLeave = () => {
hideCursor() hideCursor()
useLiveTransforms.getState().clear(movingWindowNode.id)
if (isNew) return // No original to restore for duplicates if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall // Move mode: restore to original position while off-wall
if (currentWallId && currentWallId !== original.parentId) { if (currentWallId && currentWallId !== original.parentId) {
@@ -354,6 +365,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
} }
const onCancel = () => { const onCancel = () => {
useLiveTransforms.getState().clear(movingWindowNode.id)
if (isNew) { if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id) useScene.getState().deleteNode(movingWindowNode.id)
if (currentWallId) markWallDirty(currentWallId) if (currentWallId) markWallDirty(currentWallId)
@@ -401,6 +413,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
if (original.parentId) markWallDirty(original.parentId) if (original.parentId) markWallDirty(original.parentId)
} }
} }
useLiveTransforms.getState().clear(movingWindowNode.id)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter) emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove) emitter.off('wall:move', onWallMove)