Merge pull request #117 from pascalorg/feat/polish-editing-experience
Feat/polish editing experience
This commit is contained in:
@@ -1,16 +1,18 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { initSpatialGridSync, useScene } from '@pascal-app/core'
|
import { initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||||
import { Viewer, useViewer } from '@pascal-app/viewer'
|
import { useViewer, Viewer } from '@pascal-app/viewer'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
getProjectModelPublic,
|
||||||
|
incrementProjectViews,
|
||||||
|
} from '@/features/community/lib/projects/actions'
|
||||||
|
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
||||||
import { ViewerCameraControls } from './viewer-camera-controls'
|
import { ViewerCameraControls } from './viewer-camera-controls'
|
||||||
|
import { ViewerGuestCTA } from './viewer-guest-cta'
|
||||||
import { ViewerOverlay } from './viewer-overlay'
|
import { ViewerOverlay } from './viewer-overlay'
|
||||||
import { ViewerZoneSystem } from './viewer-zone-system'
|
import { ViewerZoneSystem } from './viewer-zone-system'
|
||||||
import { ThumbnailGenerator } from './thumbnail-generator'
|
|
||||||
import { ViewerGuestCTA } from './viewer-guest-cta'
|
|
||||||
import { getProjectModelPublic, incrementProjectViews } from '@/features/community/lib/projects/actions'
|
|
||||||
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
|
||||||
|
|
||||||
export default function ViewerPage() {
|
export default function ViewerPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -106,12 +108,16 @@ export default function ViewerPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen w-full">
|
<div className="relative h-screen w-full">
|
||||||
<ViewerOverlay projectName={projectName} owner={owner} canShowScans={canShowScans} canShowGuides={canShowGuides} />
|
<ViewerOverlay
|
||||||
|
projectName={projectName}
|
||||||
|
owner={owner}
|
||||||
|
canShowScans={canShowScans}
|
||||||
|
canShowGuides={canShowGuides}
|
||||||
|
/>
|
||||||
<ViewerGuestCTA />
|
<ViewerGuestCTA />
|
||||||
<Viewer>
|
<Viewer>
|
||||||
<ViewerCameraControls />
|
<ViewerCameraControls />
|
||||||
<ViewerZoneSystem />
|
<ViewerZoneSystem />
|
||||||
<ThumbnailGenerator projectId={projectId || undefined} />
|
|
||||||
</Viewer>
|
</Viewer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { emitter } from '@pascal-app/core'
|
|
||||||
import { useThree } from '@react-three/fiber'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import * as THREE from 'three'
|
|
||||||
import { uploadProjectThumbnail } from '@/features/community/lib/projects/actions'
|
|
||||||
import { useProjectStore } from '@/features/community/lib/projects/store'
|
|
||||||
|
|
||||||
const THUMBNAIL_WIDTH = 1920
|
|
||||||
const THUMBNAIL_HEIGHT = 1080
|
|
||||||
|
|
||||||
interface ThumbnailGeneratorProps {
|
|
||||||
projectId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ThumbnailGenerator = ({ projectId: propProjectId }: ThumbnailGeneratorProps) => {
|
|
||||||
const gl = useThree((state) => state.gl)
|
|
||||||
const scene = useThree((state) => state.scene)
|
|
||||||
const camera = useThree((state) => state.camera)
|
|
||||||
const isGenerating = useRef(false)
|
|
||||||
|
|
||||||
// Use prop projectId (from URL)
|
|
||||||
const fallbackProjectId = propProjectId
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleGenerateThumbnail = async (event: { projectId: string }) => {
|
|
||||||
if (isGenerating.current) {
|
|
||||||
console.log('⏸️ Thumbnail generation already in progress')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prioritize prop projectId over event projectId (URL has priority over session)
|
|
||||||
const projectId = fallbackProjectId || event.projectId
|
|
||||||
|
|
||||||
if (!projectId) {
|
|
||||||
console.error('❌ No project ID provided')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isGenerating.current = true
|
|
||||||
console.log('📸 Generating thumbnail for project:', projectId)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Save current renderer state
|
|
||||||
const currentSize = gl.getSize(new THREE.Vector2())
|
|
||||||
const currentPixelRatio = gl.getPixelRatio()
|
|
||||||
|
|
||||||
// Temporarily resize renderer to thumbnail size
|
|
||||||
gl.setPixelRatio(1)
|
|
||||||
gl.setSize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
|
|
||||||
|
|
||||||
// Update camera aspect ratio if it's a perspective camera
|
|
||||||
if (camera instanceof THREE.PerspectiveCamera) {
|
|
||||||
camera.aspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
|
|
||||||
camera.updateProjectionMatrix()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the scene
|
|
||||||
gl.render(scene, camera)
|
|
||||||
|
|
||||||
// Wait a frame to ensure render is complete
|
|
||||||
await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
||||||
|
|
||||||
// Capture canvas as blob
|
|
||||||
const canvas = gl.domElement
|
|
||||||
canvas.toBlob(async (blob) => {
|
|
||||||
if (blob) {
|
|
||||||
// Upload to Supabase Storage
|
|
||||||
console.log('☁️ Uploading thumbnail to storage...')
|
|
||||||
const result = await uploadProjectThumbnail(projectId, blob)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
useProjectStore.getState().updateActiveThumbnail(result.data.thumbnail_url)
|
|
||||||
} else {
|
|
||||||
console.error('❌ Failed to upload thumbnail:', result.error)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('❌ Failed to create blob from canvas')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore renderer size and camera
|
|
||||||
gl.setPixelRatio(currentPixelRatio)
|
|
||||||
gl.setSize(currentSize.x, currentSize.y)
|
|
||||||
|
|
||||||
if (camera instanceof THREE.PerspectiveCamera) {
|
|
||||||
camera.aspect = currentSize.x / currentSize.y
|
|
||||||
camera.updateProjectionMatrix()
|
|
||||||
}
|
|
||||||
|
|
||||||
isGenerating.current = false
|
|
||||||
}, 'image/png')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to generate thumbnail:', error)
|
|
||||||
|
|
||||||
// Make sure to restore size even on error
|
|
||||||
const currentSize = gl.getSize(new THREE.Vector2())
|
|
||||||
const currentPixelRatio = gl.getPixelRatio()
|
|
||||||
gl.setPixelRatio(currentPixelRatio)
|
|
||||||
gl.setSize(currentSize.x, currentSize.y)
|
|
||||||
|
|
||||||
if (camera instanceof THREE.PerspectiveCamera) {
|
|
||||||
camera.aspect = currentSize.x / currentSize.y
|
|
||||||
camera.updateProjectionMatrix()
|
|
||||||
}
|
|
||||||
|
|
||||||
isGenerating.current = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
|
||||||
}
|
|
||||||
}, [gl, scene, camera, fallbackProjectId])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
@@ -2,26 +2,27 @@
|
|||||||
|
|
||||||
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||||
import { Viewer } from '@pascal-app/viewer'
|
import { Viewer } from '@pascal-app/viewer'
|
||||||
import { useKeyboard } from '@/hooks/use-keyboard'
|
|
||||||
import useEditor from '@/store/use-editor'
|
|
||||||
import { useProjectScene } from '@/features/community/lib/models/hooks'
|
|
||||||
import { useLocalProjectScene } from '@/features/community/lib/local-storage/hooks'
|
|
||||||
import { useAuth } from '@/features/community/lib/auth/hooks'
|
import { useAuth } from '@/features/community/lib/auth/hooks'
|
||||||
|
import { useLocalProjectScene } from '@/features/community/lib/local-storage/hooks'
|
||||||
|
import { useProjectScene } from '@/features/community/lib/models/hooks'
|
||||||
|
import { useKeyboard } from '@/hooks/use-keyboard'
|
||||||
|
import { initSFXBus } from '@/lib/sfx-bus'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { FeedbackDialog } from '../feedback-dialog'
|
||||||
|
import { PascalRadio } from '../pascal-radio'
|
||||||
|
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||||
import { ToolManager } from '../tools/tool-manager'
|
import { ToolManager } from '../tools/tool-manager'
|
||||||
import { ActionMenu } from '../ui/action-menu'
|
import { ActionMenu } from '../ui/action-menu'
|
||||||
import { FeedbackDialog } from '../feedback-dialog'
|
|
||||||
import { PascalRadio } from '../pascal-radio'
|
|
||||||
import { PanelManager } from '../ui/panels/panel-manager'
|
|
||||||
import { HelperManager } from '../ui/helpers/helper-manager'
|
import { HelperManager } from '../ui/helpers/helper-manager'
|
||||||
|
import { PanelManager } from '../ui/panels/panel-manager'
|
||||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
import { ExportManager } from './export-manager'
|
import { ExportManager } from './export-manager'
|
||||||
import { Grid } from './grid'
|
import { Grid } from './grid'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
import { initSFXBus } from '@/lib/sfx-bus'
|
import { ThumbnailGenerator } from './thumbnail-generator'
|
||||||
import { ThumbnailGenerator } from '@/app/viewer/[id]/thumbnail-generator'
|
|
||||||
|
|
||||||
// Load default scene initially (will be replaced when project loads)
|
// Load default scene initially (will be replaced when project loads)
|
||||||
useScene.getState().loadScene()
|
useScene.getState().loadScene()
|
||||||
@@ -74,6 +75,7 @@ export default function Editor({ projectId }: EditorProps) {
|
|||||||
<ExportManager />
|
<ExportManager />
|
||||||
{/* Editor only system to toggle zone visibility */}
|
{/* Editor only system to toggle zone visibility */}
|
||||||
<ZoneSystem />
|
<ZoneSystem />
|
||||||
|
<CeilingSystem />
|
||||||
{/* <Stats /> */}
|
{/* <Stats /> */}
|
||||||
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
|
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
|
||||||
<ToolManager />
|
<ToolManager />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
|||||||
return nodeLevelId === currentLevelId;
|
return nodeLevelId === currentLevelId;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window';
|
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window' | 'door';
|
||||||
|
|
||||||
interface SelectionStrategy {
|
interface SelectionStrategy {
|
||||||
types: SelectableNodeType[];
|
types: SelectableNodeType[];
|
||||||
@@ -44,7 +44,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
structure: {
|
structure: {
|
||||||
types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window"],
|
types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window", "door"],
|
||||||
handleSelect: (node, isShift) => {
|
handleSelect: (node, isShift) => {
|
||||||
const { selection, setSelection } = useViewer.getState();
|
const { selection, setSelection } = useViewer.getState();
|
||||||
if (node.type === 'zone') {
|
if (node.type === 'zone') {
|
||||||
@@ -80,7 +80,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
(node as ItemNode).asset.category === "window"
|
(node as ItemNode).asset.category === "window"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (node.type === "window") return true;
|
if (node.type === "window" || node.type === "door") return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { emitter, useScene } from '@pascal-app/core'
|
||||||
|
import { useThree } from '@react-three/fiber'
|
||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { uploadProjectThumbnail } from '@/features/community/lib/projects/actions'
|
||||||
|
import { useProjectStore } from '@/features/community/lib/projects/store'
|
||||||
|
|
||||||
|
const THUMBNAIL_WIDTH = 1920
|
||||||
|
const THUMBNAIL_HEIGHT = 1080
|
||||||
|
const AUTO_SAVE_DELAY = 10_000
|
||||||
|
|
||||||
|
interface ThumbnailGeneratorProps {
|
||||||
|
projectId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThumbnailGenerator = ({ projectId: propProjectId }: ThumbnailGeneratorProps) => {
|
||||||
|
const gl = useThree((state) => state.gl)
|
||||||
|
const scene = useThree((state) => state.scene)
|
||||||
|
const isGenerating = useRef(false)
|
||||||
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const pendingAutoRef = useRef(false)
|
||||||
|
|
||||||
|
const generate = useCallback(async (projectId: string) => {
|
||||||
|
if (isGenerating.current) {
|
||||||
|
console.log('⏸️ Thumbnail generation already in progress')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isGenerating.current = true
|
||||||
|
console.log('📸 Generating thumbnail for project:', projectId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const thumbnailCamera = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
|
||||||
|
|
||||||
|
// Check if the site node has a saved camera, otherwise use default isometric position
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const siteNode = Object.values(nodes).find((n) => n.type === 'site')
|
||||||
|
|
||||||
|
if (siteNode?.camera) {
|
||||||
|
const { position, target } = siteNode.camera
|
||||||
|
thumbnailCamera.position.set(position[0], position[1], position[2])
|
||||||
|
thumbnailCamera.lookAt(target[0], target[1], target[2])
|
||||||
|
} else {
|
||||||
|
thumbnailCamera.position.set(8, 8, 8)
|
||||||
|
thumbnailCamera.lookAt(0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match camera aspect to current canvas so the render looks correct
|
||||||
|
const { width, height } = gl.domElement
|
||||||
|
thumbnailCamera.aspect = width / height
|
||||||
|
thumbnailCamera.updateProjectionMatrix()
|
||||||
|
|
||||||
|
// Render with thumbnail camera — main canvas is never resized
|
||||||
|
gl.render(scene, thumbnailCamera)
|
||||||
|
|
||||||
|
// Center-crop the canvas to the thumbnail aspect ratio, then scale — avoids deformation
|
||||||
|
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)
|
||||||
|
|
||||||
|
offscreen.toBlob(async (blob) => {
|
||||||
|
if (blob) {
|
||||||
|
console.log('☁️ Uploading thumbnail to storage...')
|
||||||
|
const result = await uploadProjectThumbnail(projectId, blob)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
useProjectStore.getState().updateActiveThumbnail(result.data.thumbnail_url)
|
||||||
|
} else {
|
||||||
|
console.error('❌ Failed to upload thumbnail:', result.error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('❌ Failed to create blob from canvas')
|
||||||
|
}
|
||||||
|
|
||||||
|
isGenerating.current = false
|
||||||
|
}, 'image/png')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to generate thumbnail:', error)
|
||||||
|
isGenerating.current = false
|
||||||
|
}
|
||||||
|
}, [gl, scene])
|
||||||
|
|
||||||
|
// Manual trigger via emitter
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGenerateThumbnail = async (event: { projectId: string }) => {
|
||||||
|
const projectId = propProjectId || event.projectId
|
||||||
|
if (!projectId) {
|
||||||
|
console.error('❌ No project ID provided')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await generate(projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
||||||
|
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
|
||||||
|
}, [generate, propProjectId])
|
||||||
|
|
||||||
|
// Auto-trigger: debounced on scene changes, deferred if tab is hidden
|
||||||
|
useEffect(() => {
|
||||||
|
if (!propProjectId) return
|
||||||
|
|
||||||
|
const triggerNow = () => generate(propProjectId)
|
||||||
|
|
||||||
|
const scheduleOrDefer = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
triggerNow()
|
||||||
|
} else {
|
||||||
|
// Tab is hidden — remember to fire when the user comes back
|
||||||
|
pendingAutoRef.current = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSceneChange = () => {
|
||||||
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
||||||
|
debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible' && pendingAutoRef.current) {
|
||||||
|
pendingAutoRef.current = false
|
||||||
|
triggerNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to node changes — any structural edit resets the timer
|
||||||
|
const unsubscribe = useScene.subscribe((state, prevState) => {
|
||||||
|
if (state.nodes !== prevState.nodes) onSceneChange()
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
||||||
|
unsubscribe()
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
}
|
||||||
|
}, [propProjectId, generate])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
|
||||||
|
export const CeilingSystem = () => {
|
||||||
|
const tool = useEditor((state) => state.tool)
|
||||||
|
const selectedItem = useEditor((state) => state.selectedItem)
|
||||||
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldShowGrid =
|
||||||
|
tool === 'ceiling' ||
|
||||||
|
selectedItem?.attachTo === 'ceiling' ||
|
||||||
|
(movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling') ||
|
||||||
|
selectedIds.some((id) => {
|
||||||
|
const node = useScene.getState().nodes[id as AnyNodeId]
|
||||||
|
return node?.type === 'ceiling'
|
||||||
|
})
|
||||||
|
|
||||||
|
const ceilings = sceneRegistry.byType.ceiling
|
||||||
|
ceilings.forEach((ceiling) => {
|
||||||
|
const mesh = sceneRegistry.nodes.get(ceiling)
|
||||||
|
if (mesh) {
|
||||||
|
const ceilingGrid = mesh.getObjectByName('ceiling-grid')
|
||||||
|
if (ceilingGrid) {
|
||||||
|
ceilingGrid.visible = shouldShowGrid
|
||||||
|
ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [tool, selectedItem, movingNode, selectedIds])
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
||||||
|
import { mix, positionLocal } from 'three/tsl'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
import useEditor from '@/store/use-editor'
|
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
const CEILING_HEIGHT = 2.52
|
const CEILING_HEIGHT = 2.52
|
||||||
@@ -46,9 +46,9 @@ const calculateSnapPoint = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ceiling with the given polygon points
|
* Creates a ceiling with the given polygon points and returns its ID
|
||||||
*/
|
*/
|
||||||
const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {
|
const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
|
||||||
const { createNode, nodes } = useScene.getState()
|
const { createNode, nodes } = useScene.getState()
|
||||||
|
|
||||||
// Count existing ceilings for naming
|
// Count existing ceilings for naming
|
||||||
@@ -62,6 +62,7 @@ const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, n
|
|||||||
|
|
||||||
createNode(ceiling, levelId)
|
createNode(ceiling, levelId)
|
||||||
sfxEmitter.emit('sfx:structure-build')
|
sfxEmitter.emit('sfx:structure-build')
|
||||||
|
return ceiling.id
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CeilingTool: React.FC = () => {
|
export const CeilingTool: React.FC = () => {
|
||||||
@@ -69,8 +70,9 @@ export const CeilingTool: React.FC = () => {
|
|||||||
const gridCursorRef = useRef<Mesh>(null)
|
const gridCursorRef = useRef<Mesh>(null)
|
||||||
const mainLineRef = useRef<Line>(null!)
|
const mainLineRef = useRef<Line>(null!)
|
||||||
const closingLineRef = useRef<Line>(null!)
|
const closingLineRef = useRef<Line>(null!)
|
||||||
|
const verticalLineRef = useRef<Line>(null!)
|
||||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||||
const setTool = useEditor((state) => state.setTool)
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
|
||||||
const [points, setPoints] = useState<Array<[number, number]>>([])
|
const [points, setPoints] = useState<Array<[number, number]>>([])
|
||||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||||
@@ -79,6 +81,18 @@ export const CeilingTool: React.FC = () => {
|
|||||||
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||||
const shiftPressed = useRef(false)
|
const shiftPressed = useRef(false)
|
||||||
|
|
||||||
|
// Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY
|
||||||
|
const verticalGeo = useMemo(
|
||||||
|
() => new BufferGeometry().setFromPoints([new Vector3(0, 0, 0), new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0)]),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0
|
||||||
|
const gradientOpacityNode = useMemo(
|
||||||
|
() => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
// Update cursor position and lines on grid move
|
// Update cursor position and lines on grid move
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentLevelId) return
|
if (!currentLevelId) return
|
||||||
@@ -117,6 +131,10 @@ export const CeilingTool: React.FC = () => {
|
|||||||
previousSnappedPointRef.current = displayPoint
|
previousSnappedPointRef.current = displayPoint
|
||||||
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
|
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
|
||||||
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
||||||
|
|
||||||
|
if (verticalLineRef.current) {
|
||||||
|
verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (_event: GridEvent) => {
|
const onGridClick = (_event: GridEvent) => {
|
||||||
@@ -133,10 +151,10 @@ export const CeilingTool: React.FC = () => {
|
|||||||
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
||||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
||||||
) {
|
) {
|
||||||
// Create the ceiling
|
// Create the ceiling and select it
|
||||||
commitCeilingDrawing(currentLevelId, points)
|
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
||||||
|
setSelection({ selectedIds: [ceilingId] })
|
||||||
setPoints([])
|
setPoints([])
|
||||||
setTool(null)
|
|
||||||
} else {
|
} else {
|
||||||
// Add point to polygon
|
// Add point to polygon
|
||||||
setPoints([...points, clickPoint])
|
setPoints([...points, clickPoint])
|
||||||
@@ -148,9 +166,9 @@ export const CeilingTool: React.FC = () => {
|
|||||||
|
|
||||||
// Need at least 3 points to form a polygon
|
// Need at least 3 points to form a polygon
|
||||||
if (points.length >= 3) {
|
if (points.length >= 3) {
|
||||||
commitCeilingDrawing(currentLevelId, points)
|
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
||||||
|
setSelection({ selectedIds: [ceilingId] })
|
||||||
setPoints([])
|
setPoints([])
|
||||||
setTool(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +198,7 @@ export const CeilingTool: React.FC = () => {
|
|||||||
emitter.off('grid:double-click', onGridDoubleClick)
|
emitter.off('grid:double-click', onGridDoubleClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
}
|
}
|
||||||
}, [currentLevelId, points, cursorPosition, setTool])
|
}, [currentLevelId, points, cursorPosition, setSelection])
|
||||||
|
|
||||||
// Update line geometries when points change
|
// Update line geometries when points change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -262,6 +280,12 @@ export const CeilingTool: React.FC = () => {
|
|||||||
<meshBasicMaterial color="#a3a3a3" side={DoubleSide} depthTest={false} depthWrite={true} />
|
<meshBasicMaterial color="#a3a3a3" side={DoubleSide} depthTest={false} depthWrite={true} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
|
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<line ref={verticalLineRef} geometry={verticalGeo} renderOrder={1}>
|
||||||
|
<lineBasicNodeMaterial color="#a3a3a3" opacityNode={gradientOpacityNode} depthTest={false} depthWrite={false} transparent />
|
||||||
|
</line>
|
||||||
|
|
||||||
{/* Preview fill */}
|
{/* Preview fill */}
|
||||||
{previewShape && (
|
{previewShape && (
|
||||||
<mesh
|
<mesh
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { type AnyNodeId, type DoorNode, getScaledDimensions, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||||
|
*/
|
||||||
|
export function wallLocalToWorld(
|
||||||
|
wallNode: WallNode,
|
||||||
|
localX: number,
|
||||||
|
localY: number,
|
||||||
|
levelYOffset = 0,
|
||||||
|
slabElevation = 0,
|
||||||
|
): [number, number, number] {
|
||||||
|
const wallAngle = Math.atan2(
|
||||||
|
wallNode.end[1] - wallNode.start[1],
|
||||||
|
wallNode.end[0] - wallNode.start[0],
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
wallNode.start[0] + localX * Math.cos(wallAngle),
|
||||||
|
slabElevation + localY + levelYOffset,
|
||||||
|
wallNode.start[1] + localX * Math.sin(wallAngle),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamps door center X so it stays fully within wall bounds.
|
||||||
|
* Y is always height/2 — doors sit at floor level.
|
||||||
|
*/
|
||||||
|
export function clampToWall(
|
||||||
|
wallNode: WallNode,
|
||||||
|
localX: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): { clampedX: number; clampedY: number } {
|
||||||
|
const dx = wallNode.end[0] - wallNode.start[0]
|
||||||
|
const dz = wallNode.end[1] - wallNode.start[1]
|
||||||
|
const wallLength = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
|
||||||
|
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
|
||||||
|
const clampedY = height / 2 // Doors always sit at floor level
|
||||||
|
return { clampedX, clampedY }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a proposed door position overlaps any existing wall children.
|
||||||
|
* Handles item, window, and door types.
|
||||||
|
*/
|
||||||
|
export function hasWallChildOverlap(
|
||||||
|
wallId: string,
|
||||||
|
clampedX: number,
|
||||||
|
clampedY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
ignoreId?: string,
|
||||||
|
): boolean {
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
|
||||||
|
if (!wallNode) return true
|
||||||
|
const halfW = width / 2
|
||||||
|
const halfH = height / 2
|
||||||
|
const newBottom = clampedY - halfH
|
||||||
|
const newTop = clampedY + halfH
|
||||||
|
const newLeft = clampedX - halfW
|
||||||
|
const newRight = clampedX + halfW
|
||||||
|
|
||||||
|
for (const childId of wallNode.children) {
|
||||||
|
if (childId === ignoreId) continue
|
||||||
|
const child = nodes[childId as AnyNodeId]
|
||||||
|
if (!child) continue
|
||||||
|
|
||||||
|
let childLeft: number, childRight: number, childBottom: number, childTop: number
|
||||||
|
|
||||||
|
if (child.type === 'item') {
|
||||||
|
const item = child as ItemNode
|
||||||
|
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
|
||||||
|
const [w, h] = getScaledDimensions(item)
|
||||||
|
childLeft = item.position[0] - w / 2
|
||||||
|
childRight = item.position[0] + w / 2
|
||||||
|
childBottom = item.position[1]
|
||||||
|
childTop = item.position[1] + h
|
||||||
|
} else if (child.type === 'window') {
|
||||||
|
const win = child as WindowNode
|
||||||
|
childLeft = win.position[0] - win.width / 2
|
||||||
|
childRight = win.position[0] + win.width / 2
|
||||||
|
childBottom = win.position[1] - win.height / 2
|
||||||
|
childTop = win.position[1] + win.height / 2
|
||||||
|
} else if (child.type === 'door') {
|
||||||
|
const door = child as DoorNode
|
||||||
|
childLeft = door.position[0] - door.width / 2
|
||||||
|
childRight = door.position[0] + door.width / 2
|
||||||
|
childBottom = door.position[1] - door.height / 2
|
||||||
|
childTop = door.position[1] + door.height / 2
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const xOverlap = newLeft < childRight && newRight > childLeft
|
||||||
|
const yOverlap = newBottom < childTop && newTop > childBottom
|
||||||
|
if (xOverlap && yOverlap) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
DoorNode,
|
||||||
|
emitter,
|
||||||
|
sceneRegistry,
|
||||||
|
spatialGridManager,
|
||||||
|
useScene,
|
||||||
|
type WallEvent,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||||
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import {
|
||||||
|
calculateCursorRotation,
|
||||||
|
calculateItemRotation,
|
||||||
|
getSideFromNormal,
|
||||||
|
isValidWallSideFace,
|
||||||
|
snapToHalf,
|
||||||
|
} from '../item/placement-math'
|
||||||
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||||
|
|
||||||
|
const edgeMaterial = new LineBasicNodeMaterial({
|
||||||
|
color: 0xef4444,
|
||||||
|
linewidth: 3,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Door tool — places DoorNodes on walls only.
|
||||||
|
* Doors always sit at floor level (clampedY = height/2).
|
||||||
|
*/
|
||||||
|
export const DoorTool: React.FC = () => {
|
||||||
|
const draftRef = useRef<DoorNode | null>(null)
|
||||||
|
const cursorGroupRef = useRef<Group>(null!)
|
||||||
|
const edgesRef = useRef<LineSegments>(null!)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
const getLevelId = () => useViewer.getState().selection.levelId
|
||||||
|
const getLevelYOffset = () => {
|
||||||
|
const id = getLevelId()
|
||||||
|
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||||
|
}
|
||||||
|
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||||
|
spatialGridManager.getSlabElevationForWall(
|
||||||
|
wallEvent.node.parentId ?? '',
|
||||||
|
wallEvent.node.start,
|
||||||
|
wallEvent.node.end,
|
||||||
|
)
|
||||||
|
|
||||||
|
const markWallDirty = (wallId: string) => {
|
||||||
|
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const destroyDraft = () => {
|
||||||
|
if (!draftRef.current) return
|
||||||
|
const wallId = draftRef.current.parentId
|
||||||
|
useScene.getState().deleteNode(draftRef.current.id)
|
||||||
|
draftRef.current = null
|
||||||
|
if (wallId) markWallDirty(wallId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideCursor = () => {
|
||||||
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCursor = (
|
||||||
|
worldPosition: [number, number, number],
|
||||||
|
cursorRotationY: number,
|
||||||
|
valid: boolean,
|
||||||
|
) => {
|
||||||
|
const group = cursorGroupRef.current
|
||||||
|
if (!group) return
|
||||||
|
group.visible = true
|
||||||
|
group.position.set(...worldPosition)
|
||||||
|
group.rotation.y = cursorRotationY
|
||||||
|
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallEnter = (event: WallEvent) => {
|
||||||
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
const levelId = getLevelId()
|
||||||
|
if (!levelId) return
|
||||||
|
if (event.node.parentId !== levelId) return
|
||||||
|
|
||||||
|
destroyDraft()
|
||||||
|
|
||||||
|
const side = getSideFromNormal(event.normal)
|
||||||
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||||
|
|
||||||
|
const localX = snapToHalf(event.localPosition[0])
|
||||||
|
const width = 0.9
|
||||||
|
const height = 2.1
|
||||||
|
|
||||||
|
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||||
|
|
||||||
|
const node = DoorNode.parse({
|
||||||
|
position: [clampedX, clampedY, 0],
|
||||||
|
rotation: [0, itemRotation, 0],
|
||||||
|
side,
|
||||||
|
wallId: event.node.id,
|
||||||
|
parentId: event.node.id,
|
||||||
|
metadata: { isTransient: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||||
|
draftRef.current = node
|
||||||
|
|
||||||
|
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||||
|
|
||||||
|
updateCursor(
|
||||||
|
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||||
|
cursorRotation,
|
||||||
|
valid,
|
||||||
|
)
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallMove = (event: WallEvent) => {
|
||||||
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
|
const side = getSideFromNormal(event.normal)
|
||||||
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||||
|
|
||||||
|
const localX = snapToHalf(event.localPosition[0])
|
||||||
|
const width = draftRef.current?.width ?? 0.9
|
||||||
|
const height = draftRef.current?.height ?? 2.1
|
||||||
|
|
||||||
|
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||||
|
|
||||||
|
if (draftRef.current) {
|
||||||
|
useScene.getState().updateNode(draftRef.current.id, {
|
||||||
|
position: [clampedX, clampedY, 0],
|
||||||
|
rotation: [0, itemRotation, 0],
|
||||||
|
side,
|
||||||
|
parentId: event.node.id,
|
||||||
|
wallId: event.node.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = !hasWallChildOverlap(
|
||||||
|
event.node.id, clampedX, clampedY, width, height,
|
||||||
|
draftRef.current?.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
updateCursor(
|
||||||
|
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||||
|
cursorRotation,
|
||||||
|
valid,
|
||||||
|
)
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallClick = (event: WallEvent) => {
|
||||||
|
if (!draftRef.current) return
|
||||||
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
|
const side = getSideFromNormal(event.normal)
|
||||||
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
|
|
||||||
|
const localX = snapToHalf(event.localPosition[0])
|
||||||
|
const { clampedX, clampedY } = clampToWall(
|
||||||
|
event.node, localX,
|
||||||
|
draftRef.current.width, draftRef.current.height,
|
||||||
|
)
|
||||||
|
const valid = !hasWallChildOverlap(
|
||||||
|
event.node.id, clampedX, clampedY,
|
||||||
|
draftRef.current.width, draftRef.current.height,
|
||||||
|
draftRef.current.id,
|
||||||
|
)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
const draft = draftRef.current
|
||||||
|
draftRef.current = null
|
||||||
|
|
||||||
|
useScene.getState().deleteNode(draft.id)
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
|
||||||
|
const node = DoorNode.parse({
|
||||||
|
position: [clampedX, clampedY, 0],
|
||||||
|
rotation: [0, itemRotation, 0],
|
||||||
|
side,
|
||||||
|
wallId: event.node.id,
|
||||||
|
parentId: event.node.id,
|
||||||
|
width: draft.width,
|
||||||
|
height: draft.height,
|
||||||
|
frameThickness: draft.frameThickness,
|
||||||
|
frameDepth: draft.frameDepth,
|
||||||
|
threshold: draft.threshold,
|
||||||
|
thresholdHeight: draft.thresholdHeight,
|
||||||
|
hingesSide: draft.hingesSide,
|
||||||
|
swingDirection: draft.swingDirection,
|
||||||
|
segments: draft.segments,
|
||||||
|
handle: draft.handle,
|
||||||
|
handleHeight: draft.handleHeight,
|
||||||
|
handleSide: draft.handleSide,
|
||||||
|
doorCloser: draft.doorCloser,
|
||||||
|
panicBar: draft.panicBar,
|
||||||
|
panicBarHeight: draft.panicBarHeight,
|
||||||
|
})
|
||||||
|
|
||||||
|
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallLeave = () => {
|
||||||
|
destroyDraft()
|
||||||
|
hideCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
destroyDraft()
|
||||||
|
hideCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('wall:enter', onWallEnter)
|
||||||
|
emitter.on('wall:move', onWallMove)
|
||||||
|
emitter.on('wall:click', onWallClick)
|
||||||
|
emitter.on('wall:leave', onWallLeave)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
destroyDraft()
|
||||||
|
hideCursor()
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
emitter.off('wall:enter', onWallEnter)
|
||||||
|
emitter.off('wall:move', onWallMove)
|
||||||
|
emitter.off('wall:click', onWallClick)
|
||||||
|
emitter.off('wall:leave', onWallLeave)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Cursor geometry: door outline (default 0.9 × 2.1 × 0.07)
|
||||||
|
const boxGeo = new BoxGeometry(0.9, 2.1, 0.07)
|
||||||
|
const edgesGeo = new EdgesGeometry(boxGeo)
|
||||||
|
boxGeo.dispose()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={cursorGroupRef} visible={false}>
|
||||||
|
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} />
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
DoorNode,
|
||||||
|
emitter,
|
||||||
|
sceneRegistry,
|
||||||
|
spatialGridManager,
|
||||||
|
useScene,
|
||||||
|
type WallEvent,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||||
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import {
|
||||||
|
calculateCursorRotation,
|
||||||
|
calculateItemRotation,
|
||||||
|
getSideFromNormal,
|
||||||
|
isValidWallSideFace,
|
||||||
|
snapToHalf,
|
||||||
|
} from '../item/placement-math'
|
||||||
|
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||||
|
|
||||||
|
const edgeMaterial = new LineBasicNodeMaterial({
|
||||||
|
color: 0xef4444,
|
||||||
|
linewidth: 3,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
||||||
|
const cursorGroupRef = useRef<Group>(null!)
|
||||||
|
|
||||||
|
const exitMoveMode = () => {
|
||||||
|
useEditor.getState().setMovingNode(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
const meta = (typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null)
|
||||||
|
? movingDoorNode.metadata as Record<string, unknown>
|
||||||
|
: {}
|
||||||
|
const isNew = !!meta.isNew
|
||||||
|
|
||||||
|
const original = {
|
||||||
|
position: [...movingDoorNode.position] as [number, number, number],
|
||||||
|
rotation: [...movingDoorNode.rotation] as [number, number, number],
|
||||||
|
side: movingDoorNode.side,
|
||||||
|
parentId: movingDoorNode.parentId,
|
||||||
|
wallId: movingDoorNode.wallId,
|
||||||
|
metadata: movingDoorNode.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNew) {
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
metadata: { ...meta, isTransient: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentWallId: string | null = movingDoorNode.parentId
|
||||||
|
|
||||||
|
const markWallDirty = (wallId: string | null) => {
|
||||||
|
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLevelId = () => useViewer.getState().selection.levelId
|
||||||
|
const getLevelYOffset = () => {
|
||||||
|
const id = getLevelId()
|
||||||
|
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||||
|
}
|
||||||
|
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||||
|
spatialGridManager.getSlabElevationForWall(
|
||||||
|
wallEvent.node.parentId ?? '',
|
||||||
|
wallEvent.node.start,
|
||||||
|
wallEvent.node.end,
|
||||||
|
)
|
||||||
|
|
||||||
|
const hideCursor = () => {
|
||||||
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCursor = (
|
||||||
|
worldPosition: [number, number, number],
|
||||||
|
cursorRotationY: number,
|
||||||
|
valid: boolean,
|
||||||
|
) => {
|
||||||
|
const group = cursorGroupRef.current
|
||||||
|
if (!group) return
|
||||||
|
group.visible = true
|
||||||
|
group.position.set(...worldPosition)
|
||||||
|
group.rotation.y = cursorRotationY
|
||||||
|
edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallEnter = (event: WallEvent) => {
|
||||||
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
|
const side = getSideFromNormal(event.normal)
|
||||||
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||||
|
|
||||||
|
const localX = snapToHalf(event.localPosition[0])
|
||||||
|
const { clampedX, clampedY } = clampToWall(
|
||||||
|
event.node, localX,
|
||||||
|
movingDoorNode.width, movingDoorNode.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
const prevWallId = currentWallId
|
||||||
|
currentWallId = event.node.id
|
||||||
|
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
position: [clampedX, clampedY, 0],
|
||||||
|
rotation: [0, itemRotation, 0],
|
||||||
|
side,
|
||||||
|
parentId: event.node.id,
|
||||||
|
wallId: event.node.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
||||||
|
markWallDirty(event.node.id)
|
||||||
|
|
||||||
|
const valid = !hasWallChildOverlap(
|
||||||
|
event.node.id, clampedX, clampedY,
|
||||||
|
movingDoorNode.width, movingDoorNode.height,
|
||||||
|
movingDoorNode.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
updateCursor(
|
||||||
|
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||||
|
cursorRotation,
|
||||||
|
valid,
|
||||||
|
)
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallMove = (event: WallEvent) => {
|
||||||
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
|
const side = getSideFromNormal(event.normal)
|
||||||
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
|
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||||
|
|
||||||
|
const localX = snapToHalf(event.localPosition[0])
|
||||||
|
const { clampedX, clampedY } = clampToWall(
|
||||||
|
event.node, localX,
|
||||||
|
movingDoorNode.width, movingDoorNode.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
position: [clampedX, clampedY, 0],
|
||||||
|
rotation: [0, itemRotation, 0],
|
||||||
|
side,
|
||||||
|
parentId: event.node.id,
|
||||||
|
wallId: event.node.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (currentWallId !== event.node.id) {
|
||||||
|
markWallDirty(currentWallId)
|
||||||
|
currentWallId = event.node.id
|
||||||
|
}
|
||||||
|
markWallDirty(event.node.id)
|
||||||
|
|
||||||
|
const valid = !hasWallChildOverlap(
|
||||||
|
event.node.id, clampedX, clampedY,
|
||||||
|
movingDoorNode.width, movingDoorNode.height,
|
||||||
|
movingDoorNode.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
updateCursor(
|
||||||
|
wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)),
|
||||||
|
cursorRotation,
|
||||||
|
valid,
|
||||||
|
)
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallClick = (event: WallEvent) => {
|
||||||
|
if (!isValidWallSideFace(event.normal)) return
|
||||||
|
if (event.node.parentId !== getLevelId()) return
|
||||||
|
|
||||||
|
const side = getSideFromNormal(event.normal)
|
||||||
|
const itemRotation = calculateItemRotation(event.normal)
|
||||||
|
|
||||||
|
const localX = snapToHalf(event.localPosition[0])
|
||||||
|
const { clampedX, clampedY } = clampToWall(
|
||||||
|
event.node, localX,
|
||||||
|
movingDoorNode.width, movingDoorNode.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
const valid = !hasWallChildOverlap(
|
||||||
|
event.node.id, clampedX, clampedY,
|
||||||
|
movingDoorNode.width, movingDoorNode.height,
|
||||||
|
movingDoorNode.id,
|
||||||
|
)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
let placedId: string
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
useScene.getState().deleteNode(movingDoorNode.id)
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
|
||||||
|
const node = DoorNode.parse({
|
||||||
|
position: [clampedX, clampedY, 0],
|
||||||
|
rotation: [0, itemRotation, 0],
|
||||||
|
side,
|
||||||
|
wallId: event.node.id,
|
||||||
|
parentId: event.node.id,
|
||||||
|
width: movingDoorNode.width,
|
||||||
|
height: movingDoorNode.height,
|
||||||
|
frameThickness: movingDoorNode.frameThickness,
|
||||||
|
frameDepth: movingDoorNode.frameDepth,
|
||||||
|
threshold: movingDoorNode.threshold,
|
||||||
|
thresholdHeight: movingDoorNode.thresholdHeight,
|
||||||
|
hingesSide: movingDoorNode.hingesSide,
|
||||||
|
swingDirection: movingDoorNode.swingDirection,
|
||||||
|
segments: movingDoorNode.segments,
|
||||||
|
handle: movingDoorNode.handle,
|
||||||
|
handleHeight: movingDoorNode.handleHeight,
|
||||||
|
handleSide: movingDoorNode.handleSide,
|
||||||
|
doorCloser: movingDoorNode.doorCloser,
|
||||||
|
panicBar: movingDoorNode.panicBar,
|
||||||
|
panicBarHeight: movingDoorNode.panicBarHeight,
|
||||||
|
})
|
||||||
|
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||||
|
placedId = node.id
|
||||||
|
} else {
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
position: original.position,
|
||||||
|
rotation: original.rotation,
|
||||||
|
side: original.side,
|
||||||
|
parentId: original.parentId,
|
||||||
|
wallId: original.wallId,
|
||||||
|
metadata: original.metadata,
|
||||||
|
})
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
position: [clampedX, clampedY, 0],
|
||||||
|
rotation: [0, itemRotation, 0],
|
||||||
|
side,
|
||||||
|
parentId: event.node.id,
|
||||||
|
wallId: event.node.id,
|
||||||
|
metadata: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (original.parentId && original.parentId !== event.node.id) {
|
||||||
|
markWallDirty(original.parentId)
|
||||||
|
}
|
||||||
|
placedId = movingDoorNode.id
|
||||||
|
}
|
||||||
|
|
||||||
|
markWallDirty(event.node.id)
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
hideCursor()
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||||
|
exitMoveMode()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWallLeave = () => {
|
||||||
|
hideCursor()
|
||||||
|
if (isNew) return
|
||||||
|
if (currentWallId && currentWallId !== original.parentId) {
|
||||||
|
markWallDirty(currentWallId)
|
||||||
|
}
|
||||||
|
currentWallId = original.parentId
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
position: original.position,
|
||||||
|
rotation: original.rotation,
|
||||||
|
side: original.side,
|
||||||
|
parentId: original.parentId,
|
||||||
|
wallId: original.wallId,
|
||||||
|
})
|
||||||
|
if (original.parentId) markWallDirty(original.parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
if (isNew) {
|
||||||
|
useScene.getState().deleteNode(movingDoorNode.id)
|
||||||
|
if (currentWallId) markWallDirty(currentWallId)
|
||||||
|
} else {
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
position: original.position,
|
||||||
|
rotation: original.rotation,
|
||||||
|
side: original.side,
|
||||||
|
parentId: original.parentId,
|
||||||
|
wallId: original.wallId,
|
||||||
|
metadata: original.metadata,
|
||||||
|
})
|
||||||
|
if (original.parentId) markWallDirty(original.parentId)
|
||||||
|
}
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
hideCursor()
|
||||||
|
exitMoveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('wall:enter', onWallEnter)
|
||||||
|
emitter.on('wall:move', onWallMove)
|
||||||
|
emitter.on('wall:click', onWallClick)
|
||||||
|
emitter.on('wall:leave', onWallLeave)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
|
||||||
|
const currentMeta = current?.metadata as Record<string, unknown> | undefined
|
||||||
|
if (currentMeta?.isTransient) {
|
||||||
|
if (isNew) {
|
||||||
|
useScene.getState().deleteNode(movingDoorNode.id)
|
||||||
|
if (currentWallId) markWallDirty(currentWallId)
|
||||||
|
} else {
|
||||||
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
|
position: original.position,
|
||||||
|
rotation: original.rotation,
|
||||||
|
side: original.side,
|
||||||
|
parentId: original.parentId,
|
||||||
|
wallId: original.wallId,
|
||||||
|
metadata: original.metadata,
|
||||||
|
})
|
||||||
|
if (original.parentId) markWallDirty(original.parentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useScene.temporal.getState().resume()
|
||||||
|
emitter.off('wall:enter', onWallEnter)
|
||||||
|
emitter.off('wall:move', onWallMove)
|
||||||
|
emitter.off('wall:click', onWallClick)
|
||||||
|
emitter.off('wall:leave', onWallLeave)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
}
|
||||||
|
}, [movingDoorNode])
|
||||||
|
|
||||||
|
const edgesGeo = useMemo(() => {
|
||||||
|
const boxGeo = new BoxGeometry(
|
||||||
|
movingDoorNode.width,
|
||||||
|
movingDoorNode.height,
|
||||||
|
movingDoorNode.frameDepth ?? 0.07,
|
||||||
|
)
|
||||||
|
const geo = new EdgesGeometry(boxGeo)
|
||||||
|
boxGeo.dispose()
|
||||||
|
return geo
|
||||||
|
}, [movingDoorNode])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={cursorGroupRef} visible={false}>
|
||||||
|
<lineSegments geometry={edgesGeo} material={edgeMaterial} />
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ItemNode, WindowNode } from '@pascal-app/core'
|
import type { DoorNode, ItemNode, WindowNode } from '@pascal-app/core'
|
||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { MoveDoorTool } from '../door/move-door-tool'
|
||||||
import { MoveWindowTool } from '../window/move-window-tool'
|
import { MoveWindowTool } from '../window/move-window-tool'
|
||||||
import type { PlacementState } from './placement-types'
|
import type { PlacementState } from './placement-types'
|
||||||
import { useDraftNode } from './use-draft-node'
|
import { useDraftNode } from './use-draft-node'
|
||||||
@@ -67,6 +68,7 @@ export const MoveTool: React.FC = () => {
|
|||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
|
|
||||||
if (!movingNode) return null
|
if (!movingNode) return null
|
||||||
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
||||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
||||||
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import useEditor, { type Phase, type Tool } from '@/store/use-editor'
|
|||||||
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
||||||
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
||||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||||
|
import { DoorTool } from './door/door-tool'
|
||||||
import { ItemTool } from './item/item-tool'
|
import { ItemTool } from './item/item-tool'
|
||||||
import { MoveTool } from './item/move-tool'
|
import { MoveTool } from './item/move-tool'
|
||||||
import { RoofTool } from './roof/roof-tool'
|
import { RoofTool } from './roof/roof-tool'
|
||||||
@@ -25,6 +26,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|||||||
slab: SlabTool,
|
slab: SlabTool,
|
||||||
ceiling: CeilingTool,
|
ceiling: CeilingTool,
|
||||||
roof: RoofTool,
|
roof: RoofTool,
|
||||||
|
door: DoorTool,
|
||||||
item: ItemTool,
|
item: ItemTool,
|
||||||
zone: ZoneTool,
|
zone: ZoneTool,
|
||||||
window: WindowTool,
|
window: WindowTool,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getScaledDimensions, type AnyNodeId, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
|
import { type AnyNodeId, type DoorNode, getScaledDimensions, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||||
@@ -90,6 +90,12 @@ export function hasWallChildOverlap(
|
|||||||
childRight = win.position[0] + win.width / 2
|
childRight = win.position[0] + win.width / 2
|
||||||
childBottom = win.position[1] - win.height / 2 // windows store center Y
|
childBottom = win.position[1] - win.height / 2 // windows store center Y
|
||||||
childTop = win.position[1] + win.height / 2
|
childTop = win.position[1] + win.height / 2
|
||||||
|
} else if (child.type === 'door') {
|
||||||
|
const door = child as DoorNode
|
||||||
|
childLeft = door.position[0] - door.width / 2
|
||||||
|
childRight = door.position[0] + door.width / 2
|
||||||
|
childBottom = door.position[1] - door.height / 2 // doors store center Y
|
||||||
|
childTop = door.position[1] + door.height / 2
|
||||||
} else {
|
} else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const tools: ToolConfig[] = [
|
|||||||
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
||||||
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
||||||
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
|
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
|
||||||
{ id: 'item', iconSrc: '/icons/door.png', label: 'Door', catalogCategory: 'door' },
|
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
||||||
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
||||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,644 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||||
|
import { Switch } from '@/components/ui/primitives/switch'
|
||||||
|
|
||||||
|
export function DoorPanel() {
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
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 DoorNode | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(updates: Partial<DoorNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
|
||||||
|
},
|
||||||
|
[selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
const handleFlip = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
handleUpdate({
|
||||||
|
side: node.side === 'front' ? 'back' : 'front',
|
||||||
|
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
||||||
|
})
|
||||||
|
}, [node, handleUpdate])
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
setMovingNode(node)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!selectedId || !node) return
|
||||||
|
sfxEmitter.emit('sfx:item-delete')
|
||||||
|
deleteNode(selectedId as AnyNode['id'])
|
||||||
|
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [selectedId, node, deleteNode, setSelection])
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
if (!node || !node.parentId) return
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
|
const duplicate = DoorNode.parse({
|
||||||
|
position: [...node.position] as [number, number, number],
|
||||||
|
rotation: [...node.rotation] as [number, number, number],
|
||||||
|
side: node.side,
|
||||||
|
wallId: node.wallId,
|
||||||
|
parentId: node.parentId,
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
frameThickness: node.frameThickness,
|
||||||
|
frameDepth: node.frameDepth,
|
||||||
|
threshold: node.threshold,
|
||||||
|
thresholdHeight: node.thresholdHeight,
|
||||||
|
hingesSide: node.hingesSide,
|
||||||
|
swingDirection: node.swingDirection,
|
||||||
|
segments: node.segments.map(s => ({ ...s, columnRatios: [...s.columnRatios] })),
|
||||||
|
handle: node.handle,
|
||||||
|
handleHeight: node.handleHeight,
|
||||||
|
handleSide: node.handleSide,
|
||||||
|
doorCloser: node.doorCloser,
|
||||||
|
panicBar: node.panicBar,
|
||||||
|
panicBarHeight: node.panicBarHeight,
|
||||||
|
metadata: { isNew: true },
|
||||||
|
})
|
||||||
|
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
|
||||||
|
setMovingNode(duplicate)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
|
||||||
|
const numSegs = node!.segments.length
|
||||||
|
const totalH = node!.segments.reduce((sum, s) => sum + s.heightRatio, 0)
|
||||||
|
const normH = node!.segments.map(s => s.heightRatio / totalH)
|
||||||
|
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||||
|
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
|
||||||
|
const delta = clamped - normH[segIdx]!
|
||||||
|
const neighborVal = Math.max(0.05, normH[neighborIdx]! - delta)
|
||||||
|
const newRatios = normH.map((v, i) => {
|
||||||
|
if (i === segIdx) return clamped
|
||||||
|
if (i === neighborIdx) return neighborVal
|
||||||
|
return v
|
||||||
|
})
|
||||||
|
const updated = node!.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
|
||||||
|
handleUpdate({ segments: updated })
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSegmentColumnRatio = (segIdx: number, colIdx: number, newVal: number) => {
|
||||||
|
const seg = node!.segments[segIdx]!
|
||||||
|
const normRatios = (() => {
|
||||||
|
const sum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||||
|
return seg.columnRatios.map(r => r / sum)
|
||||||
|
})()
|
||||||
|
const numCols = normRatios.length
|
||||||
|
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||||
|
const neighborIdx = colIdx < numCols - 1 ? colIdx + 1 : colIdx - 1
|
||||||
|
const delta = clamped - normRatios[colIdx]!
|
||||||
|
const neighborVal = Math.max(0.05, normRatios[neighborIdx]! - delta)
|
||||||
|
const newRatios = normRatios.map((v, i) => {
|
||||||
|
if (i === colIdx) return clamped
|
||||||
|
if (i === neighborIdx) return neighborVal
|
||||||
|
return v
|
||||||
|
})
|
||||||
|
const updated = node!.segments.map((s, idx) =>
|
||||||
|
idx === segIdx ? { ...s, columnRatios: newRatios } : s,
|
||||||
|
)
|
||||||
|
handleUpdate({ segments: updated })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
|
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
|
||||||
|
const normHeights = node.segments.map(seg => seg.heightRatio / hSum)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md max-h-[calc(100dvh-100px)]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Image src="/icons/door.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
|
<h2 className="font-semibold text-foreground text-sm truncate">
|
||||||
|
{node.name || `Door (${node.width}×${node.height}m)`}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||||
|
|
||||||
|
{/* Position */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Position
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<NumberInput
|
||||||
|
label="X along wall"
|
||||||
|
value={Math.round(node.position[0] * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||||
|
precision={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleFlip}
|
||||||
|
>
|
||||||
|
<FlipHorizontal2 className="h-3.5 w-3.5" />
|
||||||
|
Flip Side
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dimensions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Dimensions
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Width"
|
||||||
|
value={Math.round(node.width * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ width: v })}
|
||||||
|
min={0.5}
|
||||||
|
precision={2}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Height"
|
||||||
|
value={Math.round(node.height * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })}
|
||||||
|
min={1.0}
|
||||||
|
precision={2}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frame */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Frame
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Thickness"
|
||||||
|
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||||
|
min={0.01}
|
||||||
|
precision={3}
|
||||||
|
step={0.01}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Depth"
|
||||||
|
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||||
|
min={0.01}
|
||||||
|
precision={3}
|
||||||
|
step={0.01}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Padding */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Content Padding
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Horizontal"
|
||||||
|
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||||
|
min={0}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Vertical"
|
||||||
|
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||||
|
min={0}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Swing */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Swing
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Hinges</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['left', 'right'] as const).map((side) => (
|
||||||
|
<button
|
||||||
|
key={side}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdate({ hingesSide: side })}
|
||||||
|
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
|
||||||
|
node.hingesSide === side
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{side.charAt(0).toUpperCase() + side.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Direction</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['inward', 'outward'] as const).map((dir) => (
|
||||||
|
<button
|
||||||
|
key={dir}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdate({ swingDirection: dir })}
|
||||||
|
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
|
||||||
|
node.swingDirection === dir
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dir.charAt(0).toUpperCase() + dir.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Threshold */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Threshold
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
checked={node.threshold}
|
||||||
|
onCheckedChange={(checked) => handleUpdate({ threshold: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{node.threshold && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Height"
|
||||||
|
value={Math.round(node.thresholdHeight * 1000) / 1000}
|
||||||
|
onChange={(v) => handleUpdate({ thresholdHeight: v })}
|
||||||
|
min={0.005}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Handle */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Handle
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
checked={node.handle}
|
||||||
|
onCheckedChange={(checked) => handleUpdate({ handle: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{node.handle && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Height"
|
||||||
|
value={Math.round(node.handleHeight * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ handleHeight: v })}
|
||||||
|
min={0.5}
|
||||||
|
max={node.height - 0.1}
|
||||||
|
precision={2}
|
||||||
|
step={0.05}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Side</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['left', 'right'] as const).map((side) => (
|
||||||
|
<button
|
||||||
|
key={side}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdate({ handleSide: side })}
|
||||||
|
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
|
||||||
|
node.handleSide === side
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{side.charAt(0).toUpperCase() + side.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hardware */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Hardware
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-foreground">Door Closer</span>
|
||||||
|
<Switch
|
||||||
|
checked={node.doorCloser}
|
||||||
|
onCheckedChange={(checked) => handleUpdate({ doorCloser: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-foreground">Panic Bar</span>
|
||||||
|
<Switch
|
||||||
|
checked={node.panicBar}
|
||||||
|
onCheckedChange={(checked) => handleUpdate({ panicBar: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{node.panicBar && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Bar height"
|
||||||
|
value={Math.round(node.panicBarHeight * 100) / 100}
|
||||||
|
onChange={(v) => handleUpdate({ panicBarHeight: v })}
|
||||||
|
min={0.5}
|
||||||
|
max={node.height - 0.1}
|
||||||
|
precision={2}
|
||||||
|
step={0.05}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segments */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Leaf segments (top → bottom)
|
||||||
|
</label>
|
||||||
|
{node.segments.map((seg, i) => {
|
||||||
|
const numCols = seg.columnRatios.length
|
||||||
|
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||||
|
const normCols = seg.columnRatios.map(r => r / colSum)
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded border border-border p-2 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">Segment {i + 1}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['panel', 'glass', 'empty'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const updated = node.segments.map((s, idx) =>
|
||||||
|
idx === i ? { ...s, type: t } : s,
|
||||||
|
)
|
||||||
|
handleUpdate({ segments: updated })
|
||||||
|
}}
|
||||||
|
className={`rounded border px-1.5 py-0.5 text-xs cursor-pointer transition-colors ${
|
||||||
|
seg.type === t
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Height"
|
||||||
|
value={Math.round(normHeights[i]! * 100 * 10) / 10}
|
||||||
|
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
|
||||||
|
min={5}
|
||||||
|
max={95}
|
||||||
|
precision={1}
|
||||||
|
step={1}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
||||||
|
</div>
|
||||||
|
{/* Columns */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<NumberInput
|
||||||
|
label="Columns"
|
||||||
|
value={numCols}
|
||||||
|
onChange={(v) => {
|
||||||
|
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||||
|
const updated = node.segments.map((s, idx) =>
|
||||||
|
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
|
||||||
|
)
|
||||||
|
handleUpdate({ segments: updated })
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={8}
|
||||||
|
precision={0}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
{numCols > 1 && (
|
||||||
|
<div className="space-y-1 pl-1">
|
||||||
|
{normCols.map((ratio, ci) => (
|
||||||
|
<div key={ci} className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label={`C${ci + 1}`}
|
||||||
|
value={Math.round(ratio * 100 * 10) / 10}
|
||||||
|
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
|
||||||
|
min={5}
|
||||||
|
max={95}
|
||||||
|
precision={1}
|
||||||
|
step={1}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Divider"
|
||||||
|
value={Math.round(seg.dividerThickness * 1000) / 1000}
|
||||||
|
onChange={(v) => {
|
||||||
|
const updated = node.segments.map((s, idx) =>
|
||||||
|
idx === i ? { ...s, dividerThickness: v } : s,
|
||||||
|
)
|
||||||
|
handleUpdate({ segments: updated })
|
||||||
|
}}
|
||||||
|
min={0.005}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{seg.type === 'panel' && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Inset"
|
||||||
|
value={Math.round(seg.panelInset * 1000) / 1000}
|
||||||
|
onChange={(v) => {
|
||||||
|
const updated = node.segments.map((s, idx) =>
|
||||||
|
idx === i ? { ...s, panelInset: v } : s,
|
||||||
|
)
|
||||||
|
handleUpdate({ segments: updated })
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<NumberInput
|
||||||
|
label="Depth"
|
||||||
|
value={Math.round(seg.panelDepth * 1000) / 1000}
|
||||||
|
onChange={(v) => {
|
||||||
|
const updated = node.segments.map((s, idx) =>
|
||||||
|
idx === i ? { ...s, panelDepth: v } : s,
|
||||||
|
)
|
||||||
|
handleUpdate({ segments: updated })
|
||||||
|
}}
|
||||||
|
min={0}
|
||||||
|
precision={3}
|
||||||
|
step={0.005}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const updated = [
|
||||||
|
...node.segments,
|
||||||
|
{ type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
|
||||||
|
]
|
||||||
|
handleUpdate({ segments: updated })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add segment
|
||||||
|
</button>
|
||||||
|
{node.segments.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
handleUpdate({ segments: node.segments.slice(0, -1) })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
− Remove last
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="border-t p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleMove}
|
||||||
|
>
|
||||||
|
<Move className="h-3.5 w-3.5" />
|
||||||
|
<span>Move</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
<span>Duplicate</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { ReferencePanel } from './reference-panel'
|
|||||||
import { RoofPanel } from './roof-panel'
|
import { RoofPanel } from './roof-panel'
|
||||||
import { SlabPanel } from './slab-panel'
|
import { SlabPanel } from './slab-panel'
|
||||||
import { WallPanel } from './wall-panel'
|
import { WallPanel } from './wall-panel'
|
||||||
|
import { DoorPanel } from './door-panel'
|
||||||
import { WindowPanel } from './window-panel'
|
import { WindowPanel } from './window-panel'
|
||||||
|
|
||||||
export function PanelManager() {
|
export function PanelManager() {
|
||||||
@@ -37,6 +38,8 @@ export function PanelManager() {
|
|||||||
return <CeilingPanel />
|
return <CeilingPanel />
|
||||||
case 'wall':
|
case 'wall':
|
||||||
return <WallPanel />
|
return <WallPanel />
|
||||||
|
case 'door':
|
||||||
|
return <DoorPanel />
|
||||||
case 'window':
|
case 'window':
|
||||||
return <WindowPanel />
|
return <WindowPanel />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { DoorNode } from "@pascal-app/core"
|
||||||
|
import { useViewer } from "@pascal-app/viewer"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { RenamePopover } from "./rename-popover"
|
||||||
|
import { TreeNodeWrapper } from "./tree-node"
|
||||||
|
import { TreeNodeActions } from "./tree-node-actions"
|
||||||
|
|
||||||
|
interface DoorTreeNodeProps {
|
||||||
|
node: DoorNode
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) {
|
||||||
|
const [renameOpen, setRenameOpen] = useState(false)
|
||||||
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
|
||||||
|
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||||
|
|
||||||
|
const defaultName = `Door (${node.width}×${node.height}m)`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RenamePopover
|
||||||
|
node={node}
|
||||||
|
open={renameOpen}
|
||||||
|
onOpenChange={setRenameOpen}
|
||||||
|
defaultName={defaultName}
|
||||||
|
>
|
||||||
|
<TreeNodeWrapper
|
||||||
|
icon={<Image src="/icons/door.png" alt="" width={14} height={14} className="object-contain" />}
|
||||||
|
label={node.name || defaultName}
|
||||||
|
depth={depth}
|
||||||
|
hasChildren={false}
|
||||||
|
expanded={false}
|
||||||
|
onToggle={() => {}}
|
||||||
|
onClick={() => setSelection({ selectedIds: [node.id] })}
|
||||||
|
onDoubleClick={() => setRenameOpen(true)}
|
||||||
|
onMouseEnter={() => setHoveredId(node.id)}
|
||||||
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isHovered={isHovered}
|
||||||
|
isVisible={node.visible !== false}
|
||||||
|
actions={<TreeNodeActions node={node} />}
|
||||||
|
/>
|
||||||
|
</RenamePopover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { forwardRef } from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { BuildingTreeNode } from "./building-tree-node";
|
import { BuildingTreeNode } from "./building-tree-node";
|
||||||
import { CeilingTreeNode } from "./ceiling-tree-node";
|
import { CeilingTreeNode } from "./ceiling-tree-node";
|
||||||
|
import { DoorTreeNode } from "./door-tree-node";
|
||||||
import { ItemTreeNode } from "./item-tree-node";
|
import { ItemTreeNode } from "./item-tree-node";
|
||||||
import { LevelTreeNode } from "./level-tree-node";
|
import { LevelTreeNode } from "./level-tree-node";
|
||||||
import { RoofTreeNode } from "./roof-tree-node";
|
import { RoofTreeNode } from "./roof-tree-node";
|
||||||
@@ -37,6 +38,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
|
|||||||
return <RoofTreeNode node={node} depth={depth} />;
|
return <RoofTreeNode node={node} depth={depth} />;
|
||||||
case "item":
|
case "item":
|
||||||
return <ItemTreeNode node={node} depth={depth} />;
|
return <ItemTreeNode node={node} depth={depth} />;
|
||||||
|
case "door":
|
||||||
|
return <DoorTreeNode node={node} depth={depth} />;
|
||||||
case "window":
|
case "window":
|
||||||
return <WindowTreeNode node={node} depth={depth} />;
|
return <WindowTreeNode node={node} depth={depth} />;
|
||||||
case "zone":
|
case "zone":
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { AssetInput } from '@pascal-app/core'
|
import type { AssetInput } from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
|
type DoorNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type Space,
|
type Space,
|
||||||
@@ -29,6 +30,7 @@ export type StructureTool =
|
|||||||
| 'item'
|
| 'item'
|
||||||
| 'zone'
|
| 'zone'
|
||||||
| 'window'
|
| 'window'
|
||||||
|
| 'door'
|
||||||
|
|
||||||
// Furnish mode tools (items and decoration)
|
// Furnish mode tools (items and decoration)
|
||||||
export type FurnishTool = 'item'
|
export type FurnishTool = 'item'
|
||||||
@@ -64,8 +66,8 @@ type EditorState = {
|
|||||||
setCatalogCategory: (category: CatalogCategory | null) => void
|
setCatalogCategory: (category: CatalogCategory | null) => void
|
||||||
selectedItem: AssetInput | null
|
selectedItem: AssetInput | null
|
||||||
setSelectedItem: (item: AssetInput) => void
|
setSelectedItem: (item: AssetInput) => void
|
||||||
movingNode: ItemNode | WindowNode | null
|
movingNode: ItemNode | WindowNode | DoorNode | null
|
||||||
setMovingNode: (node: ItemNode | WindowNode | null) => void
|
setMovingNode: (node: ItemNode | WindowNode | DoorNode | null) => void
|
||||||
selectedReferenceId: string | null
|
selectedReferenceId: string | null
|
||||||
setSelectedReferenceId: (id: string | null) => void
|
setSelectedReferenceId: (id: string | null) => void
|
||||||
// Space detection for cutaway mode
|
// Space detection for cutaway mode
|
||||||
@@ -194,7 +196,7 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
setCatalogCategory: (category) => set({ catalogCategory: category }),
|
||||||
selectedItem: null,
|
selectedItem: null,
|
||||||
setSelectedItem: (item) => set({ selectedItem: item }),
|
setSelectedItem: (item) => set({ selectedItem: item }),
|
||||||
movingNode: null,
|
movingNode: null as ItemNode | WindowNode | DoorNode | null,
|
||||||
setMovingNode: (node) => set({ movingNode: node }),
|
setMovingNode: (node) => set({ movingNode: node }),
|
||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ThreeEvent } from '@react-three/fiber'
|
import type { ThreeEvent } from '@react-three/fiber'
|
||||||
import mitt from 'mitt'
|
import mitt from 'mitt'
|
||||||
import type { BuildingNode, CeilingNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode } from '../schema'
|
import type { BuildingNode, CeilingNode, DoorNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode } from '../schema'
|
||||||
import type { AnyNode } from '../schema/types'
|
import type { AnyNode } from '../schema/types'
|
||||||
|
|
||||||
// Base event interfaces
|
// Base event interfaces
|
||||||
@@ -28,6 +28,7 @@ export type SlabEvent = NodeEvent<SlabNode>
|
|||||||
export type CeilingEvent = NodeEvent<CeilingNode>
|
export type CeilingEvent = NodeEvent<CeilingNode>
|
||||||
export type RoofEvent = NodeEvent<RoofNode>
|
export type RoofEvent = NodeEvent<RoofNode>
|
||||||
export type WindowEvent = NodeEvent<WindowNode>
|
export type WindowEvent = NodeEvent<WindowNode>
|
||||||
|
export type DoorEvent = NodeEvent<DoorNode>
|
||||||
|
|
||||||
// Event suffixes - exported for use in hooks
|
// Event suffixes - exported for use in hooks
|
||||||
export const eventSuffixes = [
|
export const eventSuffixes = [
|
||||||
@@ -83,6 +84,7 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'ceiling', CeilingEvent> &
|
NodeEvents<'ceiling', CeilingEvent> &
|
||||||
NodeEvents<'roof', RoofEvent> &
|
NodeEvents<'roof', RoofEvent> &
|
||||||
NodeEvents<'window', WindowEvent> &
|
NodeEvents<'window', WindowEvent> &
|
||||||
|
NodeEvents<'door', DoorEvent> &
|
||||||
CameraControlEvents &
|
CameraControlEvents &
|
||||||
ToolEvents
|
ToolEvents
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const sceneRegistry = {
|
|||||||
scan: new Set<string>(),
|
scan: new Set<string>(),
|
||||||
guide: new Set<string>(),
|
guide: new Set<string>(),
|
||||||
window: new Set<string>(),
|
window: new Set<string>(),
|
||||||
|
door: new Set<string>(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type {
|
|||||||
BuildingEvent,
|
BuildingEvent,
|
||||||
CameraControlEvent,
|
CameraControlEvent,
|
||||||
CeilingEvent,
|
CeilingEvent,
|
||||||
|
DoorEvent,
|
||||||
EventSuffix,
|
EventSuffix,
|
||||||
GridEvent,
|
GridEvent,
|
||||||
ItemEvent,
|
ItemEvent,
|
||||||
@@ -43,6 +44,7 @@ export * from './schema'
|
|||||||
export { default as useScene } from './store/use-scene'
|
export { default as useScene } from './store/use-scene'
|
||||||
// Systems
|
// Systems
|
||||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||||
|
export { DoorSystem } from './systems/door/door-system'
|
||||||
export { ItemSystem } from './systems/item/item-system'
|
export { ItemSystem } from './systems/item/item-system'
|
||||||
export { RoofSystem } from './systems/roof/roof-system'
|
export { RoofSystem } from './systems/roof/roof-system'
|
||||||
export { SlabSystem } from './systems/slab/slab-system'
|
export { SlabSystem } from './systems/slab/slab-system'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export { RoofNode } from './nodes/roof'
|
|||||||
export { ScanNode } from './nodes/scan'
|
export { ScanNode } from './nodes/scan'
|
||||||
export { GuideNode } from './nodes/guide'
|
export { GuideNode } from './nodes/guide'
|
||||||
export type { AnyNodeId, AnyNodeType } from './types'
|
export type { AnyNodeId, AnyNodeType } from './types'
|
||||||
|
export { DoorNode, DoorSegment } from './nodes/door'
|
||||||
export { WindowNode } from './nodes/window'
|
export { WindowNode } from './nodes/window'
|
||||||
// Union types
|
// Union types
|
||||||
export { AnyNode } from './types'
|
export { AnyNode } from './types'
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
export const DoorSegment = z.object({
|
||||||
|
type: z.enum(['panel', 'glass', 'empty']),
|
||||||
|
heightRatio: z.number(),
|
||||||
|
|
||||||
|
// Each segment controls its own column split
|
||||||
|
columnRatios: z.array(z.number()).default([1]),
|
||||||
|
dividerThickness: z.number().default(0.03),
|
||||||
|
|
||||||
|
// panel-specific
|
||||||
|
panelDepth: z.number().default(0.01), // + raised, - recessed
|
||||||
|
panelInset: z.number().default(0.04),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type DoorSegment = z.infer<typeof DoorSegment>
|
||||||
|
|
||||||
|
export const DoorNode = BaseNode.extend({
|
||||||
|
id: objectId('door'),
|
||||||
|
type: nodeType('door'),
|
||||||
|
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
side: z.enum(['front', 'back']).optional(),
|
||||||
|
wallId: z.string().optional(),
|
||||||
|
|
||||||
|
// Overall dimensions
|
||||||
|
width: z.number().default(0.9),
|
||||||
|
height: z.number().default(2.1),
|
||||||
|
|
||||||
|
// Frame
|
||||||
|
frameThickness: z.number().default(0.05),
|
||||||
|
frameDepth: z.number().default(0.07),
|
||||||
|
threshold: z.boolean().default(true),
|
||||||
|
thresholdHeight: z.number().default(0.02),
|
||||||
|
|
||||||
|
// Swing
|
||||||
|
hingesSide: z.enum(['left', 'right']).default('left'),
|
||||||
|
swingDirection: z.enum(['inward', 'outward']).default('inward'),
|
||||||
|
|
||||||
|
// Leaf segments — stacked top to bottom, each with its own column split
|
||||||
|
segments: z.array(DoorSegment).default([
|
||||||
|
{ type: 'panel', heightRatio: 0.4, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
|
||||||
|
{ type: 'panel', heightRatio: 0.6, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Handle
|
||||||
|
handle: z.boolean().default(true),
|
||||||
|
handleHeight: z.number().default(1.05),
|
||||||
|
handleSide: z.enum(['left', 'right']).default('right'),
|
||||||
|
|
||||||
|
// Leaf inner margin — space between leaf edge and segment content area [x, y]
|
||||||
|
contentPadding: z.tuple([z.number(), z.number()]).default([0.04, 0.04]),
|
||||||
|
|
||||||
|
// Emergency / commercial hardware
|
||||||
|
doorCloser: z.boolean().default(false),
|
||||||
|
panicBar: z.boolean().default(false),
|
||||||
|
panicBarHeight: z.number().default(1.0),
|
||||||
|
|
||||||
|
}).describe(dedent`Door node - a parametric door placed on a wall
|
||||||
|
- position: center of the door in wall-local coordinate system (Y = height/2, always at floor)
|
||||||
|
- segments: rows stacked top to bottom, each defining its own columnRatios
|
||||||
|
- type 'empty' = flush flat fill, 'panel' = raised/recessed panel, 'glass' = glazed
|
||||||
|
- hingesSide/swingDirection: which way the door opens
|
||||||
|
- doorCloser/panicBar: commercial and emergency hardware options
|
||||||
|
`)
|
||||||
|
|
||||||
|
export type DoorNode = z.infer<typeof DoorNode>
|
||||||
@@ -8,6 +8,7 @@ import { RoofNode } from './nodes/roof'
|
|||||||
import { ScanNode } from './nodes/scan'
|
import { ScanNode } from './nodes/scan'
|
||||||
import { SiteNode } from './nodes/site'
|
import { SiteNode } from './nodes/site'
|
||||||
import { SlabNode } from './nodes/slab'
|
import { SlabNode } from './nodes/slab'
|
||||||
|
import { DoorNode } from './nodes/door'
|
||||||
import { WallNode } from './nodes/wall'
|
import { WallNode } from './nodes/wall'
|
||||||
import { WindowNode } from './nodes/window'
|
import { WindowNode } from './nodes/window'
|
||||||
import { ZoneNode } from './nodes/zone'
|
import { ZoneNode } from './nodes/zone'
|
||||||
@@ -25,6 +26,7 @@ export const AnyNode = z.discriminatedUnion('type', [
|
|||||||
ScanNode,
|
ScanNode,
|
||||||
GuideNode,
|
GuideNode,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
|
DoorNode,
|
||||||
])
|
])
|
||||||
|
|
||||||
export type AnyNode = z.infer<typeof AnyNode>
|
export type AnyNode = z.infer<typeof AnyNode>
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) {
|
|||||||
mesh.geometry.dispose()
|
mesh.geometry.dispose()
|
||||||
mesh.geometry = newGeo
|
mesh.geometry = newGeo
|
||||||
|
|
||||||
|
const gridMesh = mesh.getObjectByName('ceiling-grid') as THREE.Mesh
|
||||||
|
if (gridMesh) {
|
||||||
|
gridMesh.geometry.dispose()
|
||||||
|
gridMesh.geometry = newGeo
|
||||||
|
}
|
||||||
|
|
||||||
// Position at the ceiling height
|
// Position at the ceiling height
|
||||||
mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs
|
mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||||
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
|
import type { AnyNodeId, DoorNode } from '../../schema'
|
||||||
|
import useScene from '../../store/use-scene'
|
||||||
|
|
||||||
|
const baseMaterial = new MeshStandardNodeMaterial({
|
||||||
|
name: 'door-base',
|
||||||
|
color: '#f2f0ed',
|
||||||
|
roughness: 0.5,
|
||||||
|
metalness: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const glassMaterial = new MeshStandardNodeMaterial({
|
||||||
|
name: 'door-glass',
|
||||||
|
color: 'lightblue',
|
||||||
|
roughness: 0.05,
|
||||||
|
metalness: 0.1,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.35,
|
||||||
|
side: DoubleSide,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Invisible material for root mesh — used as selection hitbox only
|
||||||
|
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
|
||||||
|
|
||||||
|
export const DoorSystem = () => {
|
||||||
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (dirtyNodes.size === 0) return
|
||||||
|
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
|
||||||
|
dirtyNodes.forEach((id) => {
|
||||||
|
const node = nodes[id]
|
||||||
|
if (!node || node.type !== 'door') return
|
||||||
|
|
||||||
|
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
||||||
|
if (!mesh) return // Keep dirty until mesh mounts
|
||||||
|
|
||||||
|
updateDoorMesh(node as DoorNode, mesh)
|
||||||
|
clearDirty(id as AnyNodeId)
|
||||||
|
|
||||||
|
// Rebuild the parent wall so its cutout reflects the updated door geometry
|
||||||
|
if ((node as DoorNode).parentId) {
|
||||||
|
useScene.getState().dirtyNodes.add((node as DoorNode).parentId as AnyNodeId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 3)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBox(
|
||||||
|
parent: THREE.Object3D,
|
||||||
|
material: THREE.Material,
|
||||||
|
w: number, h: number, d: number,
|
||||||
|
x: number, y: number, z: number,
|
||||||
|
) {
|
||||||
|
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material)
|
||||||
|
m.position.set(x, y, z)
|
||||||
|
parent.add(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
||||||
|
// Root mesh is an invisible hitbox; all visuals live in child meshes
|
||||||
|
mesh.geometry.dispose()
|
||||||
|
mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth)
|
||||||
|
mesh.material = hitboxMaterial
|
||||||
|
|
||||||
|
// Sync transform from node (React may lag behind the system by a frame during drag)
|
||||||
|
mesh.position.set(node.position[0], node.position[1], node.position[2])
|
||||||
|
mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])
|
||||||
|
|
||||||
|
// Dispose and remove all old visual children; preserve 'cutout'
|
||||||
|
for (const child of [...mesh.children]) {
|
||||||
|
if (child.name === 'cutout') continue
|
||||||
|
if (child instanceof THREE.Mesh) child.geometry.dispose()
|
||||||
|
mesh.remove(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
width, height, frameThickness, frameDepth, threshold, thresholdHeight,
|
||||||
|
segments, handle, handleHeight, handleSide,
|
||||||
|
doorCloser, panicBar, panicBarHeight, contentPadding, hingesSide,
|
||||||
|
} = node
|
||||||
|
|
||||||
|
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
|
||||||
|
const leafW = width - 2 * frameThickness
|
||||||
|
const leafH = height - frameThickness // only top frame
|
||||||
|
const leafDepth = 0.04
|
||||||
|
// Leaf center is shifted down from door center by half the top frame
|
||||||
|
const leafCenterY = -frameThickness / 2
|
||||||
|
|
||||||
|
// ── Frame members ──
|
||||||
|
// Left post — full height
|
||||||
|
addBox(mesh, baseMaterial, frameThickness, height, frameDepth, -width / 2 + frameThickness / 2, 0, 0)
|
||||||
|
// Right post — full height
|
||||||
|
addBox(mesh, baseMaterial, frameThickness, height, frameDepth, width / 2 - frameThickness / 2, 0, 0)
|
||||||
|
// Head (top bar) — full width
|
||||||
|
addBox(mesh, baseMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0)
|
||||||
|
|
||||||
|
// ── Threshold (inside the frame) ──
|
||||||
|
if (threshold) {
|
||||||
|
addBox(mesh, baseMaterial, leafW, thresholdHeight, frameDepth, 0, -height / 2 + thresholdHeight / 2, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Leaf — contentPadding border strips (no full backing; glass areas are open) ──
|
||||||
|
const cpX = contentPadding[0]
|
||||||
|
const cpY = contentPadding[1]
|
||||||
|
if (cpY > 0) {
|
||||||
|
// Top strip
|
||||||
|
addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0)
|
||||||
|
// Bottom strip
|
||||||
|
addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0)
|
||||||
|
}
|
||||||
|
if (cpX > 0) {
|
||||||
|
const innerH = leafH - 2 * cpY
|
||||||
|
// Left strip
|
||||||
|
addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0)
|
||||||
|
// Right strip
|
||||||
|
addBox(mesh, baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content area inside padding
|
||||||
|
const contentW = leafW - 2 * cpX
|
||||||
|
const contentH = leafH - 2 * cpY
|
||||||
|
|
||||||
|
// ── Segments (stacked top to bottom within content area) ──
|
||||||
|
const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0)
|
||||||
|
const contentTop = leafCenterY + contentH / 2
|
||||||
|
|
||||||
|
let segY = contentTop
|
||||||
|
for (const seg of segments) {
|
||||||
|
const segH = (seg.heightRatio / totalRatio) * contentH
|
||||||
|
const segCenterY = segY - segH / 2
|
||||||
|
|
||||||
|
const numCols = seg.columnRatios.length
|
||||||
|
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
|
||||||
|
const usableW = contentW - (numCols - 1) * seg.dividerThickness
|
||||||
|
const colWidths = seg.columnRatios.map(r => (r / colSum) * usableW)
|
||||||
|
|
||||||
|
// Column x-centers (relative to mesh center)
|
||||||
|
const colXCenters: number[] = []
|
||||||
|
let cx = -contentW / 2
|
||||||
|
for (let c = 0; c < numCols; c++) {
|
||||||
|
colXCenters.push(cx + colWidths[c]! / 2)
|
||||||
|
cx += colWidths[c]!
|
||||||
|
if (c < numCols - 1) cx += seg.dividerThickness
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column dividers within this segment
|
||||||
|
cx = -contentW / 2
|
||||||
|
for (let c = 0; c < numCols - 1; c++) {
|
||||||
|
cx += colWidths[c]!
|
||||||
|
addBox(mesh, baseMaterial, seg.dividerThickness, segH, leafDepth + 0.001, cx + seg.dividerThickness / 2, segCenterY, 0)
|
||||||
|
cx += seg.dividerThickness
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment content per column
|
||||||
|
for (let c = 0; c < numCols; c++) {
|
||||||
|
const colW = colWidths[c]!
|
||||||
|
const colX = colXCenters[c]!
|
||||||
|
|
||||||
|
if (seg.type === 'glass') {
|
||||||
|
// Glass only — no opaque backing so it's truly transparent
|
||||||
|
const glassDepth = Math.max(0.004, leafDepth * 0.15)
|
||||||
|
addBox(mesh, glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0)
|
||||||
|
} else if (seg.type === 'panel') {
|
||||||
|
// Opaque leaf backing for this column
|
||||||
|
addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)
|
||||||
|
// Raised panel detail
|
||||||
|
const panelW = colW - 2 * seg.panelInset
|
||||||
|
const panelH = segH - 2 * seg.panelInset
|
||||||
|
if (panelW > 0.01 && panelH > 0.01) {
|
||||||
|
const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)
|
||||||
|
const panelZ = leafDepth / 2 + effectiveDepth / 2
|
||||||
|
addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 'empty' — opaque backing, no detail
|
||||||
|
addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
segY -= segH
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handle ──
|
||||||
|
if (handle) {
|
||||||
|
// Convert from floor-based height to mesh-center-based Y
|
||||||
|
const handleY = handleHeight - height / 2
|
||||||
|
// Handle grip sits on the front face (+Z) of the leaf
|
||||||
|
const faceZ = leafDepth / 2
|
||||||
|
|
||||||
|
// X position: handleSide refers to which side the grip is on
|
||||||
|
const handleX = handleSide === 'right'
|
||||||
|
? leafW / 2 - 0.045
|
||||||
|
: -leafW / 2 + 0.045
|
||||||
|
|
||||||
|
// Backplate
|
||||||
|
addBox(mesh, baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005)
|
||||||
|
// Grip lever
|
||||||
|
addBox(mesh, baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Door closer (commercial hardware at top) ──
|
||||||
|
if (doorCloser) {
|
||||||
|
const closerY = leafCenterY + leafH / 2 - 0.04
|
||||||
|
// Body
|
||||||
|
addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03)
|
||||||
|
// Arm (simplified as thin bar to frame side)
|
||||||
|
addBox(mesh, baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panic bar ──
|
||||||
|
if (panicBar) {
|
||||||
|
const barY = panicBarHeight - height / 2
|
||||||
|
addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hinges (3 knuckle-style hinges on the hinge side) ──
|
||||||
|
{
|
||||||
|
const hingeX = hingesSide === 'right'
|
||||||
|
? leafW / 2 - 0.012
|
||||||
|
: -leafW / 2 + 0.012
|
||||||
|
const hingeZ = 0 // centered in leaf depth
|
||||||
|
const hingeH = 0.1
|
||||||
|
const hingeW = 0.024
|
||||||
|
const hingeD = leafDepth + 0.016
|
||||||
|
// Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top
|
||||||
|
const leafBottom = leafCenterY - leafH / 2
|
||||||
|
const leafTop = leafCenterY + leafH / 2
|
||||||
|
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottom + 0.25, hingeZ)
|
||||||
|
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ)
|
||||||
|
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
|
||||||
|
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
|
||||||
|
if (!cutout) {
|
||||||
|
cutout = new THREE.Mesh()
|
||||||
|
cutout.name = 'cutout'
|
||||||
|
mesh.add(cutout)
|
||||||
|
}
|
||||||
|
cutout.geometry.dispose()
|
||||||
|
cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0)
|
||||||
|
cutout.visible = false
|
||||||
|
}
|
||||||
@@ -306,7 +306,7 @@ function collectCutoutBrushes(
|
|||||||
const wallMatrixInverse = wallMesh.matrixWorld.clone().invert()
|
const wallMatrixInverse = wallMesh.matrixWorld.clone().invert()
|
||||||
|
|
||||||
for (const child of childrenNodes) {
|
for (const child of childrenNodes) {
|
||||||
if (child.type !== 'item' && child.type !== 'window') continue
|
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
|
||||||
|
|
||||||
const childMesh = sceneRegistry.nodes.get(child.id)
|
const childMesh = sceneRegistry.nodes.get(child.id)
|
||||||
if (!childMesh) continue
|
if (!childMesh) continue
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
import { type CeilingNode, useRegistry } from '@pascal-app/core'
|
import { type CeilingNode, useRegistry } from '@pascal-app/core'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { faceDirection, float, mix, positionWorld, smoothstep, step } from 'three/tsl'
|
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||||
import { type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
// TSL material that renders differently based on face direction:
|
// TSL material that renders differently based on face direction:
|
||||||
// - Back face (looking up at ceiling from below): solid
|
// - Back face (looking up at ceiling from below): solid
|
||||||
// - Front face (looking down at ceiling from above): 30% opacity
|
// - Front face (looking down at ceiling from above): 30% opacity
|
||||||
const ceilingMaterial = new MeshBasicNodeMaterial({
|
const ceilingTopMaterial = new MeshBasicNodeMaterial({
|
||||||
color: 0x999999,
|
color: 0xb5a78d,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
|
side: FrontSide,
|
||||||
|
// Disabled as we only show ceiling grid when needed
|
||||||
|
// alphaTestNode: float(0.4), // Discard pixels with alpha below 0.4 to create grid lines and not affect depth buffer
|
||||||
|
})
|
||||||
|
|
||||||
|
const ceilingBottomMaterial = new MeshBasicNodeMaterial({
|
||||||
|
color: 0x999999,
|
||||||
|
transparent: true,
|
||||||
|
side: BackSide,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create grid pattern based on local position
|
// Create grid pattern based on local position
|
||||||
@@ -29,12 +38,12 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.
|
|||||||
// Combine: if either X or Y is a line, show the line
|
// Combine: if either X or Y is a line, show the line
|
||||||
const gridPattern = lineX.max(lineY)
|
const gridPattern = lineX.max(lineY)
|
||||||
|
|
||||||
// Grid lines at 0.5 opacity, spaces at 0 opacity
|
// Grid lines at 0.6 opacity, spaces at 0.2 opacity
|
||||||
const gridOpacity = mix(float(0.0), float(0.5), gridPattern)
|
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
|
||||||
|
|
||||||
// faceDirection is 1.0 for front face, -1.0 for back face
|
// faceDirection is 1.0 for front face, -1.0 for back face
|
||||||
// Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid
|
// Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid
|
||||||
ceilingMaterial.opacityNode = mix(float(1.0), gridOpacity, step(float(0.0), float(faceDirection)))
|
ceilingTopMaterial.opacityNode = gridOpacity
|
||||||
|
|
||||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
@@ -43,9 +52,12 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
|||||||
const handlers = useNodeEvents(node, 'ceiling')
|
const handlers = useNodeEvents(node, 'ceiling')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh ref={ref} material={ceilingMaterial} {...handlers}>
|
<mesh ref={ref} material={ceilingBottomMaterial}>
|
||||||
{/* CeilingSystem will replace this geometry in the next frame */}
|
{/* CeilingSystem will replace this geometry in the next frame */}
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
|
<mesh name="ceiling-grid" material={ceilingTopMaterial} {...handlers} visible={false} scale={0}>
|
||||||
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
|
</mesh>
|
||||||
{node.children.map((childId) => (
|
{node.children.map((childId) => (
|
||||||
<NodeRenderer key={childId} nodeId={childId} />
|
<NodeRenderer key={childId} nodeId={childId} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useRegistry, type DoorNode } from '@pascal-app/core'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import type { Mesh } from 'three'
|
||||||
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
|
||||||
|
export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
||||||
|
const ref = useRef<Mesh>(null!)
|
||||||
|
|
||||||
|
useRegistry(node.id, 'door', ref)
|
||||||
|
const handlers = useNodeEvents(node, 'door')
|
||||||
|
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
ref={ref}
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
visible={node.visible}
|
||||||
|
position={node.position}
|
||||||
|
rotation={node.rotation}
|
||||||
|
{...(isTransient ? {} : handlers)}
|
||||||
|
>
|
||||||
|
{/* DoorSystem replaces this geometry each time the node is dirty */}
|
||||||
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
|
<meshStandardMaterial color="#d1d5db" />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { type AnyNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, useScene } from '@pascal-app/core'
|
||||||
import { BuildingRenderer } from './building/building-renderer'
|
import { BuildingRenderer } from './building/building-renderer'
|
||||||
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
||||||
|
import { DoorRenderer } from './door/door-renderer'
|
||||||
import { GuideRenderer } from './guide/guide-renderer'
|
import { GuideRenderer } from './guide/guide-renderer'
|
||||||
import { ItemRenderer } from './item/item-renderer'
|
import { ItemRenderer } from './item/item-renderer'
|
||||||
import { LevelRenderer } from './level/level-renderer'
|
import { LevelRenderer } from './level/level-renderer'
|
||||||
@@ -28,6 +29,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
|||||||
{node.type === 'item' && <ItemRenderer node={node} />}
|
{node.type === 'item' && <ItemRenderer node={node} />}
|
||||||
{node.type === 'slab' && <SlabRenderer node={node} />}
|
{node.type === 'slab' && <SlabRenderer node={node} />}
|
||||||
{node.type === 'wall' && <WallRenderer node={node} />}
|
{node.type === 'wall' && <WallRenderer node={node} />}
|
||||||
|
{node.type === 'door' && <DoorRenderer node={node} />}
|
||||||
{node.type === 'window' && <WindowRenderer node={node} />}
|
{node.type === 'window' && <WindowRenderer node={node} />}
|
||||||
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
||||||
{node.type === 'roof' && <RoofRenderer node={node} />}
|
{node.type === 'roof' && <RoofRenderer node={node} />}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
|||||||
<mesh ref={ref} castShadow receiveShadow visible={node.visible}>
|
<mesh ref={ref} castShadow receiveShadow visible={node.visible}>
|
||||||
{/* WallSystem will replace this geometry in the next frame */}
|
{/* WallSystem will replace this geometry in the next frame */}
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
<mesh name="collision-mesh" {...handlers} visible={false}>
|
{/* Collision mesh: full-wall geometry (no cutouts) for pointer events */}
|
||||||
|
<mesh name="collision-mesh" visible={false} {...handlers}>
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
|||||||
|
|
||||||
useRegistry(node.id, 'window', ref)
|
useRegistry(node.id, 'window', ref)
|
||||||
const handlers = useNodeEvents(node, 'window')
|
const handlers = useNodeEvents(node, 'window')
|
||||||
|
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
@@ -17,7 +18,7 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
|||||||
visible={node.visible}
|
visible={node.visible}
|
||||||
position={node.position}
|
position={node.position}
|
||||||
rotation={node.rotation}
|
rotation={node.rotation}
|
||||||
{...handlers}
|
{...(isTransient ? {} : handlers)}
|
||||||
>
|
>
|
||||||
{/* WindowSystem replaces this geometry each time the node is dirty */}
|
{/* WindowSystem replaces this geometry each time the node is dirty */}
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { CeilingSystem, ItemSystem, RoofSystem, SlabSystem, WallSystem, WindowSystem } from '@pascal-app/core'
|
import { CeilingSystem, DoorSystem, ItemSystem, RoofSystem, SlabSystem, WallSystem, WindowSystem } from '@pascal-app/core'
|
||||||
import { Bvh } from '@react-three/drei'
|
import { Bvh } from '@react-three/drei'
|
||||||
import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber'
|
import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber'
|
||||||
import * as THREE from 'three/webgpu'
|
import * as THREE from 'three/webgpu'
|
||||||
@@ -61,6 +61,7 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
|
|||||||
<WallCutout />
|
<WallCutout />
|
||||||
{/* Core systems */}
|
{/* Core systems */}
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
|
<DoorSystem />
|
||||||
<ItemSystem />
|
<ItemSystem />
|
||||||
<RoofSystem />
|
<RoofSystem />
|
||||||
<SlabSystem />
|
<SlabSystem />
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type SelectableNodeType =
|
|||||||
| 'zone'
|
| 'zone'
|
||||||
| 'wall'
|
| 'wall'
|
||||||
| 'window'
|
| 'window'
|
||||||
|
| 'door'
|
||||||
| 'item'
|
| 'item'
|
||||||
| 'slab'
|
| 'slab'
|
||||||
| 'ceiling'
|
| 'ceiling'
|
||||||
@@ -86,12 +87,19 @@ const isNodeOnLevel = (node: AnyNode, levelId: string): boolean => {
|
|||||||
// Direct child of level
|
// Direct child of level
|
||||||
if (node.parentId === levelId) return true
|
if (node.parentId === levelId) return true
|
||||||
|
|
||||||
// Wall-attached items (windows/doors): check if parent wall is on the level
|
// Wall-attached nodes (window/door/item): check if parent wall is on the level
|
||||||
if (node.type === 'item' && node.parentId) {
|
if ((node.type === 'item' || node.type === 'window' || node.type === 'door') && node.parentId) {
|
||||||
const parentNode = nodes[node.parentId as keyof typeof nodes]
|
const parentNode = nodes[node.parentId as keyof typeof nodes]
|
||||||
if (parentNode?.type === 'wall' && parentNode.parentId === levelId) {
|
if (parentNode?.type === 'wall' && parentNode.parentId === levelId) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// Ceiling/slab/roof-attached items: check if parent structure is on the level
|
||||||
|
if (
|
||||||
|
(parentNode?.type === 'ceiling' || parentNode?.type === 'slab' || parentNode?.type === 'roof') &&
|
||||||
|
parentNode.parentId === levelId
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@@ -193,9 +201,9 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows)
|
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)
|
||||||
return {
|
return {
|
||||||
types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window'],
|
types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door'],
|
||||||
handleClick: (node) => {
|
handleClick: (node) => {
|
||||||
const { selectedIds } = useViewer.getState().selection
|
const { selectedIds } = useViewer.getState().selection
|
||||||
// Toggle selection - if already selected, deselect; otherwise select
|
// Toggle selection - if already selected, deselect; otherwise select
|
||||||
@@ -217,7 +225,7 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
isValid: (node) => {
|
isValid: (node) => {
|
||||||
const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof', 'window']
|
const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door']
|
||||||
if (!validTypes.includes(node.type)) return false
|
if (!validTypes.includes(node.type)) return false
|
||||||
return isNodeInZone(node, levelId, zoneId)
|
return isNodeInZone(node, levelId, zoneId)
|
||||||
},
|
},
|
||||||
@@ -270,6 +278,7 @@ export const SelectionManager = () => {
|
|||||||
'ceiling',
|
'ceiling',
|
||||||
'roof',
|
'roof',
|
||||||
'window',
|
'window',
|
||||||
|
'door',
|
||||||
]
|
]
|
||||||
for (const type of allTypes) {
|
for (const type of allTypes) {
|
||||||
emitter.on(`${type}:enter`, onEnter)
|
emitter.on(`${type}:enter`, onEnter)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingEvent,
|
type CeilingEvent,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
|
type DoorEvent,
|
||||||
|
type DoorNode,
|
||||||
type EventSuffix,
|
type EventSuffix,
|
||||||
emitter,
|
emitter,
|
||||||
type ItemEvent,
|
type ItemEvent,
|
||||||
@@ -36,6 +38,7 @@ type NodeConfig = {
|
|||||||
ceiling: { node: CeilingNode; event: CeilingEvent }
|
ceiling: { node: CeilingNode; event: CeilingEvent }
|
||||||
roof: { node: RoofNode; event: RoofEvent }
|
roof: { node: RoofNode; event: RoofEvent }
|
||||||
window: { node: WindowNode; event: WindowEvent }
|
window: { node: WindowNode; event: WindowEvent }
|
||||||
|
door: { node: DoorNode; event: DoorEvent }
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeType = keyof NodeConfig
|
type NodeType = keyof NodeConfig
|
||||||
|
|||||||
Reference in New Issue
Block a user