editor: structure build-start/end cues + menu hover/click SFX (#378)

* wip sfx

* wip audios

* feat(editor): structure build-start/end cues + menu hover/click SFX

Sound design pass for the build/items experience:

- Split structure-build into a start cue (draft begins) and end cue
  (commit). Wall/fence fire start on the first click; slab/ceiling/zone
  tick start on every non-closing vertex; roof on the first corner.
- Doors and windows now use the structure-end cue on place + move;
  shelves use item-place instead.
- New menu_hover (round-robin retired in favour of a single sample) and
  menu_click cues wired to the Build/Items main categories, item-catalog
  tiles, the icon rail / tab bar, and the bottom action menu (via the
  shared ActionButton).
- Refresh item_pick / item_place / item_rotate samples; drop the
  now-unused numbered menu_hover variations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(editor): silence forEach return lint in updateSFXVolumes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(editor): convert SFX wavs to mp3 to shrink assets

Re-encode all SFX samples as 128kbps mp3 (~94% smaller, transparent for
short UI cues) and point the player at the .mp3 sources. Drops the .wav
originals; the freshly converted item_pick/place/rotate.mp3 override the
older committed mp3s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-06 20:15:24 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8f2e5678e8
commit 1419709131
31 changed files with 148 additions and 34 deletions
+6 -2
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { MaterialPaintPanel, useEditor } from '@pascal-app/editor' import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
import Image from 'next/image' import Image from 'next/image'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { import {
@@ -131,7 +131,11 @@ export function BuildTab() {
? 'bg-primary/10 ring-1 ring-primary/50' ? 'bg-primary/10 ring-1 ring-primary/50'
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0', : 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
)} )}
onClick={() => handleTypeClick(type)} onClick={() => {
triggerSFX('sfx:menu-click')
handleTypeClick(type)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button" type="button"
> >
<Image <Image
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7839,6 +7839,9 @@ export function FloorplanPanel() {
return return
} }
// Every non-closing vertex is a "start" tick; closing the polygon above
// creates the zone and fires the structure-build (end) cue.
sfxEmitter.emit('sfx:structure-build-start')
setZoneDraftPoints((currentPoints) => [...currentPoints, point]) setZoneDraftPoints((currentPoints) => [...currentPoints, point])
setCursorPoint(point) setCursorPoint(point)
}, },
@@ -295,6 +295,7 @@ export const RoofTool: React.FC = () => {
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} else { } else {
corner1Ref.current = [gridX, y, gridZ] corner1Ref.current = [gridX, y, gridZ]
sfxEmitter.emit('sfx:structure-build-start')
setPreview((prev) => ({ setPreview((prev) => ({
...prev, ...prev,
corner1: corner1Ref.current, corner1: corner1Ref.current,
@@ -236,7 +236,9 @@ export const ZoneTool: React.FC = () => {
mainLineRef.current.visible = false mainLineRef.current.visible = false
closingLineRef.current.visible = false closingLineRef.current.visible = false
} else { } else {
// Add point to polygon // Add point to polygon. Every non-closing vertex is a "start" tick;
// closing the polygon above fires the structure-build (end) cue.
sfxEmitter.emit('sfx:structure-build-start')
pointsRef.current = [...pointsRef.current, clickPoint] pointsRef.current = [...pointsRef.current, clickPoint]
updatePreview() updatePreview()
} }
@@ -5,6 +5,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from './../../../components/ui/primitives/tooltip' } from './../../../components/ui/primitives/tooltip'
import { triggerSFX } from './../../../lib/sfx-bus'
import { cn } from './../../../lib/utils' import { cn } from './../../../lib/utils'
interface ActionButtonProps extends React.ComponentProps<typeof Button> { interface ActionButtonProps extends React.ComponentProps<typeof Button> {
@@ -17,7 +18,18 @@ interface ActionButtonProps extends React.ComponentProps<typeof Button> {
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>( export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
( (
{ className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props }, {
className,
children,
label,
shortcut,
isActive,
tooltipContent,
tooltipSide,
onClick,
onMouseEnter,
...props
},
ref, ref,
) => { ) => {
return ( return (
@@ -25,6 +37,14 @@ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProp
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className={cn('relative h-11 w-11 transition-all', className)} className={cn('relative h-11 w-11 transition-all', className)}
onClick={(event) => {
triggerSFX('sfx:menu-click')
onClick?.(event)
}}
onMouseEnter={(event) => {
triggerSFX('sfx:menu-hover')
onMouseEnter?.(event)
}}
ref={ref} ref={ref}
{...props} {...props}
> >
@@ -9,6 +9,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from './../../../components/ui/primitives/tooltip' } from './../../../components/ui/primitives/tooltip'
import { triggerSFX } from './../../../lib/sfx-bus'
import { cn } from './../../../lib/utils' import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor' import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { CATALOG_ITEMS } from './catalog-items' import { CATALOG_ITEMS } from './catalog-items'
@@ -94,10 +95,12 @@ export function ItemCatalog({
)} )}
key={index} key={index}
onClick={() => { onClick={() => {
triggerSFX('sfx:menu-click')
setSelectedItem(item) setSelectedItem(item)
setTool('item') setTool('item')
setMode('build') setMode('build')
}} }}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button" type="button"
> >
<div className="relative aspect-square w-full overflow-hidden rounded-lg"> <div className="relative aspect-square w-full overflow-hidden rounded-lg">
@@ -4,6 +4,7 @@ import type { AssetInput } from '@pascal-app/core'
import { Root as TooltipRoot } from '@radix-ui/react-tooltip' import { Root as TooltipRoot } from '@radix-ui/react-tooltip'
import NextImage from 'next/image' import NextImage from 'next/image'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { triggerSFX } from '../../../../../lib/sfx-bus'
import { cn } from '../../../../../lib/utils' import { cn } from '../../../../../lib/utils'
import { ItemCatalog } from '../../../item-catalog/item-catalog' import { ItemCatalog } from '../../../item-catalog/item-catalog'
import { import {
@@ -132,7 +133,11 @@ export function FunctionTreePanel({
? 'bg-primary/10 ring-1 ring-primary/50' ? 'bg-primary/10 ring-1 ring-primary/50'
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0', : 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
)} )}
onClick={() => selectRoot(root.slug)} onClick={() => {
triggerSFX('sfx:menu-click')
selectRoot(root.slug)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button" type="button"
> >
{root.iconUrl ? ( {root.iconUrl ? (
@@ -3,6 +3,7 @@
import type { AssetInput } from '@pascal-app/core' import type { AssetInput } from '@pascal-app/core'
import NextImage from 'next/image' import NextImage from 'next/image'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { triggerSFX } from '../../../../../lib/sfx-bus'
import { cn } from '../../../../../lib/utils' import { cn } from '../../../../../lib/utils'
import type { CatalogCategory } from '../../../../../store/use-editor' import type { CatalogCategory } from '../../../../../store/use-editor'
import useEditor from '../../../../../store/use-editor' import useEditor from '../../../../../store/use-editor'
@@ -206,7 +207,11 @@ function LegacyItemsPanel({
: 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground', : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
)} )}
key={cat.catalogCategory} key={cat.catalogCategory}
onClick={() => selectCategory(cat.catalogCategory)} onClick={() => {
triggerSFX('sfx:menu-click')
selectCategory(cat.catalogCategory)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button" type="button"
> >
<NextImage <NextImage
@@ -1,6 +1,7 @@
'use client' 'use client'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { triggerSFX } from './../../../lib/sfx-bus'
import { cn } from './../../../lib/utils' import { cn } from './../../../lib/utils'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
@@ -33,7 +34,11 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground', : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
)} )}
key={tab.id} key={tab.id}
onClick={() => onTabChange(tab.id)} onClick={() => {
triggerSFX('sfx:menu-click')
onTabChange(tab.id)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button" type="button"
> >
{tab.label} {tab.label}
@@ -77,7 +82,11 @@ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailPr
? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0' ? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0', : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
)} )}
onClick={() => onIconClick(tab.id)} onClick={() => {
triggerSFX('sfx:menu-click')
onIconClick(tab.id)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button" type="button"
> >
{tab.icon ?? tab.label.charAt(0)} {tab.icon ?? tab.label.charAt(0)}
+7 -1
View File
@@ -10,9 +10,12 @@ type SFXEvents = {
'sfx:item-pick': undefined 'sfx:item-pick': undefined
'sfx:item-place': undefined 'sfx:item-place': undefined
'sfx:item-rotate': undefined 'sfx:item-rotate': undefined
'sfx:structure-build-start': undefined
'sfx:structure-build': undefined 'sfx:structure-build': undefined
'sfx:structure-delete': undefined 'sfx:structure-delete': undefined
'sfx:snapshot-capture': undefined 'sfx:snapshot-capture': undefined
'sfx:menu-hover': undefined
'sfx:menu-click': undefined
} }
/** /**
@@ -36,9 +39,12 @@ export function initSFXBus() {
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick')) sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace')) sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate')) sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild')) sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart'))
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete')) sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture')) sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
sfxEmitter.on('sfx:menu-hover', () => playSFX('menuHover'))
sfxEmitter.on('sfx:menu-click', () => playSFX('menuClick'))
} }
/** /**
+68 -20
View File
@@ -5,7 +5,8 @@ import useAudio from '../store/use-audio'
// so a rate range of ~0.881.12 reads as a subtle ±2 semitones — enough to kill the // so a rate range of ~0.881.12 reads as a subtle ±2 semitones — enough to kill the
// machine-gun feeling when the same SFX fires in rapid succession. // machine-gun feeling when the same SFX fires in rapid succession.
type SFXConfig = { type SFXConfig = {
src: string // One file, or several pre-rendered variations cycled round-robin per play.
src: string | string[]
// Random playback-rate range applied per play (1 = unchanged). // Random playback-rate range applied per play (1 = unchanged).
rateRange?: [number, number] rateRange?: [number, number]
// Random volume multiplier range applied per play (1 = unchanged). // Random volume multiplier range applied per play (1 = unchanged).
@@ -24,10 +25,15 @@ const DEFAULT_MIN_INTERVAL_MS = 30
// SFX sound definitions // SFX sound definitions
export const SFX: Record<string, SFXConfig> = { export const SFX: Record<string, SFXConfig> = {
gridSnap: { gridSnap: {
src: '/audios/sfx/grid_snap.mp3', src: [
rateRange: [0.94, 1.06], '/audios/sfx/grid_snap_0.mp3',
volumeRange: [0.92, 1.0], '/audios/sfx/grid_snap_1.mp3',
'/audios/sfx/grid_snap_2.mp3',
],
rateRange: [0.98, 1.02],
volumeRange: [0.5, 0.6],
panJitter: 0.15, panJitter: 0.15,
minIntervalMs: 50,
}, },
itemDelete: { itemDelete: {
src: '/audios/sfx/item_delete.mp3', src: '/audios/sfx/item_delete.mp3',
@@ -37,13 +43,13 @@ export const SFX: Record<string, SFXConfig> = {
}, },
itemPick: { itemPick: {
src: '/audios/sfx/item_pick.mp3', src: '/audios/sfx/item_pick.mp3',
rateRange: [0.92, 1.08], rateRange: [0.95, 1.05],
volumeRange: [0.92, 1.0], volumeRange: [0.92, 1.0],
panJitter: 0.15, panJitter: 0.15,
}, },
itemPlace: { itemPlace: {
src: '/audios/sfx/item_place.mp3', src: '/audios/sfx/item_place.mp3',
rateRange: [0.98, 1.06], rateRange: [0.98, 1.02],
volumeRange: [0.9, 1.0], volumeRange: [0.9, 1.0],
panJitter: 0.15, panJitter: 0.15,
}, },
@@ -53,8 +59,16 @@ export const SFX: Record<string, SFXConfig> = {
volumeRange: [0.92, 1.0], volumeRange: [0.92, 1.0],
panJitter: 0.15, panJitter: 0.15,
}, },
structureBuild: { // Fired when a structure draft begins (first click of a wall/slab/etc).
src: '/audios/sfx/structure_build.mp3', structureBuildStart: {
src: '/audios/sfx/structure_build_start.mp3',
rateRange: [0.95, 1.05],
volumeRange: [0.88, 1.0],
panJitter: 0.15,
},
// Fired when a structure is committed (segment placed / polygon closed).
structureBuildEnd: {
src: '/audios/sfx/structure_build_end.mp3',
rateRange: [0.95, 1.05], rateRange: [0.95, 1.05],
volumeRange: [0.88, 1.0], volumeRange: [0.88, 1.0],
panJitter: 0.15, panJitter: 0.15,
@@ -69,6 +83,23 @@ export const SFX: Record<string, SFXConfig> = {
// Shutter should sound consistent — no variation. // Shutter should sound consistent — no variation.
src: '/audios/sfx/snapshot_capture.mp3', src: '/audios/sfx/snapshot_capture.mp3',
}, },
// Soft tick when hovering a main category in the Build / Items panels.
// Kept quiet and rate-locked so sweeping across the grid reads as texture,
// not a melody.
menuHover: {
src: '/audios/sfx/menu_hover.mp3',
rateRange: [0.98, 1.02],
volumeRange: [0.2, 0.3],
panJitter: 0.1,
minIntervalMs: 0,
},
// Fired when a main category in the Build / Items panels is clicked.
menuClick: {
src: '/audios/sfx/menu_click.mp3',
rateRange: [0.98, 1.02],
volumeRange: [0.5, 0.6],
panJitter: 0.1,
},
} as const } as const
export type SFXName = keyof typeof SFX export type SFXName = keyof typeof SFX
@@ -77,26 +108,32 @@ function randomInRange([min, max]: [number, number]): number {
return min + Math.random() * (max - min) return min + Math.random() * (max - min)
} }
// Preload all SFX sounds // Preload all SFX sounds. Each variation gets its own Howl so they can overlap
const sfxCache = new Map<SFXName, Howl>() // and be cycled round-robin.
const sfxCache = new Map<SFXName, Howl[]>()
const lastPlayedAt = new Map<SFXName, number>() const lastPlayedAt = new Map<SFXName, number>()
const lastVariation = new Map<SFXName, number>()
// Initialize all sounds // Initialize all sounds
Object.entries(SFX).forEach(([name, config]) => { Object.entries(SFX).forEach(([name, config]) => {
const sound = new Howl({ const sources = Array.isArray(config.src) ? config.src : [config.src]
src: [config.src], const sounds = sources.map(
preload: true, (src) =>
volume: 0.5, // Will be adjusted by the bus new Howl({
}) src: [src],
sfxCache.set(name as SFXName, sound) preload: true,
volume: 0.5, // Will be adjusted by the bus
}),
)
sfxCache.set(name as SFXName, sounds)
}) })
/** /**
* Play a sound effect with volume based on audio settings * Play a sound effect with volume based on audio settings
*/ */
export function playSFX(name: SFXName) { export function playSFX(name: SFXName) {
const sound = sfxCache.get(name) const sounds = sfxCache.get(name)
if (!sound) { if (!sounds || sounds.length === 0) {
console.warn(`SFX not found: ${name}`) console.warn(`SFX not found: ${name}`)
return return
} }
@@ -110,6 +147,15 @@ export function playSFX(name: SFXName) {
if (last !== undefined && now - last < minInterval) return if (last !== undefined && now - last < minInterval) return
lastPlayedAt.set(name, now) lastPlayedAt.set(name, now)
// Pick a random variation, avoiding an immediate repeat of the last one so
// consecutive plays don't land on the same file.
let index = Math.floor(Math.random() * sounds.length)
if (sounds.length > 1 && index === lastVariation.get(name)) {
index = (index + 1) % sounds.length
}
lastVariation.set(name, index)
const sound = sounds[index]!
const { masterVolume, sfxVolume, muted } = useAudio.getState() const { masterVolume, sfxVolume, muted } = useAudio.getState()
if (muted) return if (muted) return
@@ -137,7 +183,9 @@ export function updateSFXVolumes() {
const { masterVolume, sfxVolume } = useAudio.getState() const { masterVolume, sfxVolume } = useAudio.getState()
const finalVolume = (masterVolume / 100) * (sfxVolume / 100) const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
sfxCache.forEach((sound) => { sfxCache.forEach((sounds) => {
sound.volume(finalVolume) sounds.forEach((sound) => {
sound.volume(finalVolume)
})
}) })
} }
+3
View File
@@ -201,6 +201,9 @@ export const CeilingTool: React.FC = () => {
setPoints([]) setPoints([])
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} else { } else {
// Every non-closing vertex is a "start" tick; the closing click above
// fires the structure-build (end) cue.
triggerSFX('sfx:structure-build-start')
setPoints([...points, clickPoint]) setPoints([...points, clickPoint])
} }
} }
+1 -1
View File
@@ -349,7 +349,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useLiveTransforms.getState().clear(movingDoorNode.id) useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
triggerSFX('sfx:item-place') triggerSFX('sfx:structure-build')
hideCursor() hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] }) useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode() exitMoveMode()
+1 -1
View File
@@ -323,7 +323,7 @@ const DoorTool: React.FC = () => {
useScene.getState().createNode(node, event.node.id as AnyNodeId) useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
triggerSFX('sfx:item-place') triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
+1
View File
@@ -569,6 +569,7 @@ export const FenceTool: React.FC = () => {
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
buildingState.current = 1 buildingState.current = 1
triggerSFX('sfx:structure-build-start')
previewRef.current.visible = true previewRef.current.visible = true
setDraftMeasurement(null) setDraftMeasurement(null)
} else { } else {
+1 -1
View File
@@ -181,7 +181,7 @@ const ShelfTool = () => {
}) })
useScene.getState().createNode(shelf, activeLevelId) useScene.getState().createNode(shelf, activeLevelId)
useViewer.getState().setSelection({ selectedIds: [shelf.id] }) useViewer.getState().setSelection({ selectedIds: [shelf.id] })
triggerSFX('sfx:structure-build') triggerSFX('sfx:item-place')
// The placed shelf is now a valid alignment target for the next one; // The placed shelf is now a valid alignment target for the next one;
// refresh the candidate pool and drop the guide from this drop. // refresh the candidate pool and drop the guide from this drop.
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id) alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
+3
View File
@@ -178,6 +178,9 @@ export const SlabTool: React.FC = () => {
setPoints([]) setPoints([])
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
} else { } else {
// Every non-closing vertex is a "start" tick; the closing click above
// fires the structure-build (end) cue.
triggerSFX('sfx:structure-build-start')
setPoints([...points, clickPoint]) setPoints([...points, clickPoint])
} }
} }
+1
View File
@@ -538,6 +538,7 @@ export const WallTool: React.FC = () => {
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
endingPoint.current.copy(startingPoint.current) endingPoint.current.copy(startingPoint.current)
buildingState.current = 1 buildingState.current = 1
triggerSFX('sfx:structure-build-start')
// Visibility is owned by `updateWallPreview` — it flips // Visibility is owned by `updateWallPreview` — it flips
// `mesh.visible` based on segment length. Setting it here // `mesh.visible` based on segment length. Setting it here
// (before any geometry data has been written) draws the // (before any geometry data has been written) draws the
+1 -1
View File
@@ -381,7 +381,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useLiveTransforms.getState().clear(movingWindowNode.id) useLiveTransforms.getState().clear(movingWindowNode.id)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
triggerSFX('sfx:item-place') triggerSFX('sfx:structure-build')
hideCursor() hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] }) useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode() exitMoveMode()
+1 -1
View File
@@ -331,7 +331,7 @@ const WindowTool: React.FC = () => {
useScene.getState().createNode(node, event.node.id as AnyNodeId) useScene.getState().createNode(node, event.node.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] }) useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
triggerSFX('sfx:item-place') triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()