Merge pull request #97 from pascalorg/feat/community-features

Feat/community features
This commit is contained in:
Wassim SAMAD
2026-02-17 16:25:53 +09:00
committed by GitHub
51 changed files with 1614 additions and 817 deletions
+6 -1
View File
@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'
import { ViewerCameraControls } from './viewer-camera-controls' import { ViewerCameraControls } from './viewer-camera-controls'
import { ViewerOverlay } from './viewer-overlay' import { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system' import { ViewerZoneSystem } from './viewer-zone-system'
import { ThumbnailGenerator } from './thumbnail-generator'
import { getPropertyModelPublic, incrementPropertyViews } from '@/features/community/lib/properties/actions' import { getPropertyModelPublic, incrementPropertyViews } from '@/features/community/lib/properties/actions'
export default function ViewerPage() { export default function ViewerPage() {
@@ -14,6 +15,7 @@ export default function ViewerPage() {
const id = params.id as string const id = params.id as string
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [propertyId, setPropertyId] = useState<string | null>(null)
const setScene = useScene((state) => state.setScene) const setScene = useScene((state) => state.setScene)
useEffect(() => { useEffect(() => {
@@ -35,7 +37,8 @@ export default function ViewerPage() {
const result = await getPropertyModelPublic(id) const result = await getPropertyModelPublic(id)
if (result.success && result.data) { if (result.success && result.data) {
const { model } = result.data const { property, model } = result.data
setPropertyId(property.id)
if (model?.scene_graph) { if (model?.scene_graph) {
const { nodes, rootNodeIds } = model.scene_graph const { nodes, rootNodeIds } = model.scene_graph
@@ -84,6 +87,8 @@ export default function ViewerPage() {
<ViewerCameraControls /> <ViewerCameraControls />
{/* Custom Zone System */} {/* Custom Zone System */}
<ViewerZoneSystem /> <ViewerZoneSystem />
{/* Thumbnail Generator */}
<ThumbnailGenerator propertyId={propertyId || undefined} />
</Viewer> </Viewer>
</div> </div>
) )
@@ -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
}
@@ -3,7 +3,7 @@
import { sceneRegistry, useScene } from '@pascal-app/core' import { sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' 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' import { Box3, Vector3 } from 'three'
const tempBox = new Box3() const tempBox = new Box3()
@@ -28,7 +28,7 @@ export const ViewerCameraControls = () => {
: CameraControlsImpl.ACTION.DOLLY : CameraControlsImpl.ACTION.DOLLY
return { return {
left: CameraControlsImpl.ACTION.NONE, left: CameraControlsImpl.ACTION.SCREEN_PAN,
middle: CameraControlsImpl.ACTION.SCREEN_PAN, middle: CameraControlsImpl.ACTION.SCREEN_PAN,
right: CameraControlsImpl.ACTION.ROTATE, right: CameraControlsImpl.ACTION.ROTATE,
wheel: wheelAction, wheel: wheelAction,
@@ -59,7 +59,7 @@ export const ViewerCameraControls = () => {
target[0], target[0],
target[1], target[1],
target[2], target[2],
true true,
) )
return return
} }
@@ -81,7 +81,7 @@ export const ViewerCameraControls = () => {
const cameraPos = new Vector3( const cameraPos = new Vector3(
tempCenter.x + distance * 0.7, tempCenter.x + distance * 0.7,
tempCenter.y + distance * 0.5, tempCenter.y + distance * 0.5,
tempCenter.z + distance * 0.7 tempCenter.z + distance * 0.7,
) )
controls.current.setLookAt( controls.current.setLookAt(
@@ -91,10 +91,18 @@ export const ViewerCameraControls = () => {
tempCenter.x, tempCenter.x,
tempCenter.y, tempCenter.y,
tempCenter.z, tempCenter.z,
true true,
) )
}, [targetNodeId, nodes]) }, [targetNodeId, nodes])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
}, [])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false)
}, [])
return ( return (
<CameraControls <CameraControls
ref={controls} ref={controls}
@@ -103,6 +111,10 @@ export const ViewerCameraControls = () => {
maxPolarAngle={Math.PI / 2 - 0.1} maxPolarAngle={Math.PI / 2 - 0.1}
minPolarAngle={0} minPolarAngle={0}
mouseButtons={mouseButtons} mouseButtons={mouseButtons}
onTransitionStart={onTransitionStart}
onRest={onRest}
restThreshold={0.01}
/> />
) )
} }
@@ -3,7 +3,7 @@
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Vector3 } from 'three' import { Vector3 } from 'three'
const currentTarget = new Vector3() const currentTarget = new Vector3()
@@ -51,6 +51,85 @@ export const CustomCameraControls = () => {
} }
}, [cameraMode]) }, [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(() => { useEffect(() => {
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return 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 ( return (
<CameraControls <CameraControls
makeDefault makeDefault
@@ -148,6 +235,9 @@ export const CustomCameraControls = () => {
minPolarAngle={0} minPolarAngle={0}
ref={controls} ref={controls}
mouseButtons={mouseButtons} mouseButtons={mouseButtons}
onTransitionStart={onTransitionStart}
onRest={onRest}
restThreshold={0.01}
/> />
) )
} }
+2
View File
@@ -20,6 +20,7 @@ import { ExportManager } from './export-manager'
import { Grid } from './grid' import { Grid } from './grid'
import { SelectionManager } from './selection-manager' import { SelectionManager } from './selection-manager'
import { initSFXBus } from '@/lib/sfx-bus' import { initSFXBus } from '@/lib/sfx-bus'
import { ThumbnailGenerator } from '@/app/viewer/[id]/thumbnail-generator'
// Load default scene initially (will be replaced when property loads) // Load default scene initially (will be replaced when property loads)
useScene.getState().loadScene() useScene.getState().loadScene()
@@ -73,6 +74,7 @@ export default function Editor({ propertyId }: EditorProps) {
<Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} /> <Grid cellColor="#aaa" sectionColor="#ccc" fadeDistance={500} />
<ToolManager /> <ToolManager />
<CustomCameraControls /> <CustomCameraControls />
<ThumbnailGenerator propertyId={propertyId} />
</Viewer> </Viewer>
</div> </div>
) )
@@ -20,7 +20,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
return nodeLevelId === currentLevelId; return nodeLevelId === currentLevelId;
}; };
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof'; type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof' | 'window';
interface SelectionStrategy { interface SelectionStrategy {
types: SelectableNodeType[]; types: SelectableNodeType[];
@@ -44,7 +44,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
}, },
structure: { structure: {
types: ["wall", "item", "zone", "slab", "ceiling", "roof"], types: ["wall", "item", "zone", "slab", "ceiling", "roof", "window"],
handleSelect: (node, isShift) => { handleSelect: (node, isShift) => {
const { selection, setSelection } = useViewer.getState(); const { selection, setSelection } = useViewer.getState();
if (node.type === 'zone') { if (node.type === 'zone') {
@@ -80,6 +80,8 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
(node as ItemNode).asset.category === "window" (node as ItemNode).asset.category === "window"
); );
} }
if (node.type === "window") return true;
return false; return false;
} }
}, },
@@ -144,14 +144,20 @@ export const CeilingTool: React.FC = () => {
} }
} }
const onCancel = () => {
setPoints([])
}
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick) emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
} }
}, [currentLevelId, points, cursorPosition, setTool]) }, [currentLevelId, points, cursorPosition, setTool])
@@ -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 { useViewer } from '@pascal-app/viewer'
import { useCallback, useMemo, useRef } from 'react' import { useCallback, useMemo, useRef } from 'react'
import type { Vector3 } from 'three' import type { Vector3 } from 'three'
import type { AssetInput } from '@pascal-app/core'
import { stripTransient } from './placement-math' import { stripTransient } from './placement-math'
interface OriginalState { interface OriginalState {
@@ -159,14 +158,25 @@ export function useDraftNode(): DraftNodeHandle {
if (adoptedRef.current && originalStateRef.current) { if (adoptedRef.current && originalStateRef.current) {
// Move mode: restore original state instead of deleting // Move mode: restore original state instead of deleting
const original = originalStateRef.current const original = originalStateRef.current
const id = draftRef.current.id
useScene.getState().updateNode(draftRef.current.id, { useScene.getState().updateNode(id, {
position: original.position, position: original.position,
rotation: original.rotation, rotation: original.rotation,
side: original.side, side: original.side,
parentId: original.parentId, parentId: original.parentId,
metadata: original.metadata, 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 { } else {
// Create mode: delete the transient node // Create mode: delete the transient node
useScene.getState().deleteNode(draftRef.current.id) useScene.getState().deleteNode(draftRef.current.id)
@@ -74,6 +74,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const placementState = useRef<PlacementState>( const placementState = useRef<PlacementState>(
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null }, 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 // Store config callbacks in refs to avoid re-running effect when they change
const configRef = useRef(config) const configRef = useRef(config)
@@ -104,8 +105,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
state: { ...placementState.current }, state: { ...placementState.current },
}) })
const getActiveValidators = () => shiftFreeRef.current
? { canPlaceOnFloor: () => ({ valid: true }), canPlaceOnWall: () => ({ valid: true }), canPlaceOnCeiling: () => ({ valid: true }) }
: validators
const revalidate = (): boolean => { const revalidate = (): boolean => {
const placeable = checkCanPlace(getContext(), validators) const placeable = shiftFreeRef.current || checkCanPlace(getContext(), validators)
const color = placeable ? 0x22c55e : 0xef4444 // green-500 : red-500 const color = placeable ? 0x22c55e : 0xef4444 // green-500 : red-500
edgeMaterial.color.setHex(color) edgeMaterial.color.setHex(color)
basePlaneMaterial.color.setHex(color) basePlaneMaterial.color.setHex(color)
@@ -196,7 +201,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
const result = floorStrategy.click(getContext(), event, validators) const result = floorStrategy.click(getContext(), event, getActiveValidators())
if (!result) return if (!result) return
// Preserve cursor rotation for the next draft // Preserve cursor rotation for the next draft
@@ -213,7 +218,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const onWallEnter = (event: WallEvent) => { const onWallEnter = (event: WallEvent) => {
const nodes = useScene.getState().nodes 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 if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -235,7 +240,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (ctx.state.surface !== 'wall') { if (ctx.state.surface !== 'wall') {
const nodes = useScene.getState().nodes 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 if (!enterResult) return
event.stopPropagation() event.stopPropagation()
@@ -251,7 +256,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!draftNode.current) { if (!draftNode.current) {
const nodes = useScene.getState().nodes 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 if (!setup) return
event.stopPropagation() event.stopPropagation()
@@ -259,7 +264,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return return
} }
const result = wallStrategy.move(ctx, event, validators) const result = wallStrategy.move(ctx, event, getActiveValidators())
if (!result) return if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -312,7 +317,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onWallClick = (event: WallEvent) => { const onWallClick = (event: WallEvent) => {
const result = wallStrategy.click(getContext(), event, validators) const result = wallStrategy.click(getContext(), event, getActiveValidators())
if (!result) return if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -423,7 +428,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
} }
const onCeilingClick = (event: CeilingEvent) => { const onCeilingClick = (event: CeilingEvent) => {
const result = ceilingStrategy.click(getContext(), event, validators) const result = ceilingStrategy.click(getContext(), event, getActiveValidators())
if (!result) return if (!result) return
event.stopPropagation() event.stopPropagation()
@@ -474,10 +479,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const ROTATION_STEP = Math.PI / 2 const ROTATION_STEP = Math.PI / 2
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
// Escape / right-click → cancel if (event.key === 'Shift') {
if (event.key === 'Escape' && configRef.current.onCancel) { shiftFreeRef.current = true
event.preventDefault() revalidate()
configRef.current.onCancel()
return return
} }
@@ -502,7 +506,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
revalidate() revalidate()
} }
} }
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftFreeRef.current = false
revalidate()
}
}
window.addEventListener('keydown', onKeyDown) 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 ---- // ---- Right-click cancel ----
const onContextMenu = (event: MouseEvent) => { const onContextMenu = (event: MouseEvent) => {
@@ -547,7 +568,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:move', onCeilingMove)
emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:click', onCeilingClick)
emitter.off('ceiling:leave', onCeilingLeave) emitter.off('ceiling:leave', onCeilingLeave)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('contextmenu', onContextMenu) window.removeEventListener('contextmenu', onContextMenu)
} }
}, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode]) }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode])
@@ -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 // Subscribe to events
emitter.on("grid:move", onGridMove); emitter.on("grid:move", onGridMove);
emitter.on("grid:click", onGridClick); emitter.on("grid:click", onGridClick);
emitter.on("tool:cancel", onCancel);
return () => { return () => {
emitter.off("grid:move", onGridMove); emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick); emitter.off("grid:click", onGridClick);
emitter.off("tool:cancel", onCancel);
// Reset state on unmount // Reset state on unmount
corner1Ref.current = null; corner1Ref.current = null;
@@ -138,14 +138,20 @@ export const SlabTool: React.FC = () => {
} }
} }
const onCancel = () => {
setPoints([])
}
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('grid:double-click', onGridDoubleClick) emitter.on('grid:double-click', onGridDoubleClick)
emitter.on('tool:cancel', onCancel)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('grid:double-click', onGridDoubleClick) emitter.off('grid:double-click', onGridDoubleClick)
emitter.off('tool:cancel', onCancel)
} }
}, [currentLevelId, points, cursorPosition, setSelection]) }, [currentLevelId, points, cursorPosition, setSelection])
@@ -12,6 +12,7 @@ import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabHoleEditor } from './slab/slab-hole-editor'
import { SlabTool } from './slab/slab-tool' import { SlabTool } from './slab/slab-tool'
import { WallTool } from './wall/wall-tool' import { WallTool } from './wall/wall-tool'
import { WindowTool } from './window/window-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
import { ZoneTool } from './zone/zone-tool' import { ZoneTool } from './zone/zone-tool'
@@ -26,6 +27,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
roof: RoofTool, roof: RoofTool,
item: ItemTool, item: ItemTool,
zone: ZoneTool, zone: ZoneTool,
window: WindowTool,
}, },
furnish: { furnish: {
item: ItemTool, item: ItemTool,
@@ -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:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown) window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp) window.addEventListener('keyup', onKeyUp)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp) window.removeEventListener('keyup', onKeyUp)
} }
@@ -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<WindowNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(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 (
<group ref={cursorGroupRef} visible={false}>
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} />
</group>
)
}
@@ -34,7 +34,7 @@ export function ActionMenu({ className }: { className?: string }) {
{mode === "build" && tool === "item" && catalogCategory && ( {mode === "build" && tool === "item" && catalogCategory && (
<motion.div <motion.div
className={cn( className={cn(
"overflow-hidden border-zinc-800 max-h-96 border-b px-2 py-2", "overflow-hidden border-zinc-800 border-b px-2 py-2",
)} )}
initial={{ initial={{
opacity: 0, opacity: 0,
@@ -45,7 +45,7 @@ export function ActionMenu({ className }: { className?: string }) {
}} }}
animate={{ animate={{
opacity: 1, opacity: 1,
maxHeight: 80, maxHeight: 160,
paddingTop: 8, paddingTop: 8,
paddingBottom: 8, paddingBottom: 8,
borderBottomWidth: 1, borderBottomWidth: 1,
@@ -18,7 +18,7 @@ export const tools: ToolConfig[] = [
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
{ id: 'item', iconSrc: '/icons/door.png', label: 'Door', catalogCategory: 'door' }, { id: 'item', iconSrc: '/icons/door.png', label: 'Door', catalogCategory: 'door' },
{ id: 'item', iconSrc: '/icons/window.png', label: 'Window', catalogCategory: 'window' }, { id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
] ]
@@ -0,0 +1,10 @@
export function CeilingHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,7 +1,10 @@
'use client' 'use client'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { CeilingHelper } from './ceiling-helper'
import { ItemHelper } from './item-helper' import { ItemHelper } from './item-helper'
import { RoofHelper } from './roof-helper'
import { SlabHelper } from './slab-helper'
import { WallHelper } from './wall-helper' import { WallHelper } from './wall-helper'
export function HelperManager() { export function HelperManager() {
@@ -9,15 +12,21 @@ export function HelperManager() {
const movingNode = useEditor((state) => state.movingNode) const movingNode = useEditor((state) => state.movingNode)
if (movingNode) { if (movingNode) {
return <ItemHelper /> return <ItemHelper showEsc />
} }
// Show appropriate helper based on current tool // Show appropriate helper based on current tool
switch (tool) { switch (tool) {
case 'wall': case 'wall':
return <WallHelper /> return <WallHelper />
case 'item': case 'item':
return <ItemHelper /> return <ItemHelper />
case 'slab':
return <SlabHelper />
case 'ceiling':
return <CeilingHelper />
case 'roof':
return <RoofHelper />
default: default:
return null return null
} }
@@ -1,4 +1,8 @@
export function ItemHelper() { interface ItemHelperProps {
showEsc?: boolean
}
export function ItemHelper({ showEsc }: ItemHelperProps) {
return ( return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md"> <div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
@@ -10,9 +14,15 @@ export function ItemHelper() {
<span className="text-muted-foreground">Rotate clockwise</span> <span className="text-muted-foreground">Rotate clockwise</span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd> <kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Cancel</span> <span className="text-muted-foreground">Free place</span>
</div> </div>
{showEsc && (
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
)}
</div> </div>
) )
} }
@@ -0,0 +1,10 @@
export function RoofHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -0,0 +1,10 @@
export function SlabHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -1,10 +1,14 @@
export function WallHelper() { export function WallHelper() {
return ( return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex items-center gap-2 rounded-lg border border-border bg-background/95 px-4 py-2 shadow-lg backdrop-blur-md"> <div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd> <kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Allow non-45° angles</span> <span className="text-muted-foreground">Allow non-45° angles</span>
</div> </div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div> </div>
) )
} }
File diff suppressed because it is too large Load Diff
@@ -1,27 +1,69 @@
"use client"; "use client";
import { AssetInput } from "@pascal-app/core";
import { resolveCdnUrl } from "@pascal-app/viewer";
import Image from "next/image"; import Image from "next/image";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/primitives/tooltip"; } from "@/components/ui/primitives/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import useEditor, { CatalogCategory } from "@/store/use-editor"; import useEditor, { CatalogCategory } from "@/store/use-editor";
import { CATALOG_ITEMS } from "./catalog-items"; 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 }) { export function ItemCatalog({ category }: { category: CatalogCategory }) {
const selectedItem = useEditor((state) => state.selectedItem); const selectedItem = useEditor((state) => state.selectedItem);
const setSelectedItem = useEditor((state) => state.setSelectedItem); const setSelectedItem = useEditor((state) => state.setSelectedItem);
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null);
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(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, (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 // Auto-select first item if current selection is not in the filtered list
useEffect(() => { useEffect(() => {
const isCurrentItemInCategory = filteredItems.some( const isCurrentItemInCategory = filteredItems.some(
@@ -44,50 +86,134 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
}; };
return ( return (
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2"> <div className="flex flex-col gap-2">
{filteredItems.map((item, index) => { {/* Filter chips */}
const isSelected = selectedItem?.src === item?.src; {hasFilters && (
const attachmentIcon = getAttachmentIcon(item?.attachTo); <div className="flex flex-col gap-1.5">
return ( {/* Placement row */}
<Tooltip key={index}> {placementTags.length > 0 && (
<TooltipTrigger asChild> <div className="flex flex-wrap gap-1">
<button <button
className={cn(
"relative aspect-square min-w-14 min-h-14 h-14 w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer",
isSelected && "ring-2 ring-primary-foreground",
)}
onClick={() => setSelectedItem(item)}
type="button" type="button"
> onClick={() => setActivePlacementTag(null)}
<Image className={cn(
alt={item.name} "cursor-pointer rounded-md px-2 py-0.5 text-xs font-medium transition-colors",
className="rounded-lg object-cover" activePlacementTag === null
fill ? "bg-blue-500 text-white"
src={resolveCdnUrl(item.thumbnail) || ''} : "bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200",
/>
{attachmentIcon && (
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
<Image
alt={
item.attachTo === "ceiling"
? "Ceiling attachment"
: "Wall attachment"
}
className="h-4 w-4"
height={16}
src={attachmentIcon}
width={16}
/>
</div>
)} )}
>
All
</button> </button>
</TooltipTrigger> {placementTags.map((tag) => {
<TooltipContent className="text-xs" side="top"> const count = placementCount(tag);
{item.name} const isActive = activePlacementTag === tag;
</TooltipContent> const isEmpty = count === 0 && !isActive;
</Tooltip> return (
); <button
})} key={tag}
type="button"
disabled={isEmpty}
onClick={() => setActivePlacementTag(isActive ? null : tag)}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-md pl-2 pr-1.5 py-0.5 text-xs font-medium transition-colors capitalize",
isActive
? "bg-blue-500 text-white"
: isEmpty
? "cursor-not-allowed bg-zinc-800 text-zinc-500"
: "bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200",
)}
>
{tag}
<span className={cn("text-[10px]", isActive ? "text-blue-200" : isEmpty ? "text-zinc-600" : "text-blue-500/70")}>
{count}
</span>
</button>
);
})}
</div>
)}
{/* Functional row */}
{functionalTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{functionalTags.map((tag) => {
const count = functionalCount(tag);
const isActive = activeFunctionalTag === tag;
const isEmpty = count === 0 && !isActive;
return (
<button
key={tag}
type="button"
disabled={isEmpty}
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-md pl-2 pr-1.5 py-0.5 text-xs font-medium transition-colors capitalize",
isActive
? "bg-violet-500 text-white"
: isEmpty
? "cursor-not-allowed bg-zinc-800 text-zinc-500"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
)}
>
{tag}
<span className={cn("text-[10px]", isActive ? "text-violet-200" : isEmpty ? "text-zinc-600" : "text-zinc-500/70")}>
{count}
</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* Items */}
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
{filteredItems.map((item, index) => {
const isSelected = selectedItem?.src === item?.src;
const attachmentIcon = getAttachmentIcon(item?.attachTo);
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<button
className={cn(
"relative aspect-square min-w-14 min-h-14 h-14 w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer",
isSelected && "ring-2 ring-primary-foreground",
)}
onClick={() => setSelectedItem(item)}
type="button"
>
<Image
alt={item.name}
className="rounded-lg object-cover"
fill
src={resolveCdnUrl(item.thumbnail) || ""}
/>
{attachmentIcon && (
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
<Image
alt={
item.attachTo === "ceiling"
? "Ceiling attachment"
: "Wall attachment"
}
className="h-4 w-4"
height={16}
src={attachmentIcon}
width={16}
/>
</div>
)}
</button>
</TooltipTrigger>
<TooltipContent className="text-xs" side="top">
{item.name}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div> </div>
); );
} }
@@ -1,10 +1,11 @@
import { useScene } from "@pascal-app/core"; import { emitter, useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { Download, Save, Trash2, Upload } from "lucide-react"; import { Camera, Download, Save, Trash2, Upload } from "lucide-react";
import { useRef } from "react"; import { useRef, useState } from "react";
import { Button } from "@/components/ui/primitives/button"; import { Button } from "@/components/ui/primitives/button";
import useEditor from "@/store/use-editor"; import useEditor from "@/store/use-editor";
import { AudioSettingsDialog } from "./audio-settings-dialog"; import { AudioSettingsDialog } from "./audio-settings-dialog";
import { usePropertyStore } from "@/features/community/lib/properties/store";
export function SettingsPanel() { export function SettingsPanel() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -15,6 +16,11 @@ export function SettingsPanel() {
const resetSelection = useViewer((state) => state.resetSelection); const resetSelection = useViewer((state) => state.resetSelection);
const exportScene = useViewer((state) => state.exportScene); const exportScene = useViewer((state) => state.exportScene);
const setPhase = useEditor((state) => state.setPhase); 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 () => { const handleExport = async () => {
if (exportScene) { if (exportScene) {
@@ -64,6 +70,19 @@ export function SettingsPanel() {
setPhase("site"); 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 ( return (
<div className="flex flex-col gap-6 p-3"> <div className="flex flex-col gap-6 p-3">
{/* Export Section */} {/* Export Section */}
@@ -81,6 +100,24 @@ export function SettingsPanel() {
</Button> </Button>
</div> </div>
{/* Thumbnail Section (only for cloud properties) */}
{propertyId && !isLocalProperty && (
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">
Thumbnail
</label>
<Button
className="w-full justify-start gap-2"
onClick={handleGenerateThumbnail}
variant="outline"
disabled={isGeneratingThumbnail}
>
<Camera className="size-4" />
{isGeneratingThumbnail ? 'Generating...' : 'Generate Thumbnail'}
</Button>
</div>
)}
{/* Save/Load Section */} {/* Save/Load Section */}
<div className="space-y-2"> <div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase"> <label className="font-medium text-muted-foreground text-xs uppercase">
@@ -9,6 +9,7 @@ import { LevelTreeNode } from "./level-tree-node";
import { RoofTreeNode } from "./roof-tree-node"; import { RoofTreeNode } from "./roof-tree-node";
import { SlabTreeNode } from "./slab-tree-node"; import { SlabTreeNode } from "./slab-tree-node";
import { WallTreeNode } from "./wall-tree-node"; import { WallTreeNode } from "./wall-tree-node";
import { WindowTreeNode } from "./window-tree-node";
import { ZoneTreeNode } from "./zone-tree-node"; import { ZoneTreeNode } from "./zone-tree-node";
interface TreeNodeProps { interface TreeNodeProps {
@@ -36,6 +37,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
return <RoofTreeNode node={node} depth={depth} />; return <RoofTreeNode node={node} depth={depth} />;
case "item": case "item":
return <ItemTreeNode node={node} depth={depth} />; return <ItemTreeNode node={node} depth={depth} />;
case "window":
return <WindowTreeNode node={node} depth={depth} />;
case "zone": case "zone":
return <ZoneTreeNode node={node} depth={depth} />; return <ZoneTreeNode node={node} depth={depth} />;
default: default:
@@ -0,0 +1,50 @@
'use client'
import { WindowNode } from "@pascal-app/core"
import { useViewer } from "@pascal-app/viewer"
import Image from "next/image"
import { useState } from "react"
import { RenamePopover } from "./rename-popover"
import { TreeNodeWrapper } from "./tree-node"
import { TreeNodeActions } from "./tree-node-actions"
interface WindowTreeNodeProps {
node: WindowNode
depth: number
}
export function WindowTreeNode({ node, depth }: WindowTreeNodeProps) {
const [renameOpen, setRenameOpen] = useState(false)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id))
const isHovered = useViewer((state) => state.hoveredId === node.id)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
const defaultName = `Window (${node.width}×${node.height}m)`
return (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src="/icons/window.png" alt="" width={14} height={14} className="object-contain" />}
label={node.name || defaultName}
depth={depth}
hasChildren={false}
expanded={false}
onToggle={() => {}}
onClick={() => setSelection({ selectedIds: [node.id] })}
onDoubleClick={() => setRenameOpen(true)}
onMouseEnter={() => setHoveredId(node.id)}
onMouseLeave={() => setHoveredId(null)}
isSelected={isSelected}
isHovered={isHovered}
isVisible={node.visible !== false}
actions={<TreeNodeActions node={node} />}
/>
</RenamePopover>
)
}
@@ -118,10 +118,10 @@ export function PropertyGrid({
<> <>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{properties.map((property) => ( {properties.map((property) => (
<button <div
key={property.id} key={property.id}
onClick={() => onPropertyClick(property.id)} onClick={() => onPropertyClick(property.id)}
className="group relative overflow-hidden rounded-lg border border-border bg-card hover:border-primary transition-all text-left" className="group relative overflow-hidden rounded-lg border border-border bg-card hover:border-primary transition-all text-left cursor-pointer"
> >
{/* Thumbnail */} {/* Thumbnail */}
<div className="aspect-video bg-muted relative"> <div className="aspect-video bg-muted relative">
@@ -213,7 +213,7 @@ export function PropertyGrid({
</div> </div>
)} )}
</div> </div>
</button> </div>
))} ))}
</div> </div>
@@ -65,6 +65,40 @@ export async function getUserProperties(): Promise<ActionResult<Property[]>> {
} }
} }
/**
* Get a specific property by ID for the current user
*/
export async function getPropertyById(propertyId: string): Promise<ActionResult<Property | null>> {
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<Property>()
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 * 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',
}
}
}
@@ -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<void>
}
/**
* Hook to fetch and manage user properties
*/
export function useProperties(): UsePropertiesReturn {
const [properties, setProperties] = useState<Property[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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<Property | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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<Property | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(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,
}
}
@@ -7,7 +7,7 @@ import type { Property } from './types'
import { import {
getActiveProperty, getActiveProperty,
getUserProperties, getUserProperties,
setActiveProperty as setActivePropertyAction, getPropertyById,
} from './actions' } from './actions'
interface PropertyStore { interface PropertyStore {
@@ -65,44 +65,16 @@ export const usePropertyStore = create<PropertyStore>((set, get) => ({
} }
}, },
// Set active property // Set active property by fetching it directly by ID (URL-based, no session update)
setActiveProperty: async (propertyId: string) => { setActiveProperty: async (propertyId: string) => {
set({ isLoading: true }) set({ isLoading: true })
// Update database const result = await getPropertyById(propertyId)
const result = await setActivePropertyAction(propertyId)
if (result.success) { if (result.success && result.data) {
// Fetch properties to get the full property object set({ activeProperty: result.data, isLoading: false, error: null })
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'
})
}
} else { } else {
set({ set({ isLoading: false, error: result.error || 'Property not found' })
isLoading: false,
error: result.error || 'Failed to set active property'
})
} }
}, },
+8 -1
View File
@@ -1,4 +1,5 @@
import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core' import { type EventSuffix, emitter, type GridEvent } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { Plane, Raycaster, Vector2, Vector3 } from 'three' import { Plane, Raycaster, Vector2, Vector3 } from 'three'
@@ -53,29 +54,35 @@ export function useGridEvents(gridY: number) {
} }
const handlePointerDown = (e: PointerEvent) => { const handlePointerDown = (e: PointerEvent) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('pointerdown', e) emit('pointerdown', e)
} }
const handlePointerUp = (e: PointerEvent) => { const handlePointerUp = (e: PointerEvent) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('pointerup', e) emit('pointerup', e)
} }
const handleClick = (e: PointerEvent) => { const handleClick = (e: PointerEvent) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('click', e) emit('click', e)
} }
const handlePointerMove = (e: PointerEvent) => { const handlePointerMove = (e: PointerEvent) => {
if (useViewer.getState().cameraDragging) return
emit('move', e) emit('move', e)
} }
const handleDoubleClick = (e: MouseEvent) => { const handleDoubleClick = (e: MouseEvent) => {
if (useViewer.getState().cameraDragging) return
emit('double-click', e) emit('double-click', e)
} }
const handleContextMenu = (e: MouseEvent) => { const handleContextMenu = (e: MouseEvent) => {
if (useViewer.getState().cameraDragging) return
emit('context-menu', e) emit('context-menu', e)
} }
@@ -95,5 +102,5 @@ export function useGridEvents(gridY: number) {
canvas.removeEventListener('dblclick', handleDoubleClick) canvas.removeEventListener('dblclick', handleDoubleClick)
canvas.removeEventListener('contextmenu', handleContextMenu) canvas.removeEventListener('contextmenu', handleContextMenu)
} }
}, [camera, gl, gridY]) }, [camera, gl])
} }
+5 -10
View File
@@ -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 { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import useEditor from '@/store/use-editor'
import { sfxEmitter } from '@/lib/sfx-bus' import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
export const useKeyboard = () => { export const useKeyboard = () => {
useEffect(() => { useEffect(() => {
@@ -14,13 +14,7 @@ export const useKeyboard = () => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
// Emit tool:cancel event - each tool handles its own cancellation logic emitter.emit('tool:cancel')
// if (useEditor.getState().controlMode === 'building') {
// emitter.emit('tool:cancel', undefined)
// }
// if (selectedNodeIds.length > 0) {
// handleClear()
// }
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
e.preventDefault() e.preventDefault()
useEditor.getState().setPhase('site') useEditor.getState().setPhase('site')
@@ -33,7 +27,8 @@ export const useKeyboard = () => {
e.preventDefault() e.preventDefault()
useEditor.getState().setPhase('furnish') useEditor.getState().setPhase('furnish')
useEditor.getState().setMode('select') useEditor.getState().setMode('select')
} if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { }
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault() e.preventDefault()
useEditor.getState().setMode('select') useEditor.getState().setMode('select')
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
+1
View File
@@ -27,6 +27,7 @@ export type StructureTool =
| 'stair' | 'stair'
| 'item' | 'item'
| 'zone' | 'zone'
| 'window'
// Furnish mode tools (items and decoration) // Furnish mode tools (items and decoration)
export type FurnishTool = 'item' export type FurnishTool = 'item'
+14 -2
View File
@@ -1,6 +1,6 @@
import type { ThreeEvent } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber'
import mitt from 'mitt' import mitt from 'mitt'
import type { BuildingNode, CeilingNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, ZoneNode } from '../schema' import type { BuildingNode, CeilingNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode } from '../schema'
import type { AnyNode } from '../schema/types' import type { AnyNode } from '../schema/types'
// Base event interfaces // Base event interfaces
@@ -27,6 +27,7 @@ export type ZoneEvent = NodeEvent<ZoneNode>
export type SlabEvent = NodeEvent<SlabNode> export type SlabEvent = NodeEvent<SlabNode>
export type CeilingEvent = NodeEvent<CeilingNode> export type CeilingEvent = NodeEvent<CeilingNode>
export type RoofEvent = NodeEvent<RoofNode> export type RoofEvent = NodeEvent<RoofNode>
export type WindowEvent = NodeEvent<WindowNode>
// Event suffixes - exported for use in hooks // Event suffixes - exported for use in hooks
export const eventSuffixes = [ export const eventSuffixes = [
@@ -54,12 +55,21 @@ export interface CameraControlEvent {
nodeId: AnyNode['id'] nodeId: AnyNode['id']
} }
export interface ThumbnailGenerateEvent {
propertyId: string
}
type CameraControlEvents = { type CameraControlEvents = {
'camera-controls:view': CameraControlEvent 'camera-controls:view': CameraControlEvent
'camera-controls:capture': CameraControlEvent 'camera-controls:capture': CameraControlEvent
'camera-controls:top-view': undefined 'camera-controls:top-view': undefined
'camera-controls:orbit-cw': undefined 'camera-controls:orbit-cw': undefined
'camera-controls:orbit-ccw': undefined 'camera-controls:orbit-ccw': undefined
'camera-controls:generate-thumbnail': ThumbnailGenerateEvent
}
type ToolEvents = {
'tool:cancel': undefined
} }
type EditorEvents = GridEvents & type EditorEvents = GridEvents &
@@ -72,6 +82,8 @@ type EditorEvents = GridEvents &
NodeEvents<'slab', SlabEvent> & NodeEvents<'slab', SlabEvent> &
NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'roof', RoofEvent> & NodeEvents<'roof', RoofEvent> &
CameraControlEvents NodeEvents<'window', WindowEvent> &
CameraControlEvents &
ToolEvents
export const emitter = mitt<EditorEvents>() export const emitter = mitt<EditorEvents>()
@@ -19,6 +19,7 @@ export const sceneRegistry = {
roof: new Set<string>(), roof: new Set<string>(),
scan: new Set<string>(), scan: new Set<string>(),
guide: new Set<string>(), guide: new Set<string>(),
window: new Set<string>(),
}, },
}; };
+3 -2
View File
@@ -3,17 +3,17 @@
export type { export type {
BuildingEvent, BuildingEvent,
CameraControlEvent, CameraControlEvent,
CeilingEvent,
EventSuffix, EventSuffix,
GridEvent, GridEvent,
ItemEvent, ItemEvent,
LevelEvent, LevelEvent,
NodeEvent, NodeEvent,
RoofEvent,
SiteEvent, SiteEvent,
SlabEvent, SlabEvent,
WallEvent, WallEvent,
ZoneEvent, ZoneEvent,
CeilingEvent,
RoofEvent,
} from './events/bus' } from './events/bus'
// Events // Events
export { emitter, eventSuffixes } from './events/bus' 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 { RoofSystem } from './systems/roof/roof-system'
export { SlabSystem } from './systems/slab/slab-system' export { SlabSystem } from './systems/slab/slab-system'
export { WallSystem } from './systems/wall/wall-system' export { WallSystem } from './systems/wall/wall-system'
export { WindowSystem } from './systems/window/window-system'
export { isObject } from './utils/types' export { isObject } from './utils/types'
// Asset storage // Asset storage
+1
View File
@@ -17,5 +17,6 @@ export { RoofNode } from './nodes/roof'
export { ScanNode } from './nodes/scan' export { ScanNode } from './nodes/scan'
export { GuideNode } from './nodes/guide' export { GuideNode } from './nodes/guide'
export type { AnyNodeId, AnyNodeType } from './types' export type { AnyNodeId, AnyNodeType } from './types'
export { WindowNode } from './nodes/window'
// Union types // Union types
export { AnyNode } from './types' export { AnyNode } from './types'
+2
View File
@@ -10,6 +10,7 @@ const assetSchema = z.object({
src: z.string(), src: z.string(),
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d] dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(), attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
tags: z.array(z.string()).optional(),
// These are "Corrective" transforms to normalize the GLB // These are "Corrective" transforms to normalize the GLB
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), 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]), 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 - offset: corrective position offset for the model
- rotation: corrective rotation for the model - rotation: corrective rotation for the model
- scale: corrective scale for the model - scale: corrective scale for the model
- tags: tags associated with the item
`) `)
export type ItemNode = z.infer<typeof ItemNode> export type ItemNode = z.infer<typeof ItemNode>
+46
View File
@@ -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<typeof WindowNode>
+2
View File
@@ -9,6 +9,7 @@ import { ScanNode } from './nodes/scan'
import { SiteNode } from './nodes/site' import { SiteNode } from './nodes/site'
import { SlabNode } from './nodes/slab' import { SlabNode } from './nodes/slab'
import { WallNode } from './nodes/wall' import { WallNode } from './nodes/wall'
import { WindowNode } from './nodes/window'
import { ZoneNode } from './nodes/zone' import { ZoneNode } from './nodes/zone'
export const AnyNode = z.discriminatedUnion('type', [ export const AnyNode = z.discriminatedUnion('type', [
@@ -23,6 +24,7 @@ export const AnyNode = z.discriminatedUnion('type', [
RoofNode, RoofNode,
ScanNode, ScanNode,
GuideNode, GuideNode,
WindowNode,
]) ])
export type AnyNode = z.infer<typeof AnyNode> export type AnyNode = z.infer<typeof AnyNode>
@@ -309,7 +309,7 @@ function collectCutoutBrushes(
const wallMatrixInverse = wallMesh.matrixWorld.clone().invert() const wallMatrixInverse = wallMesh.matrixWorld.clone().invert()
for (const child of childrenNodes) { for (const child of childrenNodes) {
if (child.type !== 'item') continue if (child.type !== 'item' && child.type !== 'window') continue
const childMesh = sceneRegistry.nodes.get(child.id) const childMesh = sceneRegistry.nodes.get(child.id)
if (!childMesh) continue if (!childMesh) continue
@@ -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;
}
@@ -11,6 +11,7 @@ import { ScanRenderer } from './scan/scan-renderer'
import { SiteRenderer } from './site/site-renderer' import { SiteRenderer } from './site/site-renderer'
import { SlabRenderer } from './slab/slab-renderer' import { SlabRenderer } from './slab/slab-renderer'
import { WallRenderer } from './wall/wall-renderer' import { WallRenderer } from './wall/wall-renderer'
import { WindowRenderer } from './window/window-renderer'
import { ZoneRenderer } from './zone/zone-renderer' import { ZoneRenderer } from './zone/zone-renderer'
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
@@ -27,6 +28,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
{node.type === 'item' && <ItemRenderer node={node} />} {node.type === 'item' && <ItemRenderer node={node} />}
{node.type === 'slab' && <SlabRenderer node={node} />} {node.type === 'slab' && <SlabRenderer node={node} />}
{node.type === 'wall' && <WallRenderer node={node} />} {node.type === 'wall' && <WallRenderer node={node} />}
{node.type === 'window' && <WindowRenderer node={node} />}
{node.type === 'zone' && <ZoneRenderer node={node} />} {node.type === 'zone' && <ZoneRenderer node={node} />}
{node.type === 'roof' && <RoofRenderer node={node} />} {node.type === 'roof' && <RoofRenderer node={node} />}
{node.type === 'scan' && <ScanRenderer node={node} />} {node.type === 'scan' && <ScanRenderer node={node} />}
@@ -1,7 +1,7 @@
import { type SiteNode, useRegistry } from '@pascal-app/core' import { type SiteNode, useRegistry } from '@pascal-app/core'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react' 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 { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
@@ -91,26 +91,15 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
))} ))}
{/* Transparent floor fill */} {/* Transparent floor fill */}
<mesh position={[0, Y_OFFSET - 0.005, 0]} rotation={[-Math.PI / 2, 0, 0]}> <mesh position={[0, Y_OFFSET - 0.005, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<shapeGeometry args={[floorShape]} /> <shapeGeometry args={[floorShape]} />
<meshBasicMaterial <shadowMaterial transparent opacity={0.75} />
color="#f59e0b"
transparent
opacity={0.05}
side={DoubleSide}
depthWrite={false}
/>
</mesh> </mesh>
{/* Simple boundary line */} {/* Simple boundary line */}
{/* @ts-ignore */} {/* @ts-ignore */}
<line geometry={lineGeometry} frustumCulled={false} renderOrder={9}> <line geometry={lineGeometry} frustumCulled={false} renderOrder={9}>
<lineBasicMaterial <lineBasicMaterial color="#f59e0b" linewidth={2} transparent opacity={0.6} />
color="#f59e0b"
linewidth={2}
transparent
opacity={0.6}
/>
</line> </line>
{/* Edge distance labels */} {/* Edge distance labels */}
@@ -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<Mesh>(null!)
useRegistry(node.id, 'window', ref)
return (
<mesh
ref={ref}
castShadow
receiveShadow
visible={node.visible}
position={node.position}
rotation={node.rotation}
>
{/* WindowSystem replaces this geometry each time the node is dirty */}
<boxGeometry args={[0, 0, 0]} />
<meshStandardMaterial color="#d1d5db" />
</mesh>
)
}
@@ -1,6 +1,6 @@
'use client' '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 { Bvh } from '@react-three/drei'
import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber' import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber'
import * as THREE from 'three/webgpu' import * as THREE from 'three/webgpu'
@@ -65,6 +65,7 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
<RoofSystem /> <RoofSystem />
<SlabSystem /> <SlabSystem />
<WallSystem /> <WallSystem />
<WindowSystem />
<ZoneSystem /> <ZoneSystem />
<PostProcessing /> <PostProcessing />
@@ -26,7 +26,7 @@ import useViewer from '../../store/use-viewer'
// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
export const SSGI_PARAMS = { export const SSGI_PARAMS = {
enabled: true, enabled: true,
sliceCount: 1, sliceCount: 2,
stepCount: 8, stepCount: 8,
radius: 1, radius: 1,
expFactor: 1.5, expFactor: 1.5,
@@ -3,15 +3,15 @@
import { import {
type AnyNode, type AnyNode,
type BuildingNode, type BuildingNode,
emitter,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type NodeEvent, type NodeEvent,
type WallNode,
type ZoneNode,
emitter,
pointInPolygon, pointInPolygon,
sceneRegistry, sceneRegistry,
useScene, useScene,
type WallNode,
type ZoneNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
@@ -23,14 +23,24 @@ const tempWorldPos = new Vector3()
// Tolerance for edge detection (in meters) // Tolerance for edge detection (in meters)
const EDGE_TOLERANCE = 0.5 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 // Expand polygon outward by a small amount to include items on edges
const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => { const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => {
if (polygon.length < 3) return polygon if (polygon.length < 3) return polygon
// Calculate centroid // Calculate centroid
let cx = 0, cz = 0 let cx = 0,
cz = 0
for (const [x, z] of polygon) { for (const [x, z] of polygon) {
cx += x cx += x
cz += z cz += z
@@ -50,7 +60,11 @@ const expandPolygon = (polygon: [number, number][], tolerance: number): [number,
} }
// Check if point is in polygon with tolerance for edges // 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 // First try exact check
if (pointInPolygon(x, z, polygon)) return true if (pointInPolygon(x, z, polygon)) return true
// Then try with expanded polygon for edge tolerance // 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 { return {
types: ['wall', 'item', 'slab', 'ceiling', 'roof'], types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window'],
handleClick: (node) => { handleClick: (node) => {
const { selectedIds } = useViewer.getState().selection const { selectedIds } = useViewer.getState().selection
// Toggle selection - if already selected, deselect; otherwise select // Toggle selection - if already selected, deselect; otherwise select
if (selectedIds.includes(node.id)) { 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 { } else {
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
} }
@@ -201,7 +217,7 @@ const getStrategy = (): SelectionStrategy | null => {
} }
}, },
isValid: (node) => { isValid: (node) => {
const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof'] const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof', 'window']
if (!validTypes.includes(node.type)) return false if (!validTypes.includes(node.type)) return false
return isNodeInZone(node, levelId, zoneId) return isNodeInZone(node, levelId, zoneId)
}, },
@@ -244,7 +260,17 @@ export const SelectionManager = () => {
} }
// Subscribe to all node types // 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) { for (const type of allTypes) {
emitter.on(`${type}:enter`, onEnter) emitter.on(`${type}:enter`, onEnter)
emitter.on(`${type}:leave`, onLeave) emitter.on(`${type}:leave`, onLeave)
@@ -258,7 +284,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:click`, onClick) emitter.off(`${type}:click`, onClick)
} }
} }
}, [selection]) }, [])
return ( return (
<> <>
@@ -268,12 +294,17 @@ export const SelectionManager = () => {
) )
} }
const PointerMissedHandler = ({ clickHandledRef }: { clickHandledRef: React.MutableRefObject<boolean> }) => { const PointerMissedHandler = ({
clickHandledRef,
}: {
clickHandledRef: React.MutableRefObject<boolean>
}) => {
const gl = useThree((s) => s.gl) const gl = useThree((s) => s.gl)
useEffect(() => { useEffect(() => {
const handleClick = (event: MouseEvent) => { const handleClick = (event: MouseEvent) => {
// Only handle left clicks // Only handle left clicks
if (useViewer.getState().cameraDragging) return
if (event.button !== 0) return if (event.button !== 0) return
// Use requestAnimationFrame to check after R3F event handlers // Use requestAnimationFrame to check after R3F event handlers
+9 -5
View File
@@ -21,6 +21,7 @@ import {
type ZoneNode, type ZoneNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import type { ThreeEvent } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber'
import useViewer from '../store/use-viewer';
type NodeConfig = { type NodeConfig = {
site: { node: SiteNode; event: SiteEvent } site: { node: SiteNode; event: SiteEvent }
@@ -54,21 +55,24 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]['node'], t
return { return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => { onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('pointerdown', e) emit('pointerdown', e)
}, },
onPointerUp: (e: ThreeEvent<PointerEvent>) => { onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('pointerup', e) emit('pointerup', e)
}, },
onClick: (e: ThreeEvent<PointerEvent>) => { onClick: (e: ThreeEvent<PointerEvent>) => {
if (useViewer.getState().cameraDragging) return
if (e.button !== 0) return if (e.button !== 0) return
emit('click', e) emit('click', e)
}, },
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit('enter', e), onPointerEnter: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('enter', e)},
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit('leave', e), onPointerLeave: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('leave', e)},
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit('move', e), onPointerMove: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('move', e)},
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit('double-click', e), onDoubleClick: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('double-click', e)},
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit('context-menu', e), onContextMenu: (e: ThreeEvent<PointerEvent>) => {if (useViewer.getState().cameraDragging) return; emit('context-menu', e)},
} }
} }
+6
View File
@@ -52,6 +52,9 @@ type ViewerState = {
// Export functionality // Export functionality
exportScene: (() => Promise<void>) | null exportScene: (() => Promise<void>) | null
setExportScene: (fn: (() => Promise<void>) | null) => void setExportScene: (fn: (() => Promise<void>) | null) => void
cameraDragging: boolean
setCameraDragging: (dragging: boolean) => void
} }
const useViewer = create<ViewerState>()((set, get) => ({ const useViewer = create<ViewerState>()((set, get) => ({
@@ -107,6 +110,9 @@ const useViewer = create<ViewerState>()((set, get) => ({
exportScene: null, exportScene: null,
setExportScene: (fn) => set({ exportScene: fn }), setExportScene: (fn) => set({ exportScene: fn }),
cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
})); }));
export default useViewer; export default useViewer;