Add wall drag previews and fix move arrow handles

This commit is contained in:
sudhir
2026-05-12 11:00:27 +05:30
parent 0dcc72a57a
commit f3444d4275
5 changed files with 623 additions and 104 deletions
@@ -434,7 +434,11 @@ export function FloatingActionMenu() {
? handleDuplicate ? handleDuplicate
: undefined : undefined
} }
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined} onMove={
node && node.type !== 'wall' && !DELETE_ONLY_TYPES.includes(node.type)
? handleMove
: undefined
}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()}
/> />
@@ -68,6 +68,7 @@ import { SiteEdgeLabels } from './site-edge-labels'
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay' import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator' import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label' import { WallMeasurementLabel } from './wall-measurement-label'
import { WallMoveSideHandles } from './wall-move-side-handles'
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
const DELETE_CURSOR_BADGE_COLOR = '#ef4444' const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
@@ -587,6 +588,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
<> <>
{!isFirstPersonMode && <SelectionManager />} {!isFirstPersonMode && <SelectionManager />}
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />} {!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />} {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />} {!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />} {!isFirstPersonMode && <WallMeasurementLabel />}
@@ -0,0 +1,259 @@
'use client'
import {
type AnyNodeId,
DEFAULT_WALL_HEIGHT,
getWallThickness,
sceneRegistry,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react'
import {
BufferGeometry,
ConeGeometry,
CylinderGeometry,
DoubleSide,
Float32BufferAttribute,
type Object3D,
} from 'three'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
const HANDLE_OFFSET = 0.42
const HANDLE_MIN_OFFSET = 0.5
const HANDLE_MIN_HEIGHT = 0.62
const HANDLE_TOP_INSET = 0.08
const ARROW_COLOR = '#8381ed'
const ARROW_HOVER_COLOR = '#a5b4fc'
type WallMoveHandle = {
direction: [number, number]
key: string
position: [number, number, number]
rotationY: number
}
function createArrowHandleGeometry() {
const shaft = new CylinderGeometry(0.04, 0.064, 0.25, 36)
const head = new ConeGeometry(0.13, 0.3, 48)
shaft.rotateZ(-Math.PI / 2)
shaft.translate(-0.085, 0, 0)
head.rotateZ(-Math.PI / 2)
head.translate(0.17, 0, 0)
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
for (const sourceGeometry of [shaft, head]) {
const geometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry
const position = geometry.getAttribute('position')
const normal = geometry.getAttribute('normal')
const uv = geometry.getAttribute('uv')
for (let index = 0; index < position.count; index += 1) {
positions.push(position.getX(index), position.getY(index), position.getZ(index))
normals.push(normal.getX(index), normal.getY(index), normal.getZ(index))
uvs.push(uv?.getX(index) ?? 0, uv?.getY(index) ?? 0)
}
if (geometry !== sourceGeometry) {
geometry.dispose()
}
sourceGeometry.dispose()
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
return geometry
}
export function WallMoveSideHandles() {
const selectedIds = useViewer((state) => state.selection.selectedIds)
const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const wall = useScene((state) => {
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
return node?.type === 'wall' ? node : null
})
const shouldRender =
Boolean(wall) &&
!isFloorplanHovered &&
mode !== 'delete' &&
!movingNode &&
!movingWallEndpoint &&
!movingFenceEndpoint &&
!curvingWall &&
!curvingFence
if (!shouldRender || !wall) return null
return <WallMoveSideHandlesForWall wall={wall} />
}
function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null,
)
useEffect(() => {
let frameId = 0
const resolveLevelObject = () => {
const nextLevelObject = wall.parentId
? (sceneRegistry.nodes.get(wall.parentId) ?? null)
: null
setLevelObject((currentLevelObject) => {
if (currentLevelObject === nextLevelObject) {
return currentLevelObject
}
return nextLevelObject
})
if (!nextLevelObject) {
frameId = window.requestAnimationFrame(resolveLevelObject)
}
}
resolveLevelObject()
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [wall.parentId])
const handles = useMemo(() => getWallMoveHandles(wall), [wall])
if (!levelObject || handles.length === 0) return null
return createPortal(
<group>
{handles.map((handle) => (
<WallMoveArrowHandle handle={handle} key={handle.key} wall={wall} />
))}
</group>,
levelObject,
)
}
function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMoveHandle }) {
const [isHovered, setIsHovered] = useState(false)
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
useEffect(() => {
return () => {
if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
document.body.style.cursor = ''
}
}
}, [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
const activateWallMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
event.nativeEvent.preventDefault()
document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(wall)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setMovingFenceEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null)
useViewer.getState().setSelection({ selectedIds: [] })
}
return (
<group
position={handle.position}
rotation={[0, handle.rotationY, 0]}
scale={isHovered ? 1.12 : 1}
>
<mesh
frustumCulled={false}
onPointerDown={activateWallMove}
onPointerEnter={(event) => {
event.stopPropagation()
setIsHovered(true)
document.body.style.cursor = 'grab'
}}
onPointerLeave={(event) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === 'grab') {
document.body.style.cursor = ''
}
}}
renderOrder={1002}
>
<primitive attach="geometry" object={arrowGeometry} />
<meshBasicMaterial
color={isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR}
depthTest
depthWrite
opacity={1}
side={DoubleSide}
transparent={false}
/>
</mesh>
</group>
)
}
function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1]
const length = Math.hypot(dx, dz)
if (length < 1e-6) {
return []
}
const normal: [number, number] = [-dz / length, dx / length]
const midpoint: [number, number] = [
(wall.start[0] + wall.end[0]) / 2,
(wall.start[1] + wall.end[1]) / 2,
]
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
return [
buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
]
}
function buildWallMoveHandle(
key: string,
midpoint: [number, number],
direction: [number, number],
offset: number,
height: number,
): WallMoveHandle {
return {
direction,
key,
position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset],
rotationY: Math.atan2(-direction[1], direction[0]),
}
}
@@ -3,12 +3,19 @@
import { import {
type AnyNodeId, type AnyNodeId,
constrainWallMoveDeltaToAxis, constrainWallMoveDeltaToAxis,
DEFAULT_WALL_HEIGHT,
detectSpacesForLevel,
emitter, emitter,
type GridEvent, type GridEvent,
getMaterialPresetByRef,
getPerpendicularWallMoveAxis, getPerpendicularWallMoveAxis,
getRenderableSlabPolygon,
pauseSceneHistory, pauseSceneHistory,
planWallMoveJunctions, planWallMoveJunctions,
resolveMaterial,
resumeSceneHistory, resumeSceneHistory,
type SlabNode,
SlabNode as SlabSchema,
useScene, useScene,
type WallMoveAxis, type WallMoveAxis,
type WallMoveBridgePlan, type WallMoveBridgePlan,
@@ -17,13 +24,18 @@ import {
WallNode as WallSchema, WallNode as WallSchema,
} from '@pascal-app/core' } 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, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Float32BufferAttribute, ShapeUtils, Vector2 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants'
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 { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting' 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] { function rotateVector([x, z]: [number, number], angle: number): [number, number] {
const cos = Math.cos(angle) const cos = Math.cos(angle)
const sin = Math.sin(angle) const sin = Math.sin(angle)
@@ -50,6 +62,19 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
type LinkedWallSnapshot = WallNode type LinkedWallSnapshot = WallNode
type GhostWallPreview = {
id: string
start: [number, number]
end: [number, number]
color: string
height: number
}
type GhostSlabPreview = {
id: string
polygon: Array<[number, number]>
}
function getLinkedWallSnapshots(args: { function getLinkedWallSnapshots(args: {
wallId: WallNode['id'] wallId: WallNode['id']
wallParentId: string | null wallParentId: string | null
@@ -172,7 +197,11 @@ function getPlannedLinkedWallUpdates(
) )
} }
function wallSegmentExists(walls: WallNode[], start: [number, number], end: [number, number]) { function wallSegmentExists(
walls: Array<Pick<WallNode, 'start' | 'end'>>,
start: [number, number],
end: [number, number],
) {
return walls.some( return walls.some(
(wall) => (wall) =>
(samePoint(wall.start, start) && samePoint(wall.end, end)) || (samePoint(wall.start, start) && samePoint(wall.end, end)) ||
@@ -180,6 +209,40 @@ function wallSegmentExists(walls: WallNode[], start: [number, number], end: [num
) )
} }
function getWallGhostColor(wall: WallNode) {
const presetColor =
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color
if (presetColor) {
return presetColor
}
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( function getWallsAfterUpdates(
nodes: ReturnType<typeof useScene.getState>['nodes'], nodes: ReturnType<typeof useScene.getState>['nodes'],
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>, updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
@@ -236,6 +299,240 @@ function buildBridgeWallCreates(args: {
return creates return creates
} }
function buildBridgeWallPreviews(args: {
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
nextStart: [number, number]
nextEnd: [number, number]
existingWalls: WallNode[]
}): Array<{ ghost: GhostWallPreview; wall: WallNode }> {
const { bridgePlans, nextStart, nextEnd, existingWalls } = args
const wallsForDuplicateCheck: Array<Pick<WallNode, 'start' | 'end'>> = [...existingWalls]
const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = []
for (const plan of bridgePlans) {
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
continue
}
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
continue
}
const { id: _id, children: _children, ...sourceWall } = plan.wall
const wall = WallSchema.parse({
...sourceWall,
name: 'Wall Preview',
start: plan.originalPoint,
end: nextPoint,
children: [],
metadata: stripWallIsNewMetadata(plan.wall.metadata),
})
const ghost = {
id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`,
start: [...plan.originalPoint] as [number, number],
end: [...nextPoint] as [number, number],
color: getWallGhostColor(plan.wall),
height: plan.wall.height ?? DEFAULT_WALL_HEIGHT,
}
previews.push({ ghost, wall })
wallsForDuplicateCheck.push(wall)
}
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[],
normals: number[],
uvs: number[],
) {
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
}
function createWallPreviewGeometry(length: number, height: number) {
const geometry = new BufferGeometry()
setPreviewGeometryAttributes(
geometry,
[0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0],
[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
[0, 0, 1, 0, 1, 1, 0, 1],
)
geometry.setIndex([0, 1, 2, 0, 2, 3])
geometry.computeBoundingSphere()
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]
const length = Math.hypot(dx, dz)
const angle = -Math.atan2(dz, dx)
const geometry = useMemo(() => {
return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height)
}, [length, preview.height])
useEffect(() => () => geometry?.dispose(), [geometry])
if (!geometry) {
return null
}
return (
<group position={[preview.start[0], 0.02, preview.start[1]]} rotation={[0, angle, 0]}>
<mesh frustumCulled={false} layers={EDITOR_LAYER} renderOrder={2}>
<primitive attach="geometry" object={geometry} />
<meshBasicMaterial
color={preview.color}
depthTest={false}
depthWrite={false}
opacity={0.32}
side={DoubleSide}
transparent
/>
</mesh>
</group>
)
}
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 }) => { export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const meta = const meta =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
@@ -278,6 +575,8 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const centerZ = (node.start[1] + node.end[1]) / 2 const centerZ = (node.start[1] + node.end[1]) / 2
return [centerX, 0, centerZ] return [centerX, 0, centerZ]
}) })
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
const [ghostSlabPreviews, setGhostSlabPreviews] = useState<GhostSlabPreview[]>([])
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
@@ -323,8 +622,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
nextEnd, nextEnd,
) )
const getLinkedPreviewUpdates = (nextStart: [number, number], nextEnd: [number, number]) => { const getLinkedPreviewUpdates = (
const plan = getMovePlan(nextStart, nextEnd) plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
nextStart: [number, number],
nextEnd: [number, number],
) => {
const movedUpdates = getPlannedLinkedWallUpdates( const movedUpdates = getPlannedLinkedWallUpdates(
plan, plan,
originalStart, originalStart,
@@ -344,13 +646,53 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ 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 previewPlan = getMovePlan(nextStart, nextEnd)
const previewUpdates = [
{ id: nodeId, start: nextStart, end: nextEnd }, { id: nodeId, start: nextStart, end: nextEnd },
...getLinkedPreviewUpdates(nextStart, nextEnd), ...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd),
]
const previewCollapsedWallIds = new Set([
...previewUpdates
.filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end))
.map((entry) => entry.id as AnyNodeId),
...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
]) ])
const previewSceneWalls = getWallsAfterUpdates(
useScene.getState().nodes,
previewUpdates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId))
const bridgePreviews = buildBridgeWallPreviews({
bridgePlans: previewPlan.bridgePlans,
nextStart,
nextEnd,
existingWalls: previewSceneWalls,
})
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)
} }
const restoreOriginal = () => { const restoreOriginal = () => {
setGhostWallPreviews([])
setGhostSlabPreviews([])
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd }, { id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
@@ -400,6 +742,8 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
// Restore original baseline while paused so the next resume+update // Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original). // registers as a single tracked change (undo reverts to original).
setGhostWallPreviews([])
setGhostSlabPreviews([])
applyNodePreview([ applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd }, { id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current, ...linkedOriginalsRef.current,
@@ -534,6 +878,12 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
return ( return (
<group> <group>
<CursorSphere position={cursorLocalPos} showTooltip={false} /> <CursorSphere position={cursorLocalPos} showTooltip={false} />
{ghostSlabPreviews.map((preview) => (
<GhostSlabPreviewMesh key={preview.id} preview={preview} />
))}
{ghostWallPreviews.map((preview) => (
<GhostWallPreviewMesh key={preview.id} preview={preview} />
))}
</group> </group>
) )
} }
@@ -4,60 +4,28 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getClampedWallCurveOffset, getClampedWallCurveOffset,
getEffectiveWallSurfaceMaterial,
getMaxWallCurveOffset, getMaxWallCurveOffset,
getWallCurveLength, getWallCurveLength,
getWallSurfaceMaterialSignature,
type MaterialSchema,
normalizeWallCurveOffset, normalizeWallCurveOffset,
useScene, useScene,
type WallNode, type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react' import { Move, Spline } from 'lucide-react'
import { useCallback, useMemo } from 'react' import { useCallback } from 'react'
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 { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide | null,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === null || targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === null || targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function WallPanel() { export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -88,35 +56,6 @@ export function WallPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const effectiveInteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
[node],
)
const effectiveExteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
[node],
)
const surfaceMaterialsMatch = useMemo(
() =>
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
[effectiveExteriorMaterial, effectiveInteriorMaterial],
)
const materialTargetSide =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
? selectedMaterialTarget.role
: null
const materialPickerValue =
materialTargetSide === 'interior'
? effectiveInteriorMaterial
: materialTargetSide === 'exterior'
? effectiveExteriorMaterial
: surfaceMaterialsMatch
? effectiveInteriorMaterial
: {}
const handleUpdateLength = useCallback( const handleUpdateLength = useCallback(
(newLength: number) => { (newLength: number) => {
if (!node || newLength <= 0) return if (!node || newLength <= 0) return
@@ -140,24 +79,6 @@ export function WallPanel() {
[node, handleUpdate], [node, handleUpdate],
) )
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetSide)) return
handleUpdate(
buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset),
)
},
[handleUpdate, materialTargetSide, node],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetSide)) return
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
},
[handleUpdate, materialTargetSide, node],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -239,23 +160,6 @@ export function WallPanel() {
)} )}
</PanelSection> </PanelSection>
<PanelSection title="Material">
{materialTargetSide ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the wall face you want to edit. Materials now apply to one side at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetSide}
hideSideControl
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />