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 08b5e0dc..40edca0c 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 { 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' +import { ThumbnailGenerator } from './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/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index ffe551c2..239757d8 100644 --- a/apps/editor/components/editor/selection-manager.tsx +++ b/apps/editor/components/editor/selection-manager.tsx @@ -20,7 +20,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => { 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 { types: SelectableNodeType[]; @@ -44,7 +44,7 @@ const SELECTION_STRATEGIES: Record = { }, structure: { - types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window"], + types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window", "door"], handleSelect: (node, isShift) => { const { selection, setSelection } = useViewer.getState(); if (node.type === 'zone') { @@ -80,7 +80,7 @@ const SELECTION_STRATEGIES: Record = { (node as ItemNode).asset.category === "window" ); } - if (node.type === "window") return true; + if (node.type === "window" || node.type === "door") return true; return false; } 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 +} 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..03d862b3 --- /dev/null +++ b/apps/editor/components/systems/ceiling/ceiling-system.tsx @@ -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 +} diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx index 89567f85..0af3f984 100644 --- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx +++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx @@ -2,8 +2,8 @@ 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 useEditor from '@/store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' 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() // Count existing ceilings for naming @@ -62,6 +62,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 = () => { @@ -69,8 +70,9 @@ 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 setTool = useEditor((state) => state.setTool) + const setSelection = useViewer((state) => state.setSelection) const [points, setPoints] = useState>([]) 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 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) => { @@ -133,10 +151,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 +166,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 +198,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(() => { @@ -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 && ( 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..e92afd51 --- /dev/null +++ b/apps/editor/components/ui/panels/door-panel.tsx @@ -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) => { + 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 ( +
+ {/* 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, position: [node.position[0], v / 2, node.position[2]] })} + 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 +
+
+
+ + {/* 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 */} +
+ +
+
+ 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) => { + const numCols = seg.columnRatios.length + const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) + const normCols = seg.columnRatios.map(r => r / colSum) + return ( +
+
+ Segment {i + 1} +
+ {(['panel', 'glass', 'empty'] as const).map((t) => ( + + ))} +
+
+
+ setSegmentHeightRatio(i, v / 100)} + min={5} + max={95} + precision={1} + step={1} + className="flex-1" + /> + % +
+ {/* Columns */} +
+ { + 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 && ( +
+ {normCols.map((ratio, ci) => ( +
+ setSegmentColumnRatio(i, ci, v / 100)} + min={5} + max={95} + precision={1} + step={1} + className="flex-1" + /> + % +
+ ))} +
+ { + 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" + /> + m +
+
+ )} +
+ {seg.type === 'panel' && ( +
+
+ { + 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" + /> + m +
+
+ { + 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" + /> + 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..cd8bfac6 --- /dev/null +++ b/packages/core/src/schema/nodes/door.ts @@ -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 + +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 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/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/core/src/systems/door/door-system.tsx b/packages/core/src/systems/door/door-system.tsx new file mode 100644 index 00000000..ef6cb4d7 --- /dev/null +++ b/packages/core/src/systems/door/door-system.tsx @@ -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 +} 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/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index ee86c9a5..a680002c 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -1,17 +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 { 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: 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 @@ -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 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!) @@ -43,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) => ( ))} 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..4acd54f4 --- /dev/null +++ b/packages/viewer/src/components/renderers/door/door-renderer.tsx @@ -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(null!) + + useRegistry(node.id, 'door', ref) + const handlers = useNodeEvents(node, 'door') + const isTransient = !!(node.metadata as Record | null)?.isTransient + + 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/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index d456c5ff..4663962b 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -15,7 +15,8 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { {/* WallSystem will replace this geometry in the next frame */} - + {/* Collision mesh: full-wall geometry (no cutouts) for pointer events */} + diff --git a/packages/viewer/src/components/renderers/window/window-renderer.tsx b/packages/viewer/src/components/renderers/window/window-renderer.tsx index 75c20188..f2d8677c 100644 --- a/packages/viewer/src/components/renderers/window/window-renderer.tsx +++ b/packages/viewer/src/components/renderers/window/window-renderer.tsx @@ -8,6 +8,7 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => { useRegistry(node.id, 'window', ref) const handlers = useNodeEvents(node, 'window') + const isTransient = !!(node.metadata as Record | null)?.isTransient return ( { visible={node.visible} position={node.position} rotation={node.rotation} - {...handlers} + {...(isTransient ? {} : handlers)} > {/* WindowSystem replaces this geometry each time the node is dirty */} 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 e591157c..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,12 +87,19 @@ 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 } + // 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 @@ -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 { - 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 @@ -217,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) }, @@ -270,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