World/Live: solid ball nodes + draggable layout
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

- Nodes/pawns are now solid (opaque): the standard material is opaque, idle pawns
  dim via emissiveIntensity instead of transparency, and node spheres only fade on
  appear/disappear (alpha<1) rather than rendering see-through.
- Drag any node (Live view): pointerdown on a node pins it under the pointer
  (OrbitControls paused), and the force layout — edges, pawns, beams — re-settles
  around its new position. A drag leaves the node pinned (sticky) so the new layout
  persists; a tap still selects.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-24 05:49:57 -07:00
co-authored by Claude Opus 4.8
parent 928e27cde5
commit 025a973639
+74 -17
View File
@@ -370,28 +370,72 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
const ro = new ResizeObserver(setSize); const ro = new ResizeObserver(setSize);
ro.observe(mount); ro.observe(mount);
// tap (not drag) → raycast select // Drag a node (Live view): pin it under the pointer so the force layout —
// edges, pawns, beams — re-settles around its new position; a tap selects.
const ray = new THREE.Raycaster(); const ray = new THREE.Raycaster();
let downX = 0; let downX = 0;
let downY = 0; let downY = 0;
let downT = 0; let dragId: string | null = null;
const pinned = new Map<string, { x: number; y: number; isPawn: boolean }>();
const dragPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); // layout plane (z=0)
const hitPt = new THREE.Vector3();
const ndc = (cx: number, cy: number) => {
const rect = renderer.domElement.getBoundingClientRect();
return new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
};
const pickId = (cx: number, cy: number): string | undefined => {
ray.setFromCamera(ndc(cx, cy), camera);
const hits = ray.intersectObjects([...nodeMeshes.values(), ...pawnMeshes.values()]);
return hits.length ? (hits[0].object.userData.id as string | undefined) : undefined;
};
const onDown = (e: PointerEvent) => { const onDown = (e: PointerEvent) => {
downX = e.clientX; downX = e.clientX;
downY = e.clientY; downY = e.clientY;
downT = performance.now(); if (formationRef.current !== "live") return; // drag only in the force view
const id = pickId(e.clientX, e.clientY);
if (id) {
dragId = id;
const isPawn = engine.pawns.has(id);
const cur = isPawn ? engine.pawns.get(id) : engine.nodes.get(id);
pinned.set(id, { x: cur?.x ?? 0, y: cur?.y ?? 0, isPawn });
controls.enabled = false;
try {
renderer.domElement.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
}
};
const onMove = (e: PointerEvent) => {
if (!dragId) return;
ray.setFromCamera(ndc(e.clientX, e.clientY), camera);
const pin = pinned.get(dragId);
if (pin && ray.ray.intersectPlane(dragPlane, hitPt)) {
pin.x = hitPt.x;
pin.y = hitPt.y;
}
}; };
const onUp = (e: PointerEvent) => { const onUp = (e: PointerEvent) => {
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6 || performance.now() - downT > 500) return; if (!dragId) return;
const rect = renderer.domElement.getBoundingClientRect(); const moved = Math.hypot(e.clientX - downX, e.clientY - downY) > 6;
ray.setFromCamera( const id = dragId;
new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1), dragId = null;
camera, controls.enabled = true;
); try {
const hits = ray.intersectObjects([...nodeMeshes.values(), ...pawnMeshes.values()]); renderer.domElement.releasePointerCapture(e.pointerId);
const id = hits.length ? (hits[0].object.userData.id as string | undefined) : undefined; } catch {
if (id) onSelectRef.current(id); /* ignore */
}
if (!moved) {
pinned.delete(id); // a tap = select, not a pin
onSelectRef.current(id);
}
// a real drag leaves the node pinned (sticky) so the new layout persists
}; };
renderer.domElement.addEventListener("pointerdown", onDown); renderer.domElement.addEventListener("pointerdown", onDown);
renderer.domElement.addEventListener("pointermove", onMove);
renderer.domElement.addEventListener("pointerup", onUp); renderer.domElement.addEventListener("pointerup", onUp);
const updateParticles = (dt: number) => { const updateParticles = (dt: number) => {
@@ -524,6 +568,18 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
engine.formation = formationRef.current; engine.formation = formationRef.current;
engine.step(dt); engine.step(dt);
// hold dragged/pinned nodes at their placed spot; the layout adjusts around them
for (const [id, pin] of pinned) {
const t = pin.isPawn ? engine.pawns.get(id) : engine.nodes.get(id);
if (t) {
t.x = pin.x;
t.y = pin.y;
t.vx = 0;
t.vy = 0;
} else {
pinned.delete(id);
}
}
const F = engine.formation; const F = engine.formation;
gourceGroup.visible = F === "live"; gourceGroup.visible = F === "live";
@@ -563,7 +619,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
liveNodeIds.add(n.id); liveNodeIds.add(n.id);
let m = nodeMeshes.get(n.id); let m = nodeMeshes.get(n.id);
if (!m) { if (!m) {
m = new THREE.Mesh(sphere, new THREE.MeshStandardMaterial({ transparent: true, depthWrite: false, roughness: 0.45, metalness: 0.15 })); m = new THREE.Mesh(sphere, new THREE.MeshStandardMaterial({ roughness: 0.45, metalness: 0.15 }));
m.userData.id = n.id; m.userData.id = n.id;
nodeMeshes.set(n.id, m); nodeMeshes.set(n.id, m);
gourceGroup.add(m); gourceGroup.add(m);
@@ -576,7 +632,8 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
mat.color.copy(col); mat.color.copy(col);
mat.emissive.copy(col); mat.emissive.copy(col);
mat.emissiveIntensity = 0.35 + n.heat * 0.6; mat.emissiveIntensity = 0.35 + n.heat * 0.6;
mat.opacity = n.alpha * (n.tier === "root" ? 1 : 0.6 + n.heat * 0.4); mat.opacity = n.alpha;
mat.transparent = n.alpha < 0.99; // solid when present; only fades on appear/disappear
let g = nodeGlows.get(n.id); let g = nodeGlows.get(n.id);
if (!g) { if (!g) {
g = newGlow(gourceGroup); g = newGlow(gourceGroup);
@@ -643,7 +700,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
livePawnIds.add(p.id); livePawnIds.add(p.id);
let m = pawnMeshes.get(p.id); let m = pawnMeshes.get(p.id);
if (!m) { if (!m) {
m = new THREE.Mesh(sphere, new THREE.MeshStandardMaterial({ transparent: true, depthWrite: false, roughness: 0.45, metalness: 0.15 })); m = new THREE.Mesh(sphere, new THREE.MeshStandardMaterial({ roughness: 0.45, metalness: 0.15 }));
m.userData.id = p.id; m.userData.id = p.id;
pawnMeshes.set(p.id, m); pawnMeshes.set(p.id, m);
gourceGroup.add(m); gourceGroup.add(m);
@@ -654,8 +711,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
const mat = m.material as THREE.MeshStandardMaterial; const mat = m.material as THREE.MeshStandardMaterial;
mat.color.set(p.color); mat.color.set(p.color);
mat.emissive.set(p.color); mat.emissive.set(p.color);
mat.emissiveIntensity = 0.7; mat.emissiveIntensity = 0.3 + pa * 0.7; // idle dims via emissive, stays solid
mat.opacity = pa;
let g = pawnGlows.get(p.id); let g = pawnGlows.get(p.id);
if (!g) { if (!g) {
g = newGlow(gourceGroup); g = newGlow(gourceGroup);
@@ -768,6 +824,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
ro.disconnect(); ro.disconnect();
renderer.domElement.removeEventListener("pointerdown", onDown); renderer.domElement.removeEventListener("pointerdown", onDown);
renderer.domElement.removeEventListener("pointermove", onMove);
renderer.domElement.removeEventListener("pointerup", onUp); renderer.domElement.removeEventListener("pointerup", onUp);
controls.dispose(); controls.dispose();
labels.forEach((s) => s.remove()); labels.forEach((s) => s.remove());