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 (
+
+ )
+}
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 */}