The 7th and last of the legacy interaction flags. The node being placed/moved
now lives inside the interaction scope's `placing`/`moving` variant (carried
inline, since fresh-placement/duplicate drafts aren't in the scene yet), read via
`useMovingNode()` / `getMovingNode()` / `movingNodeOf(scope)`.
- scope.ts: `placing`/`moving` carry `node: AnyNode`; add `movingNodeOf`.
- use-interaction-scope.ts: `useMovingNode` (hook) + `getMovingNode` (imperative);
no useRef snapshot needed — the node is set once at `begin`, stable for the gesture.
- use-editor.tsx: drop the `movingNode` field + the `set({ movingNode })` writes.
`setMovingNode` still drives the scope and still sets `movingNodeOrigin` /
`placementDragMode`, so cross-store subscribers keep firing. Param + ~90 call
sites unchanged.
- migrate ~17 reader sites to `useMovingNode()` / `getMovingNode()`; drop
`movingNode` from lib/scene.ts; export `movingNodeOf`.
Every interaction flag is now derived from the single authoritative scope; only
`movingNodeOrigin` + `placementDragMode` intentionally remain as useEditor flags
(they outlive the scope / gate companion behavior).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
5.6 KiB
TypeScript
159 lines
5.6 KiB
TypeScript
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useEffect } from 'react'
|
|
import { Color, type Material, type Mesh } from 'three'
|
|
import useEditor from '../../../store/use-editor'
|
|
import { useMovingNode } from '../../../store/use-interaction-scope'
|
|
|
|
const CEILING_GRID_HIGHLIGHT_COLOR = '#ffffff'
|
|
const CEILING_GRID_BASE_MATERIAL_KEY = '__pascalCeilingGridBaseMaterial'
|
|
const CEILING_GRID_HIGHLIGHT_MATERIAL_KEY = '__pascalCeilingGridHighlightMaterial'
|
|
|
|
type CeilingGridUserData = {
|
|
[CEILING_GRID_BASE_MATERIAL_KEY]?: Material | Material[]
|
|
[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]?: Material | Material[]
|
|
}
|
|
|
|
type HighlightableMaterial = Material & {
|
|
color?: Color
|
|
depthWrite?: boolean
|
|
needsUpdate?: boolean
|
|
opacity?: number
|
|
transparent?: boolean
|
|
}
|
|
|
|
function cloneCeilingGridHighlightMaterial(material: Material | Material[]): Material | Material[] {
|
|
const cloneOne = (entry: Material): Material => {
|
|
const clone = entry.clone() as HighlightableMaterial
|
|
if (clone.color instanceof Color) {
|
|
clone.color.set(CEILING_GRID_HIGHLIGHT_COLOR)
|
|
}
|
|
clone.depthWrite = false
|
|
clone.opacity = 1
|
|
clone.transparent = true
|
|
clone.needsUpdate = true
|
|
return clone
|
|
}
|
|
|
|
return Array.isArray(material) ? material.map(cloneOne) : cloneOne(material)
|
|
}
|
|
|
|
function disposeMaterial(material: Material | Material[] | undefined) {
|
|
if (!material) return
|
|
const materials = Array.isArray(material) ? material : [material]
|
|
for (const entry of materials) {
|
|
entry.dispose()
|
|
}
|
|
}
|
|
|
|
function setCeilingGridHighlighted(ceilingGrid: Mesh, highlighted: boolean) {
|
|
const userData = ceilingGrid.userData as CeilingGridUserData
|
|
|
|
if (highlighted) {
|
|
if (!userData[CEILING_GRID_BASE_MATERIAL_KEY]) {
|
|
userData[CEILING_GRID_BASE_MATERIAL_KEY] = ceilingGrid.material
|
|
userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY] = cloneCeilingGridHighlightMaterial(
|
|
ceilingGrid.material,
|
|
)
|
|
}
|
|
|
|
const highlightMaterial = userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]
|
|
if (highlightMaterial) {
|
|
ceilingGrid.material = highlightMaterial
|
|
}
|
|
return
|
|
}
|
|
|
|
const baseMaterial = userData[CEILING_GRID_BASE_MATERIAL_KEY]
|
|
if (baseMaterial) {
|
|
ceilingGrid.material = baseMaterial
|
|
}
|
|
disposeMaterial(userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY])
|
|
delete userData[CEILING_GRID_BASE_MATERIAL_KEY]
|
|
delete userData[CEILING_GRID_HIGHLIGHT_MATERIAL_KEY]
|
|
}
|
|
|
|
export const CeilingSystem = () => {
|
|
const tool = useEditor((state) => state.tool)
|
|
const selectedItem = useEditor((state) => state.selectedItem)
|
|
const movingNode = useMovingNode()
|
|
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
|
const activeLevelId = useViewer((state) => state.selection.levelId)
|
|
const hoveredId = useViewer((state) => state.hoveredId)
|
|
|
|
useEffect(() => {
|
|
const nodes = useScene.getState().nodes
|
|
const hoveredNode = hoveredId ? nodes[hoveredId as AnyNodeId] : null
|
|
const hoveredCeilingId = hoveredNode?.type === 'ceiling' ? hoveredNode.id : null
|
|
|
|
const levelsToShowCeilings = new Set<string>()
|
|
|
|
const isCeilingToolActive =
|
|
tool === 'ceiling' ||
|
|
selectedItem?.attachTo === 'ceiling' ||
|
|
(movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling')
|
|
|
|
if (isCeilingToolActive && activeLevelId) {
|
|
levelsToShowCeilings.add(activeLevelId)
|
|
}
|
|
|
|
for (const id of selectedIds) {
|
|
// Only treat a directly-selected ceiling as "reveal the grid"; a
|
|
// selected descendant (e.g. a freshly-placed ceiling light) used to
|
|
// count too, which left the opaque grid overlay covering the room
|
|
// even after the user moved on. With the grid still in front, every
|
|
// subsequent click in 3D hit the grid mesh (its `useNodeEvents`
|
|
// handlers re-selected the ceiling) instead of the items below.
|
|
const selectedNode = nodes[id as AnyNodeId]
|
|
if (selectedNode?.type !== 'ceiling') continue
|
|
|
|
let currentId: string | null = selectedNode.parentId as string | null
|
|
let levelId: string | null = null
|
|
while (currentId && nodes[currentId as AnyNodeId]) {
|
|
const node = nodes[currentId as AnyNodeId]
|
|
if (node?.type === 'level') {
|
|
levelId = node.id
|
|
break
|
|
}
|
|
currentId = node?.parentId as string | null
|
|
}
|
|
|
|
if (levelId) {
|
|
levelsToShowCeilings.add(levelId)
|
|
}
|
|
}
|
|
|
|
const ceilings = sceneRegistry.byType.ceiling!
|
|
ceilings.forEach((ceiling) => {
|
|
const mesh = sceneRegistry.nodes.get(ceiling)
|
|
if (mesh) {
|
|
const ceilingGrid = mesh.getObjectByName('ceiling-grid') as Mesh | undefined
|
|
if (ceilingGrid) {
|
|
let belongsToVisibleLevel = false
|
|
let currentId: string | null = ceiling
|
|
|
|
while (currentId && nodes[currentId as AnyNodeId]) {
|
|
const node = nodes[currentId as AnyNodeId]
|
|
if (node && levelsToShowCeilings.has(node.id)) {
|
|
belongsToVisibleLevel = true
|
|
break
|
|
}
|
|
currentId = node?.parentId as string | null
|
|
}
|
|
|
|
const shouldHighlightGrid = ceiling === hoveredCeilingId
|
|
const shouldShowGrid =
|
|
shouldHighlightGrid ||
|
|
belongsToVisibleLevel ||
|
|
(levelsToShowCeilings.size === 0 && isCeilingToolActive)
|
|
|
|
setCeilingGridHighlighted(ceilingGrid, shouldHighlightGrid)
|
|
ceilingGrid.visible = shouldShowGrid
|
|
ceilingGrid.scale.setScalar(shouldShowGrid ? 1 : 0.0) // Scale down to zero to prevent event interference when grid is hidden
|
|
}
|
|
}
|
|
})
|
|
}, [tool, selectedItem, movingNode, selectedIds, activeLevelId, hoveredId])
|
|
return null
|
|
}
|