From e1a4dba74002083634d7fa1b54d479bbf8eee52d Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 20 Jul 2026 19:27:55 +0200 Subject: [PATCH] feat: improve collaboration feedback and previews --- .../floorplan-placement-preview-layer.tsx | 79 +++++++++++++++---- .../components/tools/item/use-draft-node.ts | 24 ++++++ .../src/components/tools/tool-manager.tsx | 33 +++++++- packages/editor/src/index.tsx | 4 +- packages/editor/src/lib/sfx-player.test.ts | 40 +++++++++- packages/editor/src/lib/sfx-player.ts | 30 ++++++- packages/nodes/src/chimney/definition.ts | 1 + packages/nodes/src/chimney/tool.tsx | 17 +++- packages/nodes/src/door/definition.ts | 1 + packages/nodes/src/door/tool.tsx | 65 ++++++++++++++- packages/nodes/src/dormer/definition.ts | 1 + packages/nodes/src/dormer/tool.tsx | 30 ++++++- packages/nodes/src/item/definition.ts | 1 + packages/nodes/src/item/renderer.tsx | 28 +++++++ packages/nodes/src/skylight/definition.ts | 1 + packages/nodes/src/skylight/tool.tsx | 17 +++- packages/nodes/src/solar-panel/definition.ts | 1 + packages/nodes/src/solar-panel/tool.tsx | 18 ++++- packages/nodes/src/wall/definition.ts | 1 + packages/nodes/src/wall/preview.test.ts | 22 ++++++ packages/nodes/src/wall/preview.tsx | 58 ++++++++++++++ packages/nodes/src/window/definition.ts | 1 + packages/nodes/src/window/tool.tsx | 65 ++++++++++++++- .../components/viewer/selection-manager.tsx | 7 +- packages/viewer/src/store/use-viewer.d.ts | 2 + packages/viewer/src/store/use-viewer.test.ts | 12 +++ packages/viewer/src/store/use-viewer.ts | 15 ++++ 27 files changed, 535 insertions(+), 39 deletions(-) create mode 100644 packages/nodes/src/wall/preview.test.ts create mode 100644 packages/nodes/src/wall/preview.tsx diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx index 1f0c364f7a..a7da1b05ae 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx @@ -8,8 +8,10 @@ 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 { @@ -17,6 +19,10 @@ export interface FloorplanNodePreviewProps { parentNode?: AnyNode | null opacity?: number className?: string + selected?: boolean + highlighted?: boolean + hovered?: boolean + moving?: boolean } /** @@ -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 = { + ...(nodes as Record), + [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: (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 ( - + ) }) @@ -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 `` so the geometry's level-local * meters get the same world→SVG transform every other entry does. diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index a53a47b900..f402148cfa 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -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 { @@ -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 @@ -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): string | null => { @@ -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 @@ -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() @@ -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 @@ -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 } @@ -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( diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 05df845170..d3b0c52334 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -1,4 +1,5 @@ import { + type AnyNodeDefinition, type AnyNodeId, type BuildingNode, 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 // re-invoked across renders. const lazyToolCache = new WeakMap<() => Promise, ComponentType>() +const registryToolPreloadCache = new WeakMap>() + +export function preloadRegistryToolModules(tool: string | null): Promise { + 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> = [] + 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 @@ -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 diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index cc4eaaf2d5..5db96e10c8 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -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, @@ -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, @@ -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, diff --git a/packages/editor/src/lib/sfx-player.test.ts b/packages/editor/src/lib/sfx-player.test.ts index a9c6f1b64c..53125f734a 100644 --- a/packages/editor/src/lib/sfx-player.test.ts +++ b/packages/editor/src/lib/sfx-player.test.ts @@ -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) @@ -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 } @@ -59,6 +68,7 @@ const { disposeSFXBus, initSFXBus, triggerSFX } = await import('./sfx-bus') beforeEach(() => { disposeSFXBus() activeContext = { id: 'first' } + initialState = 'loaded' throwOnPlay = false instances.length = 0 }) @@ -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 @@ -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) + }) }) diff --git a/packages/editor/src/lib/sfx-player.ts b/packages/editor/src/lib/sfx-player.ts index bd91af9d5e..1304578650 100644 --- a/packages/editor/src/lib/sfx-player.ts +++ b/packages/editor/src/lib/sfx-player.ts @@ -108,6 +108,12 @@ export const SFX: Record = { export type SFXName = keyof typeof SFX +export type SFXPlaybackOptions = { + source?: 'local' | 'remote' + stereo?: number + volumeMultiplier?: number +} + function randomInRange([min, max]: [number, number]): number { return min + Math.random() * (max - min) } @@ -172,7 +178,7 @@ export function disposeSFX() { /** * 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 { masterVolume, sfxVolume, muted } = useAudio.getState() @@ -183,9 +189,15 @@ export function playSFX(name: SFXName) { const now = performance.now() if (now < sfxRetryAfter) return 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 - 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 { preloadSFX() @@ -200,11 +212,21 @@ export function playSFX(name: SFXName) { } lastVariation.set(name, 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 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 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) } catch { // Optional audio must never abort an editor input callback. Rebuild from diff --git a/packages/nodes/src/chimney/definition.ts b/packages/nodes/src/chimney/definition.ts index f68d397c5c..986e3a0060 100644 --- a/packages/nodes/src/chimney/definition.ts +++ b/packages/nodes/src/chimney/definition.ts @@ -399,6 +399,7 @@ export const chimneyDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + preview: () => import('./preview'), tool: () => import('./tool'), toolHints: [ diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index e8455b0219..a6f91cc6f9 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -10,7 +10,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' @@ -107,6 +107,15 @@ const ChimneyTool = () => { setSegmentXform(xform) setHitLocal([hit.localX, hit.localY, hit.localZ]) 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({ roof: event.node as RoofNode, segment: hit.segment, @@ -137,6 +146,8 @@ const ChimneyTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [chimney.id] }) triggerSFX('sfx:item-place') + const placementPreview = usePlacementPreview.getState() + if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -149,6 +160,8 @@ const ChimneyTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + const placementPreview = usePlacementPreview.getState() + if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() } }, [activeBuildingId, setSelection, previewNode]) @@ -162,6 +175,8 @@ const ChimneyTool = () => { setSegmentXform(null) setHitLocal(null) setPreviewSegment(null) + const placementPreview = usePlacementPreview.getState() + if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() }} /> diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 8b335cca9d..4a6f6cc6da 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -217,6 +217,7 @@ export const doorDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + preview: () => import('./preview'), system: { module: () => import('./system'), // Priority 3 mirrors the legacy DoorSystem (after animation at 2, diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index be1fc32943..8abf2635d6 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -1,4 +1,5 @@ import { + type AnyNode, type AnyNodeId, DoorNode, emitter, @@ -11,6 +12,7 @@ import { useScene, type WallEvent, type WallNode, + WallNode as WallNodeSchema, } from '@pascal-app/core' import { calculateCursorRotation, @@ -23,6 +25,7 @@ import { useAlignmentGuides, useEditor, useFacingPose, + usePlacementPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' @@ -106,6 +109,34 @@ const DoorTool: React.FC = () => { useEffect(() => { useScene.temporal.getState().pause() + const ownedPreviewIds = new Set() + 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 // 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 @@ -136,10 +167,15 @@ const DoorTool: React.FC = () => { } const destroyDraft = () => { - if (!draftRef.current) return - const wallId = draftRef.current.parentId - useScene.getState().deleteNode(draftRef.current.id) + const draft = draftRef.current + if (!draft) { + clearPlacementPreview() + return + } + const wallId = draft.parentId + useScene.getState().deleteNode(draft.id) draftRef.current = null + clearPlacementPreview() if (wallId) markHostDirty(wallId) } @@ -149,6 +185,7 @@ const DoorTool: React.FC = () => { clearOpeningGuides3D() setFallbackPose(null) useFacingPose.getState().clear() + clearPlacementPreview() } // Alignment candidates — anchors of every alignable object; refreshed @@ -192,6 +229,23 @@ const DoorTool: React.FC = () => { rotationY: sideFlip ? Math.PI : 0, 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() clearOpeningGuides3D() // Off-host (invalid) floating ghost — no direction triangle. @@ -290,6 +344,7 @@ const DoorTool: React.FC = () => { roofFace: undefined, }) } + publishDraftPreview(wall) updateCursor( wallLocalToWorld( @@ -331,6 +386,7 @@ const DoorTool: React.FC = () => { ) => { const draft = draftRef.current if (!draft) return + clearPlacementPreview() draftRef.current = null hostKind = null @@ -530,6 +586,7 @@ const DoorTool: React.FC = () => { useScene.getState().createNode(node, segment.id as AnyNodeId) draftRef.current = node } + publishDraftPreview(segment) // Opening guides are wall-specific; clear them while over a roof face. clearOpeningGuides3D() updateRoofCursor(target, event.node as RoofNode) @@ -545,6 +602,7 @@ const DoorTool: React.FC = () => { const { segment, face, position } = target const draft = draftRef.current + clearPlacementPreview() draftRef.current = null hostKind = null @@ -654,6 +712,7 @@ const DoorTool: React.FC = () => { return () => { destroyDraft() hideCursor() + clearPlacementPreview() useAlignmentGuides.getState().clear() clearOpeningGuides3D() useScene.temporal.getState().resume() diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index ab7a15185f..4b661634db 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -498,6 +498,7 @@ export const dormerDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + preview: () => import('./preview'), tool: () => import('./tool'), toolHints: [ diff --git a/packages/nodes/src/dormer/tool.tsx b/packages/nodes/src/dormer/tool.tsx index 9b23c1cffb..e649ebb9e8 100644 --- a/packages/nodes/src/dormer/tool.tsx +++ b/packages/nodes/src/dormer/tool.tsx @@ -1,8 +1,9 @@ 'use client' import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core' +import { usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { dormerDefinition } from './definition' import { DormerPlacementGuides } from './placement-guides' @@ -67,9 +68,36 @@ const DormerTool = () => { state.createNode(dormer, hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) 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 ( <> = { kind: 'parametric', module: () => import('./renderer'), }, + preview: () => import('./renderer').then(({ ItemPreview }) => ({ default: ItemPreview })), system: { module: () => import('./system'), // Same priority as the legacy ItemSystem. diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 6fdac59902..53b6545594 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -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 + return ( + + + + ) +} + +export const ItemPreview = ({ node }: { node: ItemNode }) => { + const url = resolveCdnUrl(node.asset.src) || '' + if (!url) return + return ( + }> + } scope="item-preview-model"> + + + + ) +} + const ClearPreviewModel = ({ node }: { node: ItemNode }) => { const shading = useViewer((s) => s.shading) const [w, h, d] = getScaledDimensions(node) diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index fa7161fbb6..f76dc610b1 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -254,6 +254,7 @@ export const skylightDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + preview: () => import('./preview'), system: { module: () => import('./system'), priority: 3, diff --git a/packages/nodes/src/skylight/tool.tsx b/packages/nodes/src/skylight/tool.tsx index 66de3abdd9..c23fbfc37b 100644 --- a/packages/nodes/src/skylight/tool.tsx +++ b/packages/nodes/src/skylight/tool.tsx @@ -9,7 +9,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' @@ -77,6 +77,15 @@ const SkylightTool = () => { setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) 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({ roof: event.node as RoofNode, segment: hit.segment, @@ -107,6 +116,8 @@ const SkylightTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [skylight.id] }) triggerSFX('sfx:item-place') + const placementPreview = usePlacementPreview.getState() + if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -119,6 +130,8 @@ const SkylightTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + const placementPreview = usePlacementPreview.getState() + if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() } }, [activeBuildingId, setSelection, previewNode]) @@ -131,6 +144,8 @@ const SkylightTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + const placementPreview = usePlacementPreview.getState() + if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() }} /> diff --git a/packages/nodes/src/solar-panel/definition.ts b/packages/nodes/src/solar-panel/definition.ts index e38ab4a192..6433403a8b 100644 --- a/packages/nodes/src/solar-panel/definition.ts +++ b/packages/nodes/src/solar-panel/definition.ts @@ -259,6 +259,7 @@ export const solarPanelDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + preview: () => import('./preview'), tool: () => import('./tool'), affordanceTools: { diff --git a/packages/nodes/src/solar-panel/tool.tsx b/packages/nodes/src/solar-panel/tool.tsx index 48144ae8eb..35b7909c98 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -9,7 +9,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { triggerSFX, usePlacementPreview } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' @@ -90,6 +90,16 @@ const SolarPanelTool = () => { setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) 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({ roof: event.node as RoofNode, segment: hit.segment, @@ -128,6 +138,8 @@ const SolarPanelTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [panel.id] }) triggerSFX('sfx:item-place') + const placementPreview = usePlacementPreview.getState() + if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -140,6 +152,8 @@ const SolarPanelTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + const placementPreview = usePlacementPreview.getState() + if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() } }, [activeBuildingId, setSelection, previewNode]) @@ -152,6 +166,8 @@ const SolarPanelTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + const placementPreview = usePlacementPreview.getState() + if (placementPreview.node?.id === previewNode.id) placementPreview.clear() clearRoofSurfacePlacementGuides() }} /> diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index dcf04c3865..73c0aceabc 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -91,6 +91,7 @@ export const wallDefinition: NodeDefinition = { // auto-slab live preview, history dances). Placement is wired via // `def.tool`. tool: () => import('./tool'), + preview: () => import('./preview'), affordanceTools: { curve: () => import('./curve-tool'), 'move-endpoint': () => import('./move-endpoint-tool'), diff --git a/packages/nodes/src/wall/preview.test.ts b/packages/nodes/src/wall/preview.test.ts new file mode 100644 index 0000000000..85f133cdf1 --- /dev/null +++ b/packages/nodes/src/wall/preview.test.ts @@ -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() + }) +}) diff --git a/packages/nodes/src/wall/preview.tsx b/packages/nodes/src/wall/preview.tsx new file mode 100644 index 0000000000..070a523246 --- /dev/null +++ b/packages/nodes/src/wall/preview.tsx @@ -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, +) { + 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 ( + undefined} renderOrder={1}> + + + ) +} + +export default WallPreview diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index 6c6e8b4382..a61ea3cb7f 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -208,6 +208,7 @@ export const windowDefinition: NodeDefinition = { kind: 'parametric', module: () => import('./renderer'), }, + preview: () => import('./preview'), system: { module: () => import('./system'), priority: 3, diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index c0418f9986..ede4907b28 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -1,4 +1,5 @@ import { + type AnyNode, type AnyNodeId, emitter, type GridEvent, @@ -10,6 +11,7 @@ import { useScene, type WallEvent, type WallNode, + WallNode as WallNodeSchema, WindowNode, } from '@pascal-app/core' import { @@ -24,6 +26,7 @@ import { useAlignmentGuides, useEditor, useFacingPose, + usePlacementPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' @@ -119,6 +122,34 @@ const WindowTool: React.FC = () => { useEffect(() => { useScene.temporal.getState().pause() + const ownedPreviewIds = new Set() + 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 // 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 @@ -148,10 +179,15 @@ const WindowTool: React.FC = () => { } const destroyDraft = () => { - if (!draftRef.current) return - const wallId = draftRef.current.parentId - useScene.getState().deleteNode(draftRef.current.id) + const draft = draftRef.current + if (!draft) { + clearPlacementPreview() + return + } + const wallId = draft.parentId + useScene.getState().deleteNode(draft.id) draftRef.current = null + clearPlacementPreview() // Rebuild wall so it removes the cutout from the deleted draft if (wallId) markHostDirty(wallId) } @@ -162,6 +198,7 @@ const WindowTool: React.FC = () => { clearOpeningGuides3D() setFallbackPose(null) useFacingPose.getState().clear() + clearPlacementPreview() } // Alignment candidates — anchors of every alignable object; refreshed @@ -207,6 +244,23 @@ const WindowTool: React.FC = () => { floorY, 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() clearOpeningGuides3D() // Off-host (invalid) floating ghost — no direction triangle. @@ -349,6 +403,7 @@ const WindowTool: React.FC = () => { roofFace: undefined, }) } + publishDraftPreview(wall) updateCursor( wallLocalToWorld( @@ -390,6 +445,7 @@ const WindowTool: React.FC = () => { ) => { const draft = draftRef.current if (!draft) return + clearPlacementPreview() draftRef.current = null hostKind = null @@ -592,6 +648,7 @@ const WindowTool: React.FC = () => { useScene.getState().createNode(node, segment.id as AnyNodeId) draftRef.current = node } + publishDraftPreview(segment) // Opening guides are wall-specific; clear them while over a roof face. clearOpeningGuides3D() updateRoofCursor(target, event.node as RoofNode) @@ -607,6 +664,7 @@ const WindowTool: React.FC = () => { const { segment, face, position } = target const draft = draftRef.current + clearPlacementPreview() draftRef.current = null hostKind = null @@ -707,6 +765,7 @@ const WindowTool: React.FC = () => { return () => { destroyDraft() hideCursor() + clearPlacementPreview() useAlignmentGuides.getState().clear() clearOpeningGuides3D() useScene.temporal.getState().resume() diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index d7cb73484a..e6abb7e421 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -449,14 +449,17 @@ const PointerMissedHandler = ({ const OutlinerSync = () => { const selection = useViewer((s) => s.selection) + const externalSelectedIds = useViewer((s) => s.externalSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) const outliner = useViewer((s) => s.outliner) + const geometryRevision = useViewer((s) => s.geometryRevision) const nodes = useScene((s) => s.nodes) useEffect(() => { + void geometryRevision // Sync selected objects 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] if (node?.type === 'slab') continue const obj = sceneRegistry.nodes.get(id) @@ -471,7 +474,7 @@ const OutlinerSync = () => { const obj = sceneRegistry.nodes.get(hoveredId) if (obj) outliner.hoveredObjects.push(obj) } - }, [selection, hoveredId, outliner, nodes]) + }, [selection, externalSelectedIds, hoveredId, outliner, nodes, geometryRevision]) return null } diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 13fab0944d..355274353b 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -15,6 +15,8 @@ type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void + externalSelectedIds: BaseNode['id'][] + setExternalSelectedIds: (ids: BaseNode['id'][]) => void hoverHighlightMode: string setHoverHighlightMode: (mode: string) => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null diff --git a/packages/viewer/src/store/use-viewer.test.ts b/packages/viewer/src/store/use-viewer.test.ts index 0ef8a08ba5..817b4045e7 100644 --- a/packages/viewer/src/store/use-viewer.test.ts +++ b/packages/viewer/src/store/use-viewer.test.ts @@ -5,6 +5,7 @@ import useViewer from './use-viewer' const resetMeasurementPreferences = () => { useViewer.setState({ + externalSelectedIds: [], projectId: null, projectPreferences: {}, showMeasurements: true, @@ -49,3 +50,14 @@ describe('measurement display preferences', () => { 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) + }) +}) diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 981f06c37e..085efb5992 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -27,6 +27,10 @@ type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] 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 setHoverHighlightMode: (mode: string) => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null @@ -314,6 +318,17 @@ const useViewer = create()( selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, previewSelectedIds: [], 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', setHoverHighlightMode: (mode) => set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),