Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,21 @@ import {
nodeRegistry,
useScene,
} from '@pascal-app/core'
import { memo } from 'react'
import { useViewer } from '@pascal-app/viewer'
import { memo, useMemo } from 'react'
import usePlacementPreview from '../../../store/use-placement-preview'
import { useFloorplanRender } from '../floorplan-render-context'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'

export interface FloorplanNodePreviewProps {
node: AnyNode
parentNode?: AnyNode | null
opacity?: number
className?: string
selected?: boolean
highlighted?: boolean
hovered?: boolean
moving?: boolean
}

/**
Expand All @@ -29,27 +35,67 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
parentNode = null,
opacity = 0.5,
className,
selected = false,
highlighted = false,
hovered = false,
moving = false,
}: FloorplanNodePreviewProps) {
const builder = nodeRegistry.get(node.type)?.floorplan
if (!builder) return null
const nodes = useScene((state) => state.nodes)
const unit = useViewer((state) => state.unit)
const renderContext = useFloorplanRender()

const ctx = {
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
children: [],
siblings: [],
parent: parentNode,
viewState: undefined,
} as unknown as GeometryContext
const geometry = useMemo(() => {
const definition = nodeRegistry.get(node.type)
const builder = definition?.floorplan
if (!builder) return null

const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
node,
ctx,
)
const contextNodes: Record<string, AnyNode> = {
...(nodes as Record<string, AnyNode>),
[node.id]: node,
}
if (parentNode) contextNodes[parentNode.id] = parentNode
const resolvedParent =
parentNode ?? (node.parentId ? (contextNodes[node.parentId] ?? null) : null)
const childIds = (node as AnyNode & { children?: AnyNodeId[] }).children ?? []
const children = childIds.flatMap((id) => {
const child = contextNodes[id]
return child ? [child] : []
})
const siblings = Object.values(contextNodes).filter(
(candidate) =>
candidate.id !== node.id &&
candidate.type === node.type &&
candidate.parentId === node.parentId,
)
const levelData = definition.computeFloorplanLevelData?.({
siblings: [node, ...siblings],
nodes: contextNodes,
})
const ctx: GeometryContext = {
resolve: <N = AnyNode>(id: AnyNodeId) => contextNodes[id] as N | undefined,
children,
siblings,
parent: resolvedParent,
levelData,
viewState: renderContext
? {
selected,
unit,
highlighted,
hovered,
moving,
palette: renderContext.palette,
}
: undefined,
}

return (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(node, ctx)
}, [highlighted, hovered, moving, node, nodes, parentNode, renderContext, selected, unit])
if (!geometry) return null

return (
<g className={className} opacity={opacity} pointerEvents="none">
<FloorplanGeometryRenderer geometry={geometry} />
<FloorplanGeometryRenderer geometry={geometry} pointerEventsOverride="none" />
</g>
)
})
Expand All @@ -62,7 +108,8 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
* cursor dot + alignment guides — no sense of the footprint they were about
* to drop. The placement tool publishes a transient, already-positioned +
* aligned node to `usePlacementPreview`; we build its `def.floorplan`
* footprint with a minimal (unselected) context and render it.
* footprint with active sibling, level-data, and theme context so kind-specific
* shapes match the committed renderer.
*
* Mounted inside the floor-plan scene `<g>` so the geometry's level-local
* meters get the same world→SVG transform every other entry does.
Expand Down
24 changes: 24 additions & 0 deletions packages/editor/src/components/tools/item/use-draft-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useMemo, useRef } from 'react'
import type { Vector3 } from 'three'
import usePlacementPreview from '../../../store/use-placement-preview'
import { stripTransient } from './placement-math'

interface OriginalState {
Expand Down Expand Up @@ -80,6 +81,9 @@ export function useDraftNode(): DraftNodeHandle {
})

useScene.getState().createNode(node, currentLevelId)
usePlacementPreview
.getState()
.set(node, useScene.getState().nodes[currentLevelId as AnyNodeId] ?? null)
draftRef.current = node
adoptedRef.current = false
originalStateRef.current = null
Expand Down Expand Up @@ -112,6 +116,12 @@ export function useDraftNode(): DraftNodeHandle {
useScene.getState().updateNode(node.id, {
metadata: { ...meta, isTransient: true },
})
usePlacementPreview
.getState()
.set(
node,
node.parentId ? (useScene.getState().nodes[node.parentId as AnyNodeId] ?? null) : null,
)
}, [])

const commit = useCallback((finalUpdate: Partial<ItemNode>): string | null => {
Expand Down Expand Up @@ -159,6 +169,9 @@ export function useDraftNode(): DraftNodeHandle {
useScene.temporal.getState().pause()

const id = draft.id
if (usePlacementPreview.getState().node?.id === id) {
usePlacementPreview.getState().clear()
}
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
Expand Down Expand Up @@ -194,6 +207,9 @@ export function useDraftNode(): DraftNodeHandle {
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
})
useScene.getState().createNode(finalNode, parentId)
if (usePlacementPreview.getState().node?.id === draft.id) {
usePlacementPreview.getState().clear()
}

// Re-pause for next draft cycle
useScene.temporal.getState().pause()
Expand All @@ -206,6 +222,8 @@ export function useDraftNode(): DraftNodeHandle {
const destroy = useCallback(() => {
if (!draftRef.current) return

const draftId = draftRef.current.id

if (adoptedRef.current && originalStateRef.current) {
// Move mode: restore original state instead of deleting — but only
// if no other system has already committed a new position for this
Expand All @@ -227,6 +245,9 @@ export function useDraftNode(): DraftNodeHandle {
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
if (usePlacementPreview.getState().node?.id === draftId) {
usePlacementPreview.getState().clear()
}
return
}

Expand Down Expand Up @@ -257,6 +278,9 @@ export function useDraftNode(): DraftNodeHandle {
draftRef.current = null
adoptedRef.current = false
originalStateRef.current = null
if (usePlacementPreview.getState().node?.id === draftId) {
usePlacementPreview.getState().clear()
}
}, [])

return useMemo(
Expand Down
33 changes: 29 additions & 4 deletions packages/editor/src/components/tools/tool-manager.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type AnyNodeDefinition,
type AnyNodeId,
type BuildingNode,
type CeilingNode,
Expand Down Expand Up @@ -36,6 +37,29 @@ import { ZoneTool } from './zone/zone-tool'
// Cache lazy tool components keyed by their loader so React.lazy isn't
// re-invoked across renders.
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
const registryToolPreloadCache = new WeakMap<AnyNodeDefinition, Promise<void>>()

export function preloadRegistryToolModules(tool: string | null): Promise<void> {
if (!tool) return Promise.resolve()
const def = nodeRegistry.get(tool)
if (!def) return Promise.resolve()
const cached = registryToolPreloadCache.get(def)
if (cached) return cached

const loaders: Array<() => Promise<unknown>> = []
if (def.tool) loaders.push(def.tool)
if (def.preview) loaders.push(def.preview)
if (def.renderer?.kind === 'parametric') loaders.push(def.renderer.module)
if (def.system) loaders.push(def.system.module)
if (def.parametrics?.customPanel) loaders.push(def.parametrics.customPanel)
if (def.parametrics?.trailingSection) loaders.push(def.parametrics.trailingSection)
const moveTool = def.affordanceTools?.move
if (moveTool) loaders.push(moveTool)

const preload = Promise.allSettled(loaders.map((loader) => loader())).then(() => undefined)
registryToolPreloadCache.set(def, preload)
return preload
}

function getRegistryTool(tool: Tool | null): ComponentType | null {
if (!tool) return null
Expand All @@ -44,10 +68,11 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
const cached = lazyToolCache.get(def.tool)
if (cached) return cached
const Comp = lazy(async () => {
// A placed node is selected immediately. Resolve its custom inspector with
// the tool so that selection cannot introduce a second async boundary.
const [module] = await Promise.all([def.tool!(), def.parametrics?.customPanel?.()])
return module as { default: ComponentType }
// Placement can only begin once the node's preview, committed renderer,
// inspector, and move contribution are warm. This keeps the click itself
// synchronous even under Next.js dev-time on-demand compilation.
await preloadRegistryToolModules(tool)
return def.tool!() as Promise<{ default: ComponentType }>
})
lazyToolCache.set(def.tool, Comp)
return Comp
Expand Down
4 changes: 3 additions & 1 deletion packages/editor/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export {
DEFAULT_STAIR_TYPE,
DEFAULT_STAIR_WIDTH,
} from './components/tools/stair/stair-defaults'
export { ToolManager } from './components/tools/tool-manager'
export { preloadRegistryToolModules, ToolManager } from './components/tools/tool-manager'
export {
chainEndJoinsExistingWall,
createWallOnCurrentLevel,
Expand Down Expand Up @@ -254,6 +254,7 @@ export type { SaveStatus } from './hooks/use-auto-save'
export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
export { markToolCancelConsumed } from './hooks/use-keyboard'
export { useReducedMotion } from './hooks/use-reduced-motion'
export { type Selection, useSelection } from './hooks/use-selection'
export {
clearPlacementSurface,
Expand Down Expand Up @@ -391,6 +392,7 @@ export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
export { movementSfxStepKey } from './lib/sfx/movement-tick'
export { triggerSFX } from './lib/sfx-bus'
export { playSFX, type SFXName, type SFXPlaybackOptions } from './lib/sfx-player'
export {
clearSlabSnapFeedback,
resolveSlabEdgeBandSnap,
Expand Down
40 changes: 38 additions & 2 deletions packages/editor/src/lib/sfx-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
type FakeContext = { id: string }

let activeContext: FakeContext = { id: 'first' }
let initialState: 'loaded' | 'loading' = 'loaded'
let throwOnPlay = false
const instances: FakeHowl[] = []

class FakeHowl {
stateValue: 'loaded' | 'unloaded' = 'loaded'
stateValue: 'loaded' | 'loading' | 'unloaded' = initialState
unloadCount = 0
playCount = 0
stereoCalls: Array<[number, number | undefined]> = []
volumeCalls: Array<[number, number | undefined]> = []

constructor(_options: unknown) {
instances.push(this)
Expand All @@ -21,7 +24,13 @@ class FakeHowl {
return 1
}

volume() {
volume(value: number, id?: number) {
this.volumeCalls.push([value, id])
return this
}

stereo(value: number, id?: number) {
this.stereoCalls.push([value, id])
return this
}

Expand Down Expand Up @@ -59,6 +68,7 @@ const { disposeSFXBus, initSFXBus, triggerSFX } = await import('./sfx-bus')
beforeEach(() => {
disposeSFXBus()
activeContext = { id: 'first' }
initialState = 'loaded'
throwOnPlay = false
instances.length = 0
})
Expand Down Expand Up @@ -104,6 +114,16 @@ describe('SFX audio context lifecycle', () => {
expect(instances.length).toBe(initialCount)
})

test('does not queue spatial mutations while a sound is still loading', () => {
initialState = 'loading'

playSFX('itemDelete', { source: 'remote', stereo: 0.65, volumeMultiplier: 0.25 })

expect(instances.every((sound) => sound.playCount === 0)).toBe(true)
expect(instances.every((sound) => sound.stereoCalls.length === 0)).toBe(true)
expect(instances.every((sound) => sound.volumeCalls.length === 0)).toBe(true)
})

test('disposes idempotently and recreates sounds after remount', () => {
preloadSFX()
const initialCount = instances.length
Expand All @@ -121,4 +141,20 @@ describe('SFX audio context lifecycle', () => {

expect(() => triggerSFX('sfx:item-delete')).not.toThrow()
})

test('applies bounded gain and stereo positioning to a remote cue', () => {
playSFX('itemDelete', { source: 'remote', stereo: 0.65, volumeMultiplier: 0.25 })

const played = instances.find((sound) => sound.playCount === 1)
expect(played?.volumeCalls[0]?.[0]).toBeGreaterThanOrEqual(0.225)
expect(played?.volumeCalls[0]?.[0]).toBeLessThanOrEqual(0.25)
expect(played?.stereoCalls).toEqual([[0.65, 1]])
})

test('keeps local feedback audible after the same remote cue', () => {
playSFX('itemDelete', { source: 'remote', volumeMultiplier: 0.25 })
playSFX('itemDelete')

expect(instances.reduce((total, sound) => total + sound.playCount, 0)).toBe(2)
})
})
Loading
Loading