feat: improve collaboration feedback and previews
This commit is contained in:
+62
-15
@@ -8,8 +8,10 @@ import {
|
|||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} 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 usePlacementPreview from '../../../store/use-placement-preview'
|
||||||
|
import { useFloorplanRender } from '../floorplan-render-context'
|
||||||
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
||||||
|
|
||||||
export interface FloorplanNodePreviewProps {
|
export interface FloorplanNodePreviewProps {
|
||||||
@@ -17,6 +19,10 @@ export interface FloorplanNodePreviewProps {
|
|||||||
parentNode?: AnyNode | null
|
parentNode?: AnyNode | null
|
||||||
opacity?: number
|
opacity?: number
|
||||||
className?: string
|
className?: string
|
||||||
|
selected?: boolean
|
||||||
|
highlighted?: boolean
|
||||||
|
hovered?: boolean
|
||||||
|
moving?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,27 +35,67 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
|
|||||||
parentNode = null,
|
parentNode = null,
|
||||||
opacity = 0.5,
|
opacity = 0.5,
|
||||||
className,
|
className,
|
||||||
|
selected = false,
|
||||||
|
highlighted = false,
|
||||||
|
hovered = false,
|
||||||
|
moving = false,
|
||||||
}: FloorplanNodePreviewProps) {
|
}: FloorplanNodePreviewProps) {
|
||||||
const builder = nodeRegistry.get(node.type)?.floorplan
|
const nodes = useScene((state) => state.nodes)
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
|
const renderContext = useFloorplanRender()
|
||||||
|
|
||||||
|
const geometry = useMemo(() => {
|
||||||
|
const definition = nodeRegistry.get(node.type)
|
||||||
|
const builder = definition?.floorplan
|
||||||
if (!builder) return null
|
if (!builder) return null
|
||||||
|
|
||||||
const ctx = {
|
const contextNodes: Record<string, AnyNode> = {
|
||||||
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
|
...(nodes as Record<string, AnyNode>),
|
||||||
children: [],
|
[node.id]: node,
|
||||||
siblings: [],
|
}
|
||||||
parent: parentNode,
|
if (parentNode) contextNodes[parentNode.id] = parentNode
|
||||||
viewState: undefined,
|
const resolvedParent =
|
||||||
} as unknown as GeometryContext
|
parentNode ?? (node.parentId ? (contextNodes[node.parentId] ?? null) : null)
|
||||||
|
const childIds = (node as AnyNode & { children?: AnyNodeId[] }).children ?? []
|
||||||
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
const children = childIds.flatMap((id) => {
|
||||||
node,
|
const child = contextNodes[id]
|
||||||
ctx,
|
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
|
if (!geometry) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g className={className} opacity={opacity} pointerEvents="none">
|
<g className={className} opacity={opacity} pointerEvents="none">
|
||||||
<FloorplanGeometryRenderer geometry={geometry} />
|
<FloorplanGeometryRenderer geometry={geometry} pointerEventsOverride="none" />
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -62,7 +108,8 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({
|
|||||||
* cursor dot + alignment guides — no sense of the footprint they were about
|
* cursor dot + alignment guides — no sense of the footprint they were about
|
||||||
* to drop. The placement tool publishes a transient, already-positioned +
|
* to drop. The placement tool publishes a transient, already-positioned +
|
||||||
* aligned node to `usePlacementPreview`; we build its `def.floorplan`
|
* 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
|
* Mounted inside the floor-plan scene `<g>` so the geometry's level-local
|
||||||
* meters get the same world→SVG transform every other entry does.
|
* meters get the same world→SVG transform every other entry does.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useMemo, useRef } from 'react'
|
import { useCallback, useMemo, useRef } from 'react'
|
||||||
import type { Vector3 } from 'three'
|
import type { Vector3 } from 'three'
|
||||||
|
import usePlacementPreview from '../../../store/use-placement-preview'
|
||||||
import { stripTransient } from './placement-math'
|
import { stripTransient } from './placement-math'
|
||||||
|
|
||||||
interface OriginalState {
|
interface OriginalState {
|
||||||
@@ -80,6 +81,9 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useScene.getState().createNode(node, currentLevelId)
|
useScene.getState().createNode(node, currentLevelId)
|
||||||
|
usePlacementPreview
|
||||||
|
.getState()
|
||||||
|
.set(node, useScene.getState().nodes[currentLevelId as AnyNodeId] ?? null)
|
||||||
draftRef.current = node
|
draftRef.current = node
|
||||||
adoptedRef.current = false
|
adoptedRef.current = false
|
||||||
originalStateRef.current = null
|
originalStateRef.current = null
|
||||||
@@ -112,6 +116,12 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
useScene.getState().updateNode(node.id, {
|
useScene.getState().updateNode(node.id, {
|
||||||
metadata: { ...meta, isTransient: true },
|
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 => {
|
const commit = useCallback((finalUpdate: Partial<ItemNode>): string | null => {
|
||||||
@@ -159,6 +169,9 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
const id = draft.id
|
const id = draft.id
|
||||||
|
if (usePlacementPreview.getState().node?.id === id) {
|
||||||
|
usePlacementPreview.getState().clear()
|
||||||
|
}
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
adoptedRef.current = false
|
adoptedRef.current = false
|
||||||
originalStateRef.current = null
|
originalStateRef.current = null
|
||||||
@@ -194,6 +207,9 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
||||||
})
|
})
|
||||||
useScene.getState().createNode(finalNode, parentId)
|
useScene.getState().createNode(finalNode, parentId)
|
||||||
|
if (usePlacementPreview.getState().node?.id === draft.id) {
|
||||||
|
usePlacementPreview.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
// Re-pause for next draft cycle
|
// Re-pause for next draft cycle
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
@@ -206,6 +222,8 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
const destroy = useCallback(() => {
|
const destroy = useCallback(() => {
|
||||||
if (!draftRef.current) return
|
if (!draftRef.current) return
|
||||||
|
|
||||||
|
const draftId = draftRef.current.id
|
||||||
|
|
||||||
if (adoptedRef.current && originalStateRef.current) {
|
if (adoptedRef.current && originalStateRef.current) {
|
||||||
// Move mode: restore original state instead of deleting — but only
|
// Move mode: restore original state instead of deleting — but only
|
||||||
// if no other system has already committed a new position for this
|
// if no other system has already committed a new position for this
|
||||||
@@ -227,6 +245,9 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
adoptedRef.current = false
|
adoptedRef.current = false
|
||||||
originalStateRef.current = null
|
originalStateRef.current = null
|
||||||
|
if (usePlacementPreview.getState().node?.id === draftId) {
|
||||||
|
usePlacementPreview.getState().clear()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +278,9 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
adoptedRef.current = false
|
adoptedRef.current = false
|
||||||
originalStateRef.current = null
|
originalStateRef.current = null
|
||||||
|
if (usePlacementPreview.getState().node?.id === draftId) {
|
||||||
|
usePlacementPreview.getState().clear()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
type AnyNodeDefinition,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
@@ -36,6 +37,29 @@ import { ZoneTool } from './zone/zone-tool'
|
|||||||
// Cache lazy tool components keyed by their loader so React.lazy isn't
|
// Cache lazy tool components keyed by their loader so React.lazy isn't
|
||||||
// re-invoked across renders.
|
// re-invoked across renders.
|
||||||
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
|
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 {
|
function getRegistryTool(tool: Tool | null): ComponentType | null {
|
||||||
if (!tool) return null
|
if (!tool) return null
|
||||||
@@ -44,10 +68,11 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
|
|||||||
const cached = lazyToolCache.get(def.tool)
|
const cached = lazyToolCache.get(def.tool)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
const Comp = lazy(async () => {
|
const Comp = lazy(async () => {
|
||||||
// A placed node is selected immediately. Resolve its custom inspector with
|
// Placement can only begin once the node's preview, committed renderer,
|
||||||
// the tool so that selection cannot introduce a second async boundary.
|
// inspector, and move contribution are warm. This keeps the click itself
|
||||||
const [module] = await Promise.all([def.tool!(), def.parametrics?.customPanel?.()])
|
// synchronous even under Next.js dev-time on-demand compilation.
|
||||||
return module as { default: ComponentType }
|
await preloadRegistryToolModules(tool)
|
||||||
|
return def.tool!() as Promise<{ default: ComponentType }>
|
||||||
})
|
})
|
||||||
lazyToolCache.set(def.tool, Comp)
|
lazyToolCache.set(def.tool, Comp)
|
||||||
return Comp
|
return Comp
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export {
|
|||||||
DEFAULT_STAIR_TYPE,
|
DEFAULT_STAIR_TYPE,
|
||||||
DEFAULT_STAIR_WIDTH,
|
DEFAULT_STAIR_WIDTH,
|
||||||
} from './components/tools/stair/stair-defaults'
|
} from './components/tools/stair/stair-defaults'
|
||||||
export { ToolManager } from './components/tools/tool-manager'
|
export { preloadRegistryToolModules, ToolManager } from './components/tools/tool-manager'
|
||||||
export {
|
export {
|
||||||
chainEndJoinsExistingWall,
|
chainEndJoinsExistingWall,
|
||||||
createWallOnCurrentLevel,
|
createWallOnCurrentLevel,
|
||||||
@@ -254,6 +254,7 @@ export type { SaveStatus } from './hooks/use-auto-save'
|
|||||||
export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
|
export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
|
||||||
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
|
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
|
||||||
export { markToolCancelConsumed } from './hooks/use-keyboard'
|
export { markToolCancelConsumed } from './hooks/use-keyboard'
|
||||||
|
export { useReducedMotion } from './hooks/use-reduced-motion'
|
||||||
export { type Selection, useSelection } from './hooks/use-selection'
|
export { type Selection, useSelection } from './hooks/use-selection'
|
||||||
export {
|
export {
|
||||||
clearPlacementSurface,
|
clearPlacementSurface,
|
||||||
@@ -391,6 +392,7 @@ export type { SceneGraph } from './lib/scene'
|
|||||||
export { applySceneGraphToEditor } from './lib/scene'
|
export { applySceneGraphToEditor } from './lib/scene'
|
||||||
export { movementSfxStepKey } from './lib/sfx/movement-tick'
|
export { movementSfxStepKey } from './lib/sfx/movement-tick'
|
||||||
export { triggerSFX } from './lib/sfx-bus'
|
export { triggerSFX } from './lib/sfx-bus'
|
||||||
|
export { playSFX, type SFXName, type SFXPlaybackOptions } from './lib/sfx-player'
|
||||||
export {
|
export {
|
||||||
clearSlabSnapFeedback,
|
clearSlabSnapFeedback,
|
||||||
resolveSlabEdgeBandSnap,
|
resolveSlabEdgeBandSnap,
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|||||||
type FakeContext = { id: string }
|
type FakeContext = { id: string }
|
||||||
|
|
||||||
let activeContext: FakeContext = { id: 'first' }
|
let activeContext: FakeContext = { id: 'first' }
|
||||||
|
let initialState: 'loaded' | 'loading' = 'loaded'
|
||||||
let throwOnPlay = false
|
let throwOnPlay = false
|
||||||
const instances: FakeHowl[] = []
|
const instances: FakeHowl[] = []
|
||||||
|
|
||||||
class FakeHowl {
|
class FakeHowl {
|
||||||
stateValue: 'loaded' | 'unloaded' = 'loaded'
|
stateValue: 'loaded' | 'loading' | 'unloaded' = initialState
|
||||||
unloadCount = 0
|
unloadCount = 0
|
||||||
playCount = 0
|
playCount = 0
|
||||||
|
stereoCalls: Array<[number, number | undefined]> = []
|
||||||
|
volumeCalls: Array<[number, number | undefined]> = []
|
||||||
|
|
||||||
constructor(_options: unknown) {
|
constructor(_options: unknown) {
|
||||||
instances.push(this)
|
instances.push(this)
|
||||||
@@ -21,7 +24,13 @@ class FakeHowl {
|
|||||||
return 1
|
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
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +68,7 @@ const { disposeSFXBus, initSFXBus, triggerSFX } = await import('./sfx-bus')
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
disposeSFXBus()
|
disposeSFXBus()
|
||||||
activeContext = { id: 'first' }
|
activeContext = { id: 'first' }
|
||||||
|
initialState = 'loaded'
|
||||||
throwOnPlay = false
|
throwOnPlay = false
|
||||||
instances.length = 0
|
instances.length = 0
|
||||||
})
|
})
|
||||||
@@ -104,6 +114,16 @@ describe('SFX audio context lifecycle', () => {
|
|||||||
expect(instances.length).toBe(initialCount)
|
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', () => {
|
test('disposes idempotently and recreates sounds after remount', () => {
|
||||||
preloadSFX()
|
preloadSFX()
|
||||||
const initialCount = instances.length
|
const initialCount = instances.length
|
||||||
@@ -121,4 +141,20 @@ describe('SFX audio context lifecycle', () => {
|
|||||||
|
|
||||||
expect(() => triggerSFX('sfx:item-delete')).not.toThrow()
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ export const SFX: Record<string, SFXConfig> = {
|
|||||||
|
|
||||||
export type SFXName = keyof typeof SFX
|
export type SFXName = keyof typeof SFX
|
||||||
|
|
||||||
|
export type SFXPlaybackOptions = {
|
||||||
|
source?: 'local' | 'remote'
|
||||||
|
stereo?: number
|
||||||
|
volumeMultiplier?: number
|
||||||
|
}
|
||||||
|
|
||||||
function randomInRange([min, max]: [number, number]): number {
|
function randomInRange([min, max]: [number, number]): number {
|
||||||
return min + Math.random() * (max - min)
|
return min + Math.random() * (max - min)
|
||||||
}
|
}
|
||||||
@@ -172,7 +178,7 @@ export function disposeSFX() {
|
|||||||
/**
|
/**
|
||||||
* 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, options: SFXPlaybackOptions = {}) {
|
||||||
const config = SFX[name]!
|
const config = SFX[name]!
|
||||||
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
||||||
|
|
||||||
@@ -183,9 +189,15 @@ export function playSFX(name: SFXName) {
|
|||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
if (now < sfxRetryAfter) return
|
if (now < sfxRetryAfter) return
|
||||||
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
||||||
const last = lastPlayedAt.get(name)
|
const source = options.source ?? 'local'
|
||||||
|
const playbackKey = `${source}:${name}`
|
||||||
|
const last = lastPlayedAt.get(playbackKey)
|
||||||
if (last !== undefined && now - last < minInterval) return
|
if (last !== undefined && now - last < minInterval) return
|
||||||
lastPlayedAt.set(name, now)
|
// Local feedback stays legible when a collaborator makes the same kind of
|
||||||
|
// change at nearly the same time; the quieter remote cue yields instead.
|
||||||
|
const lastLocal = lastPlayedAt.get(`local:${name}`)
|
||||||
|
if (source === 'remote' && lastLocal !== undefined && now - lastLocal < 120) return
|
||||||
|
lastPlayedAt.set(playbackKey, now)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
preloadSFX()
|
preloadSFX()
|
||||||
@@ -200,11 +212,21 @@ export function playSFX(name: SFXName) {
|
|||||||
}
|
}
|
||||||
lastVariation.set(name, index)
|
lastVariation.set(name, index)
|
||||||
const sound = sounds[index]!
|
const sound = sounds[index]!
|
||||||
|
// Howler queues per-play mutations while a sound is loading. If its global
|
||||||
|
// AudioContext is replaced before that queue drains, stereo setup can try
|
||||||
|
// to connect nodes from different contexts and throw asynchronously.
|
||||||
|
if (sound.state() !== 'loaded') return
|
||||||
const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
|
const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||||
const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
|
const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
|
||||||
|
const volumeMultiplier = Number.isFinite(options.volumeMultiplier)
|
||||||
|
? Math.max(0, Math.min(2, options.volumeMultiplier!))
|
||||||
|
: 1
|
||||||
const rate = config.rateRange ? randomInRange(config.rateRange) : 1
|
const rate = config.rateRange ? randomInRange(config.rateRange) : 1
|
||||||
const id = sound.play()
|
const id = sound.play()
|
||||||
sound.volume(baseVolume * volumeJitter, id)
|
sound.volume(baseVolume * volumeJitter * volumeMultiplier, id)
|
||||||
|
if (Number.isFinite(options.stereo)) {
|
||||||
|
sound.stereo(Math.max(-1, Math.min(1, options.stereo!)), id)
|
||||||
|
}
|
||||||
if (rate !== 1) sound.rate(rate, id)
|
if (rate !== 1) sound.rate(rate, id)
|
||||||
} catch {
|
} catch {
|
||||||
// Optional audio must never abort an editor input callback. Rebuild from
|
// Optional audio must never abort an editor input callback. Rebuild from
|
||||||
|
|||||||
@@ -399,6 +399,7 @@ export const chimneyDefinition: NodeDefinition<typeof ChimneyNode> = {
|
|||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
module: () => import('./renderer'),
|
module: () => import('./renderer'),
|
||||||
},
|
},
|
||||||
|
preview: () => import('./preview'),
|
||||||
|
|
||||||
tool: () => import('./tool'),
|
tool: () => import('./tool'),
|
||||||
toolHints: [
|
toolHints: [
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { triggerSFX } from '@pascal-app/editor'
|
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
@@ -107,6 +107,15 @@ const ChimneyTool = () => {
|
|||||||
setSegmentXform(xform)
|
setSegmentXform(xform)
|
||||||
setHitLocal([hit.localX, hit.localY, hit.localZ])
|
setHitLocal([hit.localX, hit.localY, hit.localZ])
|
||||||
setPreviewSegment(hit.segment)
|
setPreviewSegment(hit.segment)
|
||||||
|
usePlacementPreview.getState().set(
|
||||||
|
ChimneyNode.parse({
|
||||||
|
...previewNode,
|
||||||
|
parentId: hit.segment.id,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
roofSegmentId: hit.segment.id,
|
||||||
|
}),
|
||||||
|
hit.segment,
|
||||||
|
)
|
||||||
publishRoofSurfacePlacementGuides({
|
publishRoofSurfacePlacementGuides({
|
||||||
roof: event.node as RoofNode,
|
roof: event.node as RoofNode,
|
||||||
segment: hit.segment,
|
segment: hit.segment,
|
||||||
@@ -137,6 +146,8 @@ const ChimneyTool = () => {
|
|||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [chimney.id] })
|
setSelection({ selectedIds: [chimney.id] })
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
clearRoofSurfacePlacementGuides()
|
clearRoofSurfacePlacementGuides()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -149,6 +160,8 @@ const ChimneyTool = () => {
|
|||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
clearRoofSurfacePlacementGuides()
|
clearRoofSurfacePlacementGuides()
|
||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection, previewNode])
|
}, [activeBuildingId, setSelection, previewNode])
|
||||||
@@ -162,6 +175,8 @@ const ChimneyTool = () => {
|
|||||||
setSegmentXform(null)
|
setSegmentXform(null)
|
||||||
setHitLocal(null)
|
setHitLocal(null)
|
||||||
setPreviewSegment(null)
|
setPreviewSegment(null)
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
clearRoofSurfacePlacementGuides()
|
clearRoofSurfacePlacementGuides()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
|||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
module: () => import('./renderer'),
|
module: () => import('./renderer'),
|
||||||
},
|
},
|
||||||
|
preview: () => import('./preview'),
|
||||||
system: {
|
system: {
|
||||||
module: () => import('./system'),
|
module: () => import('./system'),
|
||||||
// Priority 3 mirrors the legacy DoorSystem (after animation at 2,
|
// Priority 3 mirrors the legacy DoorSystem (after animation at 2,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
WallNode as WallNodeSchema,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
calculateCursorRotation,
|
calculateCursorRotation,
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
useEditor,
|
useEditor,
|
||||||
useFacingPose,
|
useFacingPose,
|
||||||
|
usePlacementPreview,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -106,6 +109,34 @@ const DoorTool: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
const ownedPreviewIds = new Set<string>()
|
||||||
|
const fallbackPreview = DoorNode.parse({
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
side: 'front',
|
||||||
|
})
|
||||||
|
const fallbackWallId = WallNodeSchema.parse({
|
||||||
|
end: [1, 0],
|
||||||
|
start: [0, 0],
|
||||||
|
thickness: 0.1,
|
||||||
|
}).id
|
||||||
|
const publishPlacementPreview = (node: AnyNode, parentNode: AnyNode | null) => {
|
||||||
|
ownedPreviewIds.add(node.id)
|
||||||
|
usePlacementPreview.getState().set(node, parentNode)
|
||||||
|
}
|
||||||
|
const clearPlacementPreview = () => {
|
||||||
|
const current = usePlacementPreview.getState().node
|
||||||
|
if (current && ownedPreviewIds.has(current.id)) usePlacementPreview.getState().clear()
|
||||||
|
}
|
||||||
|
const publishDraftPreview = (parentNode: AnyNode) => {
|
||||||
|
const draft = draftRef.current
|
||||||
|
if (!draft) return
|
||||||
|
const live = useScene.getState().nodes[draft.id as AnyNodeId]
|
||||||
|
if (live?.type !== 'door') return
|
||||||
|
draftRef.current = live
|
||||||
|
publishPlacementPreview(live, parentNode)
|
||||||
|
}
|
||||||
|
|
||||||
let hostKind: HostKind = null
|
let hostKind: HostKind = null
|
||||||
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
|
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
|
||||||
// the grid raycast from the SAME pointermove share the source DOM event's
|
// the grid raycast from the SAME pointermove share the source DOM event's
|
||||||
@@ -136,10 +167,15 @@ const DoorTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destroyDraft = () => {
|
const destroyDraft = () => {
|
||||||
if (!draftRef.current) return
|
const draft = draftRef.current
|
||||||
const wallId = draftRef.current.parentId
|
if (!draft) {
|
||||||
useScene.getState().deleteNode(draftRef.current.id)
|
clearPlacementPreview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const wallId = draft.parentId
|
||||||
|
useScene.getState().deleteNode(draft.id)
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
|
clearPlacementPreview()
|
||||||
if (wallId) markHostDirty(wallId)
|
if (wallId) markHostDirty(wallId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +185,7 @@ const DoorTool: React.FC = () => {
|
|||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
setFallbackPose(null)
|
setFallbackPose(null)
|
||||||
useFacingPose.getState().clear()
|
useFacingPose.getState().clear()
|
||||||
|
clearPlacementPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alignment candidates — anchors of every alignable object; refreshed
|
// Alignment candidates — anchors of every alignable object; refreshed
|
||||||
@@ -192,6 +229,23 @@ const DoorTool: React.FC = () => {
|
|||||||
rotationY: sideFlip ? Math.PI : 0,
|
rotationY: sideFlip ? Math.PI : 0,
|
||||||
side: sideFlip ? 'back' : 'front',
|
side: sideFlip ? 'back' : 'front',
|
||||||
})
|
})
|
||||||
|
const halfWidth = fallbackPreview.width / 2 + 0.5
|
||||||
|
const wall = WallNodeSchema.parse({
|
||||||
|
end: [position[0] + halfWidth, position[2]],
|
||||||
|
id: fallbackWallId,
|
||||||
|
start: [position[0] - halfWidth, position[2]],
|
||||||
|
thickness: 0.1,
|
||||||
|
})
|
||||||
|
const ghost = DoorNode.parse({
|
||||||
|
...fallbackPreview,
|
||||||
|
metadata: { isTransient: true },
|
||||||
|
parentId: wall.id,
|
||||||
|
position: [halfWidth, fallbackPreview.height / 2, 0],
|
||||||
|
rotation: [0, sideFlip ? Math.PI : 0, 0],
|
||||||
|
side: sideFlip ? 'back' : 'front',
|
||||||
|
wallId: wall.id,
|
||||||
|
})
|
||||||
|
publishPlacementPreview(ghost, wall)
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
// Off-host (invalid) floating ghost — no direction triangle.
|
// Off-host (invalid) floating ghost — no direction triangle.
|
||||||
@@ -290,6 +344,7 @@ const DoorTool: React.FC = () => {
|
|||||||
roofFace: undefined,
|
roofFace: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
publishDraftPreview(wall)
|
||||||
|
|
||||||
updateCursor(
|
updateCursor(
|
||||||
wallLocalToWorld(
|
wallLocalToWorld(
|
||||||
@@ -331,6 +386,7 @@ const DoorTool: React.FC = () => {
|
|||||||
) => {
|
) => {
|
||||||
const draft = draftRef.current
|
const draft = draftRef.current
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
|
clearPlacementPreview()
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
hostKind = null
|
hostKind = null
|
||||||
|
|
||||||
@@ -530,6 +586,7 @@ const DoorTool: React.FC = () => {
|
|||||||
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
||||||
draftRef.current = node
|
draftRef.current = node
|
||||||
}
|
}
|
||||||
|
publishDraftPreview(segment)
|
||||||
// Opening guides are wall-specific; clear them while over a roof face.
|
// Opening guides are wall-specific; clear them while over a roof face.
|
||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
updateRoofCursor(target, event.node as RoofNode)
|
updateRoofCursor(target, event.node as RoofNode)
|
||||||
@@ -545,6 +602,7 @@ const DoorTool: React.FC = () => {
|
|||||||
const { segment, face, position } = target
|
const { segment, face, position } = target
|
||||||
|
|
||||||
const draft = draftRef.current
|
const draft = draftRef.current
|
||||||
|
clearPlacementPreview()
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
hostKind = null
|
hostKind = null
|
||||||
|
|
||||||
@@ -654,6 +712,7 @@ const DoorTool: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
|
clearPlacementPreview()
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|||||||
@@ -498,6 +498,7 @@ export const dormerDefinition: NodeDefinition<typeof DormerNode> = {
|
|||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
module: () => import('./renderer'),
|
module: () => import('./renderer'),
|
||||||
},
|
},
|
||||||
|
preview: () => import('./preview'),
|
||||||
|
|
||||||
tool: () => import('./tool'),
|
tool: () => import('./tool'),
|
||||||
toolHints: [
|
toolHints: [
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core'
|
import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core'
|
||||||
|
import { usePlacementPreview } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { dormerDefinition } from './definition'
|
import { dormerDefinition } from './definition'
|
||||||
import { DormerPlacementGuides } from './placement-guides'
|
import { DormerPlacementGuides } from './placement-guides'
|
||||||
@@ -67,9 +68,36 @@ const DormerTool = () => {
|
|||||||
state.createNode(dormer, hit.segment.id as AnyNodeId)
|
state.createNode(dormer, hit.segment.id as AnyNodeId)
|
||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [dormer.id] })
|
setSelection({ selectedIds: [dormer.id] })
|
||||||
|
usePlacementPreview.getState().clear()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (!(hitSegment && hitLocal)) {
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
placementPreview.set(
|
||||||
|
DormerNode.parse({
|
||||||
|
...previewNode,
|
||||||
|
parentId: hitSegment.id,
|
||||||
|
position: hitLocal,
|
||||||
|
roofSegmentId: hitSegment.id,
|
||||||
|
rotation: ghostRotation,
|
||||||
|
}),
|
||||||
|
hitSegment,
|
||||||
|
)
|
||||||
|
}, [ghostRotation, hitLocal, hitSegment, previewNode])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
|
},
|
||||||
|
[previewNode.id],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
|||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
module: () => import('./renderer'),
|
module: () => import('./renderer'),
|
||||||
},
|
},
|
||||||
|
preview: () => import('./renderer').then(({ ItemPreview }) => ({ default: ItemPreview })),
|
||||||
system: {
|
system: {
|
||||||
module: () => import('./system'),
|
module: () => import('./system'),
|
||||||
// Same priority as the legacy ItemSystem.
|
// Same priority as the legacy ItemSystem.
|
||||||
|
|||||||
@@ -429,6 +429,34 @@ const PreviewModel = ({ node }: { node: ItemNode }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LoadedItemPreview = ({ node }: { node: ItemNode }) => {
|
||||||
|
const gltf = useItemGltf(resolveCdnUrl(node.asset.src) || '')
|
||||||
|
if (getUnavailableItemAsset(gltf)) return <PreviewModel node={node} />
|
||||||
|
return (
|
||||||
|
<group rotation={node.rotation} scale={node.scale}>
|
||||||
|
<Clone
|
||||||
|
dispose={null}
|
||||||
|
object={gltf.scene}
|
||||||
|
position={node.asset.offset}
|
||||||
|
rotation={node.asset.rotation}
|
||||||
|
scale={node.asset.scale || [1, 1, 1]}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemPreview = ({ node }: { node: ItemNode }) => {
|
||||||
|
const url = resolveCdnUrl(node.asset.src) || ''
|
||||||
|
if (!url) return <PreviewModel node={node} />
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<PreviewModel node={node} />}>
|
||||||
|
<ErrorBoundary fallback={<PreviewModel node={node} />} scope="item-preview-model">
|
||||||
|
<LoadedItemPreview node={node} />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const ClearPreviewModel = ({ node }: { node: ItemNode }) => {
|
const ClearPreviewModel = ({ node }: { node: ItemNode }) => {
|
||||||
const shading = useViewer((s) => s.shading)
|
const shading = useViewer((s) => s.shading)
|
||||||
const [w, h, d] = getScaledDimensions(node)
|
const [w, h, d] = getScaledDimensions(node)
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export const skylightDefinition: NodeDefinition<typeof SkylightNode> = {
|
|||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
module: () => import('./renderer'),
|
module: () => import('./renderer'),
|
||||||
},
|
},
|
||||||
|
preview: () => import('./preview'),
|
||||||
system: {
|
system: {
|
||||||
module: () => import('./system'),
|
module: () => import('./system'),
|
||||||
priority: 3,
|
priority: 3,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { triggerSFX } from '@pascal-app/editor'
|
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
@@ -77,6 +77,15 @@ const SkylightTool = () => {
|
|||||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||||
|
usePlacementPreview.getState().set(
|
||||||
|
SkylightNode.parse({
|
||||||
|
...previewNode,
|
||||||
|
parentId: hit.segment.id,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
roofSegmentId: hit.segment.id,
|
||||||
|
}),
|
||||||
|
hit.segment,
|
||||||
|
)
|
||||||
publishRoofSurfacePlacementGuides({
|
publishRoofSurfacePlacementGuides({
|
||||||
roof: event.node as RoofNode,
|
roof: event.node as RoofNode,
|
||||||
segment: hit.segment,
|
segment: hit.segment,
|
||||||
@@ -107,6 +116,8 @@ const SkylightTool = () => {
|
|||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [skylight.id] })
|
setSelection({ selectedIds: [skylight.id] })
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
clearRoofSurfacePlacementGuides()
|
clearRoofSurfacePlacementGuides()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -119,6 +130,8 @@ const SkylightTool = () => {
|
|||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
clearRoofSurfacePlacementGuides()
|
clearRoofSurfacePlacementGuides()
|
||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection, previewNode])
|
}, [activeBuildingId, setSelection, previewNode])
|
||||||
@@ -131,6 +144,8 @@ const SkylightTool = () => {
|
|||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
clearRoofSurfacePlacementGuides()
|
clearRoofSurfacePlacementGuides()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ export const solarPanelDefinition: NodeDefinition<typeof SolarPanelNode> = {
|
|||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
module: () => import('./renderer'),
|
module: () => import('./renderer'),
|
||||||
},
|
},
|
||||||
|
preview: () => import('./preview'),
|
||||||
|
|
||||||
tool: () => import('./tool'),
|
tool: () => import('./tool'),
|
||||||
affordanceTools: {
|
affordanceTools: {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { triggerSFX } from '@pascal-app/editor'
|
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
@@ -90,6 +90,16 @@ const SolarPanelTool = () => {
|
|||||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||||
|
usePlacementPreview.getState().set(
|
||||||
|
SolarPanelNode.parse({
|
||||||
|
...previewNode,
|
||||||
|
parentId: hit.segment.id,
|
||||||
|
position: [hit.localX, hit.localY, hit.localZ],
|
||||||
|
roofSegmentId: hit.segment.id,
|
||||||
|
surfaceNormal: [normal.x, normal.y, normal.z],
|
||||||
|
}),
|
||||||
|
hit.segment,
|
||||||
|
)
|
||||||
publishRoofSurfacePlacementGuides({
|
publishRoofSurfacePlacementGuides({
|
||||||
roof: event.node as RoofNode,
|
roof: event.node as RoofNode,
|
||||||
segment: hit.segment,
|
segment: hit.segment,
|
||||||
@@ -128,6 +138,8 @@ const SolarPanelTool = () => {
|
|||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [panel.id] })
|
setSelection({ selectedIds: [panel.id] })
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
clearRoofSurfacePlacementGuides()
|
clearRoofSurfacePlacementGuides()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -140,6 +152,8 @@ const SolarPanelTool = () => {
|
|||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
clearRoofSurfacePlacementGuides()
|
clearRoofSurfacePlacementGuides()
|
||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection, previewNode])
|
}, [activeBuildingId, setSelection, previewNode])
|
||||||
@@ -152,6 +166,8 @@ const SolarPanelTool = () => {
|
|||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
|
const placementPreview = usePlacementPreview.getState()
|
||||||
|
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||||
clearRoofSurfacePlacementGuides()
|
clearRoofSurfacePlacementGuides()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
|||||||
// auto-slab live preview, history dances). Placement is wired via
|
// auto-slab live preview, history dances). Placement is wired via
|
||||||
// `def.tool`.
|
// `def.tool`.
|
||||||
tool: () => import('./tool'),
|
tool: () => import('./tool'),
|
||||||
|
preview: () => import('./preview'),
|
||||||
affordanceTools: {
|
affordanceTools: {
|
||||||
curve: () => import('./curve-tool'),
|
curve: () => import('./curve-tool'),
|
||||||
'move-endpoint': () => import('./move-endpoint-tool'),
|
'move-endpoint': () => import('./move-endpoint-tool'),
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// @ts-expect-error — bun:test is provided by the Bun runtime; nodes does not
|
||||||
|
// include Bun ambient types in its production declaration build.
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { buildWallPreviewGeometry } from './preview'
|
||||||
|
|
||||||
|
describe('wall placement preview', () => {
|
||||||
|
test('uses the wall segment footprint instead of a generic box', () => {
|
||||||
|
const geometry = buildWallPreviewGeometry({
|
||||||
|
start: [1, 2],
|
||||||
|
end: [5, 2],
|
||||||
|
height: 3,
|
||||||
|
thickness: 0.2,
|
||||||
|
})
|
||||||
|
const bounds = geometry.boundingBox!
|
||||||
|
|
||||||
|
expect(bounds.max.x - bounds.min.x).toBeCloseTo(4)
|
||||||
|
expect(bounds.max.y - bounds.min.y).toBeCloseTo(3)
|
||||||
|
expect(bounds.max.z - bounds.min.z).toBeCloseTo(0.2)
|
||||||
|
|
||||||
|
geometry.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { getWallSurfacePolygon, type WallNode } from '@pascal-app/core'
|
||||||
|
import { EDITOR_LAYER } from '@pascal-app/editor'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { ExtrudeGeometry, Shape } from 'three'
|
||||||
|
|
||||||
|
const WALL_PREVIEW_HEIGHT = 2.5
|
||||||
|
const WALL_PREVIEW_THICKNESS = 0.1
|
||||||
|
|
||||||
|
export function buildWallPreviewGeometry(
|
||||||
|
node: Pick<WallNode, 'start' | 'end' | 'curveOffset' | 'height' | 'thickness'>,
|
||||||
|
) {
|
||||||
|
const polygon = getWallSurfacePolygon({
|
||||||
|
start: node.start,
|
||||||
|
end: node.end,
|
||||||
|
curveOffset: node.curveOffset,
|
||||||
|
thickness: node.thickness ?? WALL_PREVIEW_THICKNESS,
|
||||||
|
})
|
||||||
|
const shape = new Shape()
|
||||||
|
polygon.forEach((point, index) => {
|
||||||
|
if (index === 0) shape.moveTo(point.x, -point.y)
|
||||||
|
else shape.lineTo(point.x, -point.y)
|
||||||
|
})
|
||||||
|
shape.closePath()
|
||||||
|
const geometry = new ExtrudeGeometry(shape, {
|
||||||
|
bevelEnabled: false,
|
||||||
|
depth: node.height ?? WALL_PREVIEW_HEIGHT,
|
||||||
|
steps: 1,
|
||||||
|
})
|
||||||
|
geometry.rotateX(-Math.PI / 2)
|
||||||
|
geometry.computeBoundingBox()
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
const WallPreview = ({ node }: { node: WallNode }) => {
|
||||||
|
const { curveOffset, end, height, start, thickness } = node
|
||||||
|
const geometry = useMemo(
|
||||||
|
() => buildWallPreviewGeometry({ curveOffset, end, height, start, thickness }),
|
||||||
|
[curveOffset, end, height, start, thickness],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => () => geometry.dispose(), [geometry])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh geometry={geometry} layers={EDITOR_LAYER} raycast={() => undefined} renderOrder={1}>
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#818cf8"
|
||||||
|
depthTest={false}
|
||||||
|
depthWrite={false}
|
||||||
|
opacity={0.5}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WallPreview
|
||||||
@@ -208,6 +208,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
|||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
module: () => import('./renderer'),
|
module: () => import('./renderer'),
|
||||||
},
|
},
|
||||||
|
preview: () => import('./preview'),
|
||||||
system: {
|
system: {
|
||||||
module: () => import('./system'),
|
module: () => import('./system'),
|
||||||
priority: 3,
|
priority: 3,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
emitter,
|
emitter,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
WallNode as WallNodeSchema,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
useEditor,
|
useEditor,
|
||||||
useFacingPose,
|
useFacingPose,
|
||||||
|
usePlacementPreview,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -119,6 +122,34 @@ const WindowTool: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
|
const ownedPreviewIds = new Set<string>()
|
||||||
|
const fallbackPreview = WindowNode.parse({
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
side: 'front',
|
||||||
|
})
|
||||||
|
const fallbackWallId = WallNodeSchema.parse({
|
||||||
|
end: [1, 0],
|
||||||
|
start: [0, 0],
|
||||||
|
thickness: 0.1,
|
||||||
|
}).id
|
||||||
|
const publishPlacementPreview = (node: AnyNode, parentNode: AnyNode | null) => {
|
||||||
|
ownedPreviewIds.add(node.id)
|
||||||
|
usePlacementPreview.getState().set(node, parentNode)
|
||||||
|
}
|
||||||
|
const clearPlacementPreview = () => {
|
||||||
|
const current = usePlacementPreview.getState().node
|
||||||
|
if (current && ownedPreviewIds.has(current.id)) usePlacementPreview.getState().clear()
|
||||||
|
}
|
||||||
|
const publishDraftPreview = (parentNode: AnyNode) => {
|
||||||
|
const draft = draftRef.current
|
||||||
|
if (!draft) return
|
||||||
|
const live = useScene.getState().nodes[draft.id as AnyNodeId]
|
||||||
|
if (live?.type !== 'window') return
|
||||||
|
draftRef.current = live
|
||||||
|
publishPlacementPreview(live, parentNode)
|
||||||
|
}
|
||||||
|
|
||||||
let hostKind: HostKind = null
|
let hostKind: HostKind = null
|
||||||
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
|
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
|
||||||
// the grid raycast from the SAME pointermove share the source DOM event's
|
// the grid raycast from the SAME pointermove share the source DOM event's
|
||||||
@@ -148,10 +179,15 @@ const WindowTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const destroyDraft = () => {
|
const destroyDraft = () => {
|
||||||
if (!draftRef.current) return
|
const draft = draftRef.current
|
||||||
const wallId = draftRef.current.parentId
|
if (!draft) {
|
||||||
useScene.getState().deleteNode(draftRef.current.id)
|
clearPlacementPreview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const wallId = draft.parentId
|
||||||
|
useScene.getState().deleteNode(draft.id)
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
|
clearPlacementPreview()
|
||||||
// Rebuild wall so it removes the cutout from the deleted draft
|
// Rebuild wall so it removes the cutout from the deleted draft
|
||||||
if (wallId) markHostDirty(wallId)
|
if (wallId) markHostDirty(wallId)
|
||||||
}
|
}
|
||||||
@@ -162,6 +198,7 @@ const WindowTool: React.FC = () => {
|
|||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
setFallbackPose(null)
|
setFallbackPose(null)
|
||||||
useFacingPose.getState().clear()
|
useFacingPose.getState().clear()
|
||||||
|
clearPlacementPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alignment candidates — anchors of every alignable object; refreshed
|
// Alignment candidates — anchors of every alignable object; refreshed
|
||||||
@@ -207,6 +244,23 @@ const WindowTool: React.FC = () => {
|
|||||||
floorY,
|
floorY,
|
||||||
side: sideFlip ? 'back' : 'front',
|
side: sideFlip ? 'back' : 'front',
|
||||||
})
|
})
|
||||||
|
const halfWidth = fallbackPreview.width / 2 + 0.5
|
||||||
|
const wall = WallNodeSchema.parse({
|
||||||
|
end: [position[0] + halfWidth, position[2]],
|
||||||
|
id: fallbackWallId,
|
||||||
|
start: [position[0] - halfWidth, position[2]],
|
||||||
|
thickness: 0.1,
|
||||||
|
})
|
||||||
|
const ghost = WindowNode.parse({
|
||||||
|
...fallbackPreview,
|
||||||
|
metadata: { isTransient: true },
|
||||||
|
parentId: wall.id,
|
||||||
|
position: [halfWidth, FALLBACK_SILL_LIFT + fallbackPreview.height / 2, 0],
|
||||||
|
rotation: [0, sideFlip ? Math.PI : 0, 0],
|
||||||
|
side: sideFlip ? 'back' : 'front',
|
||||||
|
wallId: wall.id,
|
||||||
|
})
|
||||||
|
publishPlacementPreview(ghost, wall)
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
// Off-host (invalid) floating ghost — no direction triangle.
|
// Off-host (invalid) floating ghost — no direction triangle.
|
||||||
@@ -349,6 +403,7 @@ const WindowTool: React.FC = () => {
|
|||||||
roofFace: undefined,
|
roofFace: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
publishDraftPreview(wall)
|
||||||
|
|
||||||
updateCursor(
|
updateCursor(
|
||||||
wallLocalToWorld(
|
wallLocalToWorld(
|
||||||
@@ -390,6 +445,7 @@ const WindowTool: React.FC = () => {
|
|||||||
) => {
|
) => {
|
||||||
const draft = draftRef.current
|
const draft = draftRef.current
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
|
clearPlacementPreview()
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
hostKind = null
|
hostKind = null
|
||||||
|
|
||||||
@@ -592,6 +648,7 @@ const WindowTool: React.FC = () => {
|
|||||||
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
||||||
draftRef.current = node
|
draftRef.current = node
|
||||||
}
|
}
|
||||||
|
publishDraftPreview(segment)
|
||||||
// Opening guides are wall-specific; clear them while over a roof face.
|
// Opening guides are wall-specific; clear them while over a roof face.
|
||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
updateRoofCursor(target, event.node as RoofNode)
|
updateRoofCursor(target, event.node as RoofNode)
|
||||||
@@ -607,6 +664,7 @@ const WindowTool: React.FC = () => {
|
|||||||
const { segment, face, position } = target
|
const { segment, face, position } = target
|
||||||
|
|
||||||
const draft = draftRef.current
|
const draft = draftRef.current
|
||||||
|
clearPlacementPreview()
|
||||||
draftRef.current = null
|
draftRef.current = null
|
||||||
hostKind = null
|
hostKind = null
|
||||||
|
|
||||||
@@ -707,6 +765,7 @@ const WindowTool: React.FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
|
clearPlacementPreview()
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
clearOpeningGuides3D()
|
clearOpeningGuides3D()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|||||||
@@ -449,14 +449,17 @@ const PointerMissedHandler = ({
|
|||||||
|
|
||||||
const OutlinerSync = () => {
|
const OutlinerSync = () => {
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
|
const externalSelectedIds = useViewer((s) => s.externalSelectedIds)
|
||||||
const hoveredId = useViewer((s) => s.hoveredId)
|
const hoveredId = useViewer((s) => s.hoveredId)
|
||||||
const outliner = useViewer((s) => s.outliner)
|
const outliner = useViewer((s) => s.outliner)
|
||||||
|
const geometryRevision = useViewer((s) => s.geometryRevision)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
void geometryRevision
|
||||||
// Sync selected objects
|
// Sync selected objects
|
||||||
outliner.selectedObjects.length = 0
|
outliner.selectedObjects.length = 0
|
||||||
for (const id of selection.selectedIds) {
|
for (const id of new Set([...selection.selectedIds, ...externalSelectedIds])) {
|
||||||
const node = nodes[id as AnyNodeId]
|
const node = nodes[id as AnyNodeId]
|
||||||
if (node?.type === 'slab') continue
|
if (node?.type === 'slab') continue
|
||||||
const obj = sceneRegistry.nodes.get(id)
|
const obj = sceneRegistry.nodes.get(id)
|
||||||
@@ -471,7 +474,7 @@ const OutlinerSync = () => {
|
|||||||
const obj = sceneRegistry.nodes.get(hoveredId)
|
const obj = sceneRegistry.nodes.get(hoveredId)
|
||||||
if (obj) outliner.hoveredObjects.push(obj)
|
if (obj) outliner.hoveredObjects.push(obj)
|
||||||
}
|
}
|
||||||
}, [selection, hoveredId, outliner, nodes])
|
}, [selection, externalSelectedIds, hoveredId, outliner, nodes, geometryRevision])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -15,6 +15,8 @@ type ViewerState = {
|
|||||||
selection: SelectionPath
|
selection: SelectionPath
|
||||||
previewSelectedIds: BaseNode['id'][]
|
previewSelectedIds: BaseNode['id'][]
|
||||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||||
|
externalSelectedIds: BaseNode['id'][]
|
||||||
|
setExternalSelectedIds: (ids: BaseNode['id'][]) => void
|
||||||
hoverHighlightMode: string
|
hoverHighlightMode: string
|
||||||
setHoverHighlightMode: (mode: string) => void
|
setHoverHighlightMode: (mode: string) => void
|
||||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import useViewer from './use-viewer'
|
|||||||
|
|
||||||
const resetMeasurementPreferences = () => {
|
const resetMeasurementPreferences = () => {
|
||||||
useViewer.setState({
|
useViewer.setState({
|
||||||
|
externalSelectedIds: [],
|
||||||
projectId: null,
|
projectId: null,
|
||||||
projectPreferences: {},
|
projectPreferences: {},
|
||||||
showMeasurements: true,
|
showMeasurements: true,
|
||||||
@@ -49,3 +50,14 @@ describe('measurement display preferences', () => {
|
|||||||
expect(useViewer.getState().showMeasurements).toBe(false)
|
expect(useViewer.getState().showMeasurements).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('external selection highlights', () => {
|
||||||
|
test('tracks host-owned highlights without changing the local selection', () => {
|
||||||
|
const localSelection = useViewer.getState().selection
|
||||||
|
|
||||||
|
useViewer.getState().setExternalSelectedIds(['wall_remote'])
|
||||||
|
|
||||||
|
expect(useViewer.getState().externalSelectedIds).toEqual(['wall_remote'])
|
||||||
|
expect(useViewer.getState().selection).toBe(localSelection)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ type ViewerState = {
|
|||||||
selection: SelectionPath
|
selection: SelectionPath
|
||||||
previewSelectedIds: BaseNode['id'][]
|
previewSelectedIds: BaseNode['id'][]
|
||||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||||
|
/** Host-owned selection highlights rendered through the viewer's native
|
||||||
|
* selection paths without changing the local user's editable selection. */
|
||||||
|
externalSelectedIds: BaseNode['id'][]
|
||||||
|
setExternalSelectedIds: (ids: BaseNode['id'][]) => void
|
||||||
hoverHighlightMode: string
|
hoverHighlightMode: string
|
||||||
setHoverHighlightMode: (mode: string) => void
|
setHoverHighlightMode: (mode: string) => void
|
||||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||||
@@ -314,6 +318,17 @@ const useViewer = create<ViewerState>()(
|
|||||||
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
||||||
previewSelectedIds: [],
|
previewSelectedIds: [],
|
||||||
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
|
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
|
||||||
|
externalSelectedIds: [],
|
||||||
|
setExternalSelectedIds: (ids) =>
|
||||||
|
set((state) => {
|
||||||
|
if (
|
||||||
|
state.externalSelectedIds.length === ids.length &&
|
||||||
|
state.externalSelectedIds.every((id, index) => id === ids[index])
|
||||||
|
) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
return { externalSelectedIds: ids }
|
||||||
|
}),
|
||||||
hoverHighlightMode: 'default',
|
hoverHighlightMode: 'default',
|
||||||
setHoverHighlightMode: (mode) =>
|
setHoverHighlightMode: (mode) =>
|
||||||
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
|
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
|
||||||
|
|||||||
Reference in New Issue
Block a user