Add roof drag controls to the 2D floorplan
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user