diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx index a44851c9..ed4266ea 100644 --- a/apps/editor/app/viewer/[id]/page.tsx +++ b/apps/editor/app/viewer/[id]/page.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react' import { ViewerCameraControls } from './viewer-camera-controls' import { ViewerOverlay } from './viewer-overlay' import { ViewerZoneSystem } from './viewer-zone-system' +import { ThumbnailGenerator } from './thumbnail-generator' import { getPropertyModelPublic, incrementPropertyViews } from '@/features/community/lib/properties/actions' export default function ViewerPage() { @@ -14,6 +15,7 @@ export default function ViewerPage() { const id = params.id as string const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [propertyId, setPropertyId] = useState(null) const setScene = useScene((state) => state.setScene) useEffect(() => { @@ -35,7 +37,8 @@ export default function ViewerPage() { const result = await getPropertyModelPublic(id) if (result.success && result.data) { - const { model } = result.data + const { property, model } = result.data + setPropertyId(property.id) if (model?.scene_graph) { const { nodes, rootNodeIds } = model.scene_graph @@ -84,6 +87,8 @@ export default function ViewerPage() { {/* Custom Zone System */} + {/* Thumbnail Generator */} + ) diff --git a/apps/editor/app/viewer/[id]/thumbnail-generator.tsx b/apps/editor/app/viewer/[id]/thumbnail-generator.tsx new file mode 100644 index 00000000..f9909caf --- /dev/null +++ b/apps/editor/app/viewer/[id]/thumbnail-generator.tsx @@ -0,0 +1,122 @@ +'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 { uploadPropertyThumbnail } from '@/features/community/lib/properties/actions' + +const THUMBNAIL_WIDTH = 1920 +const THUMBNAIL_HEIGHT = 1080 + +interface ThumbnailGeneratorProps { + propertyId?: string +} + +export const ThumbnailGenerator = ({ propertyId: propPropertyId }: 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 propertyId (from URL) + const fallbackPropertyId = propPropertyId + + useEffect(() => { + const handleGenerateThumbnail = async (event: { propertyId: string }) => { + if (isGenerating.current) { + console.log('⏸️ Thumbnail generation already in progress') + return + } + + // Prioritize prop propertyId over event propertyId (URL has priority over session) + const propertyId = fallbackPropertyId || event.propertyId + + if (!propertyId) { + console.error('❌ No property ID provided') + return + } + + isGenerating.current = true + console.log('πŸ“Έ Generating thumbnail for property:', propertyId) + console.log('πŸ“ Property ID from URL/prop:', fallbackPropertyId) + console.log('πŸ“ Property ID from event:', event.propertyId) + console.log('βœ… Using property ID:', propertyId, fallbackPropertyId ? '(from URL)' : '(from event)') + + 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 uploadPropertyThumbnail(propertyId, blob) + + if (result.success) { + console.log('βœ… Thumbnail uploaded successfully!') + console.log('πŸ”— URL:', 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, fallbackPropertyId]) + + return null +} diff --git a/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx b/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx index e43bd28e..ab59152b 100644 --- a/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx +++ b/apps/editor/app/viewer/[id]/viewer-camera-controls.tsx @@ -3,7 +3,7 @@ import { sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' -import { useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { Box3, Vector3 } from 'three' const tempBox = new Box3() @@ -28,7 +28,7 @@ export const ViewerCameraControls = () => { : CameraControlsImpl.ACTION.DOLLY return { - left: CameraControlsImpl.ACTION.NONE, + left: CameraControlsImpl.ACTION.SCREEN_PAN, middle: CameraControlsImpl.ACTION.SCREEN_PAN, right: CameraControlsImpl.ACTION.ROTATE, wheel: wheelAction, @@ -59,7 +59,7 @@ export const ViewerCameraControls = () => { target[0], target[1], target[2], - true + true, ) return } @@ -81,7 +81,7 @@ export const ViewerCameraControls = () => { const cameraPos = new Vector3( tempCenter.x + distance * 0.7, tempCenter.y + distance * 0.5, - tempCenter.z + distance * 0.7 + tempCenter.z + distance * 0.7, ) controls.current.setLookAt( @@ -91,10 +91,18 @@ export const ViewerCameraControls = () => { tempCenter.x, tempCenter.y, tempCenter.z, - true + true, ) }, [targetNodeId, nodes]) + const onTransitionStart = useCallback(() => { + useViewer.getState().setCameraDragging(true) + }, []) + + const onRest = useCallback(() => { + useViewer.getState().setCameraDragging(false) + }, []) + return ( { maxPolarAngle={Math.PI / 2 - 0.1} minPolarAngle={0} mouseButtons={mouseButtons} + onTransitionStart={onTransitionStart} + onRest={onRest} + restThreshold={0.01} + /> ) } diff --git a/apps/editor/components/editor/custom-camera-controls.tsx b/apps/editor/components/editor/custom-camera-controls.tsx index b6039a52..07e717d9 100644 --- a/apps/editor/components/editor/custom-camera-controls.tsx +++ b/apps/editor/components/editor/custom-camera-controls.tsx @@ -3,7 +3,7 @@ import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' -import { useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { Vector3 } from 'three' const currentTarget = new Vector3() @@ -51,6 +51,85 @@ export const CustomCameraControls = () => { } }, [cameraMode]) + useEffect(() => { + const keyState = { + shiftRight: false, + shiftLeft: false, + controlRight: false, + controlLeft: false, + space: false, + } + + const updateConfig = () => { + if (!controls.current) return + + const shift = keyState.shiftRight || keyState.shiftLeft + const control = keyState.controlRight || keyState.controlLeft + const space = keyState.space + + const wheelAction = + cameraMode === 'orthographic' + ? CameraControlsImpl.ACTION.ZOOM + : CameraControlsImpl.ACTION.DOLLY + controls.current.mouseButtons.wheel = wheelAction + controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE + controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN + controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE + if (space) { + controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN + } + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === 'Space') { + keyState.space = true + document.body.style.cursor = 'grab' + } + if (event.code === 'ShiftRight') { + keyState.shiftRight = true + } + if (event.code === 'ShiftLeft') { + keyState.shiftLeft = true + } + if (event.code === 'ControlRight') { + keyState.controlRight = true + } + if (event.code === 'ControlLeft') { + keyState.controlLeft = true + } + updateConfig() + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.code === 'Space') { + keyState.space = false + document.body.style.cursor = '' + } + if (event.code === 'ShiftRight') { + keyState.shiftRight = false + } + if (event.code === 'ShiftLeft') { + keyState.shiftLeft = false + } + if (event.code === 'ControlRight') { + keyState.controlRight = false + } + if (event.code === 'ControlLeft') { + keyState.controlLeft = false + } + updateConfig() + } + + document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + updateConfig() + + return () => { + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + } + }, [cameraMode]) + useEffect(() => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { if (!controls.current) return @@ -139,6 +218,14 @@ export const CustomCameraControls = () => { } }, []) + const onTransitionStart = useCallback(() => { + useViewer.getState().setCameraDragging(true) + }, []) + + const onRest = useCallback(() => { + useViewer.getState().setCameraDragging(false) + }, []) + return ( { minPolarAngle={0} ref={controls} mouseButtons={mouseButtons} + onTransitionStart={onTransitionStart} + onRest={onRest} + restThreshold={0.01} /> ) } diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 680722c3..62202521 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -20,6 +20,7 @@ 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 property loads) useScene.getState().loadScene() @@ -73,6 +74,7 @@ export default function Editor({ propertyId }: EditorProps) { + ) diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index acd9f0cb..ffe551c2 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'; +type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window'; interface SelectionStrategy { types: SelectableNodeType[]; @@ -44,7 +44,7 @@ const SELECTION_STRATEGIES: Record = { }, structure: { - types: ["wall", "item", "zone", "slab", "ceiling", "roof"], + types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window"], handleSelect: (node, isShift) => { const { selection, setSelection } = useViewer.getState(); if (node.type === 'zone') { @@ -80,6 +80,8 @@ const SELECTION_STRATEGIES: Record = { (node as ItemNode).asset.category === "window" ); } + if (node.type === "window") return true; + return false; } }, diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx index 0621208d..bab38edc 100644 --- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx +++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx @@ -144,14 +144,20 @@ export const CeilingTool: React.FC = () => { } } + const onCancel = () => { + setPoints([]) + } + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('grid:double-click', onGridDoubleClick) + emitter.on('tool:cancel', onCancel) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('grid:double-click', onGridDoubleClick) + emitter.off('tool:cancel', onCancel) } }, [currentLevelId, points, cursorPosition, setTool]) diff --git a/apps/editor/components/tools/item/use-draft-node.ts b/apps/editor/components/tools/item/use-draft-node.ts index d6e8ae21..07213229 100644 --- a/apps/editor/components/tools/item/use-draft-node.ts +++ b/apps/editor/components/tools/item/use-draft-node.ts @@ -1,8 +1,7 @@ -import { type AnyNodeId, ItemNode, useScene } from '@pascal-app/core' +import { type AnyNodeId, type AssetInput, ItemNode, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback, useMemo, useRef } from 'react' import type { Vector3 } from 'three' -import type { AssetInput } from '@pascal-app/core' import { stripTransient } from './placement-math' interface OriginalState { @@ -159,14 +158,25 @@ export function useDraftNode(): DraftNodeHandle { if (adoptedRef.current && originalStateRef.current) { // Move mode: restore original state instead of deleting const original = originalStateRef.current + const id = draftRef.current.id - useScene.getState().updateNode(draftRef.current.id, { + useScene.getState().updateNode(id, { position: original.position, rotation: original.rotation, side: original.side, parentId: original.parentId, metadata: original.metadata, }) + + // Also reset the Three.js mesh directly β€” the store update triggers a React + // re-render but the mesh position was mutated by useFrame and may not reset + // until the next render cycle, leaving a visual glitch. + const mesh = sceneRegistry.nodes.get(id as AnyNodeId) + if (mesh) { + mesh.position.set(original.position[0], original.position[1], original.position[2]) + mesh.rotation.y = original.rotation[1] ?? 0 + mesh.visible = true + } } else { // Create mode: delete the transient node useScene.getState().deleteNode(draftRef.current.id) diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index 10a215df..71c97732 100644 --- a/apps/editor/components/tools/item/use-placement-coordinator.tsx +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -74,6 +74,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const placementState = useRef( config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null }, ) + const shiftFreeRef = useRef(false) // Store config callbacks in refs to avoid re-running effect when they change const configRef = useRef(config) @@ -104,8 +105,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea state: { ...placementState.current }, }) + const getActiveValidators = () => shiftFreeRef.current + ? { canPlaceOnFloor: () => ({ valid: true }), canPlaceOnWall: () => ({ valid: true }), canPlaceOnCeiling: () => ({ valid: true }) } + : validators + const revalidate = (): boolean => { - const placeable = checkCanPlace(getContext(), validators) + const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators) const color = placeable ? 0x22c55e : 0xef4444 // green-500 : red-500 edgeMaterial.color.setHex(color) basePlaneMaterial.color.setHex(color) @@ -196,7 +201,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onGridClick = (event: GridEvent) => { - const result = floorStrategy.click(getContext(), event, validators) + const result = floorStrategy.click(getContext(), event, getActiveValidators()) if (!result) return // Preserve cursor rotation for the next draft @@ -213,7 +218,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onWallEnter = (event: WallEvent) => { const nodes = useScene.getState().nodes - const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators) + const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, getActiveValidators()) if (!result) return event.stopPropagation() @@ -235,7 +240,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (ctx.state.surface !== 'wall') { const nodes = useScene.getState().nodes - const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes, validators) + const enterResult = wallStrategy.enter(ctx, event, resolveLevelId, nodes, getActiveValidators()) if (!enterResult) return event.stopPropagation() @@ -251,7 +256,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!draftNode.current) { const nodes = useScene.getState().nodes - const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, validators) + const setup = wallStrategy.enter(getContext(), event, resolveLevelId, nodes, getActiveValidators()) if (!setup) return event.stopPropagation() @@ -259,7 +264,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - const result = wallStrategy.move(ctx, event, validators) + const result = wallStrategy.move(ctx, event, getActiveValidators()) if (!result) return event.stopPropagation() @@ -312,7 +317,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onWallClick = (event: WallEvent) => { - const result = wallStrategy.click(getContext(), event, validators) + const result = wallStrategy.click(getContext(), event, getActiveValidators()) if (!result) return event.stopPropagation() @@ -423,7 +428,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onCeilingClick = (event: CeilingEvent) => { - const result = ceilingStrategy.click(getContext(), event, validators) + const result = ceilingStrategy.click(getContext(), event, getActiveValidators()) if (!result) return event.stopPropagation() @@ -474,10 +479,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const ROTATION_STEP = Math.PI / 2 const onKeyDown = (event: KeyboardEvent) => { - // Escape / right-click β†’ cancel - if (event.key === 'Escape' && configRef.current.onCancel) { - event.preventDefault() - configRef.current.onCancel() + if (event.key === 'Shift') { + shiftFreeRef.current = true + revalidate() return } @@ -502,7 +506,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea revalidate() } } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Shift') { + shiftFreeRef.current = false + revalidate() + } + } + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + + // ---- tool:cancel (Escape / programmatic) ---- + const onCancel = () => { + if (configRef.current.onCancel) { + configRef.current.onCancel() + } + } + emitter.on('tool:cancel', onCancel) // ---- Right-click cancel ---- const onContextMenu = (event: MouseEvent) => { @@ -547,7 +568,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) window.removeEventListener('contextmenu', onContextMenu) } }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode]) diff --git a/apps/editor/components/tools/roof/roof-tool.tsx b/apps/editor/components/tools/roof/roof-tool.tsx index 5c5bb52e..9f351007 100644 --- a/apps/editor/components/tools/roof/roof-tool.tsx +++ b/apps/editor/components/tools/roof/roof-tool.tsx @@ -151,13 +151,23 @@ export const RoofTool: React.FC = () => { } }; + const onCancel = () => { + if (corner1Ref.current) { + corner1Ref.current = null; + outlineRef.current.visible = false; + setPreview((prev) => ({ ...prev, corner1: null })); + } + }; + // Subscribe to events emitter.on("grid:move", onGridMove); emitter.on("grid:click", onGridClick); + emitter.on("tool:cancel", onCancel); return () => { emitter.off("grid:move", onGridMove); emitter.off("grid:click", onGridClick); + emitter.off("tool:cancel", onCancel); // Reset state on unmount corner1Ref.current = null; diff --git a/apps/editor/components/tools/slab/slab-tool.tsx b/apps/editor/components/tools/slab/slab-tool.tsx index 3022cb2d..59739d2e 100644 --- a/apps/editor/components/tools/slab/slab-tool.tsx +++ b/apps/editor/components/tools/slab/slab-tool.tsx @@ -138,14 +138,20 @@ export const SlabTool: React.FC = () => { } } + const onCancel = () => { + setPoints([]) + } + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('grid:double-click', onGridDoubleClick) + emitter.on('tool:cancel', onCancel) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('grid:double-click', onGridDoubleClick) + emitter.off('tool:cancel', onCancel) } }, [currentLevelId, points, cursorPosition, setSelection]) diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 5a5ec72c..5de5725e 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -12,6 +12,7 @@ import { SlabBoundaryEditor } from './slab/slab-boundary-editor' import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabTool } from './slab/slab-tool' import { WallTool } from './wall/wall-tool' +import { WindowTool } from './window/window-tool' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneTool } from './zone/zone-tool' @@ -26,6 +27,7 @@ const tools: Record>> = { roof: RoofTool, item: ItemTool, zone: ZoneTool, + window: WindowTool, }, furnish: { item: ItemTool, diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx index 12c19860..fa51d65b 100644 --- a/apps/editor/components/tools/wall/wall-tool.tsx +++ b/apps/editor/components/tools/wall/wall-tool.tsx @@ -158,14 +158,23 @@ export const WallTool: React.FC = () => { } } + const onCancel = () => { + if (buildingState.current === 1) { + buildingState.current = 0 + wallPreviewRef.current.visible = false + } + } + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) window.addEventListener('keydown', onKeyDown) window.addEventListener('keyup', onKeyUp) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) } diff --git a/apps/editor/components/tools/window/window-tool.tsx b/apps/editor/components/tools/window/window-tool.tsx new file mode 100644 index 00000000..10c7663b --- /dev/null +++ b/apps/editor/components/tools/window/window-tool.tsx @@ -0,0 +1,333 @@ +import { + type AnyNodeId, + emitter, + type ItemNode, + useScene, + type WallEvent, + type WallNode, + WindowNode, +} 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' + +// Shared edge material β€” reuse across renders, just toggle color +const edgeMaterial = new LineBasicNodeMaterial({ + color: 0xef4444, // red-500 default (invalid) + linewidth: 3, + depthTest: false, + depthWrite: false, +}) + +/** + * Converts wall-local (X along wall, Y = height) to world XYZ. + * Wall-local Y maps directly to world Y; X maps along the wall direction. + */ +function wallLocalToWorld( + wallNode: WallNode, + localX: number, + localY: number, +): [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), + localY, + wallNode.start[1] + localX * Math.sin(wallAngle), + ] +} + +/** + * Clamps window center position so it stays fully within wall bounds. + */ +function clampToWall( + wallNode: WallNode, + localX: number, + localY: 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 wallHeight = wallNode.height ?? 2.5 + + const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) + const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) + return { clampedX, clampedY } +} + +/** + * Directly checks the wall's children for bounding-box overlap with a proposed window. + * Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center). + * The spatial grid only tracks `item` nodes, so windows must be checked this way. + * Reads the wall's latest children from the store (not the event node) to avoid stale data. + */ +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 // Block if wall not found + 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] = item.asset.dimensions + childLeft = item.position[0] - w / 2 + childRight = item.position[0] + w / 2 + childBottom = item.position[1] // items store bottom Y + 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 // windows store center Y + childTop = win.position[1] + win.height / 2 + } else { + continue + } + + const xOverlap = newLeft < childRight && newRight > childLeft + const yOverlap = newBottom < childTop && newTop > childBottom + if (xOverlap && yOverlap) return true + } + + return false +} + +/** + * Window tool β€” places WindowNodes on walls only. + * Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions. + */ +export const WindowTool: 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 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 + // Rebuild wall so it removes the cutout from the deleted draft + 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 + + 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 localY = snapToHalf(event.localPosition[1]) + + const width = 1.5 + const height = 1.5 + + const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) + + const node = WindowNode.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), cursorRotation, valid) + event.stopPropagation() + } + + const onWallMove = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) 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 localY = snapToHalf(event.localPosition[1]) + + const width = draftRef.current?.width ?? 1.5 + const height = draftRef.current?.height ?? 1.5 + + const { clampedX, clampedY } = clampToWall(event.node, localX, localY, 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), cursorRotation, valid) + event.stopPropagation() + } + + const onWallClick = (event: WallEvent) => { + if (!draftRef.current) return + if (!isValidWallSideFace(event.normal)) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + + const localX = snapToHalf(event.localPosition[0]) + const localY = snapToHalf(event.localPosition[1]) + const { clampedX, clampedY } = clampToWall( + event.node, localX, localY, + 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 + + // Delete transient draft (paused, invisible to undo) + useScene.getState().deleteNode(draft.id) + + // Resume β†’ create permanent node (single undoable action) + useScene.temporal.getState().resume() + + const node = WindowNode.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, + columnRatios: draft.columnRatios, + rowRatios: draft.rowRatios, + dividerThickness: draft.dividerThickness, + sill: draft.sill, + sillDepth: draft.sillDepth, + sillThickness: draft.sillThickness, + }) + + useScene.getState().createNode(node, event.node.id as AnyNodeId) + 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: window outline rectangle (width Γ— height Γ— frameDepth) + const boxGeo = new BoxGeometry(1.5, 1.5, 0.07) + const edgesGeo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + + return ( + + + + ) +} diff --git a/apps/editor/components/ui/action-menu/index.tsx b/apps/editor/components/ui/action-menu/index.tsx index cc08a43c..0579c69b 100644 --- a/apps/editor/components/ui/action-menu/index.tsx +++ b/apps/editor/components/ui/action-menu/index.tsx @@ -34,7 +34,7 @@ export function ActionMenu({ className }: { className?: string }) { {mode === "build" && tool === "item" && catalogCategory && ( +
+ Esc + Cancel +
+ + ) +} diff --git a/apps/editor/components/ui/helpers/helper-manager.tsx b/apps/editor/components/ui/helpers/helper-manager.tsx index 6db3e4ac..591689f4 100644 --- a/apps/editor/components/ui/helpers/helper-manager.tsx +++ b/apps/editor/components/ui/helpers/helper-manager.tsx @@ -1,7 +1,10 @@ 'use client' import useEditor from '@/store/use-editor' +import { CeilingHelper } from './ceiling-helper' import { ItemHelper } from './item-helper' +import { RoofHelper } from './roof-helper' +import { SlabHelper } from './slab-helper' import { WallHelper } from './wall-helper' export function HelperManager() { @@ -9,15 +12,21 @@ export function HelperManager() { const movingNode = useEditor((state) => state.movingNode) if (movingNode) { - return + return } - + // Show appropriate helper based on current tool switch (tool) { case 'wall': return case 'item': return + case 'slab': + return + case 'ceiling': + return + case 'roof': + return default: return null } diff --git a/apps/editor/components/ui/helpers/item-helper.tsx b/apps/editor/components/ui/helpers/item-helper.tsx index 91345b4d..9688979c 100644 --- a/apps/editor/components/ui/helpers/item-helper.tsx +++ b/apps/editor/components/ui/helpers/item-helper.tsx @@ -1,4 +1,8 @@ -export function ItemHelper() { +interface ItemHelperProps { + showEsc?: boolean +} + +export function ItemHelper({ showEsc }: ItemHelperProps) { return (
@@ -10,9 +14,15 @@ export function ItemHelper() { Rotate clockwise
- Esc - Cancel + Shift + Free place
+ {showEsc && ( +
+ Esc + Cancel +
+ )}
) } diff --git a/apps/editor/components/ui/helpers/roof-helper.tsx b/apps/editor/components/ui/helpers/roof-helper.tsx new file mode 100644 index 00000000..8571a3da --- /dev/null +++ b/apps/editor/components/ui/helpers/roof-helper.tsx @@ -0,0 +1,10 @@ +export function RoofHelper() { + return ( +
+
+ Esc + Cancel +
+
+ ) +} diff --git a/apps/editor/components/ui/helpers/slab-helper.tsx b/apps/editor/components/ui/helpers/slab-helper.tsx new file mode 100644 index 00000000..135616ca --- /dev/null +++ b/apps/editor/components/ui/helpers/slab-helper.tsx @@ -0,0 +1,10 @@ +export function SlabHelper() { + return ( +
+
+ Esc + Cancel +
+
+ ) +} diff --git a/apps/editor/components/ui/helpers/wall-helper.tsx b/apps/editor/components/ui/helpers/wall-helper.tsx index 7e880e80..dd0339d4 100644 --- a/apps/editor/components/ui/helpers/wall-helper.tsx +++ b/apps/editor/components/ui/helpers/wall-helper.tsx @@ -1,10 +1,14 @@ export function WallHelper() { return ( -
+
Shift Allow non-45Β° angles
+
+ Esc + Cancel +
) } diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx index b59c192e..b775301d 100644 --- a/apps/editor/components/ui/item-catalog/catalog-items.tsx +++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx @@ -1,209 +1,100 @@ import { AssetInput, ItemNode } from "@pascal-app/core"; export const CATALOG_ITEMS: AssetInput[] = [ - - { - "id": "pillar", - "category": "outdoor", - "name": "Pillar", - "thumbnail": "/items/pillar/thumbnail.webp", - "src": "/items/pillar/model.glb", - "scale": [ - 1, - 1, - 1 - ], - "offset": [ - 0, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 0.5, - 1.3, - 0.5 - ] - }, - - - - { - "id": "high-fence", - "category": "outdoor", - "name": "High Fence", - "thumbnail": "/items/high-fence/thumbnail.webp", - "src": "/items/high-fence/model.glb", - "scale": [ - 1, - 1, - 1 - ], - "offset": [ - 0, - 0.01, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 4, - 4.1, - 0.5 - ] + id: "pillar", + category: "outdoor", + tags: ["structure", "fencing"], + name: "Pillar", + thumbnail: "/items/pillar/thumbnail.webp", + src: "/items/pillar/model.glb", + scale: [1, 1, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + dimensions: [0.5, 1.3, 0.5], }, { - "id": "medium-fence", - "category": "outdoor", - "name": "Medium Fence", - "thumbnail": "/items/medium-fence/thumbnail.webp", - "src": "/items/medium-fence/model.glb", - "scale": [ - 0.49, - 0.49, - 0.49 - ], - "offset": [ - 0, - 0.01, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 2, - 2, - 0.5 - ] - }, - - { - "id": "low-fence", - "category": "outdoor", - "name": "Low Fence", - "thumbnail": "/items/low-fence/thumbnail.webp", - "src": "/items/low-fence/model.glb", - "scale": [ - 1, - 1, - 1 - ], - "offset": [ - 0, - 0.01, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 2, - 0.8, - 0.5 - ] + id: "high-fence", + category: "outdoor", + tags: ["fencing"], + name: "High Fence", + thumbnail: "/items/high-fence/thumbnail.webp", + src: "/items/high-fence/model.glb", + scale: [1, 1, 1], + offset: [0, 0.01, 0], + rotation: [0, 0, 0], + dimensions: [4, 4.1, 0.5], }, { - "id": "bush", - "category": "outdoor", - "name": "Bush", - "thumbnail": "/items/bush/thumbnail.webp", - "src": "/items/bush/model.glb", - "scale": [ - 0.96, - 0.96, - 0.96 - ], - "offset": [ - -0.14, - 0.01, - -0.13 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 3, - 1.1, - 1 - ] + id: "medium-fence", + category: "outdoor", + tags: ["fencing"], + name: "Medium Fence", + thumbnail: "/items/medium-fence/thumbnail.webp", + src: "/items/medium-fence/model.glb", + scale: [0.49, 0.49, 0.49], + offset: [0, 0.01, 0], + rotation: [0, 0, 0], + dimensions: [2, 2, 0.5], }, - + { - "id": "fir-tree", - "category": "outdoor", - "name": "Fir", - "thumbnail": "/items/fir-tree/thumbnail.webp", - "src": "/items/fir-tree/model.glb", - "scale": [ - 1, - 1, - 1 - ], - "offset": [ - -0.01, - 0.05, - -0.07 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 0.5, - 3, - 0.5 - ] + id: "low-fence", + category: "outdoor", + tags: ["fencing"], + name: "Low Fence", + thumbnail: "/items/low-fence/thumbnail.webp", + src: "/items/low-fence/model.glb", + scale: [1, 1, 1], + offset: [0, 0.01, 0], + rotation: [0, 0, 0], + dimensions: [2, 0.8, 0.5], }, - + { - "id": "tree", - "category": "outdoor", - "name": "Tree", - "thumbnail": "/items/tree/thumbnail.webp", - "src": "/items/tree/model.glb", - "scale": [ - 0.65, - 0.65, - 0.65 - ], - "offset": [ - -0.02, - 0.17, - -0.04 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 1, - 5, - 1 - ] + id: "bush", + category: "outdoor", + tags: ["vegetation"], + name: "Bush", + thumbnail: "/items/bush/thumbnail.webp", + src: "/items/bush/model.glb", + scale: [0.96, 0.96, 0.96], + offset: [-0.14, 0.01, -0.13], + rotation: [0, 0, 0], + dimensions: [3, 1.1, 1], + }, + + { + id: "fir-tree", + category: "outdoor", + tags: ["vegetation"], + name: "Fir", + thumbnail: "/items/fir-tree/thumbnail.webp", + src: "/items/fir-tree/model.glb", + scale: [1, 1, 1], + offset: [-0.01, 0.05, -0.07], + rotation: [0, 0, 0], + dimensions: [0.5, 3, 0.5], + }, + + { + id: "tree", + category: "outdoor", + tags: ["vegetation"], + name: "Tree", + thumbnail: "/items/tree/thumbnail.webp", + src: "/items/tree/model.glb", + scale: [0.65, 0.65, 0.65], + offset: [-0.02, 0.17, -0.04], + rotation: [0, 0, 0], + dimensions: [1, 5, 1], }, - { id: "palm", category: "outdoor", + tags: ["vegetation"], name: "Palm", thumbnail: "/items/palm/thumbnail.webp", src: "/items/palm/model.glb", @@ -214,36 +105,22 @@ export const CATALOG_ITEMS: AssetInput[] = [ }, { - "id": "patio-umbrella", - "category": "outdoor", - "name": "Patio Umbrella", - "thumbnail": "/items/patio-umbrella/thumbnail.webp", - "src": "/items/patio-umbrella/model.glb", - "scale": [ - 1, - 1, - 1 - ], - "offset": [ - 0, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 0.5, - 3.7, - 0.5 - ] + id: "patio-umbrella", + category: "outdoor", + tags: ["leisure", "floor"], + name: "Patio Umbrella", + thumbnail: "/items/patio-umbrella/thumbnail.webp", + src: "/items/patio-umbrella/model.glb", + scale: [1, 1, 1], + offset: [0, 0, 0], + rotation: [0, 0, 0], + dimensions: [0.5, 3.7, 0.5], }, { id: "sunbed", category: "outdoor", + tags: ["leisure", "seating", "floor"], name: "Sunbed", thumbnail: "/items/sunbed/thumbnail.webp", src: "/items/sunbed/model.glb", @@ -254,125 +131,65 @@ export const CATALOG_ITEMS: AssetInput[] = [ }, { - "id": "window-double", - "category": "window", - "name": "Double Window", - "thumbnail": "/items/window-double/thumbnail.webp", - "src": "/items/window-double/model.glb", - "scale": [ - 0.81, - 0.81, - 0.81 - ], - "offset": [ - 0, - -0.32, - 0 - ], - "rotation": [ - 0, - 3.14, - 0 - ], - "dimensions": [ - 1.5, - 1.5, - 0.5 - ], - "attachTo": "wall" - }, - - { - "id": "window-simple", - "category": "window", - "name": "Simple Window", - "thumbnail": "/items/window-simple/thumbnail.webp", - "src": "/items/window-simple/model.glb", - "scale": [ - 1, - 1, - 1 - ], - "offset": [ - 1.06, - -0.21, - 0.05 - ], - "rotation": [ - 0, - 3.14, - 0 - ], - "dimensions": [ - 1.5, - 2, - 0.5 - ], - "attachTo": "wall" -}, - - - { - "id": "window-rectangle", - "category": "window", - "name": "Rectangle Window", - "thumbnail": "/items/window-rectangle/thumbnail.webp", - "src": "/items/window-rectangle/model.glb", - "scale": [ - 0.81, - 0.81, - 0.81 - ], - "offset": [ - -1.41, - -0.28, - 0.08 - ], - "rotation": [ - 0, - 3.14, - 0 - ], - "dimensions": [ - 2.5, - 1.5, - 0.5 - ], - "attachTo": "wall" + id: "window-double", + category: "window", + tags: ["wall"], + name: "Double Window", + thumbnail: "/items/window-double/thumbnail.webp", + src: "/items/window-double/model.glb", + scale: [0.81, 0.81, 0.81], + offset: [0, -0.32, 0], + rotation: [0, 3.14, 0], + dimensions: [1.5, 1.5, 0.5], + attachTo: "wall", }, { - "id": "door-bar", - "category": "door", - "name": "Door with bar", - "thumbnail": "/items/door-bar/thumbnail.webp", - "src": "/items/door-bar/model.glb", - "scale": [ - 1, - 1, - 1 - ], - "offset": [ - -0.48, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 1.5, - 2.5, - 0.5 - ], - "attachTo": "wall" + id: "window-simple", + category: "window", + tags: ["wall"], + name: "Simple Window", + thumbnail: "/items/window-simple/thumbnail.webp", + src: "/items/window-simple/model.glb", + scale: [1, 1, 1], + offset: [1.06, -0.21, 0.05], + rotation: [0, 3.14, 0], + dimensions: [1.5, 2, 0.5], + attachTo: "wall", + }, + + { + id: "window-rectangle", + category: "window", + tags: ["wall"], + name: "Rectangle Window", + thumbnail: "/items/window-rectangle/thumbnail.webp", + src: "/items/window-rectangle/model.glb", + scale: [0.81, 0.81, 0.81], + offset: [-1.41, -0.28, 0.08], + rotation: [0, 3.14, 0], + dimensions: [2.5, 1.5, 0.5], + attachTo: "wall", + }, + + { + id: "door-bar", + category: "door", + tags: ["wall"], + name: "Door with bar", + thumbnail: "/items/door-bar/thumbnail.webp", + src: "/items/door-bar/model.glb", + scale: [1, 1, 1], + offset: [-0.48, 0, 0], + rotation: [0, 0, 0], + dimensions: [1.5, 2.5, 0.5], + attachTo: "wall", }, { id: "glass-door", category: "door", + tags: ["wall"], name: "Glass Door", thumbnail: "/items/glass-door/thumbnail.webp", src: "/items/glass-door/model.glb", @@ -384,37 +201,23 @@ export const CATALOG_ITEMS: AssetInput[] = [ }, { - "id": "door", - "category": "door", - "name": "Door", - "thumbnail": "/items/door/thumbnail.webp", - "src": "/items/door/model.glb", - "scale": [ - 0.79, - 0.79, - 0.79 - ], - "offset": [ - -0.43, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 1.5, - 2, - 0.4 - ], - "attachTo": "wall" + id: "door", + category: "door", + tags: ["wall"], + name: "Door", + thumbnail: "/items/door/thumbnail.webp", + src: "/items/door/model.glb", + scale: [0.79, 0.79, 0.79], + offset: [-0.43, 0, 0], + rotation: [0, 0, 0], + dimensions: [1.5, 2, 0.4], + attachTo: "wall", }, { id: "parking-spot", category: "outdoor", + tags: ["leisure", "floor"], name: "Parking Spot", thumbnail: "/items/parking-spot/thumbnail.webp", src: "/items/parking-spot/model.glb", @@ -427,6 +230,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "outdoor-playhouse", category: "outdoor", + tags: ["leisure", "kids", "floor"], name: "Outdoor Playhouse", thumbnail: "/items/outdoor-playhouse/thumbnail.webp", src: "/items/outdoor-playhouse/model.glb", @@ -439,6 +243,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "skate", category: "outdoor", + tags: ["leisure", "kids", "floor"], name: "Skate", thumbnail: "/items/skate/thumbnail.webp", src: "/items/skate/model.glb", @@ -451,6 +256,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "scooter", category: "outdoor", + tags: ["leisure", "kids", "floor"], name: "Scooter", thumbnail: "/items/scooter/thumbnail.webp", src: "/items/scooter/model.glb", @@ -463,6 +269,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "basket-hoop", category: "outdoor", + tags: ["leisure", "sports", "floor"], name: "Basket Hoop", thumbnail: "/items/basket-hoop/thumbnail.webp", src: "/items/basket-hoop/model.glb", @@ -472,10 +279,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [1, 1.8, 1], }, - { id: "ball", category: "outdoor", + tags: ["leisure", "sports", "floor"], name: "Ball", thumbnail: "/items/ball/thumbnail.webp", src: "/items/ball/model.glb", @@ -488,6 +295,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "wine-bottle", category: "kitchen", + tags: ["countertop", "decor"], name: "Wine Bottle", thumbnail: "/items/wine-bottle/thumbnail.webp", src: "/items/wine-bottle/model.glb", @@ -500,6 +308,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "fruits", category: "kitchen", + tags: ["countertop", "decor"], name: "Fruits", thumbnail: "/items/fruits/thumbnail.webp", src: "/items/fruits/model.glb", @@ -512,6 +321,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "cutting-board", category: "kitchen", + tags: ["countertop"], name: "Cutting Board", thumbnail: "/items/cutting-board/thumbnail.webp", src: "/items/cutting-board/model.glb", @@ -524,6 +334,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "frying-pan", category: "kitchen", + tags: ["countertop"], name: "Frying Pan", thumbnail: "/items/frying-pan/thumbnail.webp", src: "/items/frying-pan/model.glb", @@ -536,6 +347,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "kitchen-utensils", category: "kitchen", + tags: ["countertop"], name: "Kitchen Utensils", thumbnail: "/items/kitchen-utensils/thumbnail.webp", src: "/items/kitchen-utensils/model.glb", @@ -548,6 +360,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "microwave", category: "kitchen", + tags: ["countertop", "electronics"], name: "Microwave", thumbnail: "/items/microwave/thumbnail.webp", src: "/items/microwave/model.glb", @@ -560,6 +373,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "stove", category: "kitchen", + tags: ["floor", "large"], name: "Stove", thumbnail: "/items/stove/thumbnail.webp", src: "/items/stove/model.glb", @@ -572,6 +386,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "fridge", category: "kitchen", + tags: ["floor", "large"], name: "Fridge", thumbnail: "/items/fridge/thumbnail.webp", src: "/items/fridge/model.glb", @@ -584,6 +399,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "hood", category: "kitchen", + tags: ["wall"], name: "Hood", thumbnail: "/items/hood/thumbnail.webp", src: "/items/hood/model.glb", @@ -597,6 +413,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "kitchen-shelf", category: "kitchen", + tags: ["wall", "storage"], name: "Kitchen Shelf", thumbnail: "/items/kitchen-shelf/thumbnail.webp", src: "/items/kitchen-shelf/model.glb", @@ -610,6 +427,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "kitchen-counter", category: "kitchen", + tags: ["floor", "large", "storage"], name: "Kitchen Counter", thumbnail: "/items/kitchen-counter/thumbnail.webp", src: "/items/kitchen-counter/model.glb", @@ -622,6 +440,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "kitchen-cabinet", category: "kitchen", + tags: ["floor", "large", "storage"], name: "Kitchen Cabinet", thumbnail: "/items/kitchen-cabinet/thumbnail.webp", src: "/items/kitchen-cabinet/model.glb", @@ -634,6 +453,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "kitchen", category: "kitchen", + tags: ["floor", "large"], name: "Kitchen", thumbnail: "/items/kitchen/thumbnail.webp", src: "/items/kitchen/model.glb", @@ -646,6 +466,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "toilet-paper", category: "bathroom", + tags: ["wall", "decor"], name: "Toilet Paper", thumbnail: "/items/toilet-paper/thumbnail.webp", src: "/items/toilet-paper/model.glb", @@ -659,6 +480,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "shower-rug", category: "bathroom", + tags: ["floor", "decor"], name: "Shower Rug", thumbnail: "/items/shower-rug/thumbnail.webp", src: "/items/shower-rug/model.glb", @@ -671,6 +493,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "laundry-bag", category: "bathroom", + tags: ["floor"], name: "Laundry Bag", thumbnail: "/items/laundry-bag/thumbnail.webp", src: "/items/laundry-bag/model.glb", @@ -683,6 +506,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "drying-rack", category: "bathroom", + tags: ["floor"], name: "Drying Rack", thumbnail: "/items/drying-rack/thumbnail.webp", src: "/items/drying-rack/model.glb", @@ -695,6 +519,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "washing-machine", category: "bathroom", + tags: ["floor", "large", "electronics"], name: "Washing Machine", thumbnail: "/items/washing-machine/thumbnail.webp", src: "/items/washing-machine/model.glb", @@ -704,10 +529,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [1, 1, 1], }, - { id: "toilet", category: "bathroom", + tags: ["floor", "large"], name: "Toilet", thumbnail: "/items/toilet/thumbnail.webp", src: "/items/toilet/model.glb", @@ -720,6 +545,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "shower-square", category: "bathroom", + tags: ["floor", "large"], name: "Squared Shower", thumbnail: "/items/shower-square/thumbnail.webp", src: "/items/shower-square/model.glb", @@ -732,6 +558,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "shower-angle", category: "bathroom", + tags: ["floor", "large"], name: "Angle Shower", thumbnail: "/items/shower-angle/thumbnail.webp", src: "/items/shower-angle/model.glb", @@ -744,6 +571,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "bathtub", category: "bathroom", + tags: ["floor", "large"], name: "Bathtub", thumbnail: "/items/bathtub/thumbnail.webp", src: "/items/bathtub/model.glb", @@ -756,6 +584,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "bathroom-sink", category: "bathroom", + tags: ["floor", "large"], name: "Bathroom Sink", thumbnail: "/items/bathroom-sink/thumbnail.webp", src: "/items/bathroom-sink/model.glb", @@ -765,10 +594,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [2, 1, 1.5], }, - { id: "ceiling-fan", category: "appliance", + tags: ["ceiling", "climate"], name: "Ceiling fan", thumbnail: "/items/ceiling-fan/thumbnail.webp", src: "/items/ceiling-fan/model.glb", @@ -782,6 +611,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "electric-panel", category: "appliance", + tags: ["wall", "electrical"], name: "Electric Panel", thumbnail: "/items/electric-panel/thumbnail.webp", src: "/items/electric-panel/model.glb", @@ -795,6 +625,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "sprinkler", category: "appliance", + tags: ["ceiling", "safety"], name: "Sprinkler", thumbnail: "/items/sprinkler/thumbnail.webp", src: "/items/sprinkler/model.glb", @@ -808,6 +639,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "smoke-detector", category: "appliance", + tags: ["ceiling", "safety"], name: "Smoke Detector", thumbnail: "/items/smoke-detector/thumbnail.webp", src: "/items/smoke-detector/model.glb", @@ -817,11 +649,11 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [0.5, 0.5, 0.5], attachTo: "ceiling", }, - { id: "fire-detector", category: "appliance", + tags: ["wall", "safety"], name: "Fire Detector", thumbnail: "/items/fire-detector/thumbnail.webp", src: "/items/fire-detector/model.glb", @@ -835,6 +667,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "exit-sign", category: "appliance", + tags: ["wall", "safety"], name: "Exit Sign", thumbnail: "/items/exit-sign/thumbnail.webp", src: "/items/exit-sign/model.glb", @@ -848,6 +681,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "hydrant", category: "appliance", + tags: ["floor", "safety"], name: "Hydrant", thumbnail: "/items/hydrant/thumbnail.webp", src: "/items/hydrant/model.glb", @@ -860,6 +694,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "alarm-keypad", category: "appliance", + tags: ["wall", "safety", "electrical"], name: "Alarm Keypad", thumbnail: "/items/alarm-keypad/thumbnail.webp", src: "/items/alarm-keypad/model.glb", @@ -869,10 +704,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [0.5, 0.1, 0.5], }, - { id: "thermostat", category: "appliance", + tags: ["wall", "climate", "electrical"], name: "Thermostat", thumbnail: "/items/thermostat/thumbnail.webp", src: "/items/thermostat/model.glb", @@ -886,6 +721,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "air-conditioning", category: "appliance", + tags: ["wall", "climate"], name: "Air Conditioning", thumbnail: "/items/air-conditioning/thumbnail.webp", src: "/items/air-conditioning/model.glb", @@ -899,6 +735,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "ac-block", category: "appliance", + tags: ["floor", "climate"], name: "AC block", thumbnail: "/items/ac-block/thumbnail.webp", src: "/items/ac-block/model.glb", @@ -911,6 +748,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "toaster", category: "appliance", + tags: ["countertop", "electronics"], name: "Toaster", thumbnail: "/items/toaster/thumbnail.webp", src: "/items/toaster/model.glb", @@ -923,6 +761,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "sewing-machine", category: "appliance", + tags: ["countertop", "electronics"], name: "Sewing Machine", thumbnail: "/items/sewing-machine/thumbnail.webp", src: "/items/sewing-machine/model.glb", @@ -932,10 +771,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [1, 0.7, 0.5], }, - { id: "kettle", category: "appliance", + tags: ["countertop", "electronics"], name: "Kettle", thumbnail: "/items/kettle/thumbnail.webp", src: "/items/kettle/model.glb", @@ -948,6 +787,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "iron", category: "appliance", + tags: ["countertop", "electronics"], name: "Iron", thumbnail: "/items/iron/thumbnail.webp", src: "/items/iron/model.glb", @@ -960,6 +800,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "coffee-machine", category: "appliance", + tags: ["countertop", "electronics"], name: "Coffee Machine", thumbnail: "/items/coffee-machine/thumbnail.webp", src: "/items/coffee-machine/model.glb", @@ -972,6 +813,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "television", category: "appliance", + tags: ["floor", "electronics"], name: "Television", thumbnail: "/items/television/thumbnail.webp", src: "/items/television/model.glb", @@ -981,10 +823,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [2, 1.1, 0.5], }, - { id: "computer", category: "appliance", + tags: ["countertop", "electronics"], name: "Computer", thumbnail: "/items/computer/thumbnail.webp", src: "/items/computer/model.glb", @@ -997,6 +839,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "stereo-speaker", category: "appliance", + tags: ["floor", "electronics"], name: "Stereo Speaker", thumbnail: "/items/stereo-speaker/thumbnail.webp", src: "/items/stereo-speaker/model.glb", @@ -1006,11 +849,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [0.5, 1.1, 0.5], }, - - { id: "threadmill", category: "furniture", + tags: ["floor", "fitness"], name: "Threadmill", thumbnail: "/items/threadmill/thumbnail.webp", src: "/items/threadmill/model.glb", @@ -1020,10 +862,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [2.5, 1.5, 1], }, - { id: "barbell-stand", category: "furniture", + tags: ["floor", "fitness"], name: "Barbell Stand", thumbnail: "/items/barbell-stand/thumbnail.webp", src: "/items/barbell-stand/model.glb", @@ -1036,6 +878,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "barbell", category: "furniture", + tags: ["floor", "fitness"], name: "Barbell", thumbnail: "/items/barbell/thumbnail.webp", src: "/items/barbell/model.glb", @@ -1048,6 +891,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "toy", category: "furniture", + tags: ["floor", "kids", "decor"], name: "Toy", thumbnail: "/items/toy/thumbnail.webp", src: "/items/toy/model.glb", @@ -1057,10 +901,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [0.5, 0.5, 0.5], }, - { id: "car-toy", category: "furniture", + tags: ["floor", "kids", "decor"], name: "Car Toy", thumbnail: "/items/car-toy/thumbnail.webp", src: "/items/car-toy/model.glb", @@ -1073,6 +917,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "easel", category: "furniture", + tags: ["floor", "decor"], name: "Easel", thumbnail: "/items/easel/thumbnail.webp", src: "/items/easel/model.glb", @@ -1085,6 +930,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "pool-table", category: "furniture", + tags: ["floor", "leisure"], name: "Pool table", thumbnail: "/items/pool-table/thumbnail.webp", src: "/items/pool-table/model.glb", @@ -1097,6 +943,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "guitar", category: "furniture", + tags: ["floor", "decor"], name: "Guitar", thumbnail: "/items/guitar/thumbnail.webp", src: "/items/guitar/model.glb", @@ -1109,6 +956,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "piano", category: "furniture", + tags: ["floor", "decor"], name: "Piano", thumbnail: "/items/piano/thumbnail.webp", src: "/items/piano/model.glb", @@ -1121,6 +969,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "round-carpet", category: "furniture", + tags: ["floor", "decor"], name: "Round Carpet", thumbnail: "/items/round-carpet/thumbnail.webp", src: "/items/round-carpet/model.glb", @@ -1133,6 +982,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "rectangular-carpet", category: "furniture", + tags: ["floor", "decor"], name: "Rectangular Carpet", thumbnail: "/items/rectangular-carpet/thumbnail.webp", src: "/items/rectangular-carpet/model.glb", @@ -1145,6 +995,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "cactus", category: "furniture", + tags: ["floor", "decor", "vegetation"], name: "Cactus", thumbnail: "/items/cactus/thumbnail.webp", src: "/items/cactus/model.glb", @@ -1157,6 +1008,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "small-indoor-plant", category: "furniture", + tags: ["countertop", "decor", "vegetation"], name: "Small Plant", thumbnail: "/items/small-indoor-plant/thumbnail.webp", src: "/items/small-indoor-plant/model.glb", @@ -1169,6 +1021,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "indoor-plant", category: "furniture", + tags: ["floor", "decor", "vegetation"], name: "Indoor Plant", thumbnail: "/items/indoor-plant/thumbnail.webp", src: "/items/indoor-plant/model.glb", @@ -1181,6 +1034,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "ironing-board", category: "furniture", + tags: ["floor"], name: "Ironing Board", thumbnail: "/items/ironing-board/thumbnail.webp", src: "/items/ironing-board/model.glb", @@ -1193,6 +1047,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "coat-rack", category: "furniture", + tags: ["floor", "storage"], name: "Coat Rack", thumbnail: "/items/coat-rack/thumbnail.webp", src: "/items/coat-rack/model.glb", @@ -1205,6 +1060,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "trash-bin", category: "furniture", + tags: ["floor"], name: "Trash Bin", thumbnail: "/items/trash-bin/thumbnail.webp", src: "/items/trash-bin/model.glb", @@ -1217,6 +1073,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "round-mirror", category: "furniture", + tags: ["wall", "decor"], name: "Rounded Mirror", thumbnail: "/items/round-mirror/thumbnail.webp", src: "/items/round-mirror/model.glb", @@ -1230,6 +1087,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "picture", category: "furniture", + tags: ["wall", "decor"], name: "Picture", thumbnail: "/items/picture/thumbnail.webp", src: "/items/picture/model.glb", @@ -1243,6 +1101,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "books", category: "furniture", + tags: ["countertop", "decor"], name: "Books", thumbnail: "/items/books/thumbnail.webp", src: "/items/books/model.glb", @@ -1253,64 +1112,35 @@ export const CATALOG_ITEMS: AssetInput[] = [ }, { - "id": "column", - "category": "furniture", - "name": "Column", - "thumbnail": "/items/column/thumbnail.webp", - "src": "/items/column/model.glb", - "scale": [ - 1, - 1, - 1 - ], - "offset": [ - 0, - 1.26, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 0.5, - 2.6, - 0.5 - ] + id: "column", + category: "furniture", + tags: ["floor", "structure"], + name: "Column", + thumbnail: "/items/column/thumbnail.webp", + src: "/items/column/model.glb", + scale: [1, 1, 1], + offset: [0, 1.26, 0], + rotation: [0, 0, 0], + dimensions: [0.5, 2.6, 0.5], }, - + { - "id": "stairs", - "category": "furniture", - "name": "Stairs", - "thumbnail": "/items/stairs/thumbnail.webp", - "src": "/items/stairs/model.glb", - "scale": [ - 0.61, - 0.61, - 0.61 - ], - "offset": [ - 0, - 0.03, - 1.8 - ], - "rotation": [ - 0, - 0, - 0 - ], - "dimensions": [ - 1.5, - 2.5, - 3.5 - ] + id: "stairs", + category: "furniture", + tags: ["floor", "structure"], + name: "Stairs", + thumbnail: "/items/stairs/thumbnail.webp", + src: "/items/stairs/model.glb", + scale: [0.61, 0.61, 0.61], + offset: [0, 0.03, 1.8], + rotation: [0, 0, 0], + dimensions: [1.5, 2.5, 3.5], }, { id: "suspended-fireplace", category: "furniture", + tags: ["ceiling", "decor"], name: "Suspended Fireplace", thumbnail: "/items/suspended-fireplace/thumbnail.webp", src: "/items/suspended-fireplace/model.glb", @@ -1324,6 +1154,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "tv-stand", category: "furniture", + tags: ["floor", "storage"], name: "TV Stand", thumbnail: "/items/tv-stand/thumbnail.webp", src: "/items/tv-stand/model.glb", @@ -1336,6 +1167,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "shelf", category: "furniture", + tags: ["wall", "storage"], name: "Shelf", thumbnail: "/items/shelf/thumbnail.webp", src: "/items/shelf/model.glb", @@ -1349,6 +1181,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "bookshelf", category: "furniture", + tags: ["floor", "storage"], name: "Bookshelf", thumbnail: "/items/bookshelf/thumbnail.webp", src: "/items/bookshelf/model.glb", @@ -1361,6 +1194,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "ceiling-lamp", category: "furniture", + tags: ["ceiling", "lighting"], name: "Ceiling Lamp", thumbnail: "/items/ceiling-lamp/thumbnail.webp", src: "/items/ceiling-lamp/model.glb", @@ -1374,6 +1208,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "floor-lamp", category: "furniture", + tags: ["floor", "lighting"], name: "Floor Lamp", thumbnail: "/items/floor-lamp/thumbnail.webp", src: "/items/floor-lamp/model.glb", @@ -1386,6 +1221,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "table-lamp", category: "furniture", + tags: ["countertop", "lighting"], name: "Table Lamp", thumbnail: "/items/table-lamp/thumbnail.webp", src: "/items/table-lamp/model.glb", @@ -1395,10 +1231,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [0.5, 0.8, 1], }, - { id: "closet", category: "furniture", + tags: ["floor", "storage", "bedroom"], name: "Closet", thumbnail: "/items/closet/thumbnail.webp", src: "/items/closet/model.glb", @@ -1411,6 +1247,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "dresser", category: "furniture", + tags: ["floor", "storage", "bedroom"], name: "Dresser", thumbnail: "/items/dresser/thumbnail.webp", src: "/items/dresser/model.glb", @@ -1423,6 +1260,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "bunkbed", category: "furniture", + tags: ["floor", "bedroom"], name: "Bunkbed", thumbnail: "/items/bunkbed/thumbnail.webp", src: "/items/bunkbed/model.glb", @@ -1435,6 +1273,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "double-bed", category: "furniture", + tags: ["floor", "bedroom"], name: "Double Bed", thumbnail: "/items/double-bed/thumbnail.webp", src: "/items/double-bed/model.glb", @@ -1447,6 +1286,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "single-bed", category: "furniture", + tags: ["floor", "bedroom"], name: "Single Bed", thumbnail: "/items/single-bed/thumbnail.webp", src: "/items/single-bed/model.glb", @@ -1456,10 +1296,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ dimensions: [1.5, 0.7, 2.5], }, - { id: "sofa", category: "furniture", + tags: ["floor", "seating"], name: "Sofa", thumbnail: "/items/sofa/thumbnail.webp", src: "/items/sofa/model.glb", @@ -1472,6 +1312,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "lounge-chair", category: "furniture", + tags: ["floor", "seating"], name: "Lounge Chair", thumbnail: "/items/lounge-chair/thumbnail.webp", src: "/items/lounge-chair/model.glb", @@ -1484,6 +1325,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "stool", category: "furniture", + tags: ["floor", "seating"], name: "Stool", thumbnail: "/items/stool/thumbnail.webp", src: "/items/stool/model.glb", @@ -1496,6 +1338,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "dining-chair", category: "furniture", + tags: ["floor", "seating"], name: "Dining Chair", thumbnail: "/items/dining-chair/thumbnail.webp", src: "/items/dining-chair/model.glb", @@ -1508,6 +1351,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "office-chair", category: "furniture", + tags: ["floor", "seating"], name: "Office Chair", thumbnail: "/items/office-chair/thumbnail.webp", src: "/items/office-chair/model.glb", @@ -1520,6 +1364,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "livingroom-chair", category: "furniture", + tags: ["floor", "seating"], name: "Livingroom Chair", thumbnail: "/items/livingroom-chair/thumbnail.webp", src: "/items/livingroom-chair/model.glb", @@ -1532,6 +1377,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "bedside-table", category: "furniture", + tags: ["floor", "bedroom"], name: "Bedside Table", thumbnail: "/items/bedside-table/thumbnail.webp", src: "/items/bedside-table/model.glb", @@ -1544,6 +1390,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "coffee-table", category: "furniture", + tags: ["floor", "table"], name: "Coffee Table", thumbnail: "/items/coffee-table/thumbnail.webp", src: "/items/coffee-table/model.glb", @@ -1556,6 +1403,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "office-table", category: "furniture", + tags: ["floor", "table"], name: "Office Table", thumbnail: "/items/office-table/thumbnail.webp", src: "/items/office-table/model.glb", @@ -1568,6 +1416,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ { id: "dining-table", category: "furniture", + tags: ["floor", "table"], name: "Dining Table", thumbnail: "/items/dining-table/thumbnail.webp", src: "/items/dining-table/model.glb", diff --git a/apps/editor/components/ui/item-catalog/item-catalog.tsx b/apps/editor/components/ui/item-catalog/item-catalog.tsx index e6c00856..3c439e64 100644 --- a/apps/editor/components/ui/item-catalog/item-catalog.tsx +++ b/apps/editor/components/ui/item-catalog/item-catalog.tsx @@ -1,27 +1,69 @@ "use client"; +import { AssetInput } from "@pascal-app/core"; +import { resolveCdnUrl } from "@pascal-app/viewer"; import Image from "next/image"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/primitives/tooltip"; - import { cn } from "@/lib/utils"; import useEditor, { CatalogCategory } from "@/store/use-editor"; import { CATALOG_ITEMS } from "./catalog-items"; -import { AssetInput } from "@pascal-app/core"; -import { resolveCdnUrl } from "@pascal-app/viewer"; + +const PLACEMENT_TAGS = new Set(["floor", "wall", "ceiling", "countertop"]); export function ItemCatalog({ category }: { category: CatalogCategory }) { const selectedItem = useEditor((state) => state.selectedItem); const setSelectedItem = useEditor((state) => state.setSelectedItem); + const [activePlacementTag, setActivePlacementTag] = useState(null); + const [activeFunctionalTag, setActiveFunctionalTag] = useState(null); - const filteredItems = CATALOG_ITEMS.filter( + // Reset tag filters when category changes + useEffect(() => { + setActivePlacementTag(null); + setActiveFunctionalTag(null); + }, [category]); + + const categoryItems = CATALOG_ITEMS.filter( (item) => item.category === category, ); + // Collect tags available in this category + const allTags = Array.from( + new Set(categoryItems.flatMap((item) => item.tags ?? [])), + ); + const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t)); + const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t)); + const hasFilters = allTags.length > 1; + + // Count items for a placement tag given the current functional filter + const placementCount = (tag: string | null) => + categoryItems.filter((item) => { + const tags = item.tags ?? []; + if (tag !== null && !tags.includes(tag)) return false; + if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false; + return true; + }).length; + + // Count items for a functional tag given the current placement filter + const functionalCount = (tag: string) => + categoryItems.filter((item) => { + const tags = item.tags ?? []; + if (!tags.includes(tag)) return false; + if (activePlacementTag && !tags.includes(activePlacementTag)) return false; + return true; + }).length; + + const filteredItems = categoryItems.filter((item) => { + const tags = item.tags ?? []; + if (activePlacementTag && !tags.includes(activePlacementTag)) return false; + if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false; + return true; + }); + // Auto-select first item if current selection is not in the filtered list useEffect(() => { const isCurrentItemInCategory = filteredItems.some( @@ -44,50 +86,134 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) { }; return ( -
- {filteredItems.map((item, index) => { - const isSelected = selectedItem?.src === item?.src; - const attachmentIcon = getAttachmentIcon(item?.attachTo); - return ( - - +
+ {/* Filter chips */} + {hasFilters && ( +
+ {/* Placement row */} + {placementTags.length > 0 && ( +
- - - {item.name} - - - ); - })} + {placementTags.map((tag) => { + const count = placementCount(tag); + const isActive = activePlacementTag === tag; + const isEmpty = count === 0 && !isActive; + return ( + + ); + })} +
+ )} + + {/* Functional row */} + {functionalTags.length > 0 && ( +
+ {functionalTags.map((tag) => { + const count = functionalCount(tag); + const isActive = activeFunctionalTag === tag; + const isEmpty = count === 0 && !isActive; + return ( + + ); + })} +
+ )} +
+ )} + + {/* Items */} +
+ {filteredItems.map((item, index) => { + const isSelected = selectedItem?.src === item?.src; + const attachmentIcon = getAttachmentIcon(item?.attachTo); + return ( + + + + + + {item.name} + + + ); + })} +
); } diff --git a/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx index 7009d558..f0e80906 100644 --- a/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx @@ -1,10 +1,11 @@ -import { useScene } from "@pascal-app/core"; +import { emitter, useScene } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; -import { Download, Save, Trash2, Upload } from "lucide-react"; -import { useRef } from "react"; +import { Camera, Download, Save, Trash2, Upload } from "lucide-react"; +import { useRef, useState } from "react"; import { Button } from "@/components/ui/primitives/button"; import useEditor from "@/store/use-editor"; import { AudioSettingsDialog } from "./audio-settings-dialog"; +import { usePropertyStore } from "@/features/community/lib/properties/store"; export function SettingsPanel() { const fileInputRef = useRef(null); @@ -15,6 +16,11 @@ export function SettingsPanel() { const resetSelection = useViewer((state) => state.resetSelection); const exportScene = useViewer((state) => state.exportScene); const setPhase = useEditor((state) => state.setPhase); + const activeProperty = usePropertyStore((state) => state.activeProperty); + const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false); + + const propertyId = activeProperty?.id; + const isLocalProperty = false; // Store only contains cloud properties const handleExport = async () => { if (exportScene) { @@ -64,6 +70,19 @@ export function SettingsPanel() { setPhase("site"); }; + const handleGenerateThumbnail = () => { + if (!propertyId) { + console.error('❌ No property ID found'); + return; + } + console.log('🎯 Generate thumbnail clicked for property:', propertyId); + setIsGeneratingThumbnail(true); + emitter.emit('camera-controls:generate-thumbnail', { propertyId }); + console.log('πŸ“€ Event emitted with property ID:', propertyId); + // Reset loading state after a delay (thumbnail generation is async) + setTimeout(() => setIsGeneratingThumbnail(false), 3000); + }; + return (
{/* Export Section */} @@ -81,6 +100,24 @@ export function SettingsPanel() {
+ {/* Thumbnail Section (only for cloud properties) */} + {propertyId && !isLocalProperty && ( +
+ + +
+ )} + {/* Save/Load Section */}
))}
diff --git a/apps/editor/features/community/lib/properties/actions.ts b/apps/editor/features/community/lib/properties/actions.ts index 2cb80226..4dd67d57 100644 --- a/apps/editor/features/community/lib/properties/actions.ts +++ b/apps/editor/features/community/lib/properties/actions.ts @@ -65,6 +65,40 @@ export async function getUserProperties(): Promise> { } } +/** + * Get a specific property by ID for the current user + */ +export async function getPropertyById(propertyId: string): Promise> { + try { + const session = await getSession() + + if (!session?.user) { + return { success: false, error: 'Not authenticated', data: null } + } + + const supabase = await createServerSupabaseClient() + + const { data, error } = await supabase + .from('properties') + .select(`*, address:properties_addresses(*)`) + .eq('id', propertyId) + .eq('owner_id', session.user.id) + .single() + + if (error) { + return { success: false, error: error.message, data: null } + } + + return { success: true, data: data as Property } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch property', + data: null, + } + } +} + /** * Get the active property for the current session */ @@ -891,3 +925,84 @@ export async function togglePropertyLike( } } } + +/** + * Upload a thumbnail image to Supabase Storage and update the property + */ +export async function uploadPropertyThumbnail( + propertyId: string, + blob: Blob +): Promise<{ success: true; data: { thumbnail_url: string } } | { success: false; error: string }> { + try { + const session = await getSession() + if (!session?.user?.id) { + return { success: false, error: 'Not authenticated' } + } + + // Validate file size (max 10MB) + const MAX_SIZE = 10 * 1024 * 1024 + if (blob.size > MAX_SIZE) { + return { success: false, error: `Image too large (${(blob.size / 1024 / 1024).toFixed(1)}MB). Maximum size is 10MB.` } + } + + const supabase = await createServerSupabaseClient() + + // Verify the user owns this property + const { data: property, error: propertyError } = await supabase + .from('properties') + .select('owner_id') + .eq('id', propertyId) + .single() + + if (propertyError || !property) { + return { success: false, error: 'Property not found' } + } + + if ((property as any).owner_id !== session.user.id) { + return { success: false, error: 'Not authorized to update this property' } + } + + // Generate a unique filename + const timestamp = Date.now() + const filename = `${propertyId}/${timestamp}.png` + + // Upload to Supabase Storage + const { data: uploadData, error: uploadError } = await supabase.storage + .from('property-thumbnails') + .upload(filename, blob, { + contentType: 'image/png', + upsert: false, + }) + + if (uploadError) { + return { success: false, error: `Upload failed: ${uploadError.message}` } + } + + // Get the public URL + const { data: urlData } = supabase.storage + .from('property-thumbnails') + .getPublicUrl(uploadData.path) + + const thumbnailUrl = urlData.publicUrl + + // Update the property with the new thumbnail URL + const { error: updateError } = await (supabase + .from('properties') as any) + .update({ thumbnail_url: thumbnailUrl }) + .eq('id', propertyId) + + if (updateError) { + return { success: false, error: `Failed to update property: ${updateError.message}` } + } + + return { + success: true, + data: { thumbnail_url: thumbnailUrl }, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to upload thumbnail', + } + } +} diff --git a/apps/editor/features/community/lib/properties/hooks.ts b/apps/editor/features/community/lib/properties/hooks.ts deleted file mode 100644 index edc73204..00000000 --- a/apps/editor/features/community/lib/properties/hooks.ts +++ /dev/null @@ -1,234 +0,0 @@ -'use client' - -import { useCallback, useEffect, useRef, useState } from 'react' -import { - getActiveProperty, - getUserProperties, - setActiveProperty as setActivePropertyAction, -} from './actions' -import type { Property } from './types' - -interface UsePropertiesReturn { - properties: Property[] - isLoading: boolean - error: string | null - refetch: () => Promise -} - -/** - * Hook to fetch and manage user properties - */ -export function useProperties(): UsePropertiesReturn { - const [properties, setProperties] = useState([]) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchProperties = useCallback(async () => { - try { - setIsLoading(true) - setError(null) - - const result = await getUserProperties() - - if (result.success) { - setProperties(result.data || []) - } else { - setError(result.error || 'Failed to fetch properties') - setProperties([]) - } - } catch (err) { - setError(err instanceof Error ? err.message : 'An unexpected error occurred') - setProperties([]) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - fetchProperties() - }, [fetchProperties]) - - return { - properties, - isLoading, - error, - refetch: fetchProperties, - } -} - -/** - * Hook to fetch a single property by ID - */ -export function useProperty(propertyId: string | undefined) { - const [property, setProperty] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - if (!propertyId) { - setProperty(null) - setIsLoading(false) - return - } - - const fetchProperty = async () => { - try { - setIsLoading(true) - setError(null) - - const result = await getUserProperties() - - if (result.success) { - const found = result.data?.find((p) => p.id === propertyId) - if (found) { - setProperty(found) - } else { - setError('Property not found') - setProperty(null) - } - } else { - setError(result.error || 'Failed to fetch property') - setProperty(null) - } - } catch (err) { - setError(err instanceof Error ? err.message : 'An unexpected error occurred') - setProperty(null) - } finally { - setIsLoading(false) - } - } - - fetchProperty() - }, [propertyId]) - - return { - property, - isLoading, - error, - } -} - -/** - * Hook to manage the active property for the current session - */ -export function useActiveProperty() { - const [activeProperty, setActivePropertyState] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - const [isPending, setIsPending] = useState(false) - const isInitialFetchRef = useRef(true) - - const fetchActiveProperty = useCallback(async (allowAutoSelect = false) => { - try { - setIsLoading(true) - setError(null) - - const result = await getActiveProperty() - - if (result.success) { - setActivePropertyState(result.data || null) - - // If no active property is set, automatically set the first property as active - // Only do this on initial mount to avoid interfering with property selection/creation - if (!result.data && allowAutoSelect) { - console.log('[useActiveProperty] No active property, checking if we should auto-select') - const propertiesResult = await getUserProperties() - - if ( - propertiesResult.success && - propertiesResult.data && - propertiesResult.data.length > 0 - ) { - console.log('[useActiveProperty] Found properties, auto-selecting first one') - const firstProperty = propertiesResult.data[0] - if (firstProperty) { - const setActiveResult = await setActivePropertyAction(firstProperty.id) - - if (setActiveResult.success) { - console.log('[useActiveProperty] Auto-selected property:', firstProperty.name) - setActivePropertyState(firstProperty) - } - } - } - } else if (!result.data && !allowAutoSelect) { - console.log('[useActiveProperty] No active property but auto-select is disabled') - } - } else { - setError(result.error || 'Failed to fetch active property') - setActivePropertyState(null) - } - } catch (err) { - setError(err instanceof Error ? err.message : 'An unexpected error occurred') - setActivePropertyState(null) - } finally { - setIsLoading(false) - } - }, []) - - const changeActiveProperty = useCallback( - async (propertyId: string | null) => { - try { - setIsPending(true) - setIsLoading(true) - const result = await setActivePropertyAction(propertyId) - - if (result.success) { - if (propertyId) { - // Fetch the property data and set it immediately - console.log('[useActiveProperty] Fetching property data for ID:', propertyId) - const propertiesResult = await getUserProperties() - - if (propertiesResult.success && propertiesResult.data) { - const selectedProperty = propertiesResult.data.find(p => p.id === propertyId) - if (selectedProperty) { - console.log('[useActiveProperty] Found property, setting as active:', selectedProperty.name) - console.log('[useActiveProperty] Current isLoading state:', isLoading) - setActivePropertyState(selectedProperty) - setIsLoading(false) - console.log('[useActiveProperty] Set isLoading to false') - } else { - console.error('[useActiveProperty] Property not found in user properties') - // Fall back to refetch - await fetchActiveProperty(false) - } - } else { - console.error('[useActiveProperty] Failed to fetch properties') - // Fall back to refetch - await fetchActiveProperty(false) - } - } else { - setActivePropertyState(null) - setIsLoading(false) - } - } else { - console.error(result.error || 'Failed to update active property') - setIsLoading(false) - } - } catch (err) { - console.error(err instanceof Error ? err.message : 'An unexpected error occurred') - setIsLoading(false) - } finally { - setIsPending(false) - } - }, - [fetchActiveProperty], - ) - - useEffect(() => { - // Only allow auto-select on the initial mount - const allowAutoSelect = isInitialFetchRef.current - if (isInitialFetchRef.current) { - isInitialFetchRef.current = false - } - fetchActiveProperty(allowAutoSelect) - }, [fetchActiveProperty]) - - return { - activeProperty, - isLoading, - error, - setActiveProperty: changeActiveProperty, - isPending, - refetch: fetchActiveProperty, - } -} diff --git a/apps/editor/features/community/lib/properties/store.ts b/apps/editor/features/community/lib/properties/store.ts index 405b4d27..b24b0e19 100644 --- a/apps/editor/features/community/lib/properties/store.ts +++ b/apps/editor/features/community/lib/properties/store.ts @@ -7,7 +7,7 @@ import type { Property } from './types' import { getActiveProperty, getUserProperties, - setActiveProperty as setActivePropertyAction, + getPropertyById, } from './actions' interface PropertyStore { @@ -65,44 +65,16 @@ export const usePropertyStore = create((set, get) => ({ } }, - // Set active property + // Set active property by fetching it directly by ID (URL-based, no session update) setActiveProperty: async (propertyId: string) => { set({ isLoading: true }) - // Update database - const result = await setActivePropertyAction(propertyId) + const result = await getPropertyById(propertyId) - if (result.success) { - // Fetch properties to get the full property object - const propertiesResult = await getUserProperties() - - if (propertiesResult.success && propertiesResult.data) { - const selectedProperty = propertiesResult.data.find(p => p.id === propertyId) - - if (selectedProperty) { - set({ - activeProperty: selectedProperty, - properties: propertiesResult.data, - isLoading: false, - error: null - }) - } else { - set({ - isLoading: false, - error: 'Property not found' - }) - } - } else { - set({ - isLoading: false, - error: 'Failed to fetch properties' - }) - } + if (result.success && result.data) { + set({ activeProperty: result.data, isLoading: false, error: null }) } else { - set({ - isLoading: false, - error: result.error || 'Failed to set active property' - }) + set({ isLoading: false, error: result.error || 'Property not found' }) } }, diff --git a/apps/editor/hooks/use-grid-events.ts b/apps/editor/hooks/use-grid-events.ts index 2a48514e..ddee9065 100644 --- a/apps/editor/hooks/use-grid-events.ts +++ b/apps/editor/hooks/use-grid-events.ts @@ -1,4 +1,5 @@ import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { Plane, Raycaster, Vector2, Vector3 } from 'three' @@ -53,29 +54,35 @@ export function useGridEvents(gridY: number) { } const handlePointerDown = (e: PointerEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('pointerdown', e) } const handlePointerUp = (e: PointerEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('pointerup', e) } const handleClick = (e: PointerEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('click', e) } const handlePointerMove = (e: PointerEvent) => { + if (useViewer.getState().cameraDragging) return emit('move', e) } const handleDoubleClick = (e: MouseEvent) => { + if (useViewer.getState().cameraDragging) return emit('double-click', e) } const handleContextMenu = (e: MouseEvent) => { + if (useViewer.getState().cameraDragging) return emit('context-menu', e) } @@ -95,5 +102,5 @@ export function useGridEvents(gridY: number) { canvas.removeEventListener('dblclick', handleDoubleClick) canvas.removeEventListener('contextmenu', handleContextMenu) } - }, [camera, gl, gridY]) + }, [camera, gl]) } diff --git a/apps/editor/hooks/use-keyboard.ts b/apps/editor/hooks/use-keyboard.ts index ce8ee20f..88b1c3e2 100644 --- a/apps/editor/hooks/use-keyboard.ts +++ b/apps/editor/hooks/use-keyboard.ts @@ -1,8 +1,8 @@ -import { type AnyNodeId, useScene } from '@pascal-app/core' +import { type AnyNodeId, emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' -import useEditor from '@/store/use-editor' import { sfxEmitter } from '@/lib/sfx-bus' +import useEditor from '@/store/use-editor' export const useKeyboard = () => { useEffect(() => { @@ -14,13 +14,7 @@ export const useKeyboard = () => { if (e.key === 'Escape') { e.preventDefault() - // Emit tool:cancel event - each tool handles its own cancellation logic - // if (useEditor.getState().controlMode === 'building') { - // emitter.emit('tool:cancel', undefined) - // } - // if (selectedNodeIds.length > 0) { - // handleClear() - // } + emitter.emit('tool:cancel') } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setPhase('site') @@ -33,7 +27,8 @@ export const useKeyboard = () => { e.preventDefault() useEditor.getState().setPhase('furnish') useEditor.getState().setMode('select') - } if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { + } + if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setMode('select') } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 1eb9e574..7ded1403 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -27,6 +27,7 @@ export type StructureTool = | 'stair' | 'item' | 'zone' + | 'window' // Furnish mode tools (items and decoration) export type FurnishTool = 'item' diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 60966b9b..3cd26468 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, ZoneNode } from '../schema' +import type { BuildingNode, CeilingNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode } from '../schema' import type { AnyNode } from '../schema/types' // Base event interfaces @@ -27,6 +27,7 @@ export type ZoneEvent = NodeEvent export type SlabEvent = NodeEvent export type CeilingEvent = NodeEvent export type RoofEvent = NodeEvent +export type WindowEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -54,12 +55,21 @@ export interface CameraControlEvent { nodeId: AnyNode['id'] } +export interface ThumbnailGenerateEvent { + propertyId: string +} + type CameraControlEvents = { 'camera-controls:view': CameraControlEvent 'camera-controls:capture': CameraControlEvent 'camera-controls:top-view': undefined 'camera-controls:orbit-cw': undefined 'camera-controls:orbit-ccw': undefined + 'camera-controls:generate-thumbnail': ThumbnailGenerateEvent +} + +type ToolEvents = { + 'tool:cancel': undefined } type EditorEvents = GridEvents & @@ -72,6 +82,8 @@ type EditorEvents = GridEvents & NodeEvents<'slab', SlabEvent> & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'roof', RoofEvent> & - CameraControlEvents + NodeEvents<'window', WindowEvent> & + CameraControlEvents & + ToolEvents export const emitter = mitt() diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 0e0b7a49..5701b1bd 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -19,6 +19,7 @@ export const sceneRegistry = { roof: new Set(), scan: new Set(), guide: new Set(), + window: new Set(), }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4ee679cf..a9eabea1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,17 +3,17 @@ export type { BuildingEvent, CameraControlEvent, + CeilingEvent, EventSuffix, GridEvent, ItemEvent, LevelEvent, NodeEvent, + RoofEvent, SiteEvent, SlabEvent, WallEvent, ZoneEvent, - CeilingEvent, - RoofEvent, } from './events/bus' // Events export { emitter, eventSuffixes } from './events/bus' @@ -37,6 +37,7 @@ export { ItemSystem } from './systems/item/item-system' export { RoofSystem } from './systems/roof/roof-system' export { SlabSystem } from './systems/slab/slab-system' export { WallSystem } from './systems/wall/wall-system' +export { WindowSystem } from './systems/window/window-system' export { isObject } from './utils/types' // Asset storage diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 8c0d8222..934e89e1 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -17,5 +17,6 @@ export { RoofNode } from './nodes/roof' export { ScanNode } from './nodes/scan' export { GuideNode } from './nodes/guide' export type { AnyNodeId, AnyNodeType } from './types' +export { WindowNode } from './nodes/window' // Union types export { AnyNode } from './types' diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index e6e0122b..b4b3f7f5 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -10,6 +10,7 @@ const assetSchema = z.object({ src: z.string(), dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), + tags: z.array(z.string()).optional(), // These are "Corrective" transforms to normalize the GLB offset: 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]), @@ -42,6 +43,7 @@ export const ItemNode = BaseNode.extend({ - offset: corrective position offset for the model - rotation: corrective rotation for the model - scale: corrective scale for the model + - tags: tags associated with the item `) export type ItemNode = z.infer diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts new file mode 100644 index 00000000..7d415e49 --- /dev/null +++ b/packages/core/src/schema/nodes/window.ts @@ -0,0 +1,46 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const WindowNode = BaseNode.extend({ + id: objectId('window'), + type: nodeType('window'), + + // Position in wall-local coordinate system (center of window) + 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(), + + // Wall reference + wallId: z.string().optional(), + + // Overall dimensions + width: z.number().default(1.5), + height: z.number().default(1.5), + + // Frame + frameThickness: z.number().default(0.05), + frameDepth: z.number().default(0.07), + + // Divisions β€” ratios allow non-uniform panes + // [0.5, 0.5] = two equal panes + // [0.6, 0.4] = one larger, one smaller + // [1] = single pane (no division) + columnRatios: z.array(z.number()).default([1]), + rowRatios: z.array(z.number()).default([1]), + dividerThickness: z.number().default(0.03), + + // Sill + sill: z.boolean().default(true), + sillDepth: z.number().default(0.08), + sillThickness: z.number().default(0.03), +}).describe(dedent`Window node - a parametric window placed on a wall + - position: center of the window in wall-local coordinate system + - width/height: overall outer dimensions + - frameThickness: width of the frame members + - frameDepth: how deep the frame sits within the wall + - columnRatios/rowRatios: pane division ratios + - sill: whether to show a window sill +`) + +export type WindowNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index bd97e9f1..6e7454c6 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -9,6 +9,7 @@ import { ScanNode } from './nodes/scan' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' import { WallNode } from './nodes/wall' +import { WindowNode } from './nodes/window' import { ZoneNode } from './nodes/zone' export const AnyNode = z.discriminatedUnion('type', [ @@ -23,6 +24,7 @@ export const AnyNode = z.discriminatedUnion('type', [ RoofNode, ScanNode, GuideNode, + WindowNode, ]) export type AnyNode = z.infer diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 133f3d1f..31208a1c 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -309,7 +309,7 @@ function collectCutoutBrushes( const wallMatrixInverse = wallMesh.matrixWorld.clone().invert() for (const child of childrenNodes) { - if (child.type !== 'item') continue + if (child.type !== 'item' && child.type !== 'window') continue const childMesh = sceneRegistry.nodes.get(child.id) if (!childMesh) continue diff --git a/packages/core/src/systems/window/window-system.tsx b/packages/core/src/systems/window/window-system.tsx new file mode 100644 index 00000000..81e481c7 --- /dev/null +++ b/packages/core/src/systems/window/window-system.tsx @@ -0,0 +1,69 @@ +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, WindowNode } from '../../schema' +import useScene from '../../store/use-scene' + +const glassMaterial = new MeshStandardNodeMaterial({ + name: 'glass', + color: 'lightgray', + roughness: 0.8, + metalness: 0, + transparent: true, + opacity: 0.35, + side: DoubleSide, + depthWrite: false, +}) + +export const WindowSystem = () => { + 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 !== 'window') return + + const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh + if (!mesh) return // Keep dirty until mesh mounts + + updateWindowMesh(node as WindowNode, mesh) + clearDirty(id as AnyNodeId) + + // Rebuild the parent wall so its cutout reflects the updated window geometry + if ((node as WindowNode).parentId) { + useScene.getState().dirtyNodes.add((node as WindowNode).parentId as AnyNodeId) + } + }) + }) + + return null +} + +function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { + // Replace geometry with a box matching the overall window dimensions + mesh.geometry.dispose() + mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) + mesh.material = glassMaterial + + // 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]) + + // Update (or create) the named cutout mesh used by wall-system for CSG subtraction + let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined + if (!cutout) { + cutout = new THREE.Mesh() + cutout.name = 'cutout' + mesh.add(cutout) + } + cutout.geometry.dispose() + // Extends 1m through the wall so the CSG brush covers full wall thickness + cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + cutout.visible = false; +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index b3aacaea..02fd4d9c 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -11,6 +11,7 @@ import { ScanRenderer } from './scan/scan-renderer' import { SiteRenderer } from './site/site-renderer' import { SlabRenderer } from './slab/slab-renderer' import { WallRenderer } from './wall/wall-renderer' +import { WindowRenderer } from './window/window-renderer' import { ZoneRenderer } from './zone/zone-renderer' export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { @@ -27,6 +28,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'item' && } {node.type === 'slab' && } {node.type === 'wall' && } + {node.type === 'window' && } {node.type === 'zone' && } {node.type === 'roof' && } {node.type === 'scan' && } diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index dd872404..3286a5f4 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -1,7 +1,7 @@ import { type SiteNode, useRegistry } from '@pascal-app/core' import { Html } from '@react-three/drei' import { useMemo, useRef } from 'react' -import { BufferGeometry, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three' +import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { NodeRenderer } from '../node-renderer' @@ -91,26 +91,15 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { ))} {/* Transparent floor fill */} - + - + {/* Simple boundary line */} {/* @ts-ignore */} - + {/* Edge distance labels */} diff --git a/packages/viewer/src/components/renderers/window/window-renderer.tsx b/packages/viewer/src/components/renderers/window/window-renderer.tsx new file mode 100644 index 00000000..761e3c41 --- /dev/null +++ b/packages/viewer/src/components/renderers/window/window-renderer.tsx @@ -0,0 +1,24 @@ +import { useRegistry, type WindowNode } from '@pascal-app/core' +import { useRef } from 'react' +import type { Mesh } from 'three' + +export const WindowRenderer = ({ node }: { node: WindowNode }) => { + const ref = useRef(null!) + + useRegistry(node.id, 'window', ref) + + return ( + + {/* 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 3533c0c4..85f66def 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 } from '@pascal-app/core' +import { CeilingSystem, 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' @@ -65,6 +65,7 @@ const Viewer: React.FC = ({ children, selectionManager = 'default' + diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index c0310b27..697952d4 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -26,7 +26,7 @@ import useViewer from '../../store/use-viewer' // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion export const SSGI_PARAMS = { enabled: true, - sliceCount: 1, + sliceCount: 2, stepCount: 8, radius: 1, expFactor: 1.5, diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index 2f45a870..e591157c 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -3,15 +3,15 @@ import { type AnyNode, type BuildingNode, + emitter, type ItemNode, type LevelNode, type NodeEvent, - type WallNode, - type ZoneNode, - emitter, pointInPolygon, sceneRegistry, useScene, + type WallNode, + type ZoneNode, } from '@pascal-app/core' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' @@ -23,14 +23,24 @@ const tempWorldPos = new Vector3() // Tolerance for edge detection (in meters) const EDGE_TOLERANCE = 0.5 -type SelectableNodeType = 'building' | 'level' | 'zone' | 'wall' | 'item' | 'slab' | 'ceiling' | 'roof' +type SelectableNodeType = + | 'building' + | 'level' + | 'zone' + | 'wall' + | 'window' + | 'item' + | 'slab' + | 'ceiling' + | 'roof' // Expand polygon outward by a small amount to include items on edges const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => { if (polygon.length < 3) return polygon // Calculate centroid - let cx = 0, cz = 0 + let cx = 0, + cz = 0 for (const [x, z] of polygon) { cx += x cz += z @@ -50,7 +60,11 @@ const expandPolygon = (polygon: [number, number][], tolerance: number): [number, } // Check if point is in polygon with tolerance for edges -const pointInPolygonWithTolerance = (x: number, z: number, polygon: [number, number][]): boolean => { +const pointInPolygonWithTolerance = ( + x: number, + z: number, + polygon: [number, number][], +): boolean => { // First try exact check if (pointInPolygon(x, z, polygon)) return true // Then try with expanded polygon for edge tolerance @@ -179,14 +193,16 @@ const getStrategy = (): SelectionStrategy | null => { } } - // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs) + // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows) return { - types: ['wall', 'item', 'slab', 'ceiling', 'roof'], + types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window'], handleClick: (node) => { const { selectedIds } = useViewer.getState().selection // Toggle selection - if already selected, deselect; otherwise select if (selectedIds.includes(node.id)) { - useViewer.getState().setSelection({ selectedIds: selectedIds.filter((id) => id !== node.id) }) + useViewer + .getState() + .setSelection({ selectedIds: selectedIds.filter((id) => id !== node.id) }) } else { useViewer.getState().setSelection({ selectedIds: [node.id] }) } @@ -201,7 +217,7 @@ const getStrategy = (): SelectionStrategy | null => { } }, isValid: (node) => { - const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof'] + const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof', 'window'] if (!validTypes.includes(node.type)) return false return isNodeInZone(node, levelId, zoneId) }, @@ -244,7 +260,17 @@ export const SelectionManager = () => { } // Subscribe to all node types - const allTypes: SelectableNodeType[] = ['building', 'level', 'zone', 'wall', 'item', 'slab', 'ceiling', 'roof'] + const allTypes: SelectableNodeType[] = [ + 'building', + 'level', + 'zone', + 'wall', + 'item', + 'slab', + 'ceiling', + 'roof', + 'window', + ] for (const type of allTypes) { emitter.on(`${type}:enter`, onEnter) emitter.on(`${type}:leave`, onLeave) @@ -258,7 +284,7 @@ export const SelectionManager = () => { emitter.off(`${type}:click`, onClick) } } - }, [selection]) + }, []) return ( <> @@ -268,12 +294,17 @@ export const SelectionManager = () => { ) } -const PointerMissedHandler = ({ clickHandledRef }: { clickHandledRef: React.MutableRefObject }) => { +const PointerMissedHandler = ({ + clickHandledRef, +}: { + clickHandledRef: React.MutableRefObject +}) => { const gl = useThree((s) => s.gl) useEffect(() => { const handleClick = (event: MouseEvent) => { // Only handle left clicks + if (useViewer.getState().cameraDragging) return if (event.button !== 0) return // Use requestAnimationFrame to check after R3F event handlers diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index c350edb7..41d4a453 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -21,6 +21,7 @@ import { type ZoneNode, } from '@pascal-app/core' import type { ThreeEvent } from '@react-three/fiber' +import useViewer from '../store/use-viewer'; type NodeConfig = { site: { node: SiteNode; event: SiteEvent } @@ -54,21 +55,24 @@ export function useNodeEvents(node: NodeConfig[T]['node'], t return { onPointerDown: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('pointerdown', e) }, onPointerUp: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('pointerup', e) }, onClick: (e: ThreeEvent) => { + if (useViewer.getState().cameraDragging) return if (e.button !== 0) return emit('click', e) }, - onPointerEnter: (e: ThreeEvent) => emit('enter', e), - onPointerLeave: (e: ThreeEvent) => emit('leave', e), - onPointerMove: (e: ThreeEvent) => emit('move', e), - onDoubleClick: (e: ThreeEvent) => emit('double-click', e), - onContextMenu: (e: ThreeEvent) => emit('context-menu', e), + onPointerEnter: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('enter', e)}, + onPointerLeave: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('leave', e)}, + onPointerMove: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('move', e)}, + onDoubleClick: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('double-click', e)}, + onContextMenu: (e: ThreeEvent) => {if (useViewer.getState().cameraDragging) return; emit('context-menu', e)}, } } diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index b772e157..d87dc117 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -52,6 +52,9 @@ type ViewerState = { // Export functionality exportScene: (() => Promise) | null setExportScene: (fn: (() => Promise) | null) => void + + cameraDragging: boolean + setCameraDragging: (dragging: boolean) => void } const useViewer = create()((set, get) => ({ @@ -107,6 +110,9 @@ const useViewer = create()((set, get) => ({ exportScene: null, setExportScene: (fn) => set({ exportScene: fn }), + + cameraDragging: false, + setCameraDragging: (dragging) => set({ cameraDragging: dragging }), })); export default useViewer;