From 9b9398c5ffcd459388ed96106218450a7dfa9df9 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 25 Feb 2026 07:35:03 +0900 Subject: [PATCH 01/12] remove ceiling from depthcalc --- .../src/components/renderers/ceiling/ceiling-renderer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index ee86c9a5..19c23e26 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -1,7 +1,7 @@ import { type CeilingNode, useRegistry } from '@pascal-app/core' import { useRef } from 'react' import { faceDirection, float, mix, positionWorld, smoothstep, step } from 'three/tsl' -import { type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' +import { DoubleSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' import { NodeRenderer } from '../node-renderer' @@ -12,6 +12,8 @@ const ceilingMaterial = new MeshBasicNodeMaterial({ color: 0x999999, transparent: true, depthWrite: false, + side: DoubleSide, + alphaTestNode: float(0.4), }) // Create grid pattern based on local position From c53f1f9679b6f7685986f8fa23152a2b85ee469d Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 25 Feb 2026 08:59:04 +0900 Subject: [PATCH 02/12] ceiling system + reveal/hide --- apps/editor/components/editor/index.tsx | 20 ++++++----- .../systems/ceiling/ceiling-system.tsx | 33 +++++++++++++++++++ .../components/tools/ceiling/ceiling-tool.tsx | 20 +++++------ .../src/systems/ceiling/ceiling-system.tsx | 6 ++++ .../renderers/ceiling/ceiling-renderer.tsx | 30 +++++++++++------ 5 files changed, 80 insertions(+), 29 deletions(-) create mode 100644 apps/editor/components/systems/ceiling/ceiling-system.tsx diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 08b5e0dc..391b47a4 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -2,26 +2,27 @@ import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core' 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 { ThumbnailGenerator } from '@/app/viewer/[id]/thumbnail-generator' 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 { ToolManager } from '../tools/tool-manager' 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 { PanelManager } from '../ui/panels/panel-manager' import { SidebarProvider } from '../ui/primitives/sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar' import { CustomCameraControls } from './custom-camera-controls' import { ExportManager } from './export-manager' import { Grid } from './grid' import { SelectionManager } from './selection-manager' -import { initSFXBus } from '@/lib/sfx-bus' -import { ThumbnailGenerator } from '@/app/viewer/[id]/thumbnail-generator' // Load default scene initially (will be replaced when project loads) useScene.getState().loadScene() @@ -74,6 +75,7 @@ export default function Editor({ projectId }: EditorProps) { {/* Editor only system to toggle zone visibility */} + {/* */} diff --git a/apps/editor/components/systems/ceiling/ceiling-system.tsx b/apps/editor/components/systems/ceiling/ceiling-system.tsx new file mode 100644 index 00000000..0ec6658c --- /dev/null +++ b/apps/editor/components/systems/ceiling/ceiling-system.tsx @@ -0,0 +1,33 @@ +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 + } + } + }) + }, [tool, selectedItem, movingNode, selectedIds]) + return null +} diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx index 89567f85..9736185b 100644 --- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx +++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx @@ -3,7 +3,6 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three' import { sfxEmitter } from '@/lib/sfx-bus' -import useEditor from '@/store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' const CEILING_HEIGHT = 2.52 @@ -46,9 +45,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() // Count existing ceilings for naming @@ -62,6 +61,7 @@ const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, n createNode(ceiling, levelId) sfxEmitter.emit('sfx:structure-build') + return ceiling.id } export const CeilingTool: React.FC = () => { @@ -70,7 +70,7 @@ export const CeilingTool: React.FC = () => { const mainLineRef = useRef(null!) const closingLineRef = useRef(null!) const currentLevelId = useViewer((state) => state.selection.levelId) - const setTool = useEditor((state) => state.setTool) + const setSelection = useViewer((state) => state.setSelection) const [points, setPoints] = useState>([]) const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) @@ -133,10 +133,10 @@ export const CeilingTool: React.FC = () => { Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 ) { - // Create the ceiling - commitCeilingDrawing(currentLevelId, points) + // Create the ceiling and select it + const ceilingId = commitCeilingDrawing(currentLevelId, points) + setSelection({ selectedIds: [ceilingId] }) setPoints([]) - setTool(null) } else { // Add point to polygon setPoints([...points, clickPoint]) @@ -148,9 +148,9 @@ export const CeilingTool: React.FC = () => { // Need at least 3 points to form a polygon if (points.length >= 3) { - commitCeilingDrawing(currentLevelId, points) + const ceilingId = commitCeilingDrawing(currentLevelId, points) + setSelection({ selectedIds: [ceilingId] }) setPoints([]) - setTool(null) } } @@ -180,7 +180,7 @@ export const CeilingTool: React.FC = () => { emitter.off('grid:double-click', onGridDoubleClick) emitter.off('tool:cancel', onCancel) } - }, [currentLevelId, points, cursorPosition, setTool]) + }, [currentLevelId, points, cursorPosition, setSelection]) // Update line geometries when points change useEffect(() => { diff --git a/packages/core/src/systems/ceiling/ceiling-system.tsx b/packages/core/src/systems/ceiling/ceiling-system.tsx index 85cb598a..6787864a 100644 --- a/packages/core/src/systems/ceiling/ceiling-system.tsx +++ b/packages/core/src/systems/ceiling/ceiling-system.tsx @@ -42,6 +42,12 @@ function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) { mesh.geometry.dispose() 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 mesh.position.y = (node.height ?? 2.5) - 0.01 // Slight offset to avoid z-fighting with upper-level slabs } diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 19c23e26..4c044bb1 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -1,19 +1,26 @@ import { type CeilingNode, useRegistry } from '@pascal-app/core' import { useRef } from 'react' -import { faceDirection, float, mix, positionWorld, smoothstep, step } from 'three/tsl' -import { DoubleSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' +import { float, mix, positionWorld, smoothstep } from 'three/tsl' +import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' import { NodeRenderer } from '../node-renderer' // TSL material that renders differently based on face direction: // - Back face (looking up at ceiling from below): solid // - Front face (looking down at ceiling from above): 30% opacity -const ceilingMaterial = new MeshBasicNodeMaterial({ - color: 0x999999, +const ceilingTopMaterial = new MeshBasicNodeMaterial({ + color: 0xb5a78d, transparent: true, depthWrite: false, - side: DoubleSide, - alphaTestNode: float(0.4), + 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 @@ -31,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 const gridPattern = lineX.max(lineY) -// Grid lines at 0.5 opacity, spaces at 0 opacity -const gridOpacity = mix(float(0.0), float(0.5), gridPattern) +// Grid lines at 0.6 opacity, spaces at 0.2 opacity +const gridOpacity = mix(float(0.2), float(0.6), gridPattern) // 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 -ceilingMaterial.opacityNode = mix(float(1.0), gridOpacity, step(float(0.0), float(faceDirection))) +ceilingTopMaterial.opacityNode = gridOpacity export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) @@ -45,9 +52,12 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const handlers = useNodeEvents(node, 'ceiling') return ( - + {/* CeilingSystem will replace this geometry in the next frame */} + + + {node.children.map((childId) => ( ))} From 31927da54332c96b3a51ea2c052dd06ca08c3ea5 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 25 Feb 2026 09:25:00 +0900 Subject: [PATCH 03/12] event handlers for ceiling --- apps/editor/components/systems/ceiling/ceiling-system.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/editor/components/systems/ceiling/ceiling-system.tsx b/apps/editor/components/systems/ceiling/ceiling-system.tsx index 0ec6658c..03d862b3 100644 --- a/apps/editor/components/systems/ceiling/ceiling-system.tsx +++ b/apps/editor/components/systems/ceiling/ceiling-system.tsx @@ -25,6 +25,7 @@ export const CeilingSystem = () => { 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 } } }) From 8a0c7d31cc90b7cc536055dbc876f114f533e7ef Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 25 Feb 2026 09:28:47 +0900 Subject: [PATCH 04/12] default scale top grid ceiling for viewer --- .../src/components/renderers/ceiling/ceiling-renderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 4c044bb1..a680002c 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -55,7 +55,7 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { {/* CeilingSystem will replace this geometry in the next frame */} - + {node.children.map((childId) => ( From 53ed21ba0b6f9a3be300f48aed3f31186e0eb104 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 25 Feb 2026 09:33:31 +0900 Subject: [PATCH 05/12] fixed ceiling item not selectable in viewer --- .../viewer/src/components/viewer/selection-manager.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index e591157c..afbfb180 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -92,6 +92,13 @@ const isNodeOnLevel = (node: AnyNode, levelId: string): boolean => { if (parentNode?.type === 'wall' && parentNode.parentId === levelId) { 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 From 4be3291d64abf75e2d784d861252adb574fd9976 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 25 Feb 2026 10:46:41 +0900 Subject: [PATCH 06/12] vertical line ceiling --- .../components/tools/ceiling/ceiling-tool.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx index 9736185b..0af3f984 100644 --- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx +++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx @@ -2,6 +2,7 @@ import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from ' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three' +import { mix, positionLocal } from 'three/tsl' import { sfxEmitter } from '@/lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' @@ -69,6 +70,7 @@ export const CeilingTool: React.FC = () => { const gridCursorRef = useRef(null) const mainLineRef = useRef(null!) const closingLineRef = useRef(null!) + const verticalLineRef = useRef(null!) const currentLevelId = useViewer((state) => state.selection.levelId) const setSelection = useViewer((state) => state.setSelection) @@ -79,6 +81,18 @@ export const CeilingTool: React.FC = () => { const previousSnappedPointRef = useRef<[number, number] | null>(null) 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 useEffect(() => { if (!currentLevelId) return @@ -117,6 +131,10 @@ export const CeilingTool: React.FC = () => { previousSnappedPointRef.current = displayPoint cursorRef.current.position.set(displayPoint[0], ceilingY, 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) => { @@ -262,6 +280,12 @@ export const CeilingTool: React.FC = () => { + {/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */} + {/* @ts-ignore */} + + + + {/* Preview fill */} {previewShape && ( Date: Wed, 25 Feb 2026 12:43:14 +0900 Subject: [PATCH 07/12] automated thumbnail + position enhancement --- apps/editor/app/viewer/[id]/page.tsx | 20 ++- .../app/viewer/[id]/thumbnail-generator.tsx | 119 -------------- apps/editor/components/editor/index.tsx | 2 +- .../components/editor/thumbnail-generator.tsx | 155 ++++++++++++++++++ 4 files changed, 169 insertions(+), 127 deletions(-) delete mode 100644 apps/editor/app/viewer/[id]/thumbnail-generator.tsx create mode 100644 apps/editor/components/editor/thumbnail-generator.tsx diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx index bf0d66c4..74ac18bb 100644 --- a/apps/editor/app/viewer/[id]/page.tsx +++ b/apps/editor/app/viewer/[id]/page.tsx @@ -1,16 +1,18 @@ 'use client' 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 { 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 { ViewerGuestCTA } from './viewer-guest-cta' import { ViewerOverlay } from './viewer-overlay' 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() { const params = useParams() @@ -106,12 +108,16 @@ export default function ViewerPage() { return (
- + -
) diff --git a/apps/editor/app/viewer/[id]/thumbnail-generator.tsx b/apps/editor/app/viewer/[id]/thumbnail-generator.tsx deleted file mode 100644 index 4dec9b9f..00000000 --- a/apps/editor/app/viewer/[id]/thumbnail-generator.tsx +++ /dev/null @@ -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 -} diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 391b47a4..40edca0c 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -2,7 +2,6 @@ import { initSpaceDetectionSync, initSpatialGridSync, useScene } from '@pascal-app/core' import { Viewer } from '@pascal-app/viewer' -import { ThumbnailGenerator } from '@/app/viewer/[id]/thumbnail-generator' 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' @@ -23,6 +22,7 @@ import { CustomCameraControls } from './custom-camera-controls' import { ExportManager } from './export-manager' import { Grid } from './grid' import { SelectionManager } from './selection-manager' +import { ThumbnailGenerator } from './thumbnail-generator' // Load default scene initially (will be replaced when project loads) useScene.getState().loadScene() diff --git a/apps/editor/components/editor/thumbnail-generator.tsx b/apps/editor/components/editor/thumbnail-generator.tsx new file mode 100644 index 00000000..02a84d6b --- /dev/null +++ b/apps/editor/components/editor/thumbnail-generator.tsx @@ -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 | 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 +} From 27e4afb7572859ad1cfc2292f96b0a860c09fd79 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 25 Feb 2026 13:28:42 +0900 Subject: [PATCH 08/12] draft custom door --- .../editor/components/tools/door/door-math.ts | 102 ++++ .../components/tools/door/door-tool.tsx | 254 +++++++++ .../components/tools/door/move-door-tool.tsx | 354 ++++++++++++ .../components/tools/item/move-tool.tsx | 4 +- apps/editor/components/tools/tool-manager.tsx | 2 + .../components/tools/window/window-math.ts | 8 +- .../ui/action-menu/structure-tools.tsx | 2 +- .../components/ui/panels/door-panel.tsx | 512 ++++++++++++++++++ .../components/ui/panels/panel-manager.tsx | 3 + .../panels/site-panel/door-tree-node.tsx | 50 ++ .../sidebar/panels/site-panel/tree-node.tsx | 3 + apps/editor/store/use-editor.tsx | 8 +- packages/core/src/events/bus.ts | 4 +- .../hooks/scene-registry/scene-registry.ts | 1 + packages/core/src/index.ts | 2 + packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/door.ts | 67 +++ packages/core/src/schema/types.ts | 2 + .../core/src/systems/door/door-system.tsx | 247 +++++++++ .../core/src/systems/wall/wall-system.tsx | 2 +- .../renderers/door/door-renderer.tsx | 27 + .../components/renderers/node-renderer.tsx | 2 + .../viewer/src/components/viewer/index.tsx | 3 +- .../components/viewer/selection-manager.tsx | 12 +- packages/viewer/src/hooks/use-node-events.ts | 3 + 25 files changed, 1661 insertions(+), 14 deletions(-) create mode 100644 apps/editor/components/tools/door/door-math.ts create mode 100644 apps/editor/components/tools/door/door-tool.tsx create mode 100644 apps/editor/components/tools/door/move-door-tool.tsx create mode 100644 apps/editor/components/ui/panels/door-panel.tsx create mode 100644 apps/editor/components/ui/sidebar/panels/site-panel/door-tree-node.tsx create mode 100644 packages/core/src/schema/nodes/door.ts create mode 100644 packages/core/src/systems/door/door-system.tsx create mode 100644 packages/viewer/src/components/renderers/door/door-renderer.tsx diff --git a/apps/editor/components/tools/door/door-math.ts b/apps/editor/components/tools/door/door-math.ts new file mode 100644 index 00000000..17cf812c --- /dev/null +++ b/apps/editor/components/tools/door/door-math.ts @@ -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 +} diff --git a/apps/editor/components/tools/door/door-tool.tsx b/apps/editor/components/tools/door/door-tool.tsx new file mode 100644 index 00000000..acd275c8 --- /dev/null +++ b/apps/editor/components/tools/door/door-tool.tsx @@ -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(null) + const cursorGroupRef = useRef(null!) + const edgesRef = useRef(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 ( + + + + ) +} diff --git a/apps/editor/components/tools/door/move-door-tool.tsx b/apps/editor/components/tools/door/move-door-tool.tsx new file mode 100644 index 00000000..e8bab85d --- /dev/null +++ b/apps/editor/components/tools/door/move-door-tool.tsx @@ -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(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 + : {} + 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 | 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 ( + + + + ) +} diff --git a/apps/editor/components/tools/item/move-tool.tsx b/apps/editor/components/tools/item/move-tool.tsx index c3cc7c38..3c9cdf55 100644 --- a/apps/editor/components/tools/item/move-tool.tsx +++ b/apps/editor/components/tools/item/move-tool.tsx @@ -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 { sfxEmitter } from '@/lib/sfx-bus' import useEditor from '@/store/use-editor' +import { MoveDoorTool } from '../door/move-door-tool' import { MoveWindowTool } from '../window/move-window-tool' import type { PlacementState } from './placement-types' import { useDraftNode } from './use-draft-node' @@ -67,6 +68,7 @@ export const MoveTool: React.FC = () => { const movingNode = useEditor((state) => state.movingNode) if (!movingNode) return null + if (movingNode.type === 'door') return if (movingNode.type === 'window') return return } diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 5de5725e..d5649386 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -4,6 +4,7 @@ import useEditor, { type Phase, type Tool } from '@/store/use-editor' import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingTool } from './ceiling/ceiling-tool' +import { DoorTool } from './door/door-tool' import { ItemTool } from './item/item-tool' import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' @@ -25,6 +26,7 @@ const tools: Record>> = { slab: SlabTool, ceiling: CeilingTool, roof: RoofTool, + door: DoorTool, item: ItemTool, zone: ZoneTool, window: WindowTool, diff --git a/apps/editor/components/tools/window/window-math.ts b/apps/editor/components/tools/window/window-math.ts index 69882cad..9a3ffedc 100644 --- a/apps/editor/components/tools/window/window-math.ts +++ b/apps/editor/components/tools/window/window-math.ts @@ -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. @@ -90,6 +90,12 @@ export function hasWallChildOverlap( childRight = win.position[0] + win.width / 2 childBottom = win.position[1] - win.height / 2 // windows store center Y 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 { continue } diff --git a/apps/editor/components/ui/action-menu/structure-tools.tsx b/apps/editor/components/ui/action-menu/structure-tools.tsx index 7b698553..edaf8f7a 100644 --- a/apps/editor/components/ui/action-menu/structure-tools.tsx +++ b/apps/editor/components/ui/action-menu/structure-tools.tsx @@ -17,7 +17,7 @@ export const tools: ToolConfig[] = [ { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, { 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: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, ] diff --git a/apps/editor/components/ui/panels/door-panel.tsx b/apps/editor/components/ui/panels/door-panel.tsx new file mode 100644 index 00000000..4b35eb5c --- /dev/null +++ b/apps/editor/components/ui/panels/door-panel.tsx @@ -0,0 +1,512 @@ +'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) => { + 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]) + + if (!node || node.type !== 'door' || selectedIds.length !== 1) return null + + return ( +
+ {/* Header */} +
+
+ +

+ {node.name || `Door (${node.width}×${node.height}m)`} +

+
+ +
+ + {/* Content */} +
+ + {/* Position */} +
+ +
+ handleUpdate({ position: [v, node.position[1], node.position[2]] })} + precision={2} + /> +
+ +
+ + {/* Dimensions */} +
+ +
+
+ handleUpdate({ width: v })} + min={0.5} + precision={2} + className="flex-1" + /> + m +
+
+ handleUpdate({ height: v })} + min={1.0} + precision={2} + className="flex-1" + /> + m +
+
+
+ + {/* Frame */} +
+ +
+
+ handleUpdate({ frameThickness: v })} + min={0.01} + precision={3} + step={0.01} + className="flex-1" + /> + m +
+
+ handleUpdate({ frameDepth: v })} + min={0.01} + precision={3} + step={0.01} + className="flex-1" + /> + m +
+
+
+ + {/* Swing */} +
+ +
+
+ Hinges +
+ {(['left', 'right'] as const).map((side) => ( + + ))} +
+
+
+ Direction +
+ {(['inward', 'outward'] as const).map((dir) => ( + + ))} +
+
+
+
+ + {/* Threshold */} +
+
+ + handleUpdate({ threshold: checked })} + /> +
+ {node.threshold && ( +
+ handleUpdate({ thresholdHeight: v })} + min={0.005} + precision={3} + step={0.005} + className="flex-1" + /> + m +
+ )} +
+ + {/* Handle */} +
+
+ + handleUpdate({ handle: checked })} + /> +
+ {node.handle && ( +
+
+ handleUpdate({ handleHeight: v })} + min={0.5} + max={node.height - 0.1} + precision={2} + step={0.05} + className="flex-1" + /> + m +
+
+ Side +
+ {(['left', 'right'] as const).map((side) => ( + + ))} +
+
+
+ )} +
+ + {/* Hardware */} +
+ +
+
+ Door Closer + handleUpdate({ doorCloser: checked })} + /> +
+
+ Panic Bar + handleUpdate({ panicBar: checked })} + /> +
+ {node.panicBar && ( +
+ handleUpdate({ panicBarHeight: v })} + min={0.5} + max={node.height - 0.1} + precision={2} + step={0.05} + className="flex-1" + /> + m +
+ )} +
+
+ + {/* Segments */} +
+ + {node.segments.map((seg, i) => ( +
+
+ Segment {i + 1} +
+ {(['panel', 'glass', 'empty'] as const).map((t) => ( + + ))} +
+
+
+ { + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, heightRatio: Math.max(0.05, v) } : s, + ) + handleUpdate({ segments: updated }) + }} + min={0.05} + precision={2} + step={0.05} + className="flex-1" + /> +
+ {seg.type === 'panel' && ( +
+
+ { + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, panelInset: v } : s, + ) + handleUpdate({ segments: updated }) + }} + min={0.005} + precision={3} + step={0.005} + className="flex-1" + /> + m +
+
+ { + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, panelDepth: v } : s, + ) + handleUpdate({ segments: updated }) + }} + precision={3} + step={0.005} + className="flex-1" + /> + m +
+
+ )} +
+ ))} +
+ + {node.segments.length > 1 && ( + + )} +
+
+
+ + {/* Action Buttons */} +
+
+ + + +
+
+
+ ) +} diff --git a/apps/editor/components/ui/panels/panel-manager.tsx b/apps/editor/components/ui/panels/panel-manager.tsx index d5fa0b56..08aa3862 100644 --- a/apps/editor/components/ui/panels/panel-manager.tsx +++ b/apps/editor/components/ui/panels/panel-manager.tsx @@ -9,6 +9,7 @@ import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' import { SlabPanel } from './slab-panel' import { WallPanel } from './wall-panel' +import { DoorPanel } from './door-panel' import { WindowPanel } from './window-panel' export function PanelManager() { @@ -37,6 +38,8 @@ export function PanelManager() { return case 'wall': return + case 'door': + return case 'window': return } diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/door-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/door-tree-node.tsx new file mode 100644 index 00000000..5358bd43 --- /dev/null +++ b/apps/editor/components/ui/sidebar/panels/site-panel/door-tree-node.tsx @@ -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 ( + + } + 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={} + /> + + ) +} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx index 711e5d7f..73c4b5c4 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -4,6 +4,7 @@ import { forwardRef } from "react"; import { cn } from "@/lib/utils"; import { BuildingTreeNode } from "./building-tree-node"; import { CeilingTreeNode } from "./ceiling-tree-node"; +import { DoorTreeNode } from "./door-tree-node"; import { ItemTreeNode } from "./item-tree-node"; import { LevelTreeNode } from "./level-tree-node"; import { RoofTreeNode } from "./roof-tree-node"; @@ -37,6 +38,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) { return ; case "item": return ; + case "door": + return ; case "window": return ; case "zone": diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 70f90a37..3d0451af 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -3,6 +3,7 @@ import type { AssetInput } from '@pascal-app/core' import { type BuildingNode, + type DoorNode, type ItemNode, type LevelNode, type Space, @@ -29,6 +30,7 @@ export type StructureTool = | 'item' | 'zone' | 'window' + | 'door' // Furnish mode tools (items and decoration) export type FurnishTool = 'item' @@ -64,8 +66,8 @@ type EditorState = { setCatalogCategory: (category: CatalogCategory | null) => void selectedItem: AssetInput | null setSelectedItem: (item: AssetInput) => void - movingNode: ItemNode | WindowNode | null - setMovingNode: (node: ItemNode | WindowNode | null) => void + movingNode: ItemNode | WindowNode | DoorNode | null + setMovingNode: (node: ItemNode | WindowNode | DoorNode | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -194,7 +196,7 @@ const useEditor = create()((set, get) => ({ setCatalogCategory: (category) => set({ catalogCategory: category }), selectedItem: null, setSelectedItem: (item) => set({ selectedItem: item }), - movingNode: null, + movingNode: null as ItemNode | WindowNode | DoorNode | null, setMovingNode: (node) => set({ movingNode: node }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 7c73b92e..61250974 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,6 +1,6 @@ import type { ThreeEvent } from '@react-three/fiber' 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' // Base event interfaces @@ -28,6 +28,7 @@ export type SlabEvent = NodeEvent export type CeilingEvent = NodeEvent export type RoofEvent = NodeEvent export type WindowEvent = NodeEvent +export type DoorEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -83,6 +84,7 @@ type EditorEvents = GridEvents & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'roof', RoofEvent> & NodeEvents<'window', WindowEvent> & + NodeEvents<'door', DoorEvent> & CameraControlEvents & ToolEvents diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 5701b1bd..3a43873e 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -20,6 +20,7 @@ export const sceneRegistry = { scan: new Set(), guide: new Set(), window: new Set(), + door: new Set(), }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 877623d8..0f5ea252 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export type { BuildingEvent, CameraControlEvent, CeilingEvent, + DoorEvent, EventSuffix, GridEvent, ItemEvent, @@ -43,6 +44,7 @@ export * from './schema' export { default as useScene } from './store/use-scene' // Systems export { CeilingSystem } from './systems/ceiling/ceiling-system' +export { DoorSystem } from './systems/door/door-system' export { ItemSystem } from './systems/item/item-system' export { RoofSystem } from './systems/roof/roof-system' export { SlabSystem } from './systems/slab/slab-system' diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 84b4b5d0..c3de2791 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -17,6 +17,7 @@ export { RoofNode } from './nodes/roof' export { ScanNode } from './nodes/scan' export { GuideNode } from './nodes/guide' export type { AnyNodeId, AnyNodeType } from './types' +export { DoorNode, DoorSegment } from './nodes/door' export { WindowNode } from './nodes/window' // Union types export { AnyNode } from './types' diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts new file mode 100644 index 00000000..f0c065e5 --- /dev/null +++ b/packages/core/src/schema/nodes/door.ts @@ -0,0 +1,67 @@ +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 + +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'), + + // 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 diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 6e7454c6..7d012f1f 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -8,6 +8,7 @@ import { RoofNode } from './nodes/roof' import { ScanNode } from './nodes/scan' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' +import { DoorNode } from './nodes/door' import { WallNode } from './nodes/wall' import { WindowNode } from './nodes/window' import { ZoneNode } from './nodes/zone' @@ -25,6 +26,7 @@ export const AnyNode = z.discriminatedUnion('type', [ ScanNode, GuideNode, WindowNode, + DoorNode, ]) export type AnyNode = z.infer diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/core/src/systems/door/door-system.tsx new file mode 100644 index 00000000..52548dec --- /dev/null +++ b/packages/core/src/systems/door/door-system.tsx @@ -0,0 +1,247 @@ +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 frameMaterial = new MeshStandardNodeMaterial({ + name: 'door-frame', + color: '#e8e8e8', + roughness: 0.6, + metalness: 0, +}) + +const leafMaterial = new MeshStandardNodeMaterial({ + name: 'door-leaf', + color: '#d0c8b8', + roughness: 0.5, + metalness: 0, +}) + +const panelMaterial = new MeshStandardNodeMaterial({ + name: 'door-panel', + color: '#c5bdb0', + 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, +}) + +const thresholdMaterial = new MeshStandardNodeMaterial({ + name: 'door-threshold', + color: '#999', + roughness: 0.4, + metalness: 0.5, +}) + +const handleMaterial = new MeshStandardNodeMaterial({ + name: 'door-handle', + color: '#bbb', + roughness: 0.2, + metalness: 0.8, +}) + +const closerMaterial = new MeshStandardNodeMaterial({ + name: 'door-closer', + color: '#333', + roughness: 0.4, + metalness: 0.3, +}) + +// 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, + } = 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, frameMaterial, frameThickness, height, frameDepth, -width / 2 + frameThickness / 2, 0, 0) + // Right post — full height + addBox(mesh, frameMaterial, frameThickness, height, frameDepth, width / 2 - frameThickness / 2, 0, 0) + // Head (top bar) — full width + addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0) + + // ── Threshold ── + if (threshold) { + addBox(mesh, thresholdMaterial, width, thresholdHeight, frameDepth, 0, -height / 2 + thresholdHeight / 2, 0) + } + + // ── Door leaf — full backing ── + addBox(mesh, leafMaterial, leafW, leafH, leafDepth, 0, leafCenterY, 0) + + // ── Segments (stacked top to bottom within leaf area) ── + const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0) + const leafTop = leafCenterY + leafH / 2 + + let segY = leafTop + for (const seg of segments) { + const segH = (seg.heightRatio / totalRatio) * leafH + const segCenterY = segY - segH / 2 + + const numCols = seg.columnRatios.length + const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) + const usableW = leafW - (numCols - 1) * seg.dividerThickness + const colWidths = seg.columnRatios.map(r => (r / colSum) * usableW) + + // Column x-centers + const colXCenters: number[] = [] + let cx = -leafW / 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 = -leafW / 2 + for (let c = 0; c < numCols - 1; c++) { + cx += colWidths[c]! + addBox(mesh, leafMaterial, 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') { + const glassDepth = Math.max(0.004, leafDepth * 0.15) + addBox(mesh, glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + } else if (seg.type === 'panel') { + 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, panelMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + } + } + // 'empty' → leaf backing is already there, nothing extra + } + + 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, handleMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) + // Grip lever + addBox(mesh, handleMaterial, 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, closerMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) + // Arm (simplified as thin bar to frame side) + addBox(mesh, closerMaterial, 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, handleMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) + } + + // ── 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 +} diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index e741519a..cf8074b4 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -306,7 +306,7 @@ function collectCutoutBrushes( const wallMatrixInverse = wallMesh.matrixWorld.clone().invert() 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) if (!childMesh) continue diff --git a/packages/viewer/src/components/renderers/door/door-renderer.tsx b/packages/viewer/src/components/renderers/door/door-renderer.tsx new file mode 100644 index 00000000..d723cfd1 --- /dev/null +++ b/packages/viewer/src/components/renderers/door/door-renderer.tsx @@ -0,0 +1,27 @@ +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(null!) + + useRegistry(node.id, 'door', ref) + const handlers = useNodeEvents(node, 'door') + + return ( + + {/* DoorSystem replaces this geometry each time the node is dirty */} + + + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 02fd4d9c..87e10141 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -3,6 +3,7 @@ import { type AnyNode, useScene } from '@pascal-app/core' import { BuildingRenderer } from './building/building-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer' +import { DoorRenderer } from './door/door-renderer' import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' @@ -28,6 +29,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'item' && } {node.type === 'slab' && } {node.type === 'wall' && } + {node.type === 'door' && } {node.type === 'window' && } {node.type === 'zone' && } {node.type === 'roof' && } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 85f66def..acb05c90 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,6 @@ '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 { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber' import * as THREE from 'three/webgpu' @@ -61,6 +61,7 @@ const Viewer: React.FC = ({ children, selectionManager = 'default' {/* Core systems */} + diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index afbfb180..5fa67574 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -29,6 +29,7 @@ type SelectableNodeType = | 'zone' | 'wall' | 'window' + | 'door' | 'item' | 'slab' | 'ceiling' @@ -86,8 +87,8 @@ const isNodeOnLevel = (node: AnyNode, levelId: string): boolean => { // Direct child of level if (node.parentId === levelId) return true - // Wall-attached items (windows/doors): check if parent wall is on the level - if (node.type === 'item' && node.parentId) { + // Wall-attached nodes (window/door/item): check if parent wall is on the level + if ((node.type === 'item' || node.type === 'window' || node.type === 'door') && node.parentId) { const parentNode = nodes[node.parentId as keyof typeof nodes] if (parentNode?.type === 'wall' && parentNode.parentId === levelId) { return true @@ -200,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 { - types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window'], + types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door'], handleClick: (node) => { const { selectedIds } = useViewer.getState().selection // Toggle selection - if already selected, deselect; otherwise select @@ -224,7 +225,7 @@ const getStrategy = (): SelectionStrategy | null => { } }, 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 return isNodeInZone(node, levelId, zoneId) }, @@ -277,6 +278,7 @@ export const SelectionManager = () => { 'ceiling', 'roof', 'window', + 'door', ] for (const type of allTypes) { emitter.on(`${type}:enter`, onEnter) diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 7e166726..77f9650c 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -3,6 +3,8 @@ import { type BuildingNode, type CeilingEvent, type CeilingNode, + type DoorEvent, + type DoorNode, type EventSuffix, emitter, type ItemEvent, @@ -36,6 +38,7 @@ type NodeConfig = { ceiling: { node: CeilingNode; event: CeilingEvent } roof: { node: RoofNode; event: RoofEvent } window: { node: WindowNode; event: WindowEvent } + door: { node: DoorNode; event: DoorEvent } } type NodeType = keyof NodeConfig From 7608cad7484cd51aaadb3dc20291ce78b36f3686 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 25 Feb 2026 14:05:32 +0900 Subject: [PATCH 09/12] nice progress on doors --- .../components/ui/panels/door-panel.tsx | 278 +++++++++++++----- packages/core/src/schema/nodes/door.ts | 3 + .../core/src/systems/door/door-system.tsx | 52 +++- 3 files changed, 239 insertions(+), 94 deletions(-) diff --git a/apps/editor/components/ui/panels/door-panel.tsx b/apps/editor/components/ui/panels/door-panel.tsx index 4b35eb5c..b489a45e 100644 --- a/apps/editor/components/ui/panels/door-panel.tsx +++ b/apps/editor/components/ui/panels/door-panel.tsx @@ -91,6 +91,28 @@ export function DoorPanel() { setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection]) + 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 return ( @@ -159,7 +181,7 @@ export function DoorPanel() { handleUpdate({ height: v })} + onChange={(v) => handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })} min={1.0} precision={2} className="flex-1" @@ -202,6 +224,39 @@ export function DoorPanel() { + {/* Content Padding */} +
+ +
+
+ handleUpdate({ contentPadding: [v, node.contentPadding[1]] })} + min={0} + precision={3} + step={0.005} + className="flex-1" + /> + m +
+
+ handleUpdate({ contentPadding: [node.contentPadding[0], v] })} + min={0} + precision={3} + step={0.005} + className="flex-1" + /> + m +
+
+
+ {/* Swing */}