diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 07bc825c..e27abda6 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -9,19 +9,25 @@ import { ZoneSystem } from '../systems/zone/zone-system' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' import { CloudSaveButton } from '@/features/community/components/cloud-save-button' +import { PascalRadio } from '../pascal-radio' import { PanelManager } from '../ui/panels/panel-manager' +import { HelperManager } from '../ui/helpers/helper-manager' import { SidebarProvider } from '../ui/primitives/sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar' import { CustomCameraControls } from './custom-camera-controls' import { ExportManager } from './export-manager' import { Grid } from './grid' import { SelectionManager } from './selection-manager' +import { initSFXBus } from '@/lib/sfx-bus' // Load default scene initially (will be replaced when property loads) useScene.getState().loadScene() initSpatialGridSync() initSpaceDetectionSync(useScene, useEditor) +// Initialize SFX bus to connect events to sound effects +initSFXBus() + export default function Editor() { useKeyboard() @@ -29,7 +35,17 @@ export default function Editor() {
- + + + {/* Top-right controls */} +
+
+ +
+
+ +
+
diff --git a/apps/editor/components/pascal-radio.tsx b/apps/editor/components/pascal-radio.tsx new file mode 100644 index 00000000..25bfaeac --- /dev/null +++ b/apps/editor/components/pascal-radio.tsx @@ -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(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(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 ( +
+ + Pascal Radio +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handlePlayPause() + } + }} + > + {isPlaying ? : } +
+ + + + + +
+ {/* Current song info with prev/next */} +
+

Now Playing

+
+ +

{currentTrack.title}

+ +
+
+ + {/* Volume control */} +
+ + + {radioVolume}% +
+ + {/* Autoplay setting */} +
+ + +
+
+
+
+
+ ) +} diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx index 24f909dc..0621208d 100644 --- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx +++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx @@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three' import useEditor from '@/store/use-editor' +import { sfxEmitter } from '@/lib/sfx-bus' const CEILING_HEIGHT = 2.52 const GRID_OFFSET = 0.02 @@ -59,6 +60,7 @@ const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, n }) createNode(ceiling, levelId) + sfxEmitter.emit('sfx:structure-build') } export const CeilingTool: React.FC = () => { @@ -72,6 +74,7 @@ export const CeilingTool: React.FC = () => { const [points, setPoints] = useState>([]) const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) const [levelY, setLevelY] = useState(0) + const previousSnappedPointRef = useRef<[number, number] | null>(null) // Update cursor position and lines on grid move useEffect(() => { @@ -94,6 +97,13 @@ export const CeilingTool: React.FC = () => { const lastPoint = points[points.length - 1] 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]) gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1]) } diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index f2884345..777730fc 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -1,3 +1,4 @@ +import { sfxEmitter } from '@/lib/sfx-bus' import useEditor from '@/store/use-editor' import { useDraftNode } from './use-draft-node' import { usePlacementCoordinator } from './use-placement-coordinator' @@ -14,7 +15,10 @@ export const ItemTool: React.FC = () => { draftNode.create(gridPosition, selectedItem!) } }, - onCommitted: () => true, + onCommitted: () => { + sfxEmitter.emit('sfx:item-place') + return true + }, }) if (!selectedItem) return null diff --git a/apps/editor/components/tools/item/move-tool.tsx b/apps/editor/components/tools/item/move-tool.tsx index 77634d2e..65d3b85f 100644 --- a/apps/editor/components/tools/item/move-tool.tsx +++ b/apps/editor/components/tools/item/move-tool.tsx @@ -1,10 +1,14 @@ -import useEditor from '@/store/use-editor' 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 { 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 if (attachTo === 'wall' || attachTo === 'wall-side') { return { surface: 'wall', wallId: node.parentId, ceilingId: null } @@ -33,6 +37,7 @@ export const MoveTool: React.FC = () => { gridPosition.copy(new Vector3(...movingNode.position)) }, onCommitted: () => { + sfxEmitter.emit('sfx:item-place') exitMoveMode() return false }, diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index 892ef50d..056e465b 100644 --- a/apps/editor/components/tools/item/use-placement-coordinator.tsx +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -17,18 +17,46 @@ import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { BoxGeometry, + EdgesGeometry, Euler, + type Group, + type LineSegments, type Mesh, - type MeshStandardMaterial, + PlaneGeometry, Quaternion, Vector3, } 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 type { PlacementState, TransitionResult } from './placement-types' import type { DraftNodeHandle } from './use-draft-node' 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 { asset: AssetInput draftNode: DraftNodeHandle @@ -39,7 +67,9 @@ export interface PlacementCoordinatorConfig { } export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode { - const cursorRef = useRef(null!) + const cursorGroupRef = useRef(null!) + const edgesRef = useRef(null!) + const basePlaneRef = useRef(null!) const gridPosition = useRef(new Vector3(0, 0, 0)) const placementState = useRef( config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null }, @@ -76,15 +106,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const revalidate = (): boolean => { 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 } const applyTransition = (result: TransitionResult) => { Object.assign(placementState.current, result.stateUpdate) 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 if (draft) { @@ -96,8 +129,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const ensureDraft = (result: TransitionResult) => { 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 draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0]) @@ -120,14 +153,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (draftNode.current) { const mesh = sceneRegistry.nodes.get(draftNode.current.id) if (mesh) { - mesh.getWorldPosition(cursorRef.current.position) + mesh.getWorldPosition(cursorGroupRef.current.position) // Extract world Y rotation (handles wall-parented items correctly) const q = new Quaternion() mesh.getWorldQuaternion(q) - cursorRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y + cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y } else { - cursorRef.current.position.copy(gridPosition.current) - cursorRef.current.rotation.y = draftNode.current.rotation[1] ?? 0 + cursorGroupRef.current.position.copy(gridPosition.current) + cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0 } } @@ -135,14 +168,26 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Floor Handlers ---- + let previousGridPos: [number, number, number] | null = null + const onGridMove = (event: GridEvent) => { const result = floorStrategy.move(getContext(), event) 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) // Only update X and Z for cursor - useFrame will handle Y (slab elevation) - cursorRef.current.position.x = result.cursorPosition[0] - cursorRef.current.position.z = result.cursorPosition[2] + cursorGroupRef.current.position.x = result.cursorPosition[0] + cursorGroupRef.current.position.z = result.cursorPosition[2] const draft = draftNode.current if (draft) draft.position = result.gridPosition @@ -155,7 +200,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return // 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) if (configRef.current.onCommitted()) { @@ -225,8 +270,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.z !== result.gridPosition[2] 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 if (draft && result.nodeUpdate) { @@ -349,7 +394,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea event.stopPropagation() gridPosition.current.set(...result.gridPosition) - cursorRef.current.position.set(...result.cursorPosition) + cursorGroupRef.current.position.set(...result.cursorPosition) revalidate() @@ -429,12 +474,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (rotationDelta !== 0) { event.preventDefault() + sfxEmitter.emit('sfx:item-rotate') const currentRotation = draft.rotation const newRotationY = (currentRotation[1] ?? 0) + rotationDelta draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]] // 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) if (mesh) mesh.rotation.y = newRotationY revalidate() @@ -456,7 +502,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const dims = asset.dimensions ?? DEFAULT_DIMENSIONS const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) boxGeometry.translate(0, dims[1] / 2, 0) - cursorRef.current.geometry = boxGeometry + const edgesGeometry = new EdgesGeometry(boxGeometry) + edgesRef.current.geometry = edgesGeometry // ---- Subscribe ---- @@ -520,17 +567,26 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.current.rotation, ) 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 ( - - - - - + + + + + ) } diff --git a/apps/editor/components/tools/roof/roof-tool.tsx b/apps/editor/components/tools/roof/roof-tool.tsx index 3c7cfc1c..5c5bb52e 100644 --- a/apps/editor/components/tools/roof/roof-tool.tsx +++ b/apps/editor/components/tools/roof/roof-tool.tsx @@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer"; import { useEffect, useMemo, useRef, useState } from "react"; import { BufferGeometry, DoubleSide, type Line, Vector3 } from "three"; import useEditor from "@/store/use-editor"; +import { sfxEmitter } from '@/lib/sfx-bus'; // Default roof dimensions const DEFAULT_HEIGHT = 1.5; @@ -42,6 +43,7 @@ const commitRoofPlacement = ( }); createNode(roof, levelId); + sfxEmitter.emit('sfx:structure-build'); return roof.id; }; @@ -59,6 +61,7 @@ export const RoofTool: React.FC = () => { const setMode = useEditor((state) => state.setMode); const corner1Ref = useRef<[number, number, number] | null>(null); + const previousGridPosRef = useRef<[number, number] | null>(null); const [preview, setPreview] = useState({ corner1: null, cursorPosition: [0, 0, 0], @@ -93,6 +96,14 @@ export const RoofTool: React.FC = () => { 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({ corner1: corner1Ref.current, cursorPosition, diff --git a/apps/editor/components/tools/shared/polygon-editor.tsx b/apps/editor/components/tools/shared/polygon-editor.tsx index 6e57a3e1..dc038d10 100644 --- a/apps/editor/components/tools/shared/polygon-editor.tsx +++ b/apps/editor/components/tools/shared/polygon-editor.tsx @@ -2,6 +2,7 @@ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' import { createPortal } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three' +import { sfxEmitter } from '@/lib/sfx-bus' const Y_OFFSET = 0.02 @@ -52,6 +53,7 @@ export const PolygonEditor: React.FC = ({ const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) const lineRef = useRef(null!) + const previousPositionRef = useRef<[number, number] | null>(null) // Track the last polygon prop to detect external changes (undo/redo) const lastPolygonRef = useRef(polygon) @@ -129,7 +131,16 @@ export const PolygonEditor: React.FC = ({ const onGridMove = (event: GridEvent) => { const gridX = Math.round(event.position[0] * 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 if (dragState?.isDragging) { diff --git a/apps/editor/components/tools/slab/slab-hole-editor.tsx b/apps/editor/components/tools/slab/slab-hole-editor.tsx new file mode 100644 index 00000000..73bbb51a --- /dev/null +++ b/apps/editor/components/tools/slab/slab-hole-editor.tsx @@ -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 = ({ 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 ( + + ) +} diff --git a/apps/editor/components/tools/slab/slab-tool.tsx b/apps/editor/components/tools/slab/slab-tool.tsx index 7f887c36..3022cb2d 100644 --- a/apps/editor/components/tools/slab/slab-tool.tsx +++ b/apps/editor/components/tools/slab/slab-tool.tsx @@ -2,6 +2,7 @@ import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pa import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three' +import { sfxEmitter } from '@/lib/sfx-bus' const Y_OFFSET = 0.02 @@ -57,6 +58,7 @@ const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, numb }) createNode(slab, levelId) + sfxEmitter.emit('sfx:structure-build') return slab.id } @@ -70,6 +72,7 @@ export const SlabTool: React.FC = () => { const [points, setPoints] = useState>([]) const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0]) const [levelY, setLevelY] = useState(0) + const previousSnappedPointRef = useRef<[number, number] | null>(null) // Update cursor position and lines on grid move useEffect(() => { @@ -89,6 +92,13 @@ export const SlabTool: React.FC = () => { const lastPoint = points[points.length - 1] 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]) } diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index a467c411..64dfd5c5 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -8,6 +8,7 @@ import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SlabBoundaryEditor } from './slab/slab-boundary-editor' +import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabTool } from './slab/slab-tool' import { WallTool } from './wall/wall-tool' import { ZoneBoundaryEditor } from './zone/zone-boundary-editor' @@ -35,6 +36,7 @@ export const ToolManager: React.FC = () => { const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) const movingNode = useEditor((state) => state.movingNode) + const editingSlabHoleIndex = useEditor((state) => state.editingSlabHoleIndex) const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedIds = useViewer((state) => state.selection.selectedIds) 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 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 = - 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 const showCeilingBoundaryEditor = @@ -79,6 +85,9 @@ export const ToolManager: React.FC = () => { {showSiteBoundaryEditor && } {showZoneBoundaryEditor && selectedZoneId && } {showSlabBoundaryEditor && selectedSlabId && } + {showSlabHoleEditor && selectedSlabId && editingSlabHoleIndex !== null && ( + + )} {showCeilingBoundaryEditor && selectedCeilingId && ( )} diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx index 935dea97..12c19860 100644 --- a/apps/editor/components/tools/wall/wall-tool.tsx +++ b/apps/editor/components/tools/wall/wall-tool.tsx @@ -1,7 +1,8 @@ import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef, useMemo } from 'react' -import { DoubleSide, type Mesh, Vector3, Shape, ShapeGeometry } from 'three' +import { useEffect, useRef } from 'react' +import { DoubleSide, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' +import { sfxEmitter } from '@/lib/sfx-bus' const WALL_HEIGHT = 2.5 const WALL_THICKNESS = 0.15 @@ -88,6 +89,7 @@ const commitWallDrawing = (start: [number, number], end: [number, number]) => { const wall = WallNode.parse({ start, end }) createNode(wall, currentLevelId) + sfxEmitter.emit('sfx:structure-build') } export const WallTool: React.FC = () => { @@ -96,9 +98,11 @@ export const WallTool: React.FC = () => { const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) + const shiftPressed = useRef(false) useEffect(() => { let gridPosition: [number, number] = [0, 0] + let previousWallEnd: [number, number] | null = null const onGridMove = (event: GridEvent) => { 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]) if (buildingState.current === 1) { - // Snap to 45° angles - const snapped = snapTo45Degrees(startingPoint.current, cursorPosition) + // Snap to 45° angles only if shift is not pressed + const snapped = shiftPressed.current + ? cursorPosition + : snapTo45Degrees(startingPoint.current, cursorPosition) 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 updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current) } @@ -125,19 +139,35 @@ export const WallTool: React.FC = () => { } else if (buildingState.current === 1) { commitWallDrawing( [startingPoint.current.x, startingPoint.current.z], - [endingPoint.current.x, endingPoint.current.z] + [endingPoint.current.x, endingPoint.current.z], ) wallPreviewRef.current.visible = false 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:click', onGridClick) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) } }, []) @@ -145,8 +175,8 @@ export const WallTool: React.FC = () => { {/* Cursor indicator */} - - + + {/* Wall preview */} diff --git a/apps/editor/components/ui/helpers/helper-manager.tsx b/apps/editor/components/ui/helpers/helper-manager.tsx new file mode 100644 index 00000000..6db3e4ac --- /dev/null +++ b/apps/editor/components/ui/helpers/helper-manager.tsx @@ -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 + } + + // Show appropriate helper based on current tool + switch (tool) { + case 'wall': + return + case 'item': + return + default: + return null + } +} diff --git a/apps/editor/components/ui/helpers/item-helper.tsx b/apps/editor/components/ui/helpers/item-helper.tsx new file mode 100644 index 00000000..91345b4d --- /dev/null +++ b/apps/editor/components/ui/helpers/item-helper.tsx @@ -0,0 +1,18 @@ +export function ItemHelper() { + return ( +
+
+ R + Rotate counterclockwise +
+
+ T + Rotate clockwise +
+
+ Esc + Cancel +
+
+ ) +} diff --git a/apps/editor/components/ui/helpers/wall-helper.tsx b/apps/editor/components/ui/helpers/wall-helper.tsx new file mode 100644 index 00000000..7e880e80 --- /dev/null +++ b/apps/editor/components/ui/helpers/wall-helper.tsx @@ -0,0 +1,10 @@ +export function WallHelper() { + return ( +
+
+ Shift + Allow non-45° angles +
+
+ ) +} diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx index bed39a58..e9360923 100644 --- a/apps/editor/components/ui/panels/item-panel.tsx +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -7,6 +7,7 @@ import Image from 'next/image' import { useCallback } from 'react' import useEditor from '@/store/use-editor' import { NumberInput } from '@/components/ui/primitives/number-input' +import { sfxEmitter } from '@/lib/sfx-bus' export function ItemPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) @@ -43,6 +44,7 @@ export function ItemPanel() { const handleMove = useCallback(() => { if (node) { + sfxEmitter.emit('sfx:item-pick') setMovingNode(node) // Deselect so the panel closes setSelection({ selectedIds: [] }) @@ -51,6 +53,7 @@ export function ItemPanel() { const handleDelete = useCallback(() => { if (!selectedId) return + sfxEmitter.emit('sfx:item-delete') deleteNode(selectedId as AnyNode['id']) setSelection({ selectedIds: [] }) }, [selectedId, deleteNode, setSelection]) @@ -142,6 +145,7 @@ export function ItemPanel() { type="button" className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer" onClick={() => { + sfxEmitter.emit('sfx:item-rotate') const currentDegrees = (node.rotation[1] * 180) / Math.PI const newDegrees = currentDegrees - 90 const radians = (newDegrees * Math.PI) / 180 @@ -154,6 +158,7 @@ export function ItemPanel() { type="button" className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer" onClick={() => { + sfxEmitter.emit('sfx:item-rotate') const currentDegrees = (node.rotation[1] * 180) / Math.PI const newDegrees = currentDegrees + 90 const radians = (newDegrees * Math.PI) / 180 diff --git a/apps/editor/components/ui/panels/slab-panel.tsx b/apps/editor/components/ui/panels/slab-panel.tsx index 1c126b77..e5490d8e 100644 --- a/apps/editor/components/ui/panels/slab-panel.tsx +++ b/apps/editor/components/ui/panels/slab-panel.tsx @@ -2,9 +2,10 @@ import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' 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 { useCallback } from 'react' +import { useCallback, useEffect } from 'react' +import useEditor from '@/store/use-editor' import { NumberInput } from '@/components/ui/primitives/number-input' export function SlabPanel() { @@ -12,6 +13,8 @@ export function SlabPanel() { const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) 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 const selectedId = selectedIds[0] @@ -29,7 +32,69 @@ export function SlabPanel() { const handleClose = useCallback(() => { 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 if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null @@ -139,6 +204,86 @@ export function SlabPanel() { {area.toFixed(2)} m²
+ + {/* Holes */} +
+
+ + {editingHoleIndex !== null ? ( + + ) : ( + + )} +
+ {node.holes && node.holes.length > 0 ? ( +
+ {node.holes.map((hole, index) => { + const holeArea = calculateArea(hole) + const isEditing = editingHoleIndex === index + return ( +
+
+

+ Hole {index + 1} {isEditing && '(Editing)'} +

+

+ {holeArea.toFixed(2)} m² · {hole.length} vertices +

+
+
+ {!isEditing && ( + <> + + + + )} +
+
+ ) + })} +
+ ) : ( +

+ No holes. Click "Add Hole" to create one. +

+ )} +
diff --git a/apps/editor/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx b/apps/editor/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx new file mode 100644 index 00000000..3808727f --- /dev/null +++ b/apps/editor/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx @@ -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 ( + + + + + + + Audio Settings + + Adjust volume levels and mute settings + + +
+ {/* Master Volume */} +
+
+ + {masterVolume}% +
+ value[0] !== undefined && setMasterVolume(value[0])} + max={100} + step={1} + disabled={muted} + /> +
+ + {/* Radio Volume */} +
+
+ + {radioVolume}% +
+ value[0] !== undefined && setRadioVolume(value[0])} + max={100} + step={1} + disabled={muted} + /> +
+ + {/* SFX Volume */} +
+
+ + {sfxVolume}% +
+ value[0] !== undefined && setSfxVolume(value[0])} + max={100} + step={1} + disabled={muted} + /> +
+ + {/* Mute Toggle */} +
+ +
+
+
+
+ ) +} diff --git a/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx index 17e50228..7009d558 100644 --- a/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx @@ -4,6 +4,7 @@ import { Download, Save, Trash2, Upload } from "lucide-react"; import { useRef } from "react"; import { Button } from "@/components/ui/primitives/button"; import useEditor from "@/store/use-editor"; +import { AudioSettingsDialog } from "./audio-settings-dialog"; export function SettingsPanel() { const fileInputRef = useRef(null); @@ -113,6 +114,14 @@ export function SettingsPanel() { /> + {/* Audio Section */} +
+ + +
+ {/* Danger Zone */}