Files
mommysgoodpuppy af8fe793ab Recenter also recreates a deleted keyboard under the primary display
A deleted keyboard left no node for 53bb674 to reattach, so Recenter did
nothing for it.

Agent: Claude Code 2.1.303; model claude-opus-5-5; thread b05c29b5-7ac1-4548-9065-aa346fab238f
2026-10-19 18:22:51 +03:00

1324 lines
43 KiB
TypeScript

// @deno-types="@types/three/webgpu"
import * as THREE from "three/webgpu";
import { OBB } from "three/addons/math/OBB.js";
import type { WorkspaceOutput, WorkspaceRect } from "./workspaceDisplays.ts";
import {
DEFAULT_DISPLAY_DEPTH,
DEFAULT_DISPLAY_HEIGHT,
DISPLAY_PANEL_WIDTH,
MAX_DISPLAY_CURVATURE,
} from "./displayMetrics.ts";
export type SpatialNodeId = string;
export type SpatialAttachmentSide = "left" | "right" | "top" | "bottom";
export type SpatialTransform = {
position: [number, number, number];
rotation: [number, number, number];
scale: [number, number, number];
};
export type HingeConstraint = {
kind: "hinge";
attachmentSlotId: string;
axis: "x" | "y" | "z";
angle: number;
limits: [number, number];
parentPivot: [number, number, number];
childPivot: [number, number, number];
};
export type SpatialConstraint = HingeConstraint;
export type ParentDeleteBehavior = "preserve" | "cascade";
export type DisplayAttachmentRole = "solo" | "parent" | "child";
type SpatialNodeBase = {
id: SpatialNodeId;
parentId: SpatialNodeId | null;
originId: string;
localTransform: SpatialTransform;
constraint?: SpatialConstraint;
onParentDelete: ParentDeleteBehavior;
/** Pinned in place: grabs do not move, turn or scale it. Recenter still moves the layout. */
locked?: boolean;
/**
* Displays: the laser still lands on it and can grab it, but no longer moves or clicks the
* desktop mouse. For a window you watch rather than use, so a stray laser can't pause a video.
*/
mouseBlocked?: boolean;
/** Displays: fade out while the head is turned away from it (XSO's auto-hide). */
autoHide?: boolean;
/** 0..1, unset means opaque. Only the node itself: children keep their own. */
opacity?: number;
/** Displays: OpenVR overlay curvature, 0 < c <= `MAX_DISPLAY_CURVATURE`; unset means flat. */
curvature?: number;
};
export type BoxSnapSource = {
shape: "box";
size: [number, number, number];
localTransform: SpatialTransform;
};
export type DisplaySpatialNode = SpatialNodeBase & {
kind: "display";
ordinal: number;
/** Crop of the shared Full Workspace capture presented by this display. */
workspaceCrop?: WorkspaceRect;
workspaceOutputId?: string;
workspaceOutputName?: string;
workspaceOutputConnected?: boolean;
};
export type KeyboardSpatialNode = SpatialNodeBase & {
kind: "keyboard";
snapSource: BoxSnapSource;
};
export type SpatialControlAction = "spawn-display" | "release-hinge" | "detach";
export type ControlSpatialNode = SpatialNodeBase & {
kind: "control";
action: SpatialControlAction;
targetId: SpatialNodeId;
};
export type SpatialNode = DisplaySpatialNode | KeyboardSpatialNode | ControlSpatialNode;
export type BoxSnapHitbox = {
id: string;
ownerId: SpatialNodeId;
shape: "box";
size: [number, number, number];
localTransform: SpatialTransform;
side: SpatialAttachmentSide;
accepts: Array<SpatialNode["kind"]>;
attachments: Partial<
Record<SpatialNode["kind"], {
kind: "hinge";
axis: HingeConstraint["axis"];
angle: number;
limits: [number, number];
parentPivot: [number, number, number];
}>
>;
};
export type SpatialHitbox = BoxSnapHitbox;
export type SpatialGraph = {
nodes: Record<SpatialNodeId, SpatialNode>;
hitboxes: Record<string, SpatialHitbox>;
nextDisplayOrdinal: number;
nextControlOrdinal: number;
};
/** The world origin; the others are tracked spaces a window can be attached to; see `trackedOrigins.ts`. */
export const SCENE_ORIGIN_ID = "scene-origin";
export const IDENTITY_SPATIAL_TRANSFORM: SpatialTransform = {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
};
// Attachment pivots sit on the rendered panel's edges; a pivot that disagrees with the panel size
// separates the hinged panels by the difference (the gap users see). See displayMetrics.ts.
const DISPLAY_EDGE = DISPLAY_PANEL_WIDTH / 2;
const DISPLAY_HEIGHT = DEFAULT_DISPLAY_HEIGHT;
const DISPLAY_VERTICAL_EDGE = DEFAULT_DISPLAY_HEIGHT / 2;
const DISPLAY_SNAP_SIZE: [number, number, number] = [
DISPLAY_PANEL_WIDTH,
DISPLAY_HEIGHT,
DEFAULT_DISPLAY_DEPTH,
];
/** The keyboard tray hangs below the panel edge rather than flush with it. */
const KEYBOARD_TRAY_OFFSET = 0.035;
/** Drop targets sit in front of the panel; hinge pivots sit on its edge plane. */
const ATTACHMENT_FRONT_OFFSET = 0.025;
function transform(
position: [number, number, number],
rotation: [number, number, number] = [0, 0, 0],
scale: [number, number, number] = [1, 1, 1],
): SpatialTransform {
return { position, rotation, scale };
}
function addControl(
graph: SpatialGraph,
parentId: SpatialNodeId,
action: SpatialControlAction,
targetId: SpatialNodeId,
position: [number, number, number],
): SpatialNodeId {
const id = `control-${graph.nextControlOrdinal++}`;
graph.nodes[id] = {
id,
kind: "control",
parentId,
originId: graph.nodes[parentId].originId,
localTransform: transform(position),
onParentDelete: "cascade",
action,
targetId,
};
return id;
}
function addDefaultKeyboard(graph: SpatialGraph, displayId: SpatialNodeId): void {
const display = graph.nodes[displayId];
if (display?.kind !== "display") return;
const keyboardScale = 0.38;
const keyboardHeight = 0.2;
const snapSource: BoxSnapSource = {
shape: "box",
size: [0.5, keyboardHeight, 0.04],
localTransform: { ...IDENTITY_SPATIAL_TRANSFORM },
};
const bottomSlot = graph.hitboxes[`${displayId}-bottom-slot`];
if (bottomSlot == null) return;
graph.nodes.keyboard = {
id: "keyboard",
kind: "keyboard",
parentId: displayId,
originId: display.originId,
localTransform: transform([0, 0, 0], [0, 0, 0], [
keyboardScale,
keyboardScale,
keyboardScale,
]),
onParentDelete: "preserve",
constraint: hingeConstraintForSlot(
bottomSlot,
"keyboard",
snapSource.size,
[keyboardScale, keyboardScale, keyboardScale],
),
snapSource,
};
}
export function createInitialSpatialGraph(): SpatialGraph {
const graph: SpatialGraph = {
nodes: {},
hitboxes: {},
nextDisplayOrdinal: 2,
nextControlOrdinal: 1,
};
const rootId = "display-1";
graph.nodes[rootId] = {
id: rootId,
kind: "display",
ordinal: 1,
parentId: null,
originId: SCENE_ORIGIN_ID,
localTransform: transform([0, 0, 0]),
onParentDelete: "preserve",
};
addDisplayAttachmentSlots(graph, rootId);
addDefaultKeyboard(graph, rootId);
return reconcileDisplayControls(graph);
}
export function ensureDefaultSpatialContent(current: SpatialGraph): SpatialGraph {
const hasDisplay = Object.values(current.nodes).some((node) => node.kind === "display");
const hasKeyboard = Object.values(current.nodes).some((node) => node.kind === "keyboard");
if (hasDisplay && hasKeyboard) return current;
const graph = cloneGraph(current);
if (!hasDisplay) {
const ordinal = graph.nextDisplayOrdinal++;
const id = `display-${ordinal}`;
graph.nodes[id] = {
id,
kind: "display",
ordinal,
parentId: null,
originId: SCENE_ORIGIN_ID,
localTransform: transform([0, 0, 0]),
onParentDelete: "preserve",
};
addDisplayAttachmentSlots(graph, id);
}
if (!hasKeyboard) {
const primaryDisplay = Object.values(graph.nodes)
.filter((node): node is DisplaySpatialNode =>
node.kind === "display" && node.parentId == null
)
.sort((a, b) => a.ordinal - b.ordinal)[0];
if (primaryDisplay != null) addDefaultKeyboard(graph, primaryDisplay.id);
}
return reconcileDisplayControls(graph);
}
/** Assign physical KDE outputs to spatial displays without creating captures. */
export function assignWorkspaceOutputs(
current: SpatialGraph,
outputs: WorkspaceOutput[],
): SpatialGraph {
if (outputs.length === 0) return current;
const graph = cloneGraph(current);
const displays = Object.values(graph.nodes)
.filter((node): node is DisplaySpatialNode => node.kind === "display")
.sort((a, b) => a.ordinal - b.ordinal);
for (const [index, display] of displays.entries()) {
const output = outputs[index];
graph.nodes[display.id] = output == null
? withoutWorkspaceOutput(display)
: withWorkspaceOutput(display, output);
}
return graph;
}
export function initializeWorkspaceLayoutOutputs(
current: SpatialGraph,
outputs: WorkspaceOutput[],
restoredLayout: boolean,
): SpatialGraph {
if (restoredLayout) return reconcileWorkspaceOutputs(current, outputs);
let graph = ensureDefaultSpatialContent(current);
let displays = displayNodesByOrdinal(graph);
while (displays.length < outputs.length) {
const parent = displays.at(-1);
if (parent == null) break;
const expanded = spawnHingedDisplay(graph, parent.id);
if (expanded === graph) break;
graph = expanded;
displays = displayNodesByOrdinal(graph);
}
return assignWorkspaceOutputs(graph, outputs);
}
export function reconcileWorkspaceOutputs(
current: SpatialGraph,
outputs: WorkspaceOutput[],
): SpatialGraph {
const byId = new Map(outputs.map((output) => [output.id, output]));
const graph = cloneGraph(current);
for (const node of Object.values(graph.nodes)) {
if (node.kind !== "display" || node.workspaceOutputId == null) continue;
const output = byId.get(node.workspaceOutputId);
graph.nodes[node.id] = output == null
? withDisconnectedWorkspaceOutput(node)
: withWorkspaceOutput(node, output);
}
return graph;
}
export function assignWorkspaceOutput(
current: SpatialGraph,
displayId: SpatialNodeId,
outputId: string,
outputs: WorkspaceOutput[],
): SpatialGraph {
const target = current.nodes[displayId];
const output = outputs.find((candidate) => candidate.id === outputId);
if (target?.kind !== "display" || output == null) return current;
if (target.workspaceOutputId === outputId) return reconcileWorkspaceOutputs(current, outputs);
const graph = cloneGraph(current);
const previousOutput = outputs.find((candidate) => candidate.id === target.workspaceOutputId);
const previousOwner = Object.values(graph.nodes).find((node): node is DisplaySpatialNode =>
node.kind === "display" && node.workspaceOutputId === outputId
);
graph.nodes[target.id] = withWorkspaceOutput(target, output);
if (previousOwner != null && previousOwner.id !== target.id) {
graph.nodes[previousOwner.id] = previousOutput == null
? withoutWorkspaceOutput(previousOwner)
: withWorkspaceOutput(previousOwner, previousOutput);
}
return graph;
}
export function spawnDisplayForWorkspaceOutput(
current: SpatialGraph,
output: WorkspaceOutput,
): SpatialGraph {
if (
Object.values(current.nodes).some((node) =>
node.kind === "display" && node.workspaceOutputId === output.id
)
) return current;
let graph = current;
let displays = displayNodesByOrdinal(graph);
graph = displays.length === 0
? ensureDefaultSpatialContent(graph)
: spawnHingedDisplay(graph, displays.at(-1)!.id);
displays = displayNodesByOrdinal(graph);
const created = displays.at(-1);
return created == null ? graph : assignWorkspaceOutput(graph, created.id, output.id, [output]);
}
function displayNodesByOrdinal(graph: SpatialGraph): DisplaySpatialNode[] {
return Object.values(graph.nodes)
.filter((node): node is DisplaySpatialNode => node.kind === "display")
.sort((a, b) => a.ordinal - b.ordinal);
}
function withWorkspaceOutput(
display: DisplaySpatialNode,
output: WorkspaceOutput,
): DisplaySpatialNode {
return {
...display,
workspaceCrop: output.crop,
workspaceOutputId: output.id,
workspaceOutputName: output.name,
workspaceOutputConnected: true,
};
}
function withDisconnectedWorkspaceOutput(display: DisplaySpatialNode): DisplaySpatialNode {
return {
...display,
workspaceCrop: undefined,
workspaceOutputConnected: false,
};
}
function withoutWorkspaceOutput(display: DisplaySpatialNode): DisplaySpatialNode {
return {
...display,
workspaceCrop: undefined,
workspaceOutputId: undefined,
workspaceOutputName: undefined,
workspaceOutputConnected: undefined,
};
}
function addDisplayAttachmentSlots(graph: SpatialGraph, displayId: SpatialNodeId): void {
const sideSpecs: Array<{
side: SpatialAttachmentSide;
size: [number, number, number];
hitboxPosition: [number, number, number];
parentPivot: [number, number, number];
axis: HingeConstraint["axis"];
}> = [
{
side: "left",
size: [0.18, 0.55, 0.18],
hitboxPosition: [-DISPLAY_EDGE, 0, ATTACHMENT_FRONT_OFFSET],
parentPivot: [-DISPLAY_EDGE, 0, 0],
axis: "y",
},
{
side: "right",
size: [0.18, 0.55, 0.18],
hitboxPosition: [DISPLAY_EDGE, 0, ATTACHMENT_FRONT_OFFSET],
parentPivot: [DISPLAY_EDGE, 0, 0],
axis: "y",
},
{
side: "top",
size: [0.55, 0.14, 0.18],
hitboxPosition: [0, DISPLAY_VERTICAL_EDGE + 0.055, ATTACHMENT_FRONT_OFFSET],
parentPivot: [0, DISPLAY_VERTICAL_EDGE, 0],
axis: "x",
},
{
side: "bottom",
size: [0.55, 0.14, 0.18],
hitboxPosition: [0, -DISPLAY_VERTICAL_EDGE - 0.055, ATTACHMENT_FRONT_OFFSET],
parentPivot: [0, -DISPLAY_VERTICAL_EDGE, 0],
axis: "x",
},
];
for (const spec of sideSpecs) {
const id = `${displayId}-${spec.side}-slot`;
const displayAttachment = {
kind: "hinge" as const,
axis: spec.axis,
angle: 0,
limits: [
THREE.MathUtils.degToRad(-75),
THREE.MathUtils.degToRad(75),
] as [number, number],
parentPivot: spec.parentPivot,
};
// The keyboard tray hangs from a pivot below the panel edge so the tilted board clears the
// display; a keyboard is the only child accepted under a display.
const keyboardPivot: [number, number, number] = [
spec.parentPivot[0],
spec.parentPivot[1] - KEYBOARD_TRAY_OFFSET,
ATTACHMENT_FRONT_OFFSET,
];
graph.hitboxes[id] = {
id,
ownerId: displayId,
side: spec.side,
shape: "box",
size: spec.size,
localTransform: transform(spec.hitboxPosition),
accepts: spec.side === "bottom" ? ["display", "keyboard"] : ["display"],
attachments: spec.side === "bottom"
? {
display: displayAttachment,
keyboard: {
kind: "hinge",
axis: "x",
angle: THREE.MathUtils.degToRad(-55),
limits: [THREE.MathUtils.degToRad(-100), THREE.MathUtils.degToRad(15)],
parentPivot: keyboardPivot,
},
}
: { display: displayAttachment },
};
}
}
function cloneGraph(graph: SpatialGraph): SpatialGraph {
return {
nodes: { ...graph.nodes },
hitboxes: { ...graph.hitboxes },
nextDisplayOrdinal: graph.nextDisplayOrdinal,
nextControlOrdinal: graph.nextControlOrdinal,
};
}
function removeControls(
graph: SpatialGraph,
predicate: (node: ControlSpatialNode) => boolean,
): void {
for (const node of Object.values(graph.nodes)) {
if (node.kind === "control" && predicate(node)) {
delete graph.nodes[node.id];
}
}
}
function reconcileDisplayControls(current: SpatialGraph): SpatialGraph {
const graph = cloneGraph(current);
// Legacy graph-authored 3D controls are removed in favor of the selected
// GrabBox's UIKit contextual toolbar.
removeControls(graph, () => true);
return graph;
}
export function spawnHingedDisplay(
current: SpatialGraph,
parentId: SpatialNodeId,
preferredSide?: SpatialAttachmentSide,
): SpatialGraph {
const parent = current.nodes[parentId];
if (parent?.kind !== "display") return current;
const graph = cloneGraph(current);
const sideOrder: SpatialAttachmentSide[] = preferredSide == null
? ["right", "left", "top", "bottom"]
: [preferredSide];
const slot = sideOrder
.map((side) => graph.hitboxes[`${parentId}-${side}-slot`])
.find((candidate) =>
candidate?.accepts.includes("display") && !isAttachmentSlotOccupied(graph, candidate.id)
);
if (slot == null) return current;
const ordinal = graph.nextDisplayOrdinal++;
const id = `display-${ordinal}`;
const display: DisplaySpatialNode = {
id,
kind: "display",
ordinal,
parentId,
originId: parent.originId,
localTransform: { ...IDENTITY_SPATIAL_TRANSFORM },
onParentDelete: "preserve",
constraint: hingeConstraintForSlot(slot, "display", DISPLAY_SNAP_SIZE, [1, 1, 1]),
};
graph.nodes[id] = display;
addDisplayAttachmentSlots(graph, id);
return reconcileDisplayControls(graph);
}
export function spawnHingedDisplayWithAutomaticOutput(
current: SpatialGraph,
parentId: SpatialNodeId,
outputs: WorkspaceOutput[],
preferredSide?: SpatialAttachmentSide,
): SpatialGraph {
const assignedOutputIds = new Set(
Object.values(current.nodes)
.filter((node): node is DisplaySpatialNode => node.kind === "display")
.map((display) => display.workspaceOutputId)
.filter((id): id is string => id != null),
);
const unusedOutput = outputs.find((output) => !assignedOutputIds.has(output.id));
const existingNodeIds = new Set(Object.keys(current.nodes));
const graph = spawnHingedDisplay(current, parentId, preferredSide);
if (graph === current || unusedOutput == null) return graph;
const created = displayNodesByOrdinal(graph).find((display) => !existingNodeIds.has(display.id));
return created == null
? graph
: assignWorkspaceOutput(graph, created.id, unusedOutput.id, outputs);
}
export function setHingeAngle(
current: SpatialGraph,
nodeId: SpatialNodeId,
angle: number,
): SpatialGraph {
const node = current.nodes[nodeId];
if (node?.constraint?.kind !== "hinge") return current;
const graph = cloneGraph(current);
graph.nodes[nodeId] = {
...node,
constraint: {
...node.constraint,
angle: THREE.MathUtils.clamp(angle, node.constraint.limits[0], node.constraint.limits[1]),
},
};
return graph;
}
function composeTransform(value: SpatialTransform): THREE.Matrix4 {
return new THREE.Matrix4().compose(
new THREE.Vector3(...value.position),
new THREE.Quaternion().setFromEuler(new THREE.Euler(...value.rotation, "XYZ")),
new THREE.Vector3(...value.scale),
);
}
function decomposeTransform(matrix: THREE.Matrix4): SpatialTransform {
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3();
matrix.decompose(position, quaternion, scale);
const rotation = new THREE.Euler().setFromQuaternion(quaternion, "XYZ");
return {
position: position.toArray() as [number, number, number],
rotation: [rotation.x, rotation.y, rotation.z],
scale: scale.toArray() as [number, number, number],
};
}
export function getEffectiveLocalMatrix(node: SpatialNode): THREE.Matrix4 {
const local = composeTransform(node.localTransform);
if (node.constraint?.kind !== "hinge") return local;
const hinge = node.constraint;
const axis = hinge.axis === "x"
? new THREE.Vector3(1, 0, 0)
: hinge.axis === "y"
? new THREE.Vector3(0, 1, 0)
: new THREE.Vector3(0, 0, 1);
return new THREE.Matrix4()
.makeTranslation(...hinge.parentPivot)
.multiply(new THREE.Matrix4().makeRotationAxis(axis, hinge.angle))
.multiply(new THREE.Matrix4().makeTranslation(
-hinge.childPivot[0],
-hinge.childPivot[1],
-hinge.childPivot[2],
))
.multiply(local);
}
export function getSpatialNodeWorldMatrix(
graph: SpatialGraph,
nodeId: SpatialNodeId,
): THREE.Matrix4 {
const node = graph.nodes[nodeId];
if (!node) return new THREE.Matrix4();
const local = getEffectiveLocalMatrix(node);
return node.parentId == null
? local
: getSpatialNodeWorldMatrix(graph, node.parentId).multiply(local);
}
export function commitNodeTransform(
current: SpatialGraph,
nodeId: SpatialNodeId,
localTransform: SpatialTransform,
): SpatialGraph {
const node = current.nodes[nodeId];
if (!node || node.constraint) return current;
const graph = cloneGraph(current);
graph.nodes[nodeId] = { ...node, localTransform };
return graph;
}
/**
* Recenter: move the whole layout rigidly so the primary display (lowest-ordinal root display)
* sits at `offset` in front of the head, facing it. Only yaw is taken from the head, so looking
* down at the wrist while recentering does not tilt every display. Every root moves by the same
* transform, which keeps the arrangement between separate roots; children follow their parents.
*/
export function recenterSpatialRoots(
current: SpatialGraph,
headPosition: [number, number, number],
headYaw: number,
offset: [number, number, number],
): SpatialGraph {
// Windows attached to the head or a hand already sit where they belong.
const roots = Object.values(current.nodes).filter((node) =>
node.parentId == null && !node.constraint && node.originId === SCENE_ORIGIN_ID
);
const primary = roots
.filter((node): node is DisplaySpatialNode => node.kind === "display")
.sort((a, b) => a.ordinal - b.ordinal)[0];
if (primary == null) return current;
const up = new THREE.Vector3(0, 1, 0);
const yawOf = (rotation: [number, number, number]) => {
const forward = new THREE.Vector3(0, 0, -1).applyEuler(new THREE.Euler(...rotation, "XYZ"));
return Math.atan2(-forward.x, -forward.z);
};
const primaryPosition = new THREE.Vector3(...primary.localTransform.position);
const target = new THREE.Vector3(...offset).applyAxisAngle(up, headYaw)
.add(new THREE.Vector3(...headPosition));
const turn = new THREE.Quaternion().setFromAxisAngle(
up,
headYaw - yawOf(primary.localTransform.rotation),
);
const graph = cloneGraph(current);
for (const node of roots) {
const position = new THREE.Vector3(...node.localTransform.position)
.sub(primaryPosition)
.applyQuaternion(turn)
.add(target);
const rotation = new THREE.Euler().setFromQuaternion(
turn.clone().multiply(
new THREE.Quaternion().setFromEuler(
new THREE.Euler(...node.localTransform.rotation, "XYZ"),
),
),
"XYZ",
);
graph.nodes[node.id] = {
...node,
localTransform: {
...node.localTransform,
position: position.toArray() as [number, number, number],
rotation: [rotation.x, rotation.y, rotation.z],
},
};
}
// A keyboard that no longer hangs from a display comes back under the primary one, so one
// dropped out of reach, left on a hand or deleted is a Recenter away. A taken bottom slot keeps
// it put.
const keyboard = Object.values(graph.nodes).find((node) => node.kind === "keyboard");
const bottomSlot = `${primary.id}-bottom-slot`;
if (keyboard == null && !isAttachmentSlotOccupied(graph, bottomSlot)) {
addDefaultKeyboard(graph, primary.id);
return reconcileDisplayControls(graph);
}
if (keyboard != null && keyboard.constraint == null) {
return attachSpatialNodeToSlot(graph, keyboard.id, bottomSlot);
}
return graph;
}
/** The top of `nodeId`'s attachment hierarchy (itself when it has no parent). */
export function getSpatialRootId(graph: SpatialGraph, nodeId: SpatialNodeId): SpatialNodeId {
let id = nodeId;
for (let parent = graph.nodes[id]?.parentId; parent != null; parent = graph.nodes[id]?.parentId) {
id = parent;
}
return id;
}
/**
* Re-anchor a whole hierarchy to another origin. `localTransform` is the root's pose in the new
* origin's space (the caller measures it, so the window stays where it is in the world);
* descendants keep their transforms relative to the root.
*/
export function setSpatialRootOrigin(
current: SpatialGraph,
nodeId: SpatialNodeId,
originId: string,
localTransform: SpatialTransform,
): SpatialGraph {
const rootId = getSpatialRootId(current, nodeId);
const root = current.nodes[rootId];
if (!root) return current;
const graph = cloneGraph(current);
graph.nodes[rootId] = { ...root, originId, localTransform };
for (const node of Object.values(current.nodes)) {
if (node.id !== rootId && isSpatialDescendant(current, node.id, rootId)) {
graph.nodes[node.id] = { ...node, originId };
}
}
return graph;
}
export function setSpatialNodeLocked(
current: SpatialGraph,
nodeId: SpatialNodeId,
locked: boolean,
): SpatialGraph {
const node = current.nodes[nodeId];
if (!node || (node.locked ?? false) === locked) return current;
const graph = cloneGraph(current);
graph.nodes[nodeId] = { ...node, locked };
return graph;
}
export function setSpatialNodeMouseBlocked(
current: SpatialGraph,
nodeId: SpatialNodeId,
mouseBlocked: boolean,
): SpatialGraph {
const node = current.nodes[nodeId];
if (!node || (node.mouseBlocked ?? false) === mouseBlocked) return current;
const graph = cloneGraph(current);
const { mouseBlocked: _previous, ...rest } = node;
graph.nodes[nodeId] = (mouseBlocked ? { ...rest, mouseBlocked } : rest) as SpatialNode;
return graph;
}
export function setSpatialNodeAutoHide(
current: SpatialGraph,
nodeId: SpatialNodeId,
autoHide: boolean,
): SpatialGraph {
const node = current.nodes[nodeId];
if (!node || (node.autoHide ?? false) === autoHide) return current;
const graph = cloneGraph(current);
const { autoHide: _previous, ...rest } = node;
graph.nodes[nodeId] = (autoHide ? { ...rest, autoHide } : rest) as SpatialNode;
return graph;
}
export const MIN_SPATIAL_NODE_OPACITY = 0.1;
/** Clamped to [`MIN_SPATIAL_NODE_OPACITY`, 1], so a window can't vanish; 1 clears the field. */
export function setSpatialNodeOpacity(
current: SpatialGraph,
nodeId: SpatialNodeId,
opacity: number,
): SpatialGraph {
const node = current.nodes[nodeId];
if (!node || !Number.isFinite(opacity)) return current;
const clamped = Math.min(1, Math.max(MIN_SPATIAL_NODE_OPACITY, opacity));
if ((node.opacity ?? 1) === clamped) return current;
const graph = cloneGraph(current);
const { opacity: _previous, ...rest } = node;
graph.nodes[nodeId] = (clamped === 1 ? rest : { ...rest, opacity: clamped }) as SpatialNode;
return graph;
}
/** Clamped to [0, `MAX_DISPLAY_CURVATURE`]; 0 clears the field. */
export function setSpatialNodeCurvature(
current: SpatialGraph,
nodeId: SpatialNodeId,
curvature: number,
): SpatialGraph {
const node = current.nodes[nodeId];
if (!node || !Number.isFinite(curvature)) return current;
const clamped = Math.min(MAX_DISPLAY_CURVATURE, Math.max(0, curvature));
if ((node.curvature ?? 0) === clamped) return current;
const graph = cloneGraph(current);
const { curvature: _previous, ...rest } = node;
graph.nodes[nodeId] = (clamped === 0 ? rest : { ...rest, curvature: clamped }) as SpatialNode;
return graph;
}
/** Commit a completed free manipulation and adopt the closest overlapping attachment slot. */
export function commitSpatialNodeTransformAndSnap(
current: SpatialGraph,
nodeId: SpatialNodeId,
localTransform: SpatialTransform,
): SpatialGraph {
return snapNodeToOverlappingHitbox(
commitNodeTransform(current, nodeId, localTransform),
nodeId,
);
}
export function updateSnapSourceSize(
current: SpatialGraph,
nodeId: SpatialNodeId,
size: [number, number, number],
): SpatialGraph {
const node = current.nodes[nodeId];
if (node?.kind !== "keyboard") return current;
if (node.snapSource.size.every((value, index) => Math.abs(value - size[index]) < 0.0001)) {
return current;
}
const graph = cloneGraph(current);
graph.nodes[nodeId] = {
...node,
snapSource: { ...node.snapSource, size },
};
return graph;
}
function orientedBoxFromMatrix(
size: [number, number, number],
matrix: THREE.Matrix4,
): OBB {
const half = new THREE.Vector3(...size).multiplyScalar(0.5);
return new OBB(
new THREE.Vector3() as never,
half as never,
new THREE.Matrix3() as never,
).applyMatrix4(matrix as never);
}
export function getSpatialHitboxWorldMatrix(
graph: SpatialGraph,
hitbox: SpatialHitbox,
): THREE.Matrix4 {
return getSpatialNodeWorldMatrix(graph, hitbox.ownerId)
.multiply(composeTransform(hitbox.localTransform));
}
function displayAttachmentSlots(graph: SpatialGraph, displayId: SpatialNodeId): SpatialHitbox[] {
return Object.values(graph.hitboxes).filter((hitbox) => hitbox.ownerId === displayId);
}
export function hasAvailableDisplayAttachmentSlot(
graph: SpatialGraph,
displayId: SpatialNodeId,
): boolean {
return displayAttachmentSlots(graph, displayId).some((slot) =>
slot.accepts.includes("display") && !isAttachmentSlotOccupied(graph, slot.id)
);
}
/** True while a node hangs from the display's bottom slot (the keyboard, by default). */
/** Where a display's context toolbar goes. */
export type DisplayToolbarSide = "below" | "above" | "left" | "right";
const OPPOSITE_SIDE: Record<SpatialAttachmentSide, SpatialAttachmentSide> = {
left: "right",
right: "left",
top: "bottom",
bottom: "top",
};
/**
* Below the display unless something is there: then above, and when a stack fills both (a window
* between another window and a keyboard), beside it: on the left, where SteamVR puts an
* overlay's controls, unless only the right is free.
*
* A side is taken by a child hinged to that side's slot, or by the parent this display hangs
* from: a display in its parent's bottom slot has the parent above it.
*/
export function displayToolbarSide(
graph: SpatialGraph,
displayId: SpatialNodeId,
): DisplayToolbarSide {
const constraint = graph.nodes[displayId]?.constraint;
const parentSide = constraint?.kind === "hinge"
? constraint.attachmentSlotId.match(/-(left|right|top|bottom)-slot$/)?.[1] as
| SpatialAttachmentSide
| undefined
: undefined;
const taken = (side: SpatialAttachmentSide) =>
isAttachmentSlotOccupied(graph, `${displayId}-${side}-slot`) ||
(parentSide != null && OPPOSITE_SIDE[parentSide] === side);
if (!taken("bottom")) return "below";
if (!taken("top")) return "above";
return taken("left") && !taken("right") ? "right" : "left";
}
function isAttachmentSlotOccupied(graph: SpatialGraph, slotId: string): boolean {
return Object.values(graph.nodes).some((node) =>
node.constraint?.kind === "hinge" && node.constraint.attachmentSlotId === slotId
);
}
function nodeSnapSource(node: DisplaySpatialNode | KeyboardSpatialNode): BoxSnapSource {
return node.kind === "keyboard" ? node.snapSource : {
shape: "box",
size: DISPLAY_SNAP_SIZE,
localTransform: IDENTITY_SPATIAL_TRANSFORM,
};
}
function childPivotForSlot(
side: SpatialAttachmentSide,
size: [number, number, number],
scale: [number, number, number],
): [number, number, number] {
const halfWidth = 0.5 * size[0] * scale[0];
const halfHeight = 0.5 * size[1] * scale[1];
switch (side) {
case "left":
return [halfWidth, 0, 0];
case "right":
return [-halfWidth, 0, 0];
case "top":
return [0, -halfHeight, 0];
case "bottom":
return [0, halfHeight, 0];
}
}
function hingeConstraintForSlot(
slot: SpatialHitbox,
childKind: DisplaySpatialNode["kind"] | KeyboardSpatialNode["kind"],
childSize: [number, number, number],
childScale: [number, number, number],
): HingeConstraint {
const attachment = slot.attachments[childKind];
if (attachment == null) throw new Error(`Slot ${slot.id} does not accept ${childKind}`);
return {
kind: "hinge",
attachmentSlotId: slot.id,
axis: attachment.axis,
angle: attachment.angle,
limits: attachment.limits,
parentPivot: attachment.parentPivot,
childPivot: childPivotForSlot(slot.side, childSize, childScale),
};
}
export function attachSpatialNodeToSlot(
current: SpatialGraph,
nodeId: SpatialNodeId,
slotId: string,
): SpatialGraph {
const node = current.nodes[nodeId];
const slot = current.hitboxes[slotId];
const owner = slot && current.nodes[slot.ownerId];
if (
(node?.kind !== "keyboard" && node?.kind !== "display") ||
node.constraint != null ||
slot == null ||
owner == null ||
!slot.accepts.includes(node.kind) ||
isAttachmentSlotOccupied(current, slot.id) ||
slot.ownerId === node.id ||
isSpatialDescendant(current, slot.ownerId, node.id)
) return current;
const snapSource = nodeSnapSource(node);
const graph = cloneGraph(current);
graph.nodes[nodeId] = {
...node,
parentId: slot.ownerId,
originId: owner.originId,
localTransform: transform([0, 0, 0], [0, 0, 0], node.localTransform.scale),
constraint: hingeConstraintForSlot(
slot,
node.kind,
snapSource.size,
node.localTransform.scale,
),
};
return reconcileDisplayControls(graph);
}
/**
* Commit a hinged node's angle and uniform scale.
*
* A hinge's child pivot is the node's own half-extent, so a resize has to move it too or the
* panel's hinged edge walks off the joint as it grows. The slot table still owns the pivot: this
* only feeds it the new size, exactly as a load does.
*/
export function commitHingePose(
current: SpatialGraph,
nodeId: SpatialNodeId,
angle: number,
scale: number,
): SpatialGraph {
const node = current.nodes[nodeId];
if (
(node?.kind !== "display" && node?.kind !== "keyboard") ||
node.constraint?.kind !== "hinge" ||
!Number.isFinite(scale) || scale <= 0
) return current;
const slot = current.hitboxes[node.constraint.attachmentSlotId];
if (slot == null || slot.attachments[node.kind] == null) return current;
const uniform: [number, number, number] = [scale, scale, scale];
const hinge = hingeConstraintForSlot(slot, node.kind, nodeSnapSource(node).size, uniform);
const graph = cloneGraph(current);
graph.nodes[nodeId] = {
...node,
localTransform: { ...node.localTransform, scale: uniform },
constraint: {
...hinge,
angle: THREE.MathUtils.clamp(angle, hinge.limits[0], hinge.limits[1]),
},
};
return graph;
}
function sameNumbers(a: readonly number[], b: readonly number[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function sameHingeGeometry(a: HingeConstraint, b: HingeConstraint): boolean {
return a.attachmentSlotId === b.attachmentSlotId &&
a.axis === b.axis &&
a.angle === b.angle &&
sameNumbers(a.limits, b.limits) &&
sameNumbers(a.parentPivot, b.parentPivot) &&
sameNumbers(a.childPivot, b.childPivot);
}
/** Attachment slots as the current code lays them out, independent of what a layout persisted. */
function attachmentSlotsFor(nodes: SpatialGraph["nodes"]): SpatialGraph["hitboxes"] {
const scratch: SpatialGraph = {
nodes,
hitboxes: {},
nextDisplayOrdinal: 0,
nextControlOrdinal: 0,
};
for (const node of Object.values(nodes)) {
if (node.kind === "display") addDisplayAttachmentSlots(scratch, node.id);
}
return scratch.hitboxes;
}
/**
* Re-derive the geometry a saved layout does not own: the attachment slot table and every hinge's
* pivots.
*
* Slots and pivots are a function of the current panel metrics and each node's size, but they are
* serialized with the graph, so a layout saved before those metrics changed would keep hinging to
* the old geometry and the panels would separate. Only the user's choices — hierarchy, transforms,
* hinge angle, workspace assignment — survive; everything derived is rebuilt here. Returns `current`
* untouched when the stored geometry already matches.
*/
export function normalizeSpatialLayout(current: SpatialGraph): SpatialGraph {
const hitboxes = attachmentSlotsFor(current.nodes);
const nodes: SpatialGraph["nodes"] = {};
let changed = JSON.stringify(hitboxes) !== JSON.stringify(current.hitboxes);
for (const node of Object.values(current.nodes)) {
if (
(node.kind !== "display" && node.kind !== "keyboard") || node.constraint?.kind !== "hinge"
) {
nodes[node.id] = node;
continue;
}
const slot = hitboxes[node.constraint.attachmentSlotId];
if (slot == null || slot.attachments[node.kind] == null) {
nodes[node.id] = node;
continue;
}
const normalized = hingeConstraintForSlot(
slot,
node.kind,
nodeSnapSource(node).size,
node.localTransform.scale,
);
const constraint: HingeConstraint = {
...normalized,
angle: THREE.MathUtils.clamp(
node.constraint.angle,
normalized.limits[0],
normalized.limits[1],
),
};
if (sameHingeGeometry(node.constraint, constraint)) {
nodes[node.id] = node;
continue;
}
nodes[node.id] = { ...node, constraint };
changed = true;
}
return changed ? { ...current, nodes, hitboxes } : current;
}
export function snapNodeToOverlappingHitbox(
current: SpatialGraph,
nodeId: SpatialNodeId,
): SpatialGraph {
const node = current.nodes[nodeId];
if ((node?.kind !== "keyboard" && node?.kind !== "display") || node.constraint != null) {
return current;
}
const snapSource = nodeSnapSource(node);
const sourceMatrix = getSpatialNodeWorldMatrix(current, nodeId)
.multiply(composeTransform(snapSource.localTransform));
const sourceBox = orientedBoxFromMatrix(snapSource.size, sourceMatrix);
const sourceCenter = sourceBox.center;
const candidates = Object.values(current.hitboxes)
.filter((hitbox) => hitbox.accepts.includes(node.kind))
.filter((hitbox) => hitbox.ownerId !== node.id)
// Only within one origin: a hand-attached window passing a world one must not latch on.
.filter((hitbox) => current.nodes[hitbox.ownerId]?.originId === node.originId)
.filter((hitbox) => !isAttachmentSlotOccupied(current, hitbox.id))
.filter((hitbox) => !isSpatialDescendant(current, hitbox.ownerId, node.id))
.filter((hitbox) =>
sourceBox.intersectsOBB(orientedBoxFromMatrix(
hitbox.size,
getSpatialHitboxWorldMatrix(current, hitbox),
))
)
.sort((a, b) => {
const aCenter = new THREE.Vector3().setFromMatrixPosition(
getSpatialHitboxWorldMatrix(current, a),
);
const bCenter = new THREE.Vector3().setFromMatrixPosition(
getSpatialHitboxWorldMatrix(current, b),
);
const adx = sourceCenter.x - aCenter.x;
const ady = sourceCenter.y - aCenter.y;
const adz = sourceCenter.z - aCenter.z;
const bdx = sourceCenter.x - bCenter.x;
const bdy = sourceCenter.y - bCenter.y;
const bdz = sourceCenter.z - bCenter.z;
return adx * adx + ady * ady + adz * adz - (bdx * bdx + bdy * bdy + bdz * bdz);
});
const hitbox = candidates[0];
return hitbox == null ? current : attachSpatialNodeToSlot(current, nodeId, hitbox.id);
}
function isSpatialDescendant(
graph: SpatialGraph,
candidateId: SpatialNodeId,
ancestorId: SpatialNodeId,
): boolean {
let current = graph.nodes[candidateId];
while (current?.parentId != null) {
if (current.parentId === ancestorId) return true;
current = graph.nodes[current.parentId];
}
return false;
}
export function releaseHinge(current: SpatialGraph, nodeId: SpatialNodeId): SpatialGraph {
const node = current.nodes[nodeId];
if (node?.constraint?.kind !== "hinge") return current;
const graph = cloneGraph(current);
graph.nodes[nodeId] = {
...node,
localTransform: decomposeTransform(getEffectiveLocalMatrix(node)),
constraint: undefined,
};
return reconcileDisplayControls(graph);
}
export function detachFromParent(current: SpatialGraph, nodeId: SpatialNodeId): SpatialGraph {
const node = current.nodes[nodeId];
if (!node || node.parentId == null) return current;
const world = getSpatialNodeWorldMatrix(current, nodeId);
const graph = cloneGraph(current);
graph.nodes[nodeId] = {
...node,
parentId: null,
localTransform: decomposeTransform(world),
constraint: undefined,
};
return reconcileDisplayControls(graph);
}
/**
* Detach the display edge represented by a selected hierarchy node.
* Attached parents move their complete subtree to root; root parents release
* their direct display children. Non-display children remain owned normally.
*/
export function detachDisplayHierarchy(
current: SpatialGraph,
nodeId: SpatialNodeId,
): SpatialGraph {
const node = current.nodes[nodeId];
if (node?.kind !== "display") return current;
if (node.parentId != null) return detachFromParent(current, nodeId);
const displayChildren = getSpatialChildren(current, nodeId)
.filter((child): child is DisplaySpatialNode => child.kind === "display");
return displayChildren.reduce(
(graph, child) => detachFromParent(graph, child.id),
current,
);
}
export function resetSpatialNodeTransform(
current: SpatialGraph,
nodeId: SpatialNodeId,
): SpatialGraph {
const node = current.nodes[nodeId];
if (node == null || node.kind === "control") return current;
const graph = cloneGraph(current);
// A hinge folds back to its slot's resting angle (a keyboard leans out at -55°), not to 0.
const constraint = node.constraint;
graph.nodes[nodeId] = constraint?.kind === "hinge"
? {
...node,
constraint: {
...constraint,
angle: current.hitboxes[constraint.attachmentSlotId]?.attachments[node.kind]?.angle ?? 0,
},
}
: {
...node,
localTransform: {
position: node.localTransform.position,
rotation: [0, 0, 0],
scale: [1, 1, 1],
},
};
return graph;
}
export function getDisplayAttachmentRole(
graph: SpatialGraph,
nodeId: SpatialNodeId,
): DisplayAttachmentRole | null {
const node = graph.nodes[nodeId];
if (node?.kind !== "display") return null;
if (getSpatialChildren(graph, nodeId).some((child) => child.kind === "display")) {
return "parent";
}
return node.parentId == null ? "solo" : "child";
}
export function deleteSpatialNode(
current: SpatialGraph,
nodeId: SpatialNodeId,
): SpatialGraph {
const node = current.nodes[nodeId];
if (!node || node.kind === "control") return current;
const graph = cloneGraph(current);
const deletedIds = new Set<SpatialNodeId>([nodeId]);
const collectCascadeChildren = (parentId: SpatialNodeId) => {
for (const child of getSpatialChildren(current, parentId)) {
if (child.onParentDelete !== "cascade") continue;
deletedIds.add(child.id);
collectCascadeChildren(child.id);
}
};
collectCascadeChildren(nodeId);
const survivingChildren = getSpatialChildren(current, nodeId)
.filter((child) => child.onParentDelete === "preserve");
const nextParentId = node.parentId;
const nextParentWorldInverse = nextParentId == null
? null
: getSpatialNodeWorldMatrix(current, nextParentId).invert();
for (const child of survivingChildren) {
const world = getSpatialNodeWorldMatrix(current, child.id);
const local = nextParentWorldInverse == null
? world
: nextParentWorldInverse.clone().multiply(world);
graph.nodes[child.id] = {
...child,
parentId: nextParentId,
originId: node.originId,
localTransform: decomposeTransform(local),
constraint: undefined,
};
}
for (const id of deletedIds) delete graph.nodes[id];
for (const candidate of Object.values(graph.nodes)) {
if (candidate.kind === "control" && deletedIds.has(candidate.targetId)) {
delete graph.nodes[candidate.id];
}
}
for (const hitbox of Object.values(graph.hitboxes)) {
if (deletedIds.has(hitbox.ownerId)) delete graph.hitboxes[hitbox.id];
}
return reconcileDisplayControls(graph);
}
export function getSpatialChildren(
graph: SpatialGraph,
parentId: SpatialNodeId | null,
): SpatialNode[] {
return Object.values(graph.nodes).filter((node) => node.parentId === parentId);
}