feat(editor): action menu tracks live geometry changes

Floating action menu re-derives its anchor when the selected node's
geometry rebuilds (position/index attribute versions as the key) and
accounts for roof height via getActiveRoofHeight + effective nodes.
This commit is contained in:
Aymeric Rabot
2026-06-10 14:21:24 -04:00
parent 7879b83df3
commit 07d01b18e7
@@ -10,6 +10,8 @@ import {
ElevatorNode,
FenceNode,
generateId,
getActiveRoofHeight,
getEffectiveNode,
getWallCurveLength,
getWallThickness,
ItemNode,
@@ -111,6 +113,49 @@ function getMenuYOffset(node: AnyNode | null): number {
return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
}
function getAttributeVersion(
attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute | null | undefined,
): number {
return attribute && 'version' in attribute && typeof attribute.version === 'number'
? attribute.version
: 0
}
function getObjectGeometryKey(object: THREE.Object3D): string {
const parts: string[] = []
object.traverse((child) => {
const geometry = (child as Partial<THREE.Mesh>).geometry
if (!geometry) return
parts.push(
[
geometry.id,
getAttributeVersion(geometry.getAttribute('position')),
getAttributeVersion(geometry.getIndex()),
].join(':'),
)
})
return parts.join('|')
}
function setNodeDerivedMenuAnchor(
node: AnyNode,
object: THREE.Object3D,
target: THREE.Vector3,
): boolean {
if (node.type !== 'roof-segment') return false
const visualTop =
node.wallHeight +
getActiveRoofHeight(node) +
Math.max(0, node.deckThickness ?? 0) +
Math.max(0, node.shingleThickness ?? 0)
target.set(0, visualTop, 0).applyMatrix4(object.matrixWorld)
target.y += getMenuYOffset(node)
return true
}
// Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the
// pill reads sensibly before an explicit height / thickness is set.
const FENCE_DEFAULT_HEIGHT = 1.8
@@ -171,9 +216,14 @@ export function FloatingActionMenu() {
const anchorRef = useRef(new THREE.Vector3())
const hasAnchorRef = useRef(false)
const lastMatrixRef = useRef(new THREE.Matrix4())
const lastAnchorKeyRef = useRef<{ id: string | null; node: AnyNode | null }>({
const lastAnchorKeyRef = useRef<{
id: string | null
node: AnyNode | null
geometryKey: string | null
}>({
id: null,
node: null,
geometryKey: null,
})
// Only show for single selection of specific types
@@ -218,7 +268,7 @@ export function FloatingActionMenu() {
})
useFrame((state) => {
if (!(selectedId && isValidType && groupRef.current)) return
if (!(selectedId && node && isValidType && groupRef.current)) return
// Scale the HTML menu with camera zoom (ortho) or inverse distance
// (perspective) so it feels anchored to the world, clamped on both ends
@@ -253,6 +303,8 @@ export function FloatingActionMenu() {
const obj = sceneRegistry.nodes.get(selectedId)
if (obj) {
obj.updateWorldMatrix(true, false)
// Recompute the anchor only when the object genuinely changes —
// reselected, moved (its own world matrix changed), or resized
// (a fresh store node on commit, or a live override / handle drag
@@ -261,21 +313,28 @@ export function FloatingActionMenu() {
// holds still.
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
const dragActive = activeHandleDrag?.nodeId === selectedId
const effectiveNode = getEffectiveNode(node)
const geometryKey = getObjectGeometryKey(obj)
const selectionChanged =
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
const geometryChanged = lastAnchorKeyRef.current.geometryKey !== geometryKey
if (selectionChanged || matrixChanged || overrideActive || dragActive) {
if (selectionChanged || matrixChanged || geometryChanged || overrideActive || dragActive) {
if (!setNodeDerivedMenuAnchor(effectiveNode, obj, anchorRef.current)) {
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
// Position above the object. Per-type offsets clear each kind's
// in-world chrome (height-resize arrows, measurement labels).
anchorRef.current.set(center.x, box.max.y + getMenuYOffset(node), center.z)
anchorRef.current.set(center.x, box.max.y + getMenuYOffset(effectiveNode), center.z)
hasAnchorRef.current = true
}
} else {
hasAnchorRef.current = true
}
lastMatrixRef.current.copy(obj.matrixWorld)
lastAnchorKeyRef.current = { id: selectedId, node }
lastAnchorKeyRef.current = { id: selectedId, node, geometryKey }
}
if (hasAnchorRef.current) {