World/Live: solid ball nodes + draggable layout
- 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:
co-authored by
Claude Opus 4.8
parent
928e27cde5
commit
025a973639
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user