Aymeric Rabot aab48e053f
fix(editor): harden editor interactions and WebGPU rendering
Fix editor bug sweep regressions, WebGPU CSG/material crashes, Shift snap bypass behavior, arrow handle drag projection, and the wall preview null guard covered by the Sentry follow-up PRs.
2026-06-11 13:03:34 -04:00

263 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import {
type AnyNodeId,
emitter,
type RoofEvent,
type RoofSegmentNode,
type SolarPanelNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { EDITOR_LAYER, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import {
createRelativeRoofDrag,
type RelativeRoofDragTarget,
roofSegmentLocalToBuildingLocal,
} from '../shared/relative-roof-drag'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
// MeshBasicMaterial: avoids the WebGPU "Color target has no corresponding
// fragment stage output / writeMask not zero" error that fires when
// MeshStandardMaterial (which writes to the MRT normal/roughness targets)
// is rendered in a pass whose render target lacks those attachments.
// Same fix as skylight's glass material. Visually identical for a ghost.
const previewMaterial = new THREE.MeshBasicMaterial({
color: 0x22_44_88,
transparent: true,
opacity: 0.5,
depthWrite: false,
})
export default function MoveSolarPanelTool({ node }: { node: SolarPanelNode }) {
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
const previewRef = useRef<THREE.Group>(null!)
const [previewPos, setPreviewPos] = useState<[number, number, number]>([0, 0, 0])
// Yaw = roof.rotation + segment.rotation; applied as outer rotation-y
// so the surface quat (segment-local) composes correctly — same pattern
// as the placement tool ghost.
const [previewYaw, setPreviewYaw] = useState(0)
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion>(
new THREE.Quaternion(),
)
const [hasHit, setHasHit] = useState(false)
// Compact 2×3 ghost — same size as the placement tool ghost.
const previewGeo = useMemo(() => {
const ghostRows = 2
const ghostCols = 3
const totalW = ghostCols * node.panelWidth + (ghostCols - 1) * node.gapX
const totalH = ghostRows * node.panelHeight + (ghostRows - 1) * node.gapY
const geo = new THREE.BoxGeometry(totalW, node.frameDepth, totalH)
geo.translate(0, node.standoffHeight + node.frameDepth / 2, 0)
return geo
}, [
node.panelWidth,
node.panelHeight,
node.gapX,
node.gapY,
node.frameDepth,
node.standoffHeight,
])
useEffect(() => {
useScene.temporal.getState().pause()
const original = {
position: [...node.position] as [number, number, number],
rotation: node.rotation ?? 0,
roofSegmentId: node.roofSegmentId,
parentId: node.parentId,
metadata: node.metadata,
}
const meta =
typeof node.metadata === 'object' && node.metadata !== null
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
useScene.getState().updateNode(node.id as AnyNodeId, {
metadata: { ...meta, isTransient: true },
})
const panelObj = sceneRegistry.nodes.get(node.id)
if (panelObj) panelObj.visible = false
let lastSnapX = 0
let lastSnapZ = 0
let lastTarget: RelativeRoofDragTarget | null = null
const roofDrag = createRelativeRoofDrag(original)
const updateGhost = (event: RoofEvent) => {
const target = roofDrag.resolve(event)
if (!target) return
lastTarget = target
const sx = Math.round(target.localX * 20) / 20
const sz = Math.round(target.localZ * 20) / 20
if (event.nativeEvent?.shiftKey !== true && (sx !== lastSnapX || sz !== lastSnapZ)) {
triggerSFX('sfx:grid-snap')
lastSnapX = sx
lastSnapZ = sz
}
// Use the same analytical approach as the placement tool so the
// ghost orientation matches the committed panel exactly regardless
// of segment rotation. The placement tool's ghost is always correct
// because analytical normals are computed in segment-local space
// and the yaw is applied explicitly, avoiding any world-vs-local
// normal mismatch.
const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
setPreviewSurfaceQuat(surfaceQuatFromNormal(segLocalNormal, new THREE.Quaternion()))
setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0))
setPreviewPos(
roofSegmentLocalToBuildingLocal(target.segment.id, [
target.localX,
target.localY,
target.localZ,
]),
)
setHasHit(true)
event.stopPropagation()
}
const onRoofClick = (event: RoofEvent) => {
const st = useScene.getState()
const target = lastTarget ?? roofDrag.resolve(event)
if (!target) return
const targetSegmentId = target.segment.id as AnyNodeId
// Compute segment-local normal for the committed node so the
// renderer's surfaceQuat + outer segment.rotation compose to
// the same world orientation the ghost showed.
const segLocalNormal = getAnalyticalNormal(target.localX, target.localZ, target.segment)
st.updateNode(node.id as AnyNodeId, {
position: original.position,
rotation: original.rotation,
roofSegmentId: original.roofSegmentId as AnyNodeId | undefined,
parentId: original.parentId as AnyNodeId | undefined,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId,
parentId: targetSegmentId,
position: [target.localX, target.localY, target.localZ],
rotation: original.rotation,
// Segment-local normal — must stay consistent with getAnalyticalNormal
// semantics so the renderer's surfaceQuat is in the correct frame.
surfaceNormal: [segLocalNormal.x, segLocalNormal.y, segLocalNormal.z],
visible: true,
metadata: {},
})
if (original.roofSegmentId && original.roofSegmentId !== (targetSegmentId as string)) {
const oldSeg = st.nodes[original.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined
if (oldSeg) {
st.updateNode(original.roofSegmentId as AnyNodeId, {
children: (oldSeg.children ?? []).filter((id) => id !== node.id),
})
}
const newSeg = st.nodes[targetSegmentId] as RoofSegmentNode | undefined
if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
st.updateNode(targetSegmentId, {
children: [...(newSeg.children ?? []), node.id],
})
}
st.dirtyNodes.add(original.roofSegmentId as AnyNodeId)
}
st.dirtyNodes.add(targetSegmentId)
st.dirtyNodes.add(node.id as AnyNodeId)
useScene.temporal.getState().pause()
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
triggerSFX('sfx:item-place')
exitMoveMode()
event.stopPropagation()
}
const onCancel = () => {
if (isNew) {
useScene.temporal.getState().resume()
const parentId = original.roofSegmentId
if (parentId) {
const parent = useScene.getState().nodes[parentId as AnyNodeId] as
| RoofSegmentNode
| undefined
if (parent) {
useScene.getState().updateNode(parentId as AnyNodeId, {
children: (parent.children ?? []).filter((id) => id !== node.id),
})
}
}
useScene.getState().deleteNode(node.id as AnyNodeId)
markToolCancelConsumed()
exitMoveMode()
return
}
useScene.getState().updateNode(node.id as AnyNodeId, {
position: original.position,
rotation: original.rotation,
roofSegmentId: original.roofSegmentId as AnyNodeId | undefined,
parentId: original.parentId as AnyNodeId | undefined,
metadata: original.metadata,
})
if (original.roofSegmentId) {
useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId)
}
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('roof:move', updateGhost)
emitter.on('roof:enter', updateGhost)
emitter.on('roof:click', onRoofClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('roof:move', updateGhost)
emitter.off('roof:enter', updateGhost)
emitter.off('roof:click', onRoofClick)
emitter.off('tool:cancel', onCancel)
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
useScene.temporal.getState().resume()
}
}, [exitMoveMode, node])
// Ghost layout mirrors the placement tool exactly:
// position (building-local hit point)
// → rotation-y (roof.rotation + segment.rotation — explicit yaw)
// → quaternion (segment-local surface tilt)
// This is identical to the placement ghost so drag and commit always
// show the same orientation.
return (
<group position={previewPos} ref={previewRef} visible={hasHit}>
<group rotation-y={previewYaw}>
<group quaternion={previewSurfaceQuat}>
<mesh geometry={previewGeo} layers={EDITOR_LAYER} material={previewMaterial} />
</group>
</group>
</group>
)
}