Merge pull request #95 from pascalorg/feat/polish-walls-and-sfx
Feat/polish walls and sfx
This commit is contained in:
@@ -9,19 +9,25 @@ import { ZoneSystem } from '../systems/zone/zone-system'
|
|||||||
import { ToolManager } from '../tools/tool-manager'
|
import { ToolManager } from '../tools/tool-manager'
|
||||||
import { ActionMenu } from '../ui/action-menu'
|
import { ActionMenu } from '../ui/action-menu'
|
||||||
import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
|
import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
|
||||||
|
import { PascalRadio } from '../pascal-radio'
|
||||||
import { PanelManager } from '../ui/panels/panel-manager'
|
import { PanelManager } from '../ui/panels/panel-manager'
|
||||||
|
import { HelperManager } from '../ui/helpers/helper-manager'
|
||||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
import { ExportManager } from './export-manager'
|
import { ExportManager } from './export-manager'
|
||||||
import { Grid } from './grid'
|
import { Grid } from './grid'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
|
import { initSFXBus } from '@/lib/sfx-bus'
|
||||||
|
|
||||||
// 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()
|
||||||
initSpatialGridSync()
|
initSpatialGridSync()
|
||||||
initSpaceDetectionSync(useScene, useEditor)
|
initSpaceDetectionSync(useScene, useEditor)
|
||||||
|
|
||||||
|
// Initialize SFX bus to connect events to sound effects
|
||||||
|
initSFXBus()
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
useKeyboard()
|
useKeyboard()
|
||||||
|
|
||||||
@@ -29,7 +35,17 @@ export default function Editor() {
|
|||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
<ActionMenu />
|
<ActionMenu />
|
||||||
<PanelManager />
|
<PanelManager />
|
||||||
|
<HelperManager />
|
||||||
|
|
||||||
|
{/* Top-right controls */}
|
||||||
|
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
|
||||||
|
<div className="pointer-events-auto">
|
||||||
|
<PascalRadio />
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto">
|
||||||
<CloudSaveButton />
|
<CloudSaveButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SidebarProvider className="fixed z-20">
|
<SidebarProvider className="fixed z-20">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Howl } from 'howler'
|
||||||
|
import { Disc3, Pause, Play, Settings2, SkipBack, SkipForward, Volume2 } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
|
||||||
|
import { Switch } from '@/components/ui/primitives/switch'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import useAudio from '@/store/use-audio'
|
||||||
|
|
||||||
|
const PLAYLIST = [
|
||||||
|
{
|
||||||
|
title: 'Ballroom in Miniature',
|
||||||
|
file: '/audios/radios/classic/Ballroom in Miniature.mp3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Blueprints in Springtime',
|
||||||
|
file: '/audios/radios/classic/Blueprints in Springtime.mp3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Clockwork Tea Party',
|
||||||
|
file: '/audios/radios/classic/Clockwork Tea Party.mp3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Clockwork Tea Party (Alternate)',
|
||||||
|
file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Clockwork Teacups',
|
||||||
|
file: '/audios/radios/classic/Clockwork Teacups.mp3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Evening in the Parlor',
|
||||||
|
file: '/audios/radios/classic/Evening in the Parlor.mp3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Glass Atrium',
|
||||||
|
file: '/audios/radios/classic/Glass Atrium.mp3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Moonlight On The Drafting Table',
|
||||||
|
file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sunlit Garden Reverie',
|
||||||
|
file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sunlit Waltz in Pastel Hues',
|
||||||
|
file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Shuffle array helper
|
||||||
|
function shuffleArray<T>(array: T[]): T[] {
|
||||||
|
const shuffled = [...array]
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1))
|
||||||
|
;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
|
||||||
|
}
|
||||||
|
return shuffled
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PascalRadio() {
|
||||||
|
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
|
||||||
|
const { masterVolume, radioVolume, muted, autoplay, setAutoplay } = useAudio()
|
||||||
|
const soundRef = useRef<Howl | null>(null)
|
||||||
|
const hasAutoplayedRef = useRef(false)
|
||||||
|
|
||||||
|
const currentTrack = shuffledPlaylist[currentTrackIndex]!
|
||||||
|
|
||||||
|
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
|
||||||
|
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
|
||||||
|
}, [shuffledPlaylist.length])
|
||||||
|
|
||||||
|
const handlePrevious = useCallback(() => {
|
||||||
|
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
|
||||||
|
}, [shuffledPlaylist.length])
|
||||||
|
|
||||||
|
// Initialize Howler when track changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Clean up previous sound
|
||||||
|
if (soundRef.current) {
|
||||||
|
soundRef.current.unload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasPlaying = isPlaying
|
||||||
|
|
||||||
|
// Create new sound
|
||||||
|
soundRef.current = new Howl({
|
||||||
|
src: [currentTrack.file],
|
||||||
|
volume: muted ? 0 : effectiveVolume,
|
||||||
|
onend: handleNext,
|
||||||
|
})
|
||||||
|
|
||||||
|
// If was playing, play new track
|
||||||
|
if (wasPlaying && !muted) {
|
||||||
|
soundRef.current?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
soundRef.current?.unload()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [handleNext, currentTrack.file])
|
||||||
|
|
||||||
|
// Update volume when settings change
|
||||||
|
useEffect(() => {
|
||||||
|
if (soundRef.current) {
|
||||||
|
soundRef.current.volume(muted ? 0 : effectiveVolume)
|
||||||
|
|
||||||
|
// Pause if muted, resume if unmuted and was playing
|
||||||
|
if (muted && isPlaying) {
|
||||||
|
soundRef.current.pause()
|
||||||
|
} else if (!muted && isPlaying && !soundRef.current.playing()) {
|
||||||
|
soundRef.current.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [effectiveVolume, muted, isPlaying])
|
||||||
|
|
||||||
|
// Autoplay on first user click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoplay || hasAutoplayedRef.current || muted) return
|
||||||
|
|
||||||
|
const handleFirstClick = () => {
|
||||||
|
if (!soundRef.current || hasAutoplayedRef.current) return
|
||||||
|
|
||||||
|
hasAutoplayedRef.current = true
|
||||||
|
soundRef.current.play()
|
||||||
|
setIsPlaying(true)
|
||||||
|
|
||||||
|
// Remove listener after first click
|
||||||
|
document.removeEventListener('click', handleFirstClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', handleFirstClick)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleFirstClick)
|
||||||
|
}
|
||||||
|
}, [autoplay, muted])
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (!soundRef.current || muted) return
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
soundRef.current.pause()
|
||||||
|
} else {
|
||||||
|
soundRef.current.play()
|
||||||
|
}
|
||||||
|
setIsPlaying(!isPlaying)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVolumeChange = (value: number[]) => {
|
||||||
|
useAudio.setState({ radioVolume: value[0] })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md">
|
||||||
|
<Disc3 className={cn('h-4 w-4', isPlaying && 'animate-spin')} />
|
||||||
|
<span className="hidden sm:inline">Pascal Radio</span>
|
||||||
|
<div
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
handlePlayPause()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
||||||
|
</div>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground"
|
||||||
|
aria-label="Radio Settings"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-64" align="end">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Current song info with prev/next */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Now Playing</p>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
|
||||||
|
aria-label="Previous"
|
||||||
|
>
|
||||||
|
<SkipBack className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<p className="text-sm font-medium text-center flex-1 truncate">{currentTrack.title}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
|
||||||
|
aria-label="Next"
|
||||||
|
>
|
||||||
|
<SkipForward className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume control */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Slider
|
||||||
|
value={[radioVolume]}
|
||||||
|
onValueChange={handleVolumeChange}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
className="flex-1"
|
||||||
|
aria-label="Radio Volume"
|
||||||
|
/>
|
||||||
|
<span className="w-8 text-right text-xs text-muted-foreground">{radioVolume}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Autoplay setting */}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t">
|
||||||
|
<label htmlFor="autoplay" className="text-sm font-medium cursor-pointer">
|
||||||
|
Autoplay
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
id="autoplay"
|
||||||
|
checked={autoplay}
|
||||||
|
onCheckedChange={setAutoplay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
|
||||||
const CEILING_HEIGHT = 2.52
|
const CEILING_HEIGHT = 2.52
|
||||||
const GRID_OFFSET = 0.02
|
const GRID_OFFSET = 0.02
|
||||||
@@ -59,6 +60,7 @@ const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, n
|
|||||||
})
|
})
|
||||||
|
|
||||||
createNode(ceiling, levelId)
|
createNode(ceiling, levelId)
|
||||||
|
sfxEmitter.emit('sfx:structure-build')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CeilingTool: React.FC = () => {
|
export const CeilingTool: React.FC = () => {
|
||||||
@@ -72,6 +74,7 @@ export const CeilingTool: React.FC = () => {
|
|||||||
const [points, setPoints] = useState<Array<[number, number]>>([])
|
const [points, setPoints] = useState<Array<[number, number]>>([])
|
||||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||||
const [levelY, setLevelY] = useState(0)
|
const [levelY, setLevelY] = useState(0)
|
||||||
|
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||||
|
|
||||||
// Update cursor position and lines on grid move
|
// Update cursor position and lines on grid move
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -94,6 +97,13 @@ export const CeilingTool: React.FC = () => {
|
|||||||
const lastPoint = points[points.length - 1]
|
const lastPoint = points[points.length - 1]
|
||||||
const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition
|
const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition
|
||||||
|
|
||||||
|
// Play snap sound when the snapped position actually changes (only when drawing)
|
||||||
|
if (points.length > 0 && previousSnappedPointRef.current &&
|
||||||
|
(displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1])) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
|
||||||
|
previousSnappedPointRef.current = displayPoint
|
||||||
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
|
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
|
||||||
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
import { useDraftNode } from './use-draft-node'
|
import { useDraftNode } from './use-draft-node'
|
||||||
import { usePlacementCoordinator } from './use-placement-coordinator'
|
import { usePlacementCoordinator } from './use-placement-coordinator'
|
||||||
@@ -14,7 +15,10 @@ export const ItemTool: React.FC = () => {
|
|||||||
draftNode.create(gridPosition, selectedItem!)
|
draftNode.create(gridPosition, selectedItem!)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCommitted: () => true,
|
onCommitted: () => {
|
||||||
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
return true
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!selectedItem) return null
|
if (!selectedItem) return null
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import useEditor from '@/store/use-editor'
|
|
||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import type { PlacementState } from './placement-types'
|
||||||
import { useDraftNode } from './use-draft-node'
|
import { useDraftNode } from './use-draft-node'
|
||||||
import { usePlacementCoordinator } from './use-placement-coordinator'
|
import { usePlacementCoordinator } from './use-placement-coordinator'
|
||||||
import type { PlacementState } from './placement-types'
|
|
||||||
|
|
||||||
function getInitialState(node: { asset: { attachTo?: string }; parentId: string | null }): PlacementState {
|
function getInitialState(node: {
|
||||||
|
asset: { attachTo?: string }
|
||||||
|
parentId: string | null
|
||||||
|
}): PlacementState {
|
||||||
const attachTo = node.asset.attachTo
|
const attachTo = node.asset.attachTo
|
||||||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||||||
return { surface: 'wall', wallId: node.parentId, ceilingId: null }
|
return { surface: 'wall', wallId: node.parentId, ceilingId: null }
|
||||||
@@ -33,6 +37,7 @@ export const MoveTool: React.FC = () => {
|
|||||||
gridPosition.copy(new Vector3(...movingNode.position))
|
gridPosition.copy(new Vector3(...movingNode.position))
|
||||||
},
|
},
|
||||||
onCommitted: () => {
|
onCommitted: () => {
|
||||||
|
sfxEmitter.emit('sfx:item-place')
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,18 +17,46 @@ import { useFrame } from '@react-three/fiber'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
BoxGeometry,
|
BoxGeometry,
|
||||||
|
EdgesGeometry,
|
||||||
Euler,
|
Euler,
|
||||||
|
type Group,
|
||||||
|
type LineSegments,
|
||||||
type Mesh,
|
type Mesh,
|
||||||
type MeshStandardMaterial,
|
PlaneGeometry,
|
||||||
Quaternion,
|
Quaternion,
|
||||||
Vector3,
|
Vector3,
|
||||||
} from 'three'
|
} from 'three'
|
||||||
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||||
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies'
|
import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies'
|
||||||
import type { PlacementState, TransitionResult } from './placement-types'
|
import type { PlacementState, TransitionResult } from './placement-types'
|
||||||
import type { DraftNodeHandle } from './use-draft-node'
|
import type { DraftNodeHandle } from './use-draft-node'
|
||||||
|
|
||||||
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
||||||
|
|
||||||
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
||||||
|
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
||||||
|
const edgeMaterial = new LineBasicNodeMaterial({
|
||||||
|
color: 0xef4444, // red-500 (invalid)
|
||||||
|
linewidth: 3,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const basePlaneMaterial = new MeshBasicNodeMaterial({
|
||||||
|
color: 0xef4444, // red-500 (invalid)
|
||||||
|
transparent: true,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create radial opacity: transparent in center, opaque at edges
|
||||||
|
const center = vec2(0.5, 0.5)
|
||||||
|
const dist = distance(uv(), center)
|
||||||
|
const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
|
||||||
|
basePlaneMaterial.opacityNode = radialOpacity
|
||||||
|
|
||||||
export interface PlacementCoordinatorConfig {
|
export interface PlacementCoordinatorConfig {
|
||||||
asset: AssetInput
|
asset: AssetInput
|
||||||
draftNode: DraftNodeHandle
|
draftNode: DraftNodeHandle
|
||||||
@@ -39,7 +67,9 @@ export interface PlacementCoordinatorConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
||||||
const cursorRef = useRef<Mesh>(null!)
|
const cursorGroupRef = useRef<Group>(null!)
|
||||||
|
const edgesRef = useRef<LineSegments>(null!)
|
||||||
|
const basePlaneRef = useRef<Mesh>(null!)
|
||||||
const gridPosition = useRef(new Vector3(0, 0, 0))
|
const gridPosition = useRef(new Vector3(0, 0, 0))
|
||||||
const placementState = useRef<PlacementState>(
|
const placementState = useRef<PlacementState>(
|
||||||
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null },
|
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null },
|
||||||
@@ -76,15 +106,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const revalidate = (): boolean => {
|
const revalidate = (): boolean => {
|
||||||
const placeable = checkCanPlace(getContext(), validators)
|
const placeable = checkCanPlace(getContext(), validators)
|
||||||
;(cursorRef.current.material as MeshStandardMaterial).color.set(placeable ? 'green' : 'red')
|
const color = placeable ? 0x22c55e : 0xef4444 // green-500 : red-500
|
||||||
|
edgeMaterial.color.setHex(color)
|
||||||
|
basePlaneMaterial.color.setHex(color)
|
||||||
return placeable
|
return placeable
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyTransition = (result: TransitionResult) => {
|
const applyTransition = (result: TransitionResult) => {
|
||||||
Object.assign(placementState.current, result.stateUpdate)
|
Object.assign(placementState.current, result.stateUpdate)
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
cursorRef.current.position.set(...result.cursorPosition)
|
|
||||||
cursorRef.current.rotation.y = result.cursorRotationY
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) {
|
if (draft) {
|
||||||
@@ -96,8 +129,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const ensureDraft = (result: TransitionResult) => {
|
const ensureDraft = (result: TransitionResult) => {
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
cursorRef.current.position.set(...result.cursorPosition)
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
cursorRef.current.rotation.y = result.cursorRotationY
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||||
|
|
||||||
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0])
|
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0])
|
||||||
|
|
||||||
@@ -120,14 +153,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (draftNode.current) {
|
if (draftNode.current) {
|
||||||
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
mesh.getWorldPosition(cursorRef.current.position)
|
mesh.getWorldPosition(cursorGroupRef.current.position)
|
||||||
// Extract world Y rotation (handles wall-parented items correctly)
|
// Extract world Y rotation (handles wall-parented items correctly)
|
||||||
const q = new Quaternion()
|
const q = new Quaternion()
|
||||||
mesh.getWorldQuaternion(q)
|
mesh.getWorldQuaternion(q)
|
||||||
cursorRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
||||||
} else {
|
} else {
|
||||||
cursorRef.current.position.copy(gridPosition.current)
|
cursorGroupRef.current.position.copy(gridPosition.current)
|
||||||
cursorRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,14 +168,26 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
// ---- Floor Handlers ----
|
// ---- Floor Handlers ----
|
||||||
|
|
||||||
|
let previousGridPos: [number, number, number] | null = null
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
const result = floorStrategy.move(getContext(), event)
|
const result = floorStrategy.move(getContext(), event)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
|
// Play snap sound when grid position changes
|
||||||
|
if (
|
||||||
|
previousGridPos &&
|
||||||
|
(result.gridPosition[0] !== previousGridPos[0] ||
|
||||||
|
result.gridPosition[2] !== previousGridPos[2])
|
||||||
|
) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
|
||||||
|
previousGridPos = [...result.gridPosition]
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
|
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
|
||||||
cursorRef.current.position.x = result.cursorPosition[0]
|
cursorGroupRef.current.position.x = result.cursorPosition[0]
|
||||||
cursorRef.current.position.z = result.cursorPosition[2]
|
cursorGroupRef.current.position.z = result.cursorPosition[2]
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) draft.position = result.gridPosition
|
if (draft) draft.position = result.gridPosition
|
||||||
@@ -155,7 +200,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
// Preserve cursor rotation for the next draft
|
// Preserve cursor rotation for the next draft
|
||||||
const currentRotation: [number, number, number] = [0, cursorRef.current.rotation.y, 0]
|
const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
|
||||||
|
|
||||||
draftNode.commit(result.nodeUpdate)
|
draftNode.commit(result.nodeUpdate)
|
||||||
if (configRef.current.onCommitted()) {
|
if (configRef.current.onCommitted()) {
|
||||||
@@ -225,8 +270,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
gridPosition.current.z !== result.gridPosition[2]
|
gridPosition.current.z !== result.gridPosition[2]
|
||||||
|
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
cursorRef.current.position.set(...result.cursorPosition)
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
cursorRef.current.rotation.y = result.cursorRotationY
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft && result.nodeUpdate) {
|
if (draft && result.nodeUpdate) {
|
||||||
@@ -349,7 +394,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
cursorRef.current.position.set(...result.cursorPosition)
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
|
|
||||||
revalidate()
|
revalidate()
|
||||||
|
|
||||||
@@ -429,12 +474,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
if (rotationDelta !== 0) {
|
if (rotationDelta !== 0) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
const currentRotation = draft.rotation
|
const currentRotation = draft.rotation
|
||||||
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
|
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
|
||||||
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
||||||
|
|
||||||
// Ref + cursor mesh + item mesh — no store update during drag
|
// Ref + cursor mesh + item mesh — no store update during drag
|
||||||
cursorRef.current.rotation.y = newRotationY
|
cursorGroupRef.current.rotation.y = newRotationY
|
||||||
const mesh = sceneRegistry.nodes.get(draft.id)
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||||
if (mesh) mesh.rotation.y = newRotationY
|
if (mesh) mesh.rotation.y = newRotationY
|
||||||
revalidate()
|
revalidate()
|
||||||
@@ -456,7 +502,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const dims = asset.dimensions ?? DEFAULT_DIMENSIONS
|
const dims = asset.dimensions ?? DEFAULT_DIMENSIONS
|
||||||
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||||
boxGeometry.translate(0, dims[1] / 2, 0)
|
boxGeometry.translate(0, dims[1] / 2, 0)
|
||||||
cursorRef.current.geometry = boxGeometry
|
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
||||||
|
edgesRef.current.geometry = edgesGeometry
|
||||||
|
|
||||||
// ---- Subscribe ----
|
// ---- Subscribe ----
|
||||||
|
|
||||||
@@ -520,17 +567,26 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
draftNode.current.rotation,
|
draftNode.current.rotation,
|
||||||
)
|
)
|
||||||
mesh.position.y = slabElevation
|
mesh.position.y = slabElevation
|
||||||
cursorRef.current.position.y = slabElevation
|
cursorGroupRef.current.position.y = slabElevation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const dims = config.asset.dimensions ?? DEFAULT_DIMENSIONS
|
||||||
|
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||||
|
initialBoxGeometry.translate(0, dims[1] / 2, 0)
|
||||||
|
|
||||||
|
// Base plane geometry (colored rectangle on the ground)
|
||||||
|
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
|
||||||
|
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
|
||||||
|
basePlaneGeometry.translate(0, 0.01, 0) // Slightly above ground to avoid z-fighting
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group ref={cursorGroupRef}>
|
||||||
<mesh ref={cursorRef}>
|
<lineSegments ref={edgesRef} material={edgeMaterial}>
|
||||||
<boxGeometry args={[0.1, 0.1, 0.1]} />
|
<edgesGeometry args={[initialBoxGeometry]} />
|
||||||
<meshStandardMaterial color="red" wireframe />
|
</lineSegments>
|
||||||
</mesh>
|
<mesh ref={basePlaneRef} geometry={basePlaneGeometry} material={basePlaneMaterial} />
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer";
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { BufferGeometry, DoubleSide, type Line, Vector3 } from "three";
|
import { BufferGeometry, DoubleSide, type Line, Vector3 } from "three";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus';
|
||||||
|
|
||||||
// Default roof dimensions
|
// Default roof dimensions
|
||||||
const DEFAULT_HEIGHT = 1.5;
|
const DEFAULT_HEIGHT = 1.5;
|
||||||
@@ -42,6 +43,7 @@ const commitRoofPlacement = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
createNode(roof, levelId);
|
createNode(roof, levelId);
|
||||||
|
sfxEmitter.emit('sfx:structure-build');
|
||||||
return roof.id;
|
return roof.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ export const RoofTool: React.FC = () => {
|
|||||||
const setMode = useEditor((state) => state.setMode);
|
const setMode = useEditor((state) => state.setMode);
|
||||||
|
|
||||||
const corner1Ref = useRef<[number, number, number] | null>(null);
|
const corner1Ref = useRef<[number, number, number] | null>(null);
|
||||||
|
const previousGridPosRef = useRef<[number, number] | null>(null);
|
||||||
const [preview, setPreview] = useState<PreviewState>({
|
const [preview, setPreview] = useState<PreviewState>({
|
||||||
corner1: null,
|
corner1: null,
|
||||||
cursorPosition: [0, 0, 0],
|
cursorPosition: [0, 0, 0],
|
||||||
@@ -93,6 +96,14 @@ export const RoofTool: React.FC = () => {
|
|||||||
|
|
||||||
const cursorPosition: [number, number, number] = [gridX, y, gridZ];
|
const cursorPosition: [number, number, number] = [gridX, y, gridZ];
|
||||||
|
|
||||||
|
// Play snap sound when grid position changes (only when placing)
|
||||||
|
if (corner1Ref.current && previousGridPosRef.current &&
|
||||||
|
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap');
|
||||||
|
}
|
||||||
|
|
||||||
|
previousGridPosRef.current = [gridX, gridZ];
|
||||||
|
|
||||||
setPreview({
|
setPreview({
|
||||||
corner1: corner1Ref.current,
|
corner1: corner1Ref.current,
|
||||||
cursorPosition,
|
cursorPosition,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
|||||||
import { createPortal } from '@react-three/fiber'
|
import { createPortal } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
|
||||||
const Y_OFFSET = 0.02
|
const Y_OFFSET = 0.02
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||||
|
|
||||||
const lineRef = useRef<Mesh>(null!)
|
const lineRef = useRef<Mesh>(null!)
|
||||||
|
const previousPositionRef = useRef<[number, number] | null>(null)
|
||||||
|
|
||||||
// Track the last polygon prop to detect external changes (undo/redo)
|
// Track the last polygon prop to detect external changes (undo/redo)
|
||||||
const lastPolygonRef = useRef(polygon)
|
const lastPolygonRef = useRef(polygon)
|
||||||
@@ -129,7 +131,16 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2
|
const gridX = Math.round(event.position[0] * 2) / 2
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||||
setCursorPosition([gridX, gridZ])
|
const newPosition: [number, number] = [gridX, gridZ]
|
||||||
|
|
||||||
|
// Play snap sound when cursor moves to a new grid cell during drag
|
||||||
|
if (dragState?.isDragging && previousPositionRef.current &&
|
||||||
|
(newPosition[0] !== previousPositionRef.current[0] || newPosition[1] !== previousPositionRef.current[1])) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
|
||||||
|
previousPositionRef.current = newPosition
|
||||||
|
setCursorPosition(newPosition)
|
||||||
|
|
||||||
// Update vertex position during drag
|
// Update vertex position during drag
|
||||||
if (dragState?.isDragging) {
|
if (dragState?.isDragging) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { PolygonEditor } from '../shared/polygon-editor'
|
||||||
|
|
||||||
|
interface SlabHoleEditorProps {
|
||||||
|
slabId: SlabNode['id']
|
||||||
|
holeIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slab hole editor - allows editing a specific hole polygon within a slab
|
||||||
|
* Uses the generic PolygonEditor component
|
||||||
|
*/
|
||||||
|
export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeIndex }) => {
|
||||||
|
const slabNode = useScene((state) => state.nodes[slabId])
|
||||||
|
const updateNode = useScene((state) => state.updateNode)
|
||||||
|
const setSelection = useViewer((state) => state.setSelection)
|
||||||
|
|
||||||
|
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
|
||||||
|
const holes = slab?.holes || []
|
||||||
|
const hole = holes[holeIndex]
|
||||||
|
|
||||||
|
const handlePolygonChange = useCallback(
|
||||||
|
(newPolygon: Array<[number, number]>) => {
|
||||||
|
const updatedHoles = [...holes]
|
||||||
|
updatedHoles[holeIndex] = newPolygon
|
||||||
|
updateNode(slabId, { holes: updatedHoles })
|
||||||
|
// Re-assert selection so the slab stays selected after the edit
|
||||||
|
setSelection({ selectedIds: [slabId] })
|
||||||
|
},
|
||||||
|
[slabId, holeIndex, holes, updateNode, setSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!slab || !hole || hole.length < 3) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PolygonEditor
|
||||||
|
polygon={hole}
|
||||||
|
color="#ef4444" // red for holes
|
||||||
|
onPolygonChange={handlePolygonChange}
|
||||||
|
minVertices={3}
|
||||||
|
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||||
|
surfaceHeight={slab.elevation ?? 0.05}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pa
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
|
||||||
const Y_OFFSET = 0.02
|
const Y_OFFSET = 0.02
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
|
|||||||
})
|
})
|
||||||
|
|
||||||
createNode(slab, levelId)
|
createNode(slab, levelId)
|
||||||
|
sfxEmitter.emit('sfx:structure-build')
|
||||||
return slab.id
|
return slab.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ export const SlabTool: React.FC = () => {
|
|||||||
const [points, setPoints] = useState<Array<[number, number]>>([])
|
const [points, setPoints] = useState<Array<[number, number]>>([])
|
||||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||||
const [levelY, setLevelY] = useState(0)
|
const [levelY, setLevelY] = useState(0)
|
||||||
|
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||||
|
|
||||||
// Update cursor position and lines on grid move
|
// Update cursor position and lines on grid move
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,6 +92,13 @@ export const SlabTool: React.FC = () => {
|
|||||||
const lastPoint = points[points.length - 1]
|
const lastPoint = points[points.length - 1]
|
||||||
const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition
|
const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition
|
||||||
|
|
||||||
|
// Play snap sound when the snapped position actually changes (only when drawing)
|
||||||
|
if (points.length > 0 && previousSnappedPointRef.current &&
|
||||||
|
(displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1])) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
|
||||||
|
previousSnappedPointRef.current = displayPoint
|
||||||
cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])
|
cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MoveTool } from './item/move-tool'
|
|||||||
import { RoofTool } from './roof/roof-tool'
|
import { RoofTool } from './roof/roof-tool'
|
||||||
import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
||||||
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
import { SlabBoundaryEditor } from './slab/slab-boundary-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 { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
|
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
|
||||||
@@ -35,6 +36,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
|
const editingSlabHoleIndex = useEditor((state) => state.editingSlabHoleIndex)
|
||||||
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||||
const nodes = useScene((state) => state.nodes)
|
const nodes = useScene((state) => state.nodes)
|
||||||
@@ -52,9 +54,13 @@ export const ToolManager: React.FC = () => {
|
|||||||
// Show site boundary editor when in site phase and edit mode
|
// Show site boundary editor when in site phase and edit mode
|
||||||
const showSiteBoundaryEditor = phase === 'site' && mode === 'edit'
|
const showSiteBoundaryEditor = phase === 'site' && mode === 'edit'
|
||||||
|
|
||||||
// Show slab boundary editor when in structure/select mode with a slab selected
|
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
|
||||||
const showSlabBoundaryEditor =
|
const showSlabBoundaryEditor =
|
||||||
phase === 'structure' && mode === 'select' && selectedSlabId !== undefined
|
phase === 'structure' && mode === 'select' && selectedSlabId !== undefined && editingSlabHoleIndex === null
|
||||||
|
|
||||||
|
// Show slab hole editor when editing a specific hole
|
||||||
|
const showSlabHoleEditor =
|
||||||
|
selectedSlabId !== undefined && editingSlabHoleIndex !== null
|
||||||
|
|
||||||
// Show ceiling boundary editor when in structure/select mode with a ceiling selected
|
// Show ceiling boundary editor when in structure/select mode with a ceiling selected
|
||||||
const showCeilingBoundaryEditor =
|
const showCeilingBoundaryEditor =
|
||||||
@@ -79,6 +85,9 @@ export const ToolManager: React.FC = () => {
|
|||||||
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
|
||||||
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
{showZoneBoundaryEditor && selectedZoneId && <ZoneBoundaryEditor zoneId={selectedZoneId} />}
|
||||||
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
{showSlabBoundaryEditor && selectedSlabId && <SlabBoundaryEditor slabId={selectedSlabId} />}
|
||||||
|
{showSlabHoleEditor && selectedSlabId && editingSlabHoleIndex !== null && (
|
||||||
|
<SlabHoleEditor slabId={selectedSlabId} holeIndex={editingSlabHoleIndex} />
|
||||||
|
)}
|
||||||
{showCeilingBoundaryEditor && selectedCeilingId && (
|
{showCeilingBoundaryEditor && selectedCeilingId && (
|
||||||
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
|
import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef, useMemo } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { DoubleSide, type Mesh, Vector3, Shape, ShapeGeometry } from 'three'
|
import { DoubleSide, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
|
||||||
const WALL_HEIGHT = 2.5
|
const WALL_HEIGHT = 2.5
|
||||||
const WALL_THICKNESS = 0.15
|
const WALL_THICKNESS = 0.15
|
||||||
@@ -88,6 +89,7 @@ const commitWallDrawing = (start: [number, number], end: [number, number]) => {
|
|||||||
const wall = WallNode.parse({ start, end })
|
const wall = WallNode.parse({ start, end })
|
||||||
|
|
||||||
createNode(wall, currentLevelId)
|
createNode(wall, currentLevelId)
|
||||||
|
sfxEmitter.emit('sfx:structure-build')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WallTool: React.FC = () => {
|
export const WallTool: React.FC = () => {
|
||||||
@@ -96,9 +98,11 @@ export const WallTool: React.FC = () => {
|
|||||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const buildingState = useRef(0)
|
const buildingState = useRef(0)
|
||||||
|
const shiftPressed = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let gridPosition: [number, number] = [0, 0]
|
let gridPosition: [number, number] = [0, 0]
|
||||||
|
let previousWallEnd: [number, number] | null = null
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
if (!cursorRef.current || !wallPreviewRef.current) return
|
if (!cursorRef.current || !wallPreviewRef.current) return
|
||||||
@@ -108,10 +112,20 @@ export const WallTool: React.FC = () => {
|
|||||||
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
|
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
|
||||||
|
|
||||||
if (buildingState.current === 1) {
|
if (buildingState.current === 1) {
|
||||||
// Snap to 45° angles
|
// Snap to 45° angles only if shift is not pressed
|
||||||
const snapped = snapTo45Degrees(startingPoint.current, cursorPosition)
|
const snapped = shiftPressed.current
|
||||||
|
? cursorPosition
|
||||||
|
: snapTo45Degrees(startingPoint.current, cursorPosition)
|
||||||
endingPoint.current.copy(snapped)
|
endingPoint.current.copy(snapped)
|
||||||
|
|
||||||
|
// Play snap sound only when the actual wall end position changes
|
||||||
|
const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]
|
||||||
|
if (previousWallEnd &&
|
||||||
|
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
previousWallEnd = currentWallEnd
|
||||||
|
|
||||||
// Update wall preview geometry
|
// Update wall preview geometry
|
||||||
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
||||||
}
|
}
|
||||||
@@ -125,19 +139,35 @@ export const WallTool: React.FC = () => {
|
|||||||
} else if (buildingState.current === 1) {
|
} else if (buildingState.current === 1) {
|
||||||
commitWallDrawing(
|
commitWallDrawing(
|
||||||
[startingPoint.current.x, startingPoint.current.z],
|
[startingPoint.current.x, startingPoint.current.z],
|
||||||
[endingPoint.current.x, endingPoint.current.z]
|
[endingPoint.current.x, endingPoint.current.z],
|
||||||
)
|
)
|
||||||
wallPreviewRef.current.visible = false
|
wallPreviewRef.current.visible = false
|
||||||
buildingState.current = 0
|
buildingState.current = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
shiftPressed.current = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
shiftPressed.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
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)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -145,8 +175,8 @@ export const WallTool: React.FC = () => {
|
|||||||
<group>
|
<group>
|
||||||
{/* Cursor indicator */}
|
{/* Cursor indicator */}
|
||||||
<mesh ref={cursorRef}>
|
<mesh ref={cursorRef}>
|
||||||
<boxGeometry args={[0.2, 0.2, 0.2]} />
|
<sphereGeometry args={[0.1, 16, 16]} />
|
||||||
<meshStandardMaterial color="red" />
|
<meshBasicMaterial color="#a3a3a3" depthTest={false} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Wall preview */}
|
{/* Wall preview */}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { ItemHelper } from './item-helper'
|
||||||
|
import { WallHelper } from './wall-helper'
|
||||||
|
|
||||||
|
export function HelperManager() {
|
||||||
|
const tool = useEditor((s) => s.tool)
|
||||||
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
|
|
||||||
|
if (movingNode) {
|
||||||
|
return <ItemHelper />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show appropriate helper based on current tool
|
||||||
|
switch (tool) {
|
||||||
|
case 'wall':
|
||||||
|
return <WallHelper />
|
||||||
|
case 'item':
|
||||||
|
return <ItemHelper />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function ItemHelper() {
|
||||||
|
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">R</kbd>
|
||||||
|
<span className="text-muted-foreground">Rotate counterclockwise</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">T</kbd>
|
||||||
|
<span className="text-muted-foreground">Rotate clockwise</span>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function WallHelper() {
|
||||||
|
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="flex items-center gap-2 text-sm">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import Image from 'next/image'
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
|
||||||
export function ItemPanel() {
|
export function ItemPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
@@ -43,6 +44,7 @@ export function ItemPanel() {
|
|||||||
|
|
||||||
const handleMove = useCallback(() => {
|
const handleMove = useCallback(() => {
|
||||||
if (node) {
|
if (node) {
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
setMovingNode(node)
|
setMovingNode(node)
|
||||||
// Deselect so the panel closes
|
// Deselect so the panel closes
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
@@ -51,6 +53,7 @@ export function ItemPanel() {
|
|||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
|
sfxEmitter.emit('sfx:item-delete')
|
||||||
deleteNode(selectedId as AnyNode['id'])
|
deleteNode(selectedId as AnyNode['id'])
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [selectedId, deleteNode, setSelection])
|
}, [selectedId, deleteNode, setSelection])
|
||||||
@@ -142,6 +145,7 @@ export function ItemPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||||
const newDegrees = currentDegrees - 90
|
const newDegrees = currentDegrees - 90
|
||||||
const radians = (newDegrees * Math.PI) / 180
|
const radians = (newDegrees * Math.PI) / 180
|
||||||
@@ -154,6 +158,7 @@ export function ItemPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||||
const newDegrees = currentDegrees + 90
|
const newDegrees = currentDegrees + 90
|
||||||
const radians = (newDegrees * Math.PI) / 180
|
const radians = (newDegrees * Math.PI) / 180
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { X } from 'lucide-react'
|
import { Edit, Plus, Trash2, X } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useCallback } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
import { NumberInput } from '@/components/ui/primitives/number-input'
|
import { NumberInput } from '@/components/ui/primitives/number-input'
|
||||||
|
|
||||||
export function SlabPanel() {
|
export function SlabPanel() {
|
||||||
@@ -12,6 +13,8 @@ export function SlabPanel() {
|
|||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const editingHoleIndex = useEditor((s) => s.editingSlabHoleIndex)
|
||||||
|
const setEditingHoleIndex = useEditor((s) => s.setEditingSlabHoleIndex)
|
||||||
|
|
||||||
// Get the first selected node if it's a slab
|
// Get the first selected node if it's a slab
|
||||||
const selectedId = selectedIds[0]
|
const selectedId = selectedIds[0]
|
||||||
@@ -29,7 +32,69 @@ export function SlabPanel() {
|
|||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
setEditingHoleIndex(null)
|
||||||
|
}, [setSelection, setEditingHoleIndex])
|
||||||
|
|
||||||
|
// Clear hole editing state when slab is deselected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!node) {
|
||||||
|
setEditingHoleIndex(null)
|
||||||
|
}
|
||||||
|
}, [node, setEditingHoleIndex])
|
||||||
|
|
||||||
|
// Clear hole editing state on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setEditingHoleIndex(null)
|
||||||
|
}
|
||||||
|
}, [setEditingHoleIndex])
|
||||||
|
|
||||||
|
const handleAddHole = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
// Calculate centroid of the slab polygon
|
||||||
|
const polygon = node.polygon
|
||||||
|
let cx = 0
|
||||||
|
let cz = 0
|
||||||
|
for (const [x, z] of polygon) {
|
||||||
|
cx += x
|
||||||
|
cz += z
|
||||||
|
}
|
||||||
|
cx /= polygon.length
|
||||||
|
cz /= polygon.length
|
||||||
|
|
||||||
|
// Create a default small rectangular hole centered at the slab's centroid
|
||||||
|
const holeSize = 0.5
|
||||||
|
const newHole: Array<[number, number]> = [
|
||||||
|
[cx - holeSize, cz - holeSize],
|
||||||
|
[cx + holeSize, cz - holeSize],
|
||||||
|
[cx + holeSize, cz + holeSize],
|
||||||
|
[cx - holeSize, cz + holeSize],
|
||||||
|
]
|
||||||
|
const currentHoles = node?.holes || []
|
||||||
|
handleUpdate({ holes: [...currentHoles, newHole] })
|
||||||
|
// Enter edit mode for the new hole
|
||||||
|
setEditingHoleIndex(currentHoles.length)
|
||||||
|
}, [node, handleUpdate, setEditingHoleIndex])
|
||||||
|
|
||||||
|
const handleEditHole = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setEditingHoleIndex(index)
|
||||||
|
},
|
||||||
|
[setEditingHoleIndex],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeleteHole = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const currentHoles = node?.holes || []
|
||||||
|
const newHoles = currentHoles.filter((_, i) => i !== index)
|
||||||
|
handleUpdate({ holes: newHoles })
|
||||||
|
if (editingHoleIndex === index) {
|
||||||
|
setEditingHoleIndex(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[node?.holes, handleUpdate, editingHoleIndex, setEditingHoleIndex],
|
||||||
|
)
|
||||||
|
|
||||||
// Only show if exactly one slab is selected
|
// Only show if exactly one slab is selected
|
||||||
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
|
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
|
||||||
@@ -139,6 +204,86 @@ export function SlabPanel() {
|
|||||||
{area.toFixed(2)} m²
|
{area.toFixed(2)} m²
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Holes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Holes
|
||||||
|
</label>
|
||||||
|
{editingHoleIndex !== null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded border border-green-500 bg-green-500/10 px-2 py-1 text-xs text-green-600 hover:bg-green-500/20 cursor-pointer"
|
||||||
|
onClick={() => setEditingHoleIndex(null)}
|
||||||
|
>
|
||||||
|
<span>Done Editing</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={handleAddHole}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
<span>Add Hole</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{node.holes && node.holes.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{node.holes.map((hole, index) => {
|
||||||
|
const holeArea = calculateArea(hole)
|
||||||
|
const isEditing = editingHoleIndex === index
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex items-center justify-between rounded border px-3 py-2 ${
|
||||||
|
isEditing
|
||||||
|
? 'border-green-500 bg-green-500/10'
|
||||||
|
: 'border-border bg-muted/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`text-sm font-medium ${isEditing ? 'text-green-600' : ''}`}>
|
||||||
|
Hole {index + 1} {isEditing && '(Editing)'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{holeArea.toFixed(2)} m² · {hole.length} vertices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!isEditing && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||||
|
onClick={() => handleEditHole(index)}
|
||||||
|
aria-label="Edit hole"
|
||||||
|
>
|
||||||
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||||
|
onClick={() => handleDeleteHole(index)}
|
||||||
|
aria-label="Delete hole"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground italic">
|
||||||
|
No holes. Click "Add Hole" to create one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Volume2, VolumeX } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/primitives/button'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/primitives/dialog'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import useAudio from '@/store/use-audio'
|
||||||
|
|
||||||
|
export function AudioSettingsDialog() {
|
||||||
|
const { masterVolume, sfxVolume, radioVolume, muted, setMasterVolume, setSfxVolume, setRadioVolume, toggleMute } = useAudio()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="w-full justify-start gap-2"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
||||||
|
Audio Settings
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Audio Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Adjust volume levels and mute settings
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Master Volume */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium">Master Volume</label>
|
||||||
|
<span className="text-sm text-muted-foreground">{masterVolume}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[masterVolume]}
|
||||||
|
onValueChange={(value) => value[0] !== undefined && setMasterVolume(value[0])}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
disabled={muted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Radio Volume */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium">Radio Volume</label>
|
||||||
|
<span className="text-sm text-muted-foreground">{radioVolume}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[radioVolume]}
|
||||||
|
onValueChange={(value) => value[0] !== undefined && setRadioVolume(value[0])}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
disabled={muted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SFX Volume */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium">Sound Effects</label>
|
||||||
|
<span className="text-sm text-muted-foreground">{sfxVolume}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[sfxVolume]}
|
||||||
|
onValueChange={(value) => value[0] !== undefined && setSfxVolume(value[0])}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
disabled={muted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mute Toggle */}
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<Button
|
||||||
|
onClick={toggleMute}
|
||||||
|
variant={muted ? 'default' : 'outline'}
|
||||||
|
className="w-full justify-start gap-2"
|
||||||
|
>
|
||||||
|
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
||||||
|
{muted ? 'Unmute All Sounds' : 'Mute All Sounds'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Download, Save, Trash2, Upload } from "lucide-react";
|
|||||||
import { useRef } from "react";
|
import { useRef } 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";
|
||||||
|
|
||||||
export function SettingsPanel() {
|
export function SettingsPanel() {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -113,6 +114,14 @@ export function SettingsPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Audio Section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase">
|
||||||
|
Audio
|
||||||
|
</label>
|
||||||
|
<AudioSettingsDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium text-destructive text-xs uppercase">
|
<label className="font-medium text-destructive text-xs uppercase">
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full touch-none select-none items-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
))
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
@@ -28,7 +28,7 @@ export function CloudSaveButton() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto fixed top-4 right-4 z-50">
|
<div className="pointer-events-auto">
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-md">
|
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-md">
|
||||||
<div className="h-4 w-4 animate-pulse rounded-full bg-muted" />
|
<div className="h-4 w-4 animate-pulse rounded-full bg-muted" />
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +39,7 @@ export function CloudSaveButton() {
|
|||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="pointer-events-auto fixed top-4 right-4 z-50">
|
<div className="pointer-events-auto">
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground"
|
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
onClick={() => setIsSignInDialogOpen(true)}
|
onClick={() => setIsSignInDialogOpen(true)}
|
||||||
@@ -54,7 +54,7 @@ export function CloudSaveButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto fixed top-4 right-4 z-50">
|
<div className="pointer-events-auto">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PropertyDropdown />
|
<PropertyDropdown />
|
||||||
<ProfileDropdown />
|
<ProfileDropdown />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type AnyNodeId, 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 useEditor from '@/store/use-editor'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
|
||||||
export const useKeyboard = () => {
|
export const useKeyboard = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,6 +48,18 @@ export const useKeyboard = () => {
|
|||||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
|
|
||||||
if (selectedNodeIds.length > 0) {
|
if (selectedNodeIds.length > 0) {
|
||||||
|
// Play appropriate SFX based on what's being deleted
|
||||||
|
if (selectedNodeIds.length === 1) {
|
||||||
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||||
|
if (node?.type === 'item') {
|
||||||
|
sfxEmitter.emit('sfx:item-delete')
|
||||||
|
} else {
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
}
|
||||||
|
|
||||||
useScene.getState().deleteNodes(selectedNodeIds)
|
useScene.getState().deleteNodes(selectedNodeIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import mitt from 'mitt'
|
||||||
|
import { playSFX } from './sfx-player'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SFX-specific events that tools can trigger
|
||||||
|
*/
|
||||||
|
type SFXEvents = {
|
||||||
|
'sfx:grid-snap': undefined
|
||||||
|
'sfx:item-delete': undefined
|
||||||
|
'sfx:item-pick': undefined
|
||||||
|
'sfx:item-place': undefined
|
||||||
|
'sfx:item-rotate': undefined
|
||||||
|
'sfx:structure-build': undefined
|
||||||
|
'sfx:structure-delete': undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated event emitter for SFX
|
||||||
|
* Tools should use this to trigger sound effects
|
||||||
|
*/
|
||||||
|
export const sfxEmitter = mitt<SFXEvents>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize SFX Bus - connects SFX events to actual sound playback
|
||||||
|
* Call once in your app initialization
|
||||||
|
*/
|
||||||
|
export function initSFXBus() {
|
||||||
|
// Map SFX events to sound playback
|
||||||
|
sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap'))
|
||||||
|
sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
|
||||||
|
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
|
||||||
|
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
|
||||||
|
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
|
||||||
|
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
|
||||||
|
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to trigger SFX events from tools
|
||||||
|
* @example
|
||||||
|
* triggerSFX('sfx:item-place')
|
||||||
|
*/
|
||||||
|
export function triggerSFX(event: keyof SFXEvents) {
|
||||||
|
sfxEmitter.emit(event)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Howl } from 'howler'
|
||||||
|
import useAudio from '@/store/use-audio'
|
||||||
|
|
||||||
|
// SFX sound definitions
|
||||||
|
export const SFX = {
|
||||||
|
gridSnap: '/audios/sfx/grid_snap.mp3',
|
||||||
|
itemDelete: '/audios/sfx/item_delete.mp3',
|
||||||
|
itemPick: '/audios/sfx/item_pick.mp3',
|
||||||
|
itemPlace: '/audios/sfx/item_place.mp3',
|
||||||
|
itemRotate: '/audios/sfx/item_rotate.mp3',
|
||||||
|
structureBuild: '/audios/sfx/structure_build.mp3',
|
||||||
|
structureDelete: '/audios/sfx/structure_delete.mp3',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SFXName = keyof typeof SFX
|
||||||
|
|
||||||
|
// Preload all SFX sounds
|
||||||
|
const sfxCache = new Map<SFXName, Howl>()
|
||||||
|
|
||||||
|
// Initialize all sounds
|
||||||
|
Object.entries(SFX).forEach(([name, path]) => {
|
||||||
|
const sound = new Howl({
|
||||||
|
src: [path],
|
||||||
|
preload: true,
|
||||||
|
volume: 0.5, // Will be adjusted by the bus
|
||||||
|
})
|
||||||
|
sfxCache.set(name as SFXName, sound)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a sound effect with volume based on audio settings
|
||||||
|
*/
|
||||||
|
export function playSFX(name: SFXName) {
|
||||||
|
const sound = sfxCache.get(name)
|
||||||
|
if (!sound) {
|
||||||
|
console.warn(`SFX not found: ${name}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
||||||
|
|
||||||
|
if (muted) return
|
||||||
|
|
||||||
|
// Calculate final volume (masterVolume and sfxVolume are 0-100)
|
||||||
|
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||||
|
sound.volume(finalVolume)
|
||||||
|
sound.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all cached SFX volumes (useful when settings change)
|
||||||
|
*/
|
||||||
|
export function updateSFXVolumes() {
|
||||||
|
const { masterVolume, sfxVolume } = useAudio.getState()
|
||||||
|
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||||
|
|
||||||
|
sfxCache.forEach((sound) => {
|
||||||
|
sound.volume(finalVolume)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { playSFX, updateSFXVolumes, SFX, type SFXName } from '../sfx-player'
|
||||||
|
export { initSFXBus, sfxEmitter, triggerSFX } from '../sfx-bus'
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
"better-auth": "^1.4.18",
|
"better-auth": "^1.4.18",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"howler": "^2.2.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.26.2",
|
"motion": "^12.26.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "*",
|
"@repo/typescript-config": "*",
|
||||||
|
"@types/howler": "^2.2.12",
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
"@types/react": "19.2.2",
|
"@types/react": "19.2.2",
|
||||||
"@types/react-dom": "19.2.2",
|
"@types/react-dom": "19.2.2",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,39 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
interface AudioState {
|
||||||
|
masterVolume: number
|
||||||
|
sfxVolume: number
|
||||||
|
radioVolume: number
|
||||||
|
muted: boolean
|
||||||
|
autoplay: boolean
|
||||||
|
setMasterVolume: (v: number) => void
|
||||||
|
setSfxVolume: (v: number) => void
|
||||||
|
setRadioVolume: (v: number) => void
|
||||||
|
toggleMute: () => void
|
||||||
|
setAutoplay: (v: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const useAudio = create<AudioState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
masterVolume: 70,
|
||||||
|
sfxVolume: 50,
|
||||||
|
radioVolume: 25,
|
||||||
|
muted: false,
|
||||||
|
autoplay: true,
|
||||||
|
setMasterVolume: (v) => set({ masterVolume: v }),
|
||||||
|
setSfxVolume: (v) => set({ sfxVolume: v }),
|
||||||
|
setRadioVolume: (v) => set({ radioVolume: v }),
|
||||||
|
toggleMute: () => set((state) => ({ muted: !state.muted })),
|
||||||
|
setAutoplay: (v) => set({ autoplay: v }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'pascal-audio-settings',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
export default useAudio
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type BuildingNode, type ItemNode, type LevelNode, type Space, useScene } from '@pascal-app/core'
|
import type { AssetInput } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
type BuildingNode,
|
||||||
|
type ItemNode,
|
||||||
|
type LevelNode,
|
||||||
|
type Space,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { AssetInput } from '@pascal-app/core'
|
|
||||||
|
|
||||||
export type Phase = 'site' | 'structure' | 'furnish'
|
export type Phase = 'site' | 'structure' | 'furnish'
|
||||||
|
|
||||||
@@ -63,6 +69,9 @@ type EditorState = {
|
|||||||
// Space detection for cutaway mode
|
// Space detection for cutaway mode
|
||||||
spaces: Record<string, Space>
|
spaces: Record<string, Space>
|
||||||
setSpaces: (spaces: Record<string, Space>) => void
|
setSpaces: (spaces: Record<string, Space>) => void
|
||||||
|
// Slab hole editing
|
||||||
|
editingSlabHoleIndex: number | null
|
||||||
|
setEditingSlabHoleIndex: (index: number | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useEditor = create<EditorState>()((set, get) => ({
|
const useEditor = create<EditorState>()((set, get) => ({
|
||||||
@@ -136,6 +145,14 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
|
|
||||||
const { phase, structureLayer, tool } = get()
|
const { phase, structureLayer, tool } = get()
|
||||||
|
|
||||||
|
if (mode === 'build') {
|
||||||
|
// Clear selection when entering build mode
|
||||||
|
const viewer = useViewer.getState()
|
||||||
|
viewer.setSelection({
|
||||||
|
selectedIds: [],
|
||||||
|
zoneId: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
// When entering build mode in structure phase with zones layer, activate zone tool
|
// When entering build mode in structure phase with zones layer, activate zone tool
|
||||||
if (mode === 'build' && phase === 'structure' && structureLayer === 'zones') {
|
if (mode === 'build' && phase === 'structure' && structureLayer === 'zones') {
|
||||||
set({ tool: 'zone' })
|
set({ tool: 'zone' })
|
||||||
@@ -181,6 +198,8 @@ const useEditor = create<EditorState>()((set, get) => ({
|
|||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
spaces: {},
|
spaces: {},
|
||||||
setSpaces: (spaces) => set({ spaces }),
|
setSpaces: (spaces) => set({ spaces }),
|
||||||
|
editingSlabHoleIndex: null,
|
||||||
|
setEditingSlabHoleIndex: (index) => set({ editingSlabHoleIndex: index }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export default useEditor
|
export default useEditor
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"better-auth": "^1.4.18",
|
"better-auth": "^1.4.18",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"howler": "^2.2.4",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.26.2",
|
"motion": "^12.26.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "*",
|
"@repo/typescript-config": "*",
|
||||||
|
"@types/howler": "^2.2.12",
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
"@types/react": "19.2.2",
|
"@types/react": "19.2.2",
|
||||||
"@types/react-dom": "19.2.2",
|
"@types/react-dom": "19.2.2",
|
||||||
@@ -83,7 +85,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@pascal-app/core",
|
"name": "@pascal-app/core",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dedent": "^1.7.1",
|
"dedent": "^1.7.1",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
@@ -164,7 +166,7 @@
|
|||||||
},
|
},
|
||||||
"packages/viewer": {
|
"packages/viewer": {
|
||||||
"name": "@pascal-app/viewer",
|
"name": "@pascal-app/viewer",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zustand": "^5",
|
"zustand": "^5",
|
||||||
},
|
},
|
||||||
@@ -588,6 +590,8 @@
|
|||||||
|
|
||||||
"@types/google.maps": ["@types/google.maps@3.58.1", "", {}, "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ=="],
|
"@types/google.maps": ["@types/google.maps@3.58.1", "", {}, "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ=="],
|
||||||
|
|
||||||
|
"@types/howler": ["@types/howler@2.2.12", "", {}, "sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
|
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
|
||||||
@@ -920,6 +924,8 @@
|
|||||||
|
|
||||||
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
|
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
|
||||||
|
|
||||||
|
"howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
|
||||||
|
|
||||||
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||||
|
|
||||||
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pascal-app/core",
|
"name": "@pascal-app/core",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"description": "Core library for Pascal 3D building editor",
|
"description": "Core library for Pascal 3D building editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ export class SpatialGridManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the total slab elevation at a given (x, z) position on a level.
|
* Get the total slab elevation at a given (x, z) position on a level.
|
||||||
* Returns the highest slab elevation if the point is inside any slab polygon, otherwise 0.
|
* Returns the highest slab elevation if the point is inside any slab polygon (but not in any holes), otherwise 0.
|
||||||
*/
|
*/
|
||||||
getSlabElevationAt(levelId: string, x: number, z: number): number {
|
getSlabElevationAt(levelId: string, x: number, z: number): number {
|
||||||
const slabMap = this.slabsByLevel.get(levelId)
|
const slabMap = this.slabsByLevel.get(levelId)
|
||||||
@@ -466,18 +466,29 @@ export class SpatialGridManager {
|
|||||||
let maxElevation = 0
|
let maxElevation = 0
|
||||||
for (const slab of slabMap.values()) {
|
for (const slab of slabMap.values()) {
|
||||||
if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) {
|
if (slab.polygon.length >= 3 && pointInPolygon(x, z, slab.polygon)) {
|
||||||
|
// Check if point is in any hole
|
||||||
|
let inHole = false
|
||||||
|
for (const hole of slab.holes) {
|
||||||
|
if (hole.length >= 3 && pointInPolygon(x, z, hole)) {
|
||||||
|
inHole = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inHole) {
|
||||||
const elevation = slab.elevation ?? 0.05
|
const elevation = slab.elevation ?? 0.05
|
||||||
if (elevation > maxElevation) {
|
if (elevation > maxElevation) {
|
||||||
maxElevation = elevation
|
maxElevation = elevation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return maxElevation
|
return maxElevation
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the slab elevation for an item using its full footprint (bounding box).
|
* Get the slab elevation for an item using its full footprint (bounding box).
|
||||||
* Checks if any part of the item's rotated footprint overlaps with any slab polygon.
|
* Checks if any part of the item's rotated footprint overlaps with any slab polygon (excluding holes).
|
||||||
* Returns the highest overlapping slab elevation, or 0 if none.
|
* Returns the highest overlapping slab elevation, or 0 if none.
|
||||||
*/
|
*/
|
||||||
getSlabElevationForItem(
|
getSlabElevationForItem(
|
||||||
@@ -492,17 +503,30 @@ export class SpatialGridManager {
|
|||||||
let maxElevation = -Infinity
|
let maxElevation = -Infinity
|
||||||
for (const slab of slabMap.values()) {
|
for (const slab of slabMap.values()) {
|
||||||
if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
||||||
|
// Check if item is entirely within a hole (if so, ignore this slab)
|
||||||
|
// We consider it entirely in a hole if the item center is in the hole
|
||||||
|
let inHole = false
|
||||||
|
const [cx, , cz] = position
|
||||||
|
for (const hole of slab.holes) {
|
||||||
|
if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) {
|
||||||
|
inHole = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inHole) {
|
||||||
const elevation = slab.elevation ?? 0.05
|
const elevation = slab.elevation ?? 0.05
|
||||||
if (elevation > maxElevation) {
|
if (elevation > maxElevation) {
|
||||||
maxElevation = elevation
|
maxElevation = elevation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return maxElevation === -Infinity ? 0 : maxElevation
|
return maxElevation === -Infinity ? 0 : maxElevation
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the slab elevation for a wall by checking if it overlaps with any slab polygon.
|
* Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes).
|
||||||
* Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
|
* Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
|
||||||
* Returns the highest slab elevation found, or 0 if none.
|
* Returns the highest slab elevation found, or 0 if none.
|
||||||
*/
|
*/
|
||||||
@@ -518,12 +542,25 @@ export class SpatialGridManager {
|
|||||||
for (const slab of slabMap.values()) {
|
for (const slab of slabMap.values()) {
|
||||||
if (slab.polygon.length < 3) continue
|
if (slab.polygon.length < 3) continue
|
||||||
if (wallOverlapsPolygon(start, end, slab.polygon)) {
|
if (wallOverlapsPolygon(start, end, slab.polygon)) {
|
||||||
|
// Check if wall midpoint is in a hole (if so, ignore this slab)
|
||||||
|
let inHole = false
|
||||||
|
const midX = (start[0] + end[0]) / 2
|
||||||
|
const midZ = (start[1] + end[1]) / 2
|
||||||
|
for (const hole of slab.holes) {
|
||||||
|
if (hole.length >= 3 && pointInPolygon(midX, midZ, hole)) {
|
||||||
|
inHole = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inHole) {
|
||||||
const elevation = slab.elevation ?? 0.05
|
const elevation = slab.elevation ?? 0.05
|
||||||
if (elevation > maxElevation) {
|
if (elevation > maxElevation) {
|
||||||
maxElevation = elevation
|
maxElevation = elevation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return maxElevation === -Infinity ? 0 : maxElevation
|
return maxElevation === -Infinity ? 0 : maxElevation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const SlabNode = BaseNode.extend({
|
|||||||
// Specific props
|
// Specific props
|
||||||
// Polygon boundary - array of [x, z] coordinates defining the slab
|
// Polygon boundary - array of [x, z] coordinates defining the slab
|
||||||
polygon: z.array(z.tuple([z.number(), z.number()])),
|
polygon: z.array(z.tuple([z.number(), z.number()])),
|
||||||
|
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
|
||||||
elevation: z.number().default(0.05), // Elevation in meters
|
elevation: z.number().default(0.05), // Elevation in meters
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
|
|||||||
@@ -123,6 +123,23 @@ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry {
|
|||||||
}
|
}
|
||||||
shape.closePath()
|
shape.closePath()
|
||||||
|
|
||||||
|
// Add holes to the shape
|
||||||
|
for (const holePolygon of slabNode.holes) {
|
||||||
|
if (holePolygon.length < 3) continue
|
||||||
|
|
||||||
|
const holePath = new THREE.Path()
|
||||||
|
const holeFirstPt = holePolygon[0]!
|
||||||
|
holePath.moveTo(holeFirstPt[0], -holeFirstPt[1])
|
||||||
|
|
||||||
|
for (let i = 1; i < holePolygon.length; i++) {
|
||||||
|
const pt = holePolygon[i]!
|
||||||
|
holePath.lineTo(pt[0], -pt[1])
|
||||||
|
}
|
||||||
|
holePath.closePath()
|
||||||
|
|
||||||
|
shape.holes.push(holePath)
|
||||||
|
}
|
||||||
|
|
||||||
// Extrude the shape by elevation
|
// Extrude the shape by elevation
|
||||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||||
depth: elevation,
|
depth: elevation,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pascal-app/viewer",
|
"name": "@pascal-app/viewer",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"description": "3D viewer component for Pascal building editor",
|
"description": "3D viewer component for Pascal building editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
Reference in New Issue
Block a user