feat: improve thumbnails, placement, and editor responsiveness (#258)
* wip: backport monorepo PRs 278 280 281 283 284 * refactor(editor): align floorplan panel with monorepo split version
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
"next": ">=15",
|
||||
"react": "^18 || ^19",
|
||||
"react-dom": "^18 || ^19",
|
||||
"three": "^0.183"
|
||||
"three": "^0.184"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -54,9 +54,10 @@
|
||||
"@pascal-app/viewer": "^0.5.1",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/node": "^22.19.12",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/three": "^0.183.1",
|
||||
"@types/three": "^0.184.0",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,15 @@ function LeftColumn({
|
||||
}
|
||||
}, [tabs, activePanel, setActivePanel])
|
||||
|
||||
// Leaving the items tab while furnishing should drop back to select mode
|
||||
useEffect(() => {
|
||||
if (activePanel === 'items') return
|
||||
const { phase, mode, setMode } = useEditor.getState()
|
||||
if (phase === 'furnish' && mode === 'build') {
|
||||
setMode('select')
|
||||
}
|
||||
}, [activePanel])
|
||||
|
||||
const handleResizerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -45,7 +45,6 @@ const HOLE_TYPES = ['slab', 'ceiling']
|
||||
|
||||
export function FloatingActionMenu() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||
@@ -63,12 +62,19 @@ export function FloatingActionMenu() {
|
||||
|
||||
// Only show for single selection of specific types
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||
const node = selectedId ? nodes[selectedId as AnyNodeId] : null
|
||||
|
||||
// Subscribe just to the selected node so unrelated scene updates do not
|
||||
// re-render this menu.
|
||||
const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
|
||||
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
|
||||
const canCurveSelectedWall =
|
||||
node?.type === 'wall' &&
|
||||
!(node.children ?? []).some((childId) => {
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
|
||||
// Boolean selector, only re-renders when curving availability actually flips.
|
||||
const canCurveSelectedWall = useScene((s) => {
|
||||
if (!selectedId) return false
|
||||
const selectedNode = s.nodes[selectedId as AnyNodeId]
|
||||
if (selectedNode?.type !== 'wall') return false
|
||||
return !(selectedNode.children ?? []).some((childId) => {
|
||||
const child = s.nodes[childId as AnyNodeId]
|
||||
if (!child) return false
|
||||
if (child.type === 'door' || child.type === 'window') return true
|
||||
if (child.type === 'item') {
|
||||
@@ -77,6 +83,7 @@ export function FloatingActionMenu() {
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -422,7 +429,11 @@ export function FloatingActionMenu() {
|
||||
{node?.type === 'wall' && (
|
||||
<>
|
||||
<group ref={startEndpointGroupRef}>
|
||||
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[100, 0]}>
|
||||
<Html
|
||||
center
|
||||
style={{ pointerEvents: 'auto', touchAction: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<button
|
||||
aria-label="Move wall start"
|
||||
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
||||
@@ -440,7 +451,11 @@ export function FloatingActionMenu() {
|
||||
</Html>
|
||||
</group>
|
||||
<group ref={endEndpointGroupRef}>
|
||||
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[100, 0]}>
|
||||
<Html
|
||||
center
|
||||
style={{ pointerEvents: 'auto', touchAction: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<button
|
||||
aria-label="Move wall end"
|
||||
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
||||
|
||||
@@ -15,7 +15,6 @@ export function FloatingBuildingActionMenu() {
|
||||
const levelId = useViewer((s) => s.selection.levelId)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
|
||||
const groupRef = useRef<THREE.Group>(null)
|
||||
|
||||
@@ -36,13 +35,15 @@ export function FloatingBuildingActionMenu() {
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!buildingId) return
|
||||
const node = nodes[buildingId]
|
||||
// Read lazily at click time — no need to subscribe to nodes for a
|
||||
// one-shot action.
|
||||
const node = useScene.getState().nodes[buildingId]
|
||||
if (!node || node.type !== 'building') return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node as BuildingNode)
|
||||
setSelection({ buildingId: null })
|
||||
},
|
||||
[buildingId, nodes, setMovingNode, setSelection],
|
||||
[buildingId, setMovingNode, setSelection],
|
||||
)
|
||||
|
||||
// Only show when a building is selected without a level
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,12 +20,18 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
||||
}
|
||||
|
||||
export function SiteEdgeLabels() {
|
||||
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
// Narrow subscription to just the site node — subscribing to the full
|
||||
// s.nodes dict re-rendered this on every wall/level mutation even though
|
||||
// the site itself rarely changes.
|
||||
const siteNode = useScene((state) => {
|
||||
const firstRoot = state.rootNodeIds[0]
|
||||
if (!firstRoot) return null
|
||||
const node = state.nodes[firstRoot]
|
||||
return node?.type === 'site' ? (node as SiteNode) : null
|
||||
})
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const theme = useViewer((state) => state.theme)
|
||||
|
||||
const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null
|
||||
const siteNodeId = siteNode?.id
|
||||
|
||||
const isNight = theme === 'dark'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, useScene } from '@pascal-app/core'
|
||||
import { SSGI_PARAMS, snapLevelsToTruePositions } from '@pascal-app/viewer'
|
||||
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
|
||||
import type { CameraControls } from '@react-three/drei'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -29,14 +30,24 @@ const THUMBNAIL_WIDTH = 1920
|
||||
const THUMBNAIL_HEIGHT = 1080
|
||||
const AUTO_SAVE_DELAY = 10_000
|
||||
|
||||
export interface SnapshotCameraData {
|
||||
position: [number, number, number]
|
||||
target: [number, number, number] | null
|
||||
type?: 'perspective' | 'orthographic'
|
||||
zoom?: number
|
||||
captureMode?: 'standard' | 'viewport' | 'area'
|
||||
resolution?: { w: number; h: number }
|
||||
}
|
||||
|
||||
interface ThumbnailGeneratorProps {
|
||||
onThumbnailCapture?: (blob: Blob) => void
|
||||
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
|
||||
}
|
||||
|
||||
export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
|
||||
const gl = useThree((state) => state.gl)
|
||||
const scene = useThree((state) => state.scene)
|
||||
const mainCamera = useThree((state) => state.camera)
|
||||
const controls = useThree((state) => state.controls) as CameraControls | null
|
||||
const isGenerating = useRef(false)
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingAutoRef = useRef(false)
|
||||
@@ -50,7 +61,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
onThumbnailCaptureRef.current = onThumbnailCapture
|
||||
}, [onThumbnailCapture])
|
||||
|
||||
// Build the thumbnail camera, SSGI pipeline, and render target once — reused on every capture.
|
||||
// Build the thumbnail camera, SSGI pipeline, and render target once, reused on every capture.
|
||||
useEffect(() => {
|
||||
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
|
||||
cam.layers.disable(EDITOR_LAYER)
|
||||
@@ -64,7 +75,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
if (!mounted) return
|
||||
|
||||
// pass() handles MRT internally for all material types, including custom
|
||||
// shaders — unlike renderer.setMRT() which crashes on non-NodeMaterials.
|
||||
// shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials.
|
||||
// pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
|
||||
const scenePass = pass(scene, cam)
|
||||
scenePass.setMRT(
|
||||
@@ -106,15 +117,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
const ao = (denoisePass as any).r
|
||||
const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
|
||||
|
||||
// FXAA requires a texture node as input; convertToTexture renders finalOutput
|
||||
// into an intermediate RT so FXAA can sample it with neighbour UV offsets.
|
||||
// FXAA requires a texture node as input, convertToTexture renders finalOutput
|
||||
// into an intermediate RT so FXAA can sample it with neighbor UV offsets.
|
||||
const aaOutput = fxaa(convertToTexture(finalOutput))
|
||||
|
||||
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
|
||||
pipeline.outputNode = aaOutput
|
||||
pipelineRef.current = pipeline
|
||||
|
||||
// Dedicated render target — pipeline outputs here instead of the canvas,
|
||||
// Dedicated render target, pipeline outputs here instead of the canvas,
|
||||
// so R3F's main render loop can never overwrite our capture.
|
||||
const { width, height } = gl.domElement
|
||||
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
|
||||
@@ -137,178 +148,304 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
}
|
||||
}, [gl, scene])
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
if (isGenerating.current) return
|
||||
if (!onThumbnailCaptureRef.current) return
|
||||
const generate = useCallback(
|
||||
async (
|
||||
snapLevels: boolean,
|
||||
captureMode?: 'standard' | 'viewport' | 'area',
|
||||
cropRegion?: { x: number; y: number; width: number; height: number },
|
||||
) => {
|
||||
if (isGenerating.current) return
|
||||
if (!onThumbnailCaptureRef.current) return
|
||||
|
||||
isGenerating.current = true
|
||||
isGenerating.current = true
|
||||
|
||||
try {
|
||||
const thumbnailCamera = thumbnailCameraRef.current
|
||||
if (!thumbnailCamera) return
|
||||
try {
|
||||
const thumbnailCamera = thumbnailCameraRef.current
|
||||
if (!thumbnailCamera) return
|
||||
|
||||
// Copy the main camera's transform and projection so the thumbnail
|
||||
// matches exactly what the user sees in the viewport.
|
||||
thumbnailCamera.position.copy(mainCamera.position)
|
||||
thumbnailCamera.quaternion.copy(mainCamera.quaternion)
|
||||
if (mainCamera instanceof THREE.PerspectiveCamera) {
|
||||
thumbnailCamera.fov = mainCamera.fov
|
||||
thumbnailCamera.near = mainCamera.near
|
||||
thumbnailCamera.far = mainCamera.far
|
||||
}
|
||||
const { width, height } = gl.domElement
|
||||
thumbnailCamera.aspect = width / height
|
||||
thumbnailCamera.updateProjectionMatrix()
|
||||
// Copy the main camera's transform and projection so the thumbnail
|
||||
// matches exactly what the user sees in the viewport.
|
||||
thumbnailCamera.position.copy(mainCamera.position)
|
||||
thumbnailCamera.quaternion.copy(mainCamera.quaternion)
|
||||
if (mainCamera instanceof THREE.PerspectiveCamera) {
|
||||
thumbnailCamera.fov = mainCamera.fov
|
||||
thumbnailCamera.near = mainCamera.near
|
||||
thumbnailCamera.far = mainCamera.far
|
||||
}
|
||||
const { width, height } = gl.domElement
|
||||
thumbnailCamera.aspect = width / height
|
||||
thumbnailCamera.updateProjectionMatrix()
|
||||
|
||||
const restoreLevels = snapLevelsToTruePositions()
|
||||
|
||||
let blob: Blob
|
||||
|
||||
if (pipelineRef.current && renderTargetRef.current) {
|
||||
const rt = renderTargetRef.current
|
||||
|
||||
// Resize RT if the canvas dimensions changed
|
||||
if (rt.width !== width || rt.height !== height) {
|
||||
rt.setSize(width, height)
|
||||
// Capture camera data for snapshot storage.
|
||||
const pos = mainCamera.position
|
||||
let tgt: [number, number, number] | null = null
|
||||
if (controls && 'getTarget' in controls) {
|
||||
const v = new THREE.Vector3()
|
||||
;(controls as any).getTarget(v)
|
||||
tgt = [v.x, v.y, v.z]
|
||||
}
|
||||
const isOrtho = mainCamera instanceof THREE.OrthographicCamera
|
||||
const cameraData: SnapshotCameraData = {
|
||||
position: [pos.x, pos.y, pos.z],
|
||||
target: tgt,
|
||||
type: isOrtho ? 'orthographic' : 'perspective',
|
||||
...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
|
||||
}
|
||||
|
||||
const renderer = gl as unknown as WebGPURenderer
|
||||
// For auto-save, snap levels to stacked positions and reset levelMode.
|
||||
let restoreLevelMode: (() => void) | null = null
|
||||
let restoreLevels: () => void = () => {}
|
||||
if (snapLevels) {
|
||||
const prevMode = useViewer.getState().levelMode
|
||||
if (prevMode !== 'stacked') {
|
||||
useViewer.getState().setLevelMode('stacked')
|
||||
restoreLevelMode = () => useViewer.getState().setLevelMode(prevMode)
|
||||
}
|
||||
restoreLevels = snapLevelsToTruePositions()
|
||||
}
|
||||
|
||||
// Swap selected-item materials back to originals for the capture,
|
||||
// then re-apply highlights immediately after.
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
;(renderer as any).setClearAlpha(0)
|
||||
renderer.setRenderTarget(rt)
|
||||
pipelineRef.current.render()
|
||||
renderer.setRenderTarget(null)
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
// Hide scan and guide nodes directly so they are excluded from the
|
||||
// thumbnail regardless of whether ScanSystem or GuideSystem listeners are
|
||||
// registered. Returns a function that restores the original visibility.
|
||||
const restoreNodeVisibility = (() => {
|
||||
const saved = new Map<THREE.Object3D, boolean>()
|
||||
for (const type of ['scan', 'guide'] as const) {
|
||||
const ids = sceneRegistry.byType[type]
|
||||
ids.forEach((id) => {
|
||||
const node = sceneRegistry.nodes.get(id)
|
||||
if (node) {
|
||||
saved.set(node, node.visible)
|
||||
node.visible = false
|
||||
}
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
saved.forEach((wasVisible, node) => {
|
||||
node.visible = wasVisible
|
||||
})
|
||||
}
|
||||
})()
|
||||
|
||||
// Restore level positions immediately after the render — before the async GPU readback.
|
||||
restoreLevels()
|
||||
let blob: Blob
|
||||
|
||||
// Read pixels from the RT asynchronously.
|
||||
// WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
|
||||
// depad the rows before constructing ImageData.
|
||||
const pixels = (await (renderer as any).readRenderTargetPixelsAsync(
|
||||
rt,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
)) as Uint8Array
|
||||
if (pipelineRef.current && renderTargetRef.current) {
|
||||
const rt = renderTargetRef.current
|
||||
|
||||
const actualBytesPerRow = width * 4
|
||||
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
|
||||
let tightPixels: Uint8ClampedArray
|
||||
if (paddedBytesPerRow === actualBytesPerRow) {
|
||||
// No padding — use the buffer directly
|
||||
tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
|
||||
// Resize RT if the canvas dimensions changed.
|
||||
if (rt.width !== width || rt.height !== height) {
|
||||
rt.setSize(width, height)
|
||||
}
|
||||
|
||||
const renderer = gl as unknown as WebGPURenderer
|
||||
|
||||
// Notify other systems (wall cutouts, selection manager) to restore
|
||||
// their overrides before capture and re-apply them after.
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
;(renderer as any).setClearAlpha(0)
|
||||
renderer.setRenderTarget(rt)
|
||||
pipelineRef.current.render()
|
||||
renderer.setRenderTarget(null)
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
|
||||
// Restore level positions, levelMode, and node visibility immediately after the
|
||||
// render, before the async GPU readback.
|
||||
restoreLevels()
|
||||
restoreLevelMode?.()
|
||||
restoreNodeVisibility()
|
||||
|
||||
// Read pixels from the RT asynchronously.
|
||||
// WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
|
||||
// depad the rows before constructing ImageData.
|
||||
const pixels = (await (renderer as any).readRenderTargetPixelsAsync(
|
||||
rt,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
)) as Uint8Array
|
||||
|
||||
const actualBytesPerRow = width * 4
|
||||
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
|
||||
let tightPixels: Uint8ClampedArray
|
||||
if (paddedBytesPerRow === actualBytesPerRow) {
|
||||
tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
|
||||
} else {
|
||||
tightPixels = new Uint8ClampedArray(width * height * 4)
|
||||
for (let row = 0; row < height; row++) {
|
||||
tightPixels.set(
|
||||
pixels.subarray(
|
||||
row * paddedBytesPerRow,
|
||||
row * paddedBytesPerRow + actualBytesPerRow,
|
||||
),
|
||||
row * actualBytesPerRow,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const imageData = new ImageData(
|
||||
tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
const srcCanvas = new OffscreenCanvas(width, height)
|
||||
srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0)
|
||||
|
||||
let outW: number
|
||||
let outH: number
|
||||
|
||||
if (captureMode === 'viewport') {
|
||||
outW = width
|
||||
outH = height
|
||||
const offscreen = new OffscreenCanvas(outW, outH)
|
||||
offscreen.getContext('2d')!.drawImage(srcCanvas, 0, 0)
|
||||
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
||||
} else if (captureMode === 'area' && cropRegion) {
|
||||
const sx = Math.round(cropRegion.x * width)
|
||||
const sy = Math.round(cropRegion.y * height)
|
||||
outW = Math.round(cropRegion.width * width)
|
||||
outH = Math.round(cropRegion.height * height)
|
||||
const offscreen = new OffscreenCanvas(outW, outH)
|
||||
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
|
||||
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
||||
} else {
|
||||
const srcAspect = width / height
|
||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
||||
let sx = 0,
|
||||
sy = 0,
|
||||
sWidth = width,
|
||||
sHeight = height
|
||||
if (srcAspect > dstAspect) {
|
||||
sWidth = Math.round(height * dstAspect)
|
||||
sx = Math.round((width - sWidth) / 2)
|
||||
} else if (srcAspect < dstAspect) {
|
||||
sHeight = Math.round(width / dstAspect)
|
||||
sy = Math.round((height - sHeight) / 2)
|
||||
}
|
||||
outW = THUMBNAIL_WIDTH
|
||||
outH = THUMBNAIL_HEIGHT
|
||||
const offscreen = new OffscreenCanvas(outW, outH)
|
||||
offscreen
|
||||
.getContext('2d')!
|
||||
.drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
|
||||
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
||||
}
|
||||
|
||||
if (captureMode !== undefined) cameraData.captureMode = captureMode
|
||||
cameraData.resolution = { w: outW, h: outH }
|
||||
} else {
|
||||
// Depad rows
|
||||
tightPixels = new Uint8ClampedArray(width * height * 4)
|
||||
for (let row = 0; row < height; row++) {
|
||||
tightPixels.set(
|
||||
pixels.subarray(row * paddedBytesPerRow, row * paddedBytesPerRow + actualBytesPerRow),
|
||||
row * actualBytesPerRow,
|
||||
// Fallback: plain render directly to the canvas.
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
gl.render(scene, thumbnailCamera)
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
restoreLevels()
|
||||
restoreLevelMode?.()
|
||||
restoreNodeVisibility()
|
||||
|
||||
let outW: number
|
||||
let outH: number
|
||||
|
||||
if (captureMode === 'viewport') {
|
||||
outW = width
|
||||
outH = height
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = outW
|
||||
offscreen.height = outH
|
||||
offscreen.getContext('2d')!.drawImage(gl.domElement, 0, 0)
|
||||
blob = await new Promise<Blob>((resolve, reject) =>
|
||||
offscreen.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
||||
'image/png',
|
||||
),
|
||||
)
|
||||
} else if (captureMode === 'area' && cropRegion) {
|
||||
const sx = Math.round(cropRegion.x * width)
|
||||
const sy = Math.round(cropRegion.y * height)
|
||||
outW = Math.round(cropRegion.width * width)
|
||||
outH = Math.round(cropRegion.height * height)
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = outW
|
||||
offscreen.height = outH
|
||||
offscreen
|
||||
.getContext('2d')!
|
||||
.drawImage(gl.domElement, sx, sy, outW, outH, 0, 0, outW, outH)
|
||||
blob = await new Promise<Blob>((resolve, reject) =>
|
||||
offscreen.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
||||
'image/png',
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const srcAspect = width / height
|
||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
||||
let sx = 0,
|
||||
sy = 0,
|
||||
sWidth = width,
|
||||
sHeight = height
|
||||
if (srcAspect > dstAspect) {
|
||||
sWidth = Math.round(height * dstAspect)
|
||||
sx = Math.round((width - sWidth) / 2)
|
||||
} else if (srcAspect < dstAspect) {
|
||||
sHeight = Math.round(width / dstAspect)
|
||||
sy = Math.round((height - sHeight) / 2)
|
||||
}
|
||||
outW = THUMBNAIL_WIDTH
|
||||
outH = THUMBNAIL_HEIGHT
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = outW
|
||||
offscreen.height = outH
|
||||
offscreen
|
||||
.getContext('2d')!
|
||||
.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
|
||||
blob = await new Promise<Blob>((resolve, reject) =>
|
||||
offscreen.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
||||
'image/png',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (captureMode !== undefined) cameraData.captureMode = captureMode
|
||||
cameraData.resolution = { w: outW, h: outH }
|
||||
}
|
||||
|
||||
// Crop to thumbnail aspect ratio and draw to offscreen canvas
|
||||
const srcAspect = width / height
|
||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
||||
let sx = 0,
|
||||
sy = 0,
|
||||
sWidth = width,
|
||||
sHeight = height
|
||||
if (srcAspect > dstAspect) {
|
||||
sWidth = Math.round(height * dstAspect)
|
||||
sx = Math.round((width - sWidth) / 2)
|
||||
} else if (srcAspect < dstAspect) {
|
||||
sHeight = Math.round(width / dstAspect)
|
||||
sy = Math.round((height - sHeight) / 2)
|
||||
}
|
||||
|
||||
const imageData = new ImageData(
|
||||
tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
const srcCanvas = new OffscreenCanvas(width, height)
|
||||
srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0)
|
||||
|
||||
const offscreen = new OffscreenCanvas(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||
offscreen
|
||||
.getContext('2d')!
|
||||
.drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
||||
|
||||
blob = await offscreen.convertToBlob({ type: 'image/png' })
|
||||
} else {
|
||||
// Fallback: plain render directly to the canvas
|
||||
gl.render(scene, thumbnailCamera)
|
||||
restoreLevels()
|
||||
|
||||
const srcAspect = width / height
|
||||
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
||||
let sx = 0,
|
||||
sy = 0,
|
||||
sWidth = width,
|
||||
sHeight = height
|
||||
if (srcAspect > dstAspect) {
|
||||
sWidth = Math.round(height * dstAspect)
|
||||
sx = Math.round((width - sWidth) / 2)
|
||||
} else if (srcAspect < dstAspect) {
|
||||
sHeight = Math.round(width / dstAspect)
|
||||
sy = Math.round((height - sHeight) / 2)
|
||||
}
|
||||
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = THUMBNAIL_WIDTH
|
||||
offscreen.height = THUMBNAIL_HEIGHT
|
||||
const ctx = offscreen.getContext('2d')!
|
||||
ctx.drawImage(
|
||||
gl.domElement,
|
||||
sx,
|
||||
sy,
|
||||
sWidth,
|
||||
sHeight,
|
||||
0,
|
||||
0,
|
||||
THUMBNAIL_WIDTH,
|
||||
THUMBNAIL_HEIGHT,
|
||||
)
|
||||
|
||||
blob = await new Promise<Blob>((resolve, reject) =>
|
||||
offscreen.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
|
||||
'image/png',
|
||||
),
|
||||
)
|
||||
onThumbnailCaptureRef.current?.(blob, cameraData)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to generate thumbnail:', error)
|
||||
} finally {
|
||||
isGenerating.current = false
|
||||
}
|
||||
},
|
||||
[gl, scene, mainCamera, controls],
|
||||
)
|
||||
|
||||
onThumbnailCaptureRef.current?.(blob)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to generate thumbnail:', error)
|
||||
} finally {
|
||||
isGenerating.current = false
|
||||
}
|
||||
}, [gl, scene, mainCamera])
|
||||
|
||||
// Manual trigger via emitter
|
||||
// Thumbnail request via emitter. Two call shapes:
|
||||
// - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures
|
||||
// the current pose with the supplied crop.
|
||||
// - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to
|
||||
// their true positions first for a consistent auto-thumbnail angle.
|
||||
useEffect(() => {
|
||||
const handleGenerateThumbnail = async () => {
|
||||
await generate()
|
||||
if (!onThumbnailCapture) return
|
||||
|
||||
const handleGenerateThumbnail = async (event: {
|
||||
captureMode?: 'standard' | 'viewport' | 'area'
|
||||
cropRegion?: { x: number; y: number; width: number; height: number }
|
||||
snapLevels?: boolean
|
||||
}) => {
|
||||
await generate(event.snapLevels === true, event.captureMode, event.cropRegion)
|
||||
}
|
||||
|
||||
emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
||||
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
||||
}, [generate])
|
||||
}, [generate, onThumbnailCapture])
|
||||
|
||||
// Auto-trigger: debounced on scene changes, deferred if tab is hidden
|
||||
// OSS adaptation: keep local debounced auto-capture behavior because the
|
||||
// community host-side autosave hook is not part of this repo.
|
||||
useEffect(() => {
|
||||
if (!onThumbnailCapture) return
|
||||
|
||||
const triggerNow = () => generate()
|
||||
const triggerNow = () => {
|
||||
void generate(true)
|
||||
}
|
||||
|
||||
const scheduleOrDefer = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
@@ -341,7 +478,32 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
unsubscribe()
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
}, [onThumbnailCapture, generate])
|
||||
}, [generate, onThumbnailCapture])
|
||||
|
||||
// Go-to-camera: animate camera to a saved snapshot position or target.
|
||||
useEffect(() => {
|
||||
const handler = ({
|
||||
position,
|
||||
target,
|
||||
}: {
|
||||
position: [number, number, number]
|
||||
target: [number, number, number]
|
||||
}) => {
|
||||
if (controls && 'setLookAt' in controls) {
|
||||
;(controls as any).setLookAt(
|
||||
position[0],
|
||||
position[1],
|
||||
position[2],
|
||||
target[0],
|
||||
target[1],
|
||||
target[2],
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
emitter.on('camera:go-to-position', handler)
|
||||
return () => emitter.off('camera:go-to-position', handler)
|
||||
}, [controls])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export const floorStrategy = {
|
||||
ctx.levelId,
|
||||
pos,
|
||||
getScaledDimensions(ctx.draftItem),
|
||||
[0, 0, 0],
|
||||
ctx.draftItem.rotation,
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
|
||||
@@ -558,7 +558,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
||||
ctx.levelId,
|
||||
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
|
||||
getScaledDimensions(ctx.draftItem),
|
||||
[0, 0, 0],
|
||||
ctx.draftItem.rotation,
|
||||
[ctx.draftItem.id],
|
||||
).valid
|
||||
}
|
||||
|
||||
@@ -216,6 +216,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
let previousGridPos: [number, number, number] | null = null
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
|
||||
if (draftNode.current === null && asset.attachTo === undefined) {
|
||||
configRef.current.initDraft(gridPosition.current)
|
||||
}
|
||||
|
||||
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
||||
const result = floorStrategy.move(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
@@ -233,7 +239,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
|
||||
cursorGroupRef.current.position.x = result.cursorPosition[0]
|
||||
cursorGroupRef.current.position.z = result.cursorPosition[2]
|
||||
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
||||
|
||||
const draft = draftNode.current
|
||||
if (draft) draft.position = result.gridPosition
|
||||
@@ -499,13 +504,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
return
|
||||
}
|
||||
|
||||
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
||||
const result = itemSurfaceStrategy.move(ctx, event)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
gridPosition.current.set(...result.gridPosition)
|
||||
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
||||
const ic = worldToBuildingLocal(...result.cursorPosition)
|
||||
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
|
||||
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||
@@ -609,6 +614,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
return
|
||||
}
|
||||
|
||||
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
||||
const result = ceilingStrategy.move(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
@@ -625,7 +631,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
|
||||
gridPosition.current.set(...result.gridPosition)
|
||||
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
|
||||
const cc = worldToBuildingLocal(...result.cursorPosition)
|
||||
cursorGroupRef.current.position.set(cc.x, cc.y, cc.z)
|
||||
|
||||
@@ -701,23 +706,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
const ROTATION_STEP = Math.PI / 2
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
// Don't intercept keys when focus is inside a text input
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Shift') {
|
||||
shiftFreeRef.current = true
|
||||
revalidate()
|
||||
return
|
||||
}
|
||||
|
||||
// Don't intercept keys when focus is inside a text input
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const draft = draftNode.current
|
||||
if (!draft) return
|
||||
|
||||
let rotationDelta = 0
|
||||
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
||||
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
||||
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
|
||||
rotationDelta = ROTATION_STEP
|
||||
else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
|
||||
rotationDelta = -ROTATION_STEP
|
||||
|
||||
if (rotationDelta !== 0) {
|
||||
event.preventDefault()
|
||||
@@ -825,6 +832,29 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
||||
edgesRef.current.geometry = edgesGeometry
|
||||
|
||||
// ---- Undo protection ----
|
||||
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
|
||||
// include the draft (created while temporal was paused). Re-insert it so the mesh
|
||||
// doesn't disappear mid-placement.
|
||||
// We defer via queueMicrotask to avoid nested setState during the undo callback.
|
||||
// Temporal is already paused during placement, so createNode won't enter the undo stack.
|
||||
let tearingDown = false
|
||||
const unsubDraftWatch = useScene.subscribe((state) => {
|
||||
if (tearingDown) return
|
||||
const draft = draftNode.current
|
||||
if (draft === null) return
|
||||
if (draft.id in state.nodes) return
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (tearingDown) return
|
||||
const draft = draftNode.current
|
||||
if (draft === null) return
|
||||
if (draft.id in useScene.getState().nodes) return
|
||||
// Temporal is paused during placement, createNode won't be tracked
|
||||
useScene.getState().createNode(draft, draft.parentId as AnyNodeId)
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Subscribe ----
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
@@ -843,6 +873,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
emitter.on('ceiling:leave', onCeilingLeave)
|
||||
|
||||
return () => {
|
||||
tearingDown = true
|
||||
unsubDraftWatch()
|
||||
// Clear live transform for any remaining draft
|
||||
if (draftNode.current) {
|
||||
useLiveTransforms.getState().clear(draftNode.current.id)
|
||||
|
||||
@@ -90,12 +90,17 @@ export function ControlModes() {
|
||||
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
||||
const levelId = useViewer((s) => s.selection.levelId)
|
||||
|
||||
const levelNode = useScene((state) =>
|
||||
levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
|
||||
)
|
||||
// Only subscribe to the primitive `level` number — when walls are added to
|
||||
// this level the object ref changes but this number doesn't, so Object.is
|
||||
// dedupes and we avoid a re-render.
|
||||
const levelIndex = useScene((state) => {
|
||||
if (!levelId) return null
|
||||
const node = state.nodes[levelId]
|
||||
return node?.type === 'level' ? (node as LevelNode).level : null
|
||||
})
|
||||
|
||||
const isSiteEditing = phase === 'site'
|
||||
const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
|
||||
const isGroundFloor = levelIndex === 0
|
||||
const canEnterSiteEdit = isGroundFloor || isSiteEditing
|
||||
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
|
||||
@@ -44,8 +44,12 @@ export function EditorCommands() {
|
||||
const register = useCommandRegistry((s) => s.register)
|
||||
const { navigateTo, setInputValue, setOpen } = useCommandPalette()
|
||||
|
||||
const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } =
|
||||
useEditor()
|
||||
const setPhase = useEditor((s) => s.setPhase)
|
||||
const setMode = useEditor((s) => s.setMode)
|
||||
const setTool = useEditor((s) => s.setTool)
|
||||
const setStructureLayer = useEditor((s) => s.setStructureLayer)
|
||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
||||
|
||||
const exportScene = useViewer((s) => s.exportScene)
|
||||
|
||||
|
||||
@@ -219,7 +219,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
||||
const views = usePaletteViewRegistry((s) => s.views)
|
||||
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))
|
||||
|
||||
const wallMode = useViewer((s) => s.wallMode)
|
||||
const setWallMode = useViewer((s) => s.setWallMode)
|
||||
|
||||
@@ -13,18 +13,16 @@ import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function CeilingPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const editingHole = useEditor((s) => s.editingHole)
|
||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
|
||||
: undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<CeilingNode>) => {
|
||||
@@ -129,7 +127,7 @@ export function CeilingPanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
|
||||
if (!(node && node.type === 'ceiling' && selectedId)) return null
|
||||
|
||||
const calculateArea = (polygon: Array<[number, number]>): number => {
|
||||
if (polygon.length < 3) return 0
|
||||
|
||||
@@ -25,17 +25,17 @@ import { PanelWrapper } from './panel-wrapper'
|
||||
import { PresetsPopover } from './presets/presets-popover'
|
||||
|
||||
export function DoorPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const adapter = usePresetsAdapter()
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<DoorNode>) => {
|
||||
@@ -182,7 +182,7 @@ export function DoorPanel() {
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
|
||||
if (!(node && node.type === 'door' && selectedId)) return null
|
||||
|
||||
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
|
||||
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type FenceBaseStyle,
|
||||
type FenceNode,
|
||||
type FenceStyle,
|
||||
type MaterialSchema,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { type AnyNode, type AnyNodeId, type FenceNode, type MaterialSchema, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
@@ -17,25 +9,29 @@ import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyle }[] = [
|
||||
type FenceStyleValue = 'slat' | 'rail' | 'privacy'
|
||||
type FenceBaseStyleValue = 'grounded' | 'floating'
|
||||
|
||||
const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyleValue }[] = [
|
||||
{ label: 'Slat', value: 'slat' },
|
||||
{ label: 'Rail', value: 'rail' },
|
||||
{ label: 'Privacy', value: 'privacy' },
|
||||
]
|
||||
|
||||
const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyle }[] = [
|
||||
const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyleValue }[] = [
|
||||
{ label: 'Grounded', value: 'grounded' },
|
||||
{ label: 'Floating', value: 'floating' },
|
||||
]
|
||||
|
||||
export function FencePanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<FenceNode>) => {
|
||||
@@ -85,14 +81,19 @@ export function FencePanel() {
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null
|
||||
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
||||
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
const length = Math.sqrt(dx * dx + dz * dz)
|
||||
|
||||
return (
|
||||
<PanelWrapper icon="/icons/build.png" onClose={handleClose} title={node.name || 'Fence'} width={300}>
|
||||
<PanelWrapper
|
||||
icon="/icons/build.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Fence'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Style">
|
||||
<SegmentedControl
|
||||
onChange={(value) => handleUpdate({ style: value })}
|
||||
|
||||
@@ -14,15 +14,15 @@ import { CollectionsPopover } from './collections/collections-popover'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function ItemPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const [uniformScale, setUniformScale] = useState(true)
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ItemPanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, deleteNode, setSelection])
|
||||
|
||||
if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
|
||||
if (!(node && node.type === 'item' && selectedId)) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
|
||||
@@ -19,7 +19,13 @@ import { WindowPanel } from './window-panel'
|
||||
export function PanelManager() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
// Only subscribe to the *type* of the single-selected node — string primitive
|
||||
// so we don't re-render on unrelated scene mutations.
|
||||
const selectedNodeType = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const id = selectedIds[0]
|
||||
return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
|
||||
})
|
||||
|
||||
// Show reference panel if a reference is selected
|
||||
if (selectedReferenceId) {
|
||||
@@ -27,34 +33,30 @@ export function PanelManager() {
|
||||
}
|
||||
|
||||
// Show appropriate panel based on selected node type
|
||||
if (selectedIds.length === 1) {
|
||||
const selectedNode = selectedIds[0]
|
||||
const node = nodes[selectedNode as AnyNodeId]
|
||||
if (node) {
|
||||
switch (node.type) {
|
||||
case 'item':
|
||||
return <ItemPanel />
|
||||
case 'roof':
|
||||
return <RoofPanel />
|
||||
case 'roof-segment':
|
||||
return <RoofSegmentPanel />
|
||||
case 'stair':
|
||||
return <StairPanel />
|
||||
case 'stair-segment':
|
||||
return <StairSegmentPanel />
|
||||
case 'slab':
|
||||
return <SlabPanel />
|
||||
case 'ceiling':
|
||||
return <CeilingPanel />
|
||||
case 'wall':
|
||||
return <WallPanel />
|
||||
case 'fence':
|
||||
return <FencePanel />
|
||||
case 'door':
|
||||
return <DoorPanel />
|
||||
case 'window':
|
||||
return <WindowPanel />
|
||||
}
|
||||
if (selectedNodeType) {
|
||||
switch (selectedNodeType) {
|
||||
case 'item':
|
||||
return <ItemPanel />
|
||||
case 'roof':
|
||||
return <RoofPanel />
|
||||
case 'roof-segment':
|
||||
return <RoofSegmentPanel />
|
||||
case 'stair':
|
||||
return <StairPanel />
|
||||
case 'stair-segment':
|
||||
return <StairSegmentPanel />
|
||||
case 'slab':
|
||||
return <SlabPanel />
|
||||
case 'ceiling':
|
||||
return <CeilingPanel />
|
||||
case 'wall':
|
||||
return <WallPanel />
|
||||
case 'fence':
|
||||
return <FencePanel />
|
||||
case 'door':
|
||||
return <DoorPanel />
|
||||
case 'window':
|
||||
return <WindowPanel />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,13 @@ type ReferenceNode = ScanNode | GuideNode
|
||||
export function ReferencePanel() {
|
||||
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
|
||||
const node = selectedReferenceId
|
||||
? (nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
|
||||
: undefined
|
||||
const node = useScene((s) =>
|
||||
selectedReferenceId
|
||||
? (s.nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<ReferenceNode>) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
@@ -22,15 +23,24 @@ import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function RoofPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
||||
)
|
||||
// Shallow selector — only re-renders when the segment list content changes.
|
||||
const segments = useScene(
|
||||
useShallow((s) => {
|
||||
if (!node) return []
|
||||
return (node.children ?? [])
|
||||
.map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
|
||||
}),
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<RoofNode>) => {
|
||||
@@ -137,11 +147,7 @@ export function RoofPanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
|
||||
|
||||
const segments = (node.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
|
||||
if (!(node && node.type === 'roof' && selectedId)) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
|
||||
@@ -35,16 +35,14 @@ const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
|
||||
]
|
||||
|
||||
export function RoofSegmentPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined)
|
||||
: undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<RoofSegmentNode>) => {
|
||||
@@ -123,7 +121,7 @@ export function RoofSegmentPanel() {
|
||||
}
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null
|
||||
if (!(node && node.type === 'roof-segment' && selectedId)) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
|
||||
@@ -13,16 +13,16 @@ import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function SlabPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const editingHole = useEditor((s) => s.editingHole)
|
||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<SlabNode>) => {
|
||||
@@ -127,7 +127,7 @@ export function SlabPanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
|
||||
if (!(node && node.type === 'slab' && selectedId)) return null
|
||||
|
||||
const calculateArea = (polygon: Array<[number, number]>): number => {
|
||||
if (polygon.length < 3) return 0
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
||||
@@ -54,18 +55,34 @@ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }
|
||||
]
|
||||
|
||||
export function StairPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const createNodes = useScene((s) => s.createNodes)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)
|
||||
: undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
||||
)
|
||||
const levels = useScene(
|
||||
useShallow((s) =>
|
||||
Object.values(s.nodes)
|
||||
.filter((entry): entry is LevelNode => entry.type === 'level')
|
||||
.sort((left, right) => left.level - right.level),
|
||||
),
|
||||
)
|
||||
const segments = useScene(
|
||||
useShallow((s) => {
|
||||
if (!selectedId) return []
|
||||
const stairNode = s.nodes[selectedId as AnyNode['id']] as StairNode | undefined
|
||||
if (stairNode?.type !== 'stair') return []
|
||||
return (stairNode.children ?? [])
|
||||
.map((childId) => s.nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((entry): entry is StairSegmentNode => entry?.type === 'stair-segment')
|
||||
}),
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<StairNode>) => {
|
||||
@@ -98,13 +115,15 @@ export function StairPanel() {
|
||||
const children = node.children ?? []
|
||||
const lastChildId = children[children.length - 1]
|
||||
if (lastChildId) {
|
||||
const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined
|
||||
const lastChild = useScene.getState().nodes[lastChildId as AnyNodeId] as
|
||||
| StairSegmentNode
|
||||
| undefined
|
||||
if (lastChild?.type === 'stair-segment') {
|
||||
return { fillToFloor: lastChild.fillToFloor }
|
||||
}
|
||||
}
|
||||
return { fillToFloor: true }
|
||||
}, [node, nodes])
|
||||
}, [node])
|
||||
|
||||
const handleAddFlight = useCallback(() => {
|
||||
if (!node) return
|
||||
@@ -208,16 +227,10 @@ export function StairPanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
|
||||
if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
|
||||
|
||||
const levels = Object.values(nodes)
|
||||
.filter((entry): entry is LevelNode => entry.type === 'level')
|
||||
.sort((left, right) => left.level - right.level)
|
||||
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
|
||||
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
|
||||
const segments = (node.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
|
||||
@@ -34,25 +34,24 @@ const ATTACHMENT_SIDE_OPTIONS: { label: string; value: AttachmentSide }[] = [
|
||||
]
|
||||
|
||||
export function StairSegmentPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined)
|
||||
: undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
// Check if this is the first segment in the parent stair
|
||||
const isFirstSegment = (() => {
|
||||
// Boolean selector — re-renders only when this segment's position among the
|
||||
// parent stair's children flips to/from "first".
|
||||
const isFirstSegment = useScene((s) => {
|
||||
if (!node?.parentId) return true
|
||||
const parent = nodes[node.parentId as AnyNodeId]
|
||||
const parent = s.nodes[node.parentId as AnyNodeId]
|
||||
if (!parent || parent.type !== 'stair') return true
|
||||
const children = (parent as any).children ?? []
|
||||
return children[0] === node.id
|
||||
})()
|
||||
})
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<StairSegmentNode>) => {
|
||||
@@ -130,7 +129,7 @@ export function StairSegmentPanel() {
|
||||
}
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!node || node.type !== 'stair-segment' || selectedIds.length !== 1) return null
|
||||
if (!(node && node.type === 'stair-segment' && selectedId)) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
|
||||
@@ -23,15 +23,31 @@ import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function WallPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
// Boolean selector — re-renders only when this specific wall's child
|
||||
// composition crosses the "has a door/window/wall-item" threshold.
|
||||
const hasWallChildrenBlockingCurve = useScene((s) => {
|
||||
if (!node) return false
|
||||
return (node.children ?? []).some((childId) => {
|
||||
const child = s.nodes[childId as AnyNodeId]
|
||||
if (!child) return false
|
||||
if (child.type === 'door' || child.type === 'window') return true
|
||||
if (child.type === 'item') {
|
||||
const attachTo = child.asset?.attachTo
|
||||
return attachTo === 'wall' || attachTo === 'wall-side'
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<WallNode>) => {
|
||||
@@ -97,7 +113,7 @@ export function WallPanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setCurvingWall, setSelection])
|
||||
|
||||
if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null
|
||||
if (!(node && node.type === 'wall' && selectedId)) return null
|
||||
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
@@ -107,16 +123,6 @@ export function WallPanel() {
|
||||
const thickness = node.thickness ?? 0.1
|
||||
const curveOffset = getClampedWallCurveOffset(node)
|
||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||
const hasWallChildrenBlockingCurve = (node.children ?? []).some((childId) => {
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) return false
|
||||
if (child.type === 'door' || child.type === 'window') return true
|
||||
if (child.type === 'item') {
|
||||
const attachTo = child.asset?.attachTo
|
||||
return attachTo === 'wall' || attachTo === 'wall-side'
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
|
||||
@@ -24,19 +24,17 @@ import { PanelWrapper } from './panel-wrapper'
|
||||
import { PresetsPopover } from './presets/presets-popover'
|
||||
|
||||
export function WindowPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const adapter = usePresetsAdapter()
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)
|
||||
: undefined
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<WindowNode>) => {
|
||||
@@ -153,7 +151,7 @@ export function WindowPanel() {
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
|
||||
if (!(node && node.type === 'window' && selectedId)) return null
|
||||
|
||||
const numCols = node.columnRatios.length
|
||||
const numRows = node.rowRatios.length
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Building2, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -17,7 +17,11 @@ interface BuildingTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProps) {
|
||||
export const BuildingTreeNode = memo(function BuildingTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: BuildingTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const createNode = useScene((state) => state.createNode)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
@@ -84,4 +88,4 @@ export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProp
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AnyNodeId, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
@@ -14,7 +14,11 @@ interface CeilingTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function CeilingTreeNode({ nodeId, depth, isLast }: CeilingTreeNodeProps) {
|
||||
export const CeilingTreeNode = memo(function CeilingTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: CeilingTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
||||
@@ -113,7 +117,7 @@ export function CeilingTreeNode({ nodeId, depth, isLast }: CeilingTreeNodeProps)
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Calculate the area of a polygon using the shoelace formula
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
@@ -15,7 +15,11 @@ interface DoorTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function DoorTreeNode({ nodeId, depth, isLast }: DoorTreeNodeProps) {
|
||||
export const DoorTreeNode = memo(function DoorTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: DoorTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
@@ -72,4 +76,4 @@ export function DoorTreeNode({ nodeId, depth, isLast }: DoorTreeNodeProps) {
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AnyNodeId, type FenceNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import useEditor from '../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
@@ -13,7 +13,11 @@ interface FenceTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function FenceTreeNode({ nodeId, depth, isLast }: FenceTreeNodeProps) {
|
||||
export const FenceTreeNode = memo(function FenceTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: FenceTreeNodeProps) {
|
||||
const node = useScene((state) => state.nodes[nodeId]) as FenceNode | undefined
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
@@ -62,4 +66,4 @@ export function FenceTreeNode({ nodeId, depth, isLast }: FenceTreeNodeProps) {
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { memo, useEffect, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
||||
import {
|
||||
@@ -80,7 +80,7 @@ function useSiteNode(): SiteNode | null {
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyLineSection() {
|
||||
const PropertyLineSection = memo(function PropertyLineSection() {
|
||||
const siteNode = useSiteNode()
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
@@ -218,13 +218,13 @@ function PropertyLineSection() {
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// SITE PHASE VIEW - Property line + building buttons
|
||||
// ============================================================================
|
||||
|
||||
function CameraPopover({
|
||||
const CameraPopover = memo(function CameraPopover({
|
||||
nodeId,
|
||||
hasCamera,
|
||||
open,
|
||||
@@ -303,9 +303,9 @@ function CameraPopover({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function ReferenceItem({
|
||||
const ReferenceItem = memo(function ReferenceItem({
|
||||
refNode,
|
||||
isLastRow,
|
||||
setSelectedReferenceId,
|
||||
@@ -375,7 +375,7 @@ function ReferenceItem({
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
||||
|
||||
@@ -387,7 +387,7 @@ interface LevelReferencesProps {
|
||||
onDeleteAsset?: (projectId: string, url: string) => void
|
||||
}
|
||||
|
||||
function LevelReferences({
|
||||
const LevelReferences = memo(function LevelReferences({
|
||||
levelId,
|
||||
isLastLevel,
|
||||
projectId,
|
||||
@@ -554,9 +554,9 @@ function LevelReferences({
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function LevelItem({
|
||||
const LevelItem = memo(function LevelItem({
|
||||
level,
|
||||
selectedLevelId,
|
||||
setSelection,
|
||||
@@ -784,9 +784,9 @@ function LevelItem({
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function LevelsSection({
|
||||
const LevelsSection = memo(function LevelsSection({
|
||||
projectId,
|
||||
onUploadAsset,
|
||||
onDeleteAsset,
|
||||
@@ -870,9 +870,9 @@ function LevelsSection({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function LayerToggle() {
|
||||
const LayerToggle = memo(function LayerToggle() {
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||
const phase = useEditor((state) => state.phase)
|
||||
@@ -1000,9 +1000,9 @@ function LayerToggle() {
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
|
||||
const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
|
||||
const deleteNode = useScene((state) => state.deleteNode)
|
||||
@@ -1163,9 +1163,9 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function MultiSelectionBadge() {
|
||||
const MultiSelectionBadge = memo(function MultiSelectionBadge() {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
@@ -1185,9 +1185,9 @@ function MultiSelectionBadge() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function ContentSection() {
|
||||
const ContentSection = memo(function ContentSection() {
|
||||
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
const phase = useEditor((state) => state.phase)
|
||||
@@ -1265,9 +1265,9 @@ function ContentSection() {
|
||||
</div>
|
||||
</TreeNodeDragProvider>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function BuildingItem({
|
||||
const BuildingItem = memo(function BuildingItem({
|
||||
building,
|
||||
isBuildingActive,
|
||||
buildingCameraOpen,
|
||||
@@ -1308,19 +1308,16 @@ function BuildingItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<div
|
||||
className={cn('flex shrink-0 flex-col overflow-hidden', isBuildingActive && 'min-h-0 flex-1')}
|
||||
layout
|
||||
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
|
||||
>
|
||||
<motion.div
|
||||
<div
|
||||
className={cn(
|
||||
'group/building flex h-10 shrink-0 cursor-pointer items-center border-border/50 border-b pr-2 transition-all duration-200',
|
||||
isBuildingActive
|
||||
? 'bg-accent/50 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
|
||||
)}
|
||||
layout="position"
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
ref={itemRef}
|
||||
@@ -1404,7 +1401,7 @@ function BuildingItem({
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Tools and content for the active building */}
|
||||
<AnimatePresence initial={false}>
|
||||
@@ -1433,9 +1430,9 @@ function BuildingItem({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export interface SitePanelProps {
|
||||
projectId?: string
|
||||
@@ -1534,7 +1531,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
|
||||
No buildings yet
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div className="flex min-h-0 flex-1 flex-col" layout>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{buildings.map((building) => {
|
||||
const isBuildingActive =
|
||||
(phase === 'structure' || phase === 'furnish') &&
|
||||
@@ -1553,7 +1550,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
@@ -24,7 +24,11 @@ interface ItemTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function ItemTreeNode({ nodeId, depth, isLast }: ItemTreeNodeProps) {
|
||||
export const ItemTreeNode = memo(function ItemTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: ItemTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
@@ -121,4 +125,4 @@ export function ItemTreeNode({ nodeId, depth, isLast }: ItemTreeNodeProps) {
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AnyNodeId, type LevelNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Layers } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
|
||||
@@ -13,7 +13,11 @@ interface LevelTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function LevelTreeNode({ nodeId, depth, isLast }: LevelTreeNodeProps) {
|
||||
export const LevelTreeNode = memo(function LevelTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: LevelTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
@@ -67,4 +71,4 @@ export function LevelTreeNode({ nodeId, depth, isLast }: LevelTreeNodeProps) {
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useScene } from '@
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { AnimatePresence } from 'motion/react'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor from '../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
@@ -16,7 +16,11 @@ interface RoofTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function RoofTreeNode({ nodeId, depth, isLast }: RoofTreeNodeProps) {
|
||||
export const RoofTreeNode = memo(function RoofTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: RoofTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
@@ -147,7 +151,7 @@ export function RoofTreeNode({ nodeId, depth, isLast }: RoofTreeNodeProps) {
|
||||
</TreeNodeWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function RoofSegmentTreeNode({
|
||||
node,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AnyNodeId, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
@@ -13,7 +13,11 @@ interface SlabTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
|
||||
export const SlabTreeNode = memo(function SlabTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: SlabTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const polygon = useScene((s) => (s.nodes[nodeId] as SlabNode | undefined)?.polygon ?? [])
|
||||
@@ -74,7 +78,7 @@ export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Calculate the area of a polygon using the shoelace formula
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type AnyNodeId, type StairNode, type StairSegmentNode, useScene } from
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { AnimatePresence } from 'motion/react'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor from '../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
@@ -16,7 +16,11 @@ interface StairTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function StairTreeNode({ nodeId, depth, isLast }: StairTreeNodeProps) {
|
||||
export const StairTreeNode = memo(function StairTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: StairTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
@@ -147,7 +151,7 @@ export function StairTreeNode({ nodeId, depth, isLast }: StairTreeNodeProps) {
|
||||
</TreeNodeWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function StairSegmentTreeNode({
|
||||
node,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Camera, Eye, EyeOff, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -12,7 +12,7 @@ interface TreeNodeActionsProps {
|
||||
nodeId: AnyNodeId
|
||||
}
|
||||
|
||||
export function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
|
||||
export const TreeNodeActions = memo(function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const updateNodes = useScene((state) => state.updateNodes)
|
||||
@@ -112,4 +112,4 @@ export function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { forwardRef, useEffect, useRef } from 'react'
|
||||
import { forwardRef, memo, useEffect, useRef } from 'react'
|
||||
|
||||
export function handleTreeSelection(
|
||||
e: React.MouseEvent,
|
||||
@@ -73,7 +73,7 @@ interface TreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
||||
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
||||
const nodeType = useScene((state) => state.nodes[nodeId]?.type)
|
||||
|
||||
if (!nodeType) return null
|
||||
@@ -106,7 +106,7 @@ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
interface TreeNodeWrapperProps {
|
||||
nodeId?: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
@@ -14,7 +14,11 @@ interface WallTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function WallTreeNode({ nodeId, depth, isLast }: WallTreeNodeProps) {
|
||||
export const WallTreeNode = memo(function WallTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: WallTreeNodeProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
||||
@@ -107,4 +111,4 @@ export function WallTreeNode({ nodeId, depth, isLast }: WallTreeNodeProps) {
|
||||
))}
|
||||
</TreeNodeWrapper>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import useEditor from './../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
@@ -15,7 +15,11 @@ interface WindowTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function WindowTreeNode({ nodeId, depth, isLast }: WindowTreeNodeProps) {
|
||||
export const WindowTreeNode = memo(function WindowTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: WindowTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
@@ -72,4 +76,4 @@ export function WindowTreeNode({ nodeId, depth, isLast }: WindowTreeNodeProps) {
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type AnyNodeId, useScene, type ZoneNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, TreeNodeWrapper } from './tree-node'
|
||||
@@ -12,7 +12,11 @@ interface ZoneTreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function ZoneTreeNode({ nodeId, depth, isLast }: ZoneTreeNodeProps) {
|
||||
export const ZoneTreeNode = memo(function ZoneTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: ZoneTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
@@ -61,7 +65,7 @@ export function ZoneTreeNode({ nodeId, depth, isLast }: ZoneTreeNodeProps) {
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Calculate the area of a polygon using the shoelace formula
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import Link from 'next/link'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { cn } from '../lib/utils'
|
||||
import { ActionButton } from './ui/action-menu/action-button'
|
||||
import { TooltipProvider } from './ui/primitives/tooltip'
|
||||
@@ -87,7 +88,6 @@ export const ViewerOverlay = ({
|
||||
onBack,
|
||||
}: ViewerOverlayProps) => {
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const showScans = useViewer((s) => s.showScans)
|
||||
const showGuides = useViewer((s) => s.showGuides)
|
||||
const cameraMode = useViewer((s) => s.cameraMode)
|
||||
@@ -95,24 +95,30 @@ export const ViewerOverlay = ({
|
||||
const wallMode = useViewer((s) => s.wallMode)
|
||||
const theme = useViewer((s) => s.theme)
|
||||
|
||||
const building = selection.buildingId
|
||||
? (nodes[selection.buildingId] as BuildingNode | undefined)
|
||||
: null
|
||||
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
|
||||
const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null
|
||||
|
||||
// Get the first selected item (if any)
|
||||
const selectedNode =
|
||||
selection.selectedIds.length > 0
|
||||
? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)
|
||||
: null
|
||||
|
||||
// Get all levels for the selected building
|
||||
const levels =
|
||||
building?.children
|
||||
.map((id) => nodes[id as AnyNodeId] as LevelNode | undefined)
|
||||
.filter((n): n is LevelNode => n?.type === 'level')
|
||||
.sort((a, b) => a.level - b.level) ?? []
|
||||
// Subscribe only to the specific nodes we read so that creating an unrelated
|
||||
// node elsewhere in the scene doesn't re-render this overlay.
|
||||
const firstSelectedId = selection.selectedIds[0] ?? null
|
||||
const building = useScene((s) =>
|
||||
selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null,
|
||||
)
|
||||
const level = useScene((s) =>
|
||||
selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null,
|
||||
)
|
||||
const zone = useScene((s) =>
|
||||
selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null,
|
||||
)
|
||||
const selectedNode = useScene((s) =>
|
||||
firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
|
||||
)
|
||||
const levels = useScene(
|
||||
useShallow((s) => {
|
||||
if (!building) return []
|
||||
return building.children
|
||||
.map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined)
|
||||
.filter((n): n is LevelNode => n?.type === 'level')
|
||||
.sort((a, b) => a.level - b.level)
|
||||
}),
|
||||
)
|
||||
|
||||
const handleLevelClick = (levelId: LevelNode['id']) => {
|
||||
// When switching levels, deselect zone and items
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor, { type StructureTool } from '../store/use-editor'
|
||||
|
||||
export function useContextualTools() {
|
||||
const selection = useViewer((s) => s.selection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const phase = useEditor((s) => s.phase)
|
||||
// Only resubscribe when the *types* of selected nodes change, not when any
|
||||
// node in the scene mutates.
|
||||
const selectedTypes = useScene(
|
||||
useShallow((s) =>
|
||||
selection.selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type).filter(Boolean),
|
||||
),
|
||||
)
|
||||
const structureLayer = useEditor((s) => s.structureLayer)
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -26,35 +32,30 @@ export function useContextualTools() {
|
||||
'window',
|
||||
]
|
||||
|
||||
if (selection.selectedIds.length === 0) {
|
||||
if (selectedTypes.length === 0) {
|
||||
return defaultTools
|
||||
}
|
||||
|
||||
// Get types of selected nodes
|
||||
const selectedTypes = new Set(
|
||||
selection.selectedIds.map((id) => nodes[id as AnyNodeId]?.type).filter(Boolean),
|
||||
)
|
||||
|
||||
// If a wall is selected, prioritize wall-hosted elements
|
||||
if (selectedTypes.has('wall')) {
|
||||
if (selectedTypes.includes('wall')) {
|
||||
return ['window', 'door', 'wall', 'fence'] as StructureTool[]
|
||||
}
|
||||
|
||||
// If a slab is selected, prioritize slab editing
|
||||
if (selectedTypes.has('slab')) {
|
||||
if (selectedTypes.includes('slab')) {
|
||||
return ['slab', 'wall'] as StructureTool[]
|
||||
}
|
||||
|
||||
// If a ceiling is selected, prioritize ceiling editing
|
||||
if (selectedTypes.has('ceiling')) {
|
||||
if (selectedTypes.includes('ceiling')) {
|
||||
return ['ceiling'] as StructureTool[]
|
||||
}
|
||||
|
||||
// If a roof is selected, prioritize roof editing
|
||||
if (selectedTypes.has('roof')) {
|
||||
if (selectedTypes.includes('roof')) {
|
||||
return ['roof'] as StructureTool[]
|
||||
}
|
||||
|
||||
return defaultTools
|
||||
}, [selection.selectedIds, nodes, structureLayer])
|
||||
}, [selectedTypes, structureLayer])
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ export {
|
||||
} from './components/ui/sidebar/panels/settings-panel'
|
||||
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
||||
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
||||
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
|
||||
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
||||
export { PresetsProvider } from './contexts/presets-context'
|
||||
export type { SaveStatus } from './hooks/use-auto-save'
|
||||
export type { SceneGraph } from './lib/scene'
|
||||
export { applySceneGraphToEditor } from './lib/scene'
|
||||
export { triggerSFX } from './lib/sfx-bus'
|
||||
export { default as useAudio } from './store/use-audio'
|
||||
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
||||
export type { FloorplanSelectionTool, SplitOrientation, ViewMode } from './store/use-editor'
|
||||
@@ -30,4 +32,3 @@ export {
|
||||
usePaletteViewRegistry,
|
||||
} from './store/use-palette-view-registry'
|
||||
export { useUploadStore } from './store/use-upload'
|
||||
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
|
||||
|
||||
@@ -1,26 +1,90 @@
|
||||
import { Howl } from 'howler'
|
||||
import useAudio from '../store/use-audio'
|
||||
|
||||
// Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
|
||||
// so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones, enough to kill the
|
||||
// machine-gun feeling when the same SFX fires in rapid succession.
|
||||
type SFXConfig = {
|
||||
src: string
|
||||
// Random playback-rate range applied per play (1 = unchanged).
|
||||
rateRange?: [number, number]
|
||||
// Random volume multiplier range applied per play (1 = unchanged).
|
||||
volumeRange?: [number, number]
|
||||
// Minimum gap between two plays of this SFX. Triggers within this window
|
||||
// are silently dropped so bursty sequences don't phase-stack into noise.
|
||||
minIntervalMs?: number
|
||||
// Random stereo pan per play, max absolute offset (0 = center, 1 = hard
|
||||
// right). A small value like 0.15 keeps things centered but adds just enough
|
||||
// spread to stop repeats from stacking on the same point in the field.
|
||||
panJitter?: number
|
||||
}
|
||||
|
||||
const DEFAULT_MIN_INTERVAL_MS = 30
|
||||
|
||||
// SFX sound definitions
|
||||
export const SFX = {
|
||||
gridSnap: '/audios/sfx/grid_snap.mp3',
|
||||
itemDelete: '/audios/sfx/item_delete.mp3',
|
||||
itemPick: '/audios/sfx/item_pick.mp3',
|
||||
itemPlace: '/audios/sfx/item_place.mp3',
|
||||
itemRotate: '/audios/sfx/item_rotate.mp3',
|
||||
structureBuild: '/audios/sfx/structure_build.mp3',
|
||||
structureDelete: '/audios/sfx/structure_delete.mp3',
|
||||
export const SFX: Record<string, SFXConfig> = {
|
||||
gridSnap: {
|
||||
src: '/audios/sfx/grid_snap.mp3',
|
||||
rateRange: [0.94, 1.06],
|
||||
volumeRange: [0.92, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
itemDelete: {
|
||||
src: '/audios/sfx/item_delete.mp3',
|
||||
rateRange: [0.9, 1.1],
|
||||
volumeRange: [0.9, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
itemPick: {
|
||||
src: '/audios/sfx/item_pick.mp3',
|
||||
rateRange: [0.92, 1.08],
|
||||
volumeRange: [0.92, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
itemPlace: {
|
||||
src: '/audios/sfx/item_place.mp3',
|
||||
rateRange: [0.98, 1.06],
|
||||
volumeRange: [0.9, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
itemRotate: {
|
||||
src: '/audios/sfx/item_rotate.mp3',
|
||||
rateRange: [0.94, 1.06],
|
||||
volumeRange: [0.92, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
structureBuild: {
|
||||
src: '/audios/sfx/structure_build.mp3',
|
||||
rateRange: [0.95, 1.05],
|
||||
volumeRange: [0.88, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
structureDelete: {
|
||||
src: '/audios/sfx/structure_delete.mp3',
|
||||
rateRange: [0.9, 1.1],
|
||||
volumeRange: [0.9, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
snapshotCapture: {
|
||||
// Shutter should sound consistent, no variation.
|
||||
src: '/audios/sfx/snapshot_capture.mp3',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type SFXName = keyof typeof SFX
|
||||
|
||||
function randomInRange([min, max]: [number, number]): number {
|
||||
return min + Math.random() * (max - min)
|
||||
}
|
||||
|
||||
// Preload all SFX sounds
|
||||
const sfxCache = new Map<SFXName, Howl>()
|
||||
const lastPlayedAt = new Map<SFXName, number>()
|
||||
|
||||
// Initialize all sounds
|
||||
Object.entries(SFX).forEach(([name, path]) => {
|
||||
Object.entries(SFX).forEach(([name, config]) => {
|
||||
const sound = new Howl({
|
||||
src: [path],
|
||||
src: [config.src],
|
||||
preload: true,
|
||||
volume: 0.5, // Will be adjusted by the bus
|
||||
})
|
||||
@@ -36,15 +100,34 @@ export function playSFX(name: SFXName) {
|
||||
console.warn(`SFX not found: ${name}`)
|
||||
return
|
||||
}
|
||||
const config = SFX[name]!
|
||||
|
||||
// Drop rapid repeats, two plays of the same SFX within minIntervalMs just
|
||||
// smear into noise, they don't add useful information.
|
||||
const now = performance.now()
|
||||
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
||||
const last = lastPlayedAt.get(name)
|
||||
if (last !== undefined && now - last < minInterval) return
|
||||
lastPlayedAt.set(name, now)
|
||||
|
||||
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
||||
|
||||
if (muted) return
|
||||
|
||||
// Calculate final volume (masterVolume and sfxVolume are 0-100)
|
||||
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||
sound.volume(finalVolume)
|
||||
sound.play()
|
||||
const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||
const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
|
||||
const rate = config.rateRange ? randomInRange(config.rateRange) : 1
|
||||
|
||||
// Apply per-play variation using the returned sound id so overlapping plays
|
||||
// don't fight over shared properties on the Howl.
|
||||
const id = sound.play()
|
||||
sound.volume(baseVolume * volumeJitter, id)
|
||||
if (rate !== 1) sound.rate(rate, id)
|
||||
if (config.panJitter) {
|
||||
const pan = (Math.random() * 2 - 1) * config.panJitter
|
||||
sound.stereo(pan, id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user