Refine wall move previews and expose auto-slab planning
This commit is contained in:
@@ -45,6 +45,8 @@ export { getRenderableSlabPolygon } from './lib/slab-polygon'
|
||||
export {
|
||||
detectSpacesForLevel,
|
||||
initSpaceDetectionSync,
|
||||
planAutoSlabsForLevel,
|
||||
type AutoSlabSyncPlan,
|
||||
type Space,
|
||||
wallTouchesOthers,
|
||||
} from './lib/space-detection'
|
||||
|
||||
@@ -41,6 +41,12 @@ type DetectedRoom = {
|
||||
bbox: ReturnType<typeof bboxOf>
|
||||
}
|
||||
|
||||
export type AutoSlabSyncPlan = {
|
||||
create: SlabNodeType[]
|
||||
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
|
||||
delete: Array<SlabNodeType['id']>
|
||||
}
|
||||
|
||||
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
|
||||
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
|
||||
const ROOM_CURVE_TOLERANCE = 0.04
|
||||
@@ -488,12 +494,10 @@ function buildSpace(levelId: string, polygon: Point2D[]): Space {
|
||||
}
|
||||
}
|
||||
|
||||
function syncAutoSlabsForLevel(
|
||||
levelId: string,
|
||||
export function planAutoSlabsForLevel(
|
||||
roomPolygons: Point2D[][],
|
||||
existingSlabs: SlabNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
): AutoSlabSyncPlan {
|
||||
const manualSlabs = existingSlabs.filter((slab) => !slab.autoFromWalls)
|
||||
const manualSignatures = new Set(
|
||||
manualSlabs.map((slab) => polygonSignature(slab.polygon.map(pointFromTuple))),
|
||||
@@ -618,16 +622,31 @@ function syncAutoSlabsForLevel(
|
||||
)
|
||||
}
|
||||
|
||||
if (slabsToDelete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(slabsToDelete)
|
||||
return {
|
||||
create: slabsToCreate,
|
||||
update: slabsToUpdate,
|
||||
delete: slabsToDelete,
|
||||
}
|
||||
}
|
||||
|
||||
if (slabsToUpdate.length > 0) {
|
||||
sceneStore.getState().updateNodes(slabsToUpdate)
|
||||
function syncAutoSlabsForLevel(
|
||||
levelId: string,
|
||||
roomPolygons: Point2D[][],
|
||||
existingSlabs: SlabNodeType[],
|
||||
sceneStore: any,
|
||||
) {
|
||||
const plan = planAutoSlabsForLevel(roomPolygons, existingSlabs)
|
||||
|
||||
if (plan.delete.length > 0) {
|
||||
sceneStore.getState().deleteNodes(plan.delete)
|
||||
}
|
||||
|
||||
if (slabsToCreate.length > 0) {
|
||||
sceneStore.getState().createNodes(slabsToCreate.map((node) => ({ node, parentId: levelId })))
|
||||
if (plan.update.length > 0) {
|
||||
sceneStore.getState().updateNodes(plan.update)
|
||||
}
|
||||
|
||||
if (plan.create.length > 0) {
|
||||
sceneStore.getState().createNodes(plan.create.map((node) => ({ node, parentId: levelId })))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export type FloorplanActionMenuEntry = {
|
||||
onDelete: FloorplanActionMenuHandler
|
||||
onMove: FloorplanActionMenuHandler
|
||||
onAddHole?: FloorplanActionMenuHandler
|
||||
onCurve?: FloorplanActionMenuHandler
|
||||
onDuplicate?: FloorplanActionMenuHandler
|
||||
}
|
||||
|
||||
@@ -81,6 +82,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
||||
>
|
||||
<NodeActionMenu
|
||||
onAddHole={entry.onAddHole}
|
||||
onCurve={entry.onCurve}
|
||||
onDelete={entry.onDelete}
|
||||
onDuplicate={entry.onDuplicate}
|
||||
onMove={entry.onMove}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
WallNode as WallNodeSchema,
|
||||
type WallNode,
|
||||
WindowNode,
|
||||
ZoneNode as ZoneNodeSchema,
|
||||
@@ -7554,6 +7555,7 @@ export function FloorplanPanel() {
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
|
||||
const setMovingNode = useEditor((state) => state.setMovingNode)
|
||||
const setCurvingWall = useEditor((state) => state.setCurvingWall)
|
||||
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||
@@ -9324,6 +9326,7 @@ export function FloorplanPanel() {
|
||||
selectedWallEntry,
|
||||
wallCurveDraft,
|
||||
])
|
||||
const canCurveSelectedWall = wallCurveHandles.length > 0
|
||||
const slabVertexHandles = useMemo(() => {
|
||||
if (!shouldShowSlabBoundaryHandles) {
|
||||
return []
|
||||
@@ -14066,6 +14069,57 @@ export function FloorplanPanel() {
|
||||
},
|
||||
[selectedWallEntry, setMovingNode, setSelection],
|
||||
)
|
||||
const duplicateSelectedWall = useCallback(() => {
|
||||
const wall = selectedWallEntry?.wall
|
||||
if (!wall?.parentId) {
|
||||
return
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
const cloned = structuredClone(wall) as Record<string, unknown>
|
||||
delete cloned.id
|
||||
cloned.children = []
|
||||
cloned.metadata = {
|
||||
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
|
||||
isNew: true,
|
||||
}
|
||||
|
||||
const temporal = useScene.temporal.getState()
|
||||
temporal.pause()
|
||||
try {
|
||||
const duplicate = WallNodeSchema.parse(cloned)
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
setMovingNode(duplicate)
|
||||
setSelection({ selectedIds: [] })
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate wall', error)
|
||||
} finally {
|
||||
temporal.resume()
|
||||
}
|
||||
}, [selectedWallEntry, setMovingNode, setSelection])
|
||||
const handleSelectedWallDuplicate = useCallback(
|
||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
duplicateSelectedWall()
|
||||
},
|
||||
[duplicateSelectedWall],
|
||||
)
|
||||
const handleSelectedWallCurve = useCallback(
|
||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
|
||||
const wall = selectedWallEntry?.wall
|
||||
if (!(wall && canCurveSelectedWall)) {
|
||||
return
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setCurvingWall(wall)
|
||||
setSelection({ selectedIds: [] })
|
||||
},
|
||||
[canCurveSelectedWall, selectedWallEntry, setCurvingWall, setSelection],
|
||||
)
|
||||
const handleSelectedWallDelete = useCallback(
|
||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
@@ -16105,9 +16159,17 @@ export function FloorplanPanel() {
|
||||
site,
|
||||
])
|
||||
const hasDuplicatableFloorplanSelection = Boolean(
|
||||
selectedItemEntry || selectedOpeningEntry || selectedStairEntry || selectedRoofEntry,
|
||||
selectedItemEntry ||
|
||||
selectedOpeningEntry ||
|
||||
selectedStairEntry ||
|
||||
selectedRoofEntry ||
|
||||
selectedWallEntry,
|
||||
)
|
||||
const handleDuplicateFloorplanSelection = useCallback(() => {
|
||||
if (selectedWallEntry) {
|
||||
duplicateSelectedWall()
|
||||
return
|
||||
}
|
||||
if (selectedOpeningEntry) {
|
||||
duplicateSelectedOpening()
|
||||
return
|
||||
@@ -16124,6 +16186,7 @@ export function FloorplanPanel() {
|
||||
duplicateSelectedRoof()
|
||||
}
|
||||
}, [
|
||||
duplicateSelectedWall,
|
||||
duplicateSelectedItem,
|
||||
duplicateSelectedOpening,
|
||||
duplicateSelectedRoof,
|
||||
@@ -16132,6 +16195,7 @@ export function FloorplanPanel() {
|
||||
selectedOpeningEntry,
|
||||
selectedRoofEntry,
|
||||
selectedStairEntry,
|
||||
selectedWallEntry,
|
||||
])
|
||||
const activeDraftAnchorPoint =
|
||||
referenceScaleDraft?.start ??
|
||||
@@ -16258,7 +16322,9 @@ export function FloorplanPanel() {
|
||||
}}
|
||||
wall={{
|
||||
position: selectedWallActionMenuPosition,
|
||||
onCurve: canCurveSelectedWall ? handleSelectedWallCurve : undefined,
|
||||
onDelete: handleSelectedWallDelete,
|
||||
onDuplicate: handleSelectedWallDuplicate,
|
||||
onMove: handleSelectedWallMove,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -9,13 +9,12 @@ import {
|
||||
type GridEvent,
|
||||
getMaterialPresetByRef,
|
||||
getPerpendicularWallMoveAxis,
|
||||
getRenderableSlabPolygon,
|
||||
pauseSceneHistory,
|
||||
planAutoSlabsForLevel,
|
||||
planWallMoveJunctions,
|
||||
resolveMaterial,
|
||||
resumeSceneHistory,
|
||||
type SlabNode,
|
||||
SlabNode as SlabSchema,
|
||||
useScene,
|
||||
type WallMoveAxis,
|
||||
type WallMoveBridgePlan,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, Float32BufferAttribute, ShapeUtils, Vector2 } from 'three'
|
||||
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
@@ -33,9 +32,6 @@ import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting'
|
||||
|
||||
const AUTO_SLAB_PREVIEW_ELEVATION = 0.05
|
||||
const AUTO_SLAB_PREVIEW_Y = AUTO_SLAB_PREVIEW_ELEVATION + 0.025
|
||||
|
||||
function rotateVector([x, z]: [number, number], angle: number): [number, number] {
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
@@ -70,11 +66,6 @@ type GhostWallPreview = {
|
||||
height: number
|
||||
}
|
||||
|
||||
type GhostSlabPreview = {
|
||||
id: string
|
||||
polygon: Array<[number, number]>
|
||||
}
|
||||
|
||||
function getLinkedWallSnapshots(args: {
|
||||
wallId: WallNode['id']
|
||||
wallParentId: string | null
|
||||
@@ -222,27 +213,6 @@ function getWallGhostColor(wall: WallNode) {
|
||||
return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color
|
||||
}
|
||||
|
||||
function getMinRotatedKey(values: string[]) {
|
||||
if (values.length === 0) return ''
|
||||
|
||||
let best = ''
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = [...values.slice(index), ...values.slice(0, index)].join('|')
|
||||
if (!best || value < best) {
|
||||
best = value
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
function getPolygonPreviewKey(polygon: Array<[number, number]>) {
|
||||
const values = polygon.map(([x, z]) => `${x.toFixed(3)}:${z.toFixed(3)}`)
|
||||
const forward = getMinRotatedKey(values)
|
||||
const reversed = getMinRotatedKey([...values].reverse())
|
||||
return forward < reversed ? forward : reversed
|
||||
}
|
||||
|
||||
function getWallsAfterUpdates(
|
||||
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
|
||||
@@ -257,6 +227,32 @@ function getWallsAfterUpdates(
|
||||
})
|
||||
}
|
||||
|
||||
function cloneSlabSnapshot(slab: SlabNode): SlabNode {
|
||||
return {
|
||||
...slab,
|
||||
polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]),
|
||||
holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
||||
holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })),
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
||||
return Object.values(nodes).filter(
|
||||
(entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId,
|
||||
)
|
||||
}
|
||||
|
||||
function getLevelAutoSlabs(
|
||||
levelId: string,
|
||||
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||
) {
|
||||
return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls)
|
||||
}
|
||||
|
||||
function getLevelAutoSlabSnapshots(levelId: string) {
|
||||
return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot)
|
||||
}
|
||||
|
||||
function buildBridgeWallCreates(args: {
|
||||
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
|
||||
nextStart: [number, number]
|
||||
@@ -343,68 +339,6 @@ function buildBridgeWallPreviews(args: {
|
||||
return previews
|
||||
}
|
||||
|
||||
function buildAutoSlabGhostPreviews(args: {
|
||||
levelId: string
|
||||
walls: WallNode[]
|
||||
existingSlabs: SlabNode[]
|
||||
}): GhostSlabPreview[] {
|
||||
const { levelId, walls, existingSlabs } = args
|
||||
const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId)
|
||||
|
||||
if (levelWalls.length < 3) {
|
||||
return []
|
||||
}
|
||||
|
||||
const manualSlabKeys = new Set(
|
||||
existingSlabs
|
||||
.filter((slab) => !slab.autoFromWalls)
|
||||
.map((slab) => getPolygonPreviewKey(slab.polygon)),
|
||||
)
|
||||
const existingAutoKeys = new Set(
|
||||
existingSlabs
|
||||
.filter((slab) => slab.autoFromWalls)
|
||||
.map((slab) => getPolygonPreviewKey(getRenderableSlabPolygon(slab))),
|
||||
)
|
||||
const seenPreviewKeys = new Set<string>()
|
||||
const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls)
|
||||
const previews: GhostSlabPreview[] = []
|
||||
|
||||
for (let index = 0; index < roomPolygons.length; index += 1) {
|
||||
const polygon = roomPolygons[index]
|
||||
if (!polygon || polygon.length < 3) continue
|
||||
|
||||
const rawPolygon = polygon.map((point) => [point.x, point.y] as [number, number])
|
||||
if (manualSlabKeys.has(getPolygonPreviewKey(rawPolygon))) {
|
||||
continue
|
||||
}
|
||||
|
||||
const previewSlab = SlabSchema.parse({
|
||||
polygon: rawPolygon,
|
||||
holes: [],
|
||||
elevation: AUTO_SLAB_PREVIEW_ELEVATION,
|
||||
autoFromWalls: true,
|
||||
})
|
||||
const renderablePolygon = getRenderableSlabPolygon(previewSlab)
|
||||
const previewKey = getPolygonPreviewKey(renderablePolygon)
|
||||
|
||||
if (
|
||||
renderablePolygon.length < 3 ||
|
||||
existingAutoKeys.has(previewKey) ||
|
||||
seenPreviewKeys.has(previewKey)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenPreviewKeys.add(previewKey)
|
||||
previews.push({
|
||||
id: `auto-slab:${index}:${previewKey}`,
|
||||
polygon: renderablePolygon,
|
||||
})
|
||||
}
|
||||
|
||||
return previews
|
||||
}
|
||||
|
||||
function setPreviewGeometryAttributes(
|
||||
geometry: BufferGeometry,
|
||||
positions: number[],
|
||||
@@ -430,48 +364,6 @@ function createWallPreviewGeometry(length: number, height: number) {
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createSlabPreviewGeometry(polygon: Array<[number, number]>) {
|
||||
if (polygon.length < 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
const contour = polygon.map(([x, z]) => new Vector2(x, z))
|
||||
const triangles = ShapeUtils.triangulateShape(contour, [])
|
||||
if (triangles.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let minZ = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let maxZ = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const [x, z] of polygon) {
|
||||
minX = Math.min(minX, x)
|
||||
minZ = Math.min(minZ, z)
|
||||
maxX = Math.max(maxX, x)
|
||||
maxZ = Math.max(maxZ, z)
|
||||
}
|
||||
|
||||
const width = Math.max(maxX - minX, 0.001)
|
||||
const depth = Math.max(maxZ - minZ, 0.001)
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
|
||||
for (const [x, z] of polygon) {
|
||||
positions.push(x, 0, z)
|
||||
normals.push(0, 1, 0)
|
||||
uvs.push((x - minX) / width, (z - minZ) / depth)
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
setPreviewGeometryAttributes(geometry, positions, normals, uvs)
|
||||
geometry.setIndex(triangles.flat())
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) {
|
||||
const dx = preview.end[0] - preview.start[0]
|
||||
const dz = preview.end[1] - preview.start[1]
|
||||
@@ -504,35 +396,6 @@ function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) {
|
||||
)
|
||||
}
|
||||
|
||||
function GhostSlabPreviewMesh({ preview }: { preview: GhostSlabPreview }) {
|
||||
const geometry = useMemo(() => createSlabPreviewGeometry(preview.polygon), [preview.polygon])
|
||||
|
||||
useEffect(() => () => geometry?.dispose(), [geometry])
|
||||
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[0, AUTO_SLAB_PREVIEW_Y, 0]}
|
||||
renderOrder={1}
|
||||
>
|
||||
<primitive attach="geometry" object={geometry} />
|
||||
<meshBasicMaterial
|
||||
color="#38bdf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.2}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const meta =
|
||||
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
||||
@@ -564,6 +427,9 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
originalEnd: node.end,
|
||||
}),
|
||||
)
|
||||
const originalAutoSlabsRef = useRef<SlabNode[]>(
|
||||
node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [],
|
||||
)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const nodeIdRef = useRef(node.id)
|
||||
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
|
||||
@@ -576,7 +442,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
return [centerX, 0, centerZ]
|
||||
})
|
||||
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
|
||||
const [ghostSlabPreviews, setGhostSlabPreviews] = useState<GhostSlabPreview[]>([])
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
@@ -588,9 +453,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const originalEnd = originalEndRef.current
|
||||
const originalCenter = originalCenterRef.current
|
||||
const originalHalfVector = originalHalfVectorRef.current
|
||||
const levelId = node.parentId ?? null
|
||||
const originalAutoSlabs = originalAutoSlabsRef.current
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
let shouldRestoreOnCleanup = true
|
||||
|
||||
const applyNodePreview = (
|
||||
updates: Array<{ id: WallNode['id']; start: [number, number]; end: [number, number] }>,
|
||||
@@ -606,6 +473,72 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const applyLiveAutoSlabPreview = (walls: WallNode[]) => {
|
||||
if (!levelId) {
|
||||
return
|
||||
}
|
||||
|
||||
const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId)
|
||||
const sceneState = useScene.getState()
|
||||
const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls)
|
||||
const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes))
|
||||
|
||||
if (
|
||||
slabPlan.create.length === 0 &&
|
||||
slabPlan.update.length === 0 &&
|
||||
slabPlan.delete.length === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
sceneState.applyNodeChanges({
|
||||
update: slabPlan.update.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: entry.data,
|
||||
})),
|
||||
create: slabPlan.create.map((slab) => ({
|
||||
node: slab,
|
||||
parentId: levelId as AnyNodeId,
|
||||
})),
|
||||
delete: slabPlan.delete.map((id) => id as AnyNodeId),
|
||||
})
|
||||
}
|
||||
|
||||
const restoreAutoSlabPreview = () => {
|
||||
if (!levelId) {
|
||||
return
|
||||
}
|
||||
|
||||
const sceneState = useScene.getState()
|
||||
const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id))
|
||||
const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes)
|
||||
const update = originalAutoSlabs
|
||||
.filter((slab) => sceneState.nodes[slab.id as AnyNodeId])
|
||||
.map((slab) => ({
|
||||
id: slab.id as AnyNodeId,
|
||||
data: cloneSlabSnapshot(slab),
|
||||
}))
|
||||
const create = originalAutoSlabs
|
||||
.filter((slab) => !sceneState.nodes[slab.id as AnyNodeId])
|
||||
.map((slab) => ({
|
||||
node: cloneSlabSnapshot(slab),
|
||||
parentId: levelId as AnyNodeId,
|
||||
}))
|
||||
const deleteIds = currentAutoSlabs
|
||||
.filter((slab) => !originalIds.has(slab.id))
|
||||
.map((slab) => slab.id as AnyNodeId)
|
||||
|
||||
if (update.length === 0 && create.length === 0 && deleteIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
sceneState.applyNodeChanges({
|
||||
update,
|
||||
create,
|
||||
delete: deleteIds,
|
||||
})
|
||||
}
|
||||
|
||||
const buildWallFromCenter = (center: [number, number]) => {
|
||||
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
|
||||
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
|
||||
@@ -672,31 +605,18 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
})
|
||||
const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost)
|
||||
const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall)
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
const levelId = node.parentId ?? null
|
||||
setGhostWallPreviews(nextGhostWalls)
|
||||
setGhostSlabPreviews(
|
||||
levelId
|
||||
? buildAutoSlabGhostPreviews({
|
||||
levelId,
|
||||
walls: [...previewSceneWalls, ...virtualBridgeWalls],
|
||||
existingSlabs: Object.values(sceneNodes).filter(
|
||||
(entry): entry is SlabNode =>
|
||||
entry?.type === 'slab' && (entry.parentId ?? null) === levelId,
|
||||
),
|
||||
})
|
||||
: [],
|
||||
)
|
||||
applyNodePreview(previewUpdates)
|
||||
applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls])
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
setGhostWallPreviews([])
|
||||
setGhostSlabPreviews([])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
restoreAutoSlabPreview()
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
@@ -738,16 +658,16 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
|
||||
wasCommitted = true
|
||||
shouldRestoreOnCleanup = false
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
setGhostWallPreviews([])
|
||||
setGhostSlabPreviews([])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
restoreAutoSlabPreview()
|
||||
|
||||
resumeSceneHistory(useScene)
|
||||
const commitPlan = getMovePlan(preview.start, preview.end)
|
||||
@@ -848,6 +768,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
shouldRestoreOnCleanup = false
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
@@ -862,7 +783,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
if (shouldRestoreOnCleanup) {
|
||||
restoreOriginal()
|
||||
}
|
||||
shiftPressedRef.current = false
|
||||
@@ -873,14 +794,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [exitMoveMode, isNew, node.metadata])
|
||||
}, [exitMoveMode, isNew, node.metadata, node.parentId])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
{ghostSlabPreviews.map((preview) => (
|
||||
<GhostSlabPreviewMesh key={preview.id} preview={preview} />
|
||||
))}
|
||||
{ghostWallPreviews.map((preview) => (
|
||||
<GhostWallPreviewMesh key={preview.id} preview={preview} />
|
||||
))}
|
||||
|
||||
@@ -4,12 +4,19 @@ import {
|
||||
resolveMaterial,
|
||||
useRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute } from 'three'
|
||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
function createEmptyGeometry() {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
const gridScale = 5
|
||||
const gridX = positionWorld.x.mul(gridScale).fract()
|
||||
const gridY = positionWorld.z.mul(gridScale).fract()
|
||||
@@ -51,10 +58,20 @@ function getCeilingMaterials(color = '#999999') {
|
||||
|
||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
|
||||
useRegistry(node.id, 'ceiling', ref)
|
||||
const handlers = useNodeEvents(node, 'ceiling')
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
placeholderGeometry.dispose()
|
||||
gridPlaceholderGeometry.dispose()
|
||||
},
|
||||
[gridPlaceholderGeometry, placeholderGeometry],
|
||||
)
|
||||
|
||||
const materials = useMemo(() => {
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||
@@ -69,17 +86,15 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
])
|
||||
|
||||
return (
|
||||
<mesh material={materials.bottomMaterial} ref={ref}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
<mesh geometry={placeholderGeometry} material={materials.bottomMaterial} ref={ref}>
|
||||
<mesh
|
||||
geometry={gridPlaceholderGeometry}
|
||||
material={materials.topMaterial}
|
||||
name="ceiling-grid"
|
||||
{...handlers}
|
||||
scale={0}
|
||||
visible={false}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
/>
|
||||
{node.children.map((childId) => (
|
||||
<NodeRenderer key={childId} nodeId={childId} />
|
||||
))}
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
|
||||
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
|
||||
|
||||
function createEmptyGeometry() {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
function getSlabMaterial(
|
||||
cacheKey: string,
|
||||
params: { material?: SlabNode['material']; materialPreset?: string },
|
||||
@@ -47,11 +53,14 @@ function getSlabMaterial(
|
||||
|
||||
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
|
||||
useRegistry(node.id, 'slab', ref)
|
||||
|
||||
const handlers = useNodeEvents(node, 'slab')
|
||||
|
||||
useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry])
|
||||
|
||||
const material = useMemo(() => {
|
||||
const resolvedMaterial = node.material
|
||||
const resolvedMaterialPreset = node.materialPreset
|
||||
@@ -75,13 +84,12 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={placeholderGeometry}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
{...handlers}
|
||||
material={material}
|
||||
visible={node.visible}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
|
||||
const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh
|
||||
if (gridMesh) {
|
||||
gridMesh.geometry.dispose()
|
||||
gridMesh.geometry = newGeo
|
||||
gridMesh.geometry = newGeo.clone()
|
||||
}
|
||||
|
||||
// Position at the ceiling height
|
||||
|
||||
Reference in New Issue
Block a user