Add wall drag previews and fix move arrow handles
This commit is contained in:
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user