Add wall drag previews and fix move arrow handles
This commit is contained in:
@@ -434,7 +434,11 @@ export function FloatingActionMenu() {
|
||||
? handleDuplicate
|
||||
: 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()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
@@ -68,6 +68,7 @@ import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
|
||||
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
|
||||
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 DELETE_CURSOR_BADGE_COLOR = '#ef4444'
|
||||
@@ -587,6 +588,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
<>
|
||||
{!isFirstPersonMode && <SelectionManager />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
|
||||
{!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 {
|
||||
type AnyNodeId,
|
||||
constrainWallMoveDeltaToAxis,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
detectSpacesForLevel,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getMaterialPresetByRef,
|
||||
getPerpendicularWallMoveAxis,
|
||||
getRenderableSlabPolygon,
|
||||
pauseSceneHistory,
|
||||
planWallMoveJunctions,
|
||||
resolveMaterial,
|
||||
resumeSceneHistory,
|
||||
type SlabNode,
|
||||
SlabNode as SlabSchema,
|
||||
useScene,
|
||||
type WallMoveAxis,
|
||||
type WallMoveBridgePlan,
|
||||
@@ -17,13 +24,18 @@ import {
|
||||
WallNode as WallSchema,
|
||||
} from '@pascal-app/core'
|
||||
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 { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
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)
|
||||
@@ -50,6 +62,19 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
|
||||
|
||||
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: {
|
||||
wallId: WallNode['id']
|
||||
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(
|
||||
(wall) =>
|
||||
(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(
|
||||
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
|
||||
@@ -236,6 +299,240 @@ function buildBridgeWallCreates(args: {
|
||||
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 }) => {
|
||||
const meta =
|
||||
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
|
||||
return [centerX, 0, centerZ]
|
||||
})
|
||||
const [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
|
||||
const [ghostSlabPreviews, setGhostSlabPreviews] = useState<GhostSlabPreview[]>([])
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
@@ -323,8 +622,11 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
nextEnd,
|
||||
)
|
||||
|
||||
const getLinkedPreviewUpdates = (nextStart: [number, number], nextEnd: [number, number]) => {
|
||||
const plan = getMovePlan(nextStart, nextEnd)
|
||||
const getLinkedPreviewUpdates = (
|
||||
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
|
||||
nextStart: [number, number],
|
||||
nextEnd: [number, number],
|
||||
) => {
|
||||
const movedUpdates = getPlannedLinkedWallUpdates(
|
||||
plan,
|
||||
originalStart,
|
||||
@@ -344,13 +646,53 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
||||
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
||||
setCursorLocalPos([centerX, 0, centerZ])
|
||||
applyNodePreview([
|
||||
const previewPlan = getMovePlan(nextStart, nextEnd)
|
||||
const previewUpdates = [
|
||||
{ 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 = () => {
|
||||
setGhostWallPreviews([])
|
||||
setGhostSlabPreviews([])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
@@ -400,6 +742,8 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
|
||||
// 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,
|
||||
@@ -534,6 +878,12 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
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} />
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,60 +4,28 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getClampedWallCurveOffset,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getMaxWallCurveOffset,
|
||||
getWallCurveLength,
|
||||
getWallSurfaceMaterialSignature,
|
||||
type MaterialSchema,
|
||||
normalizeWallCurveOffset,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WallSurfaceSide,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Spline } from 'lucide-react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
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() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||
@@ -88,35 +56,6 @@ export function WallPanel() {
|
||||
[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(
|
||||
(newLength: number) => {
|
||||
if (!node || newLength <= 0) return
|
||||
@@ -140,24 +79,6 @@ export function WallPanel() {
|
||||
[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(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -239,23 +160,6 @@ export function WallPanel() {
|
||||
)}
|
||||
</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">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
|
||||
Reference in New Issue
Block a user