Skip to content

Commit 6ad22fb

Browse files
montfortclaude
andauthored
feat(loom): A3.1 — explode (peel-apart floors) + dependency lines (#267)
The "exploded" in BIM exploded view: a slider separates the stacked floors vertically, and the model's dependency edges are drawn as 3D lines between component boxes that re-route as the floors move. - axon.ts: each layer is now a THREE.Group (floor) movable on y. New `setExplode(0..1)` maps to the floor gap (MIN_FLOOR_GAP..MAX_FLOOR_GAP). - One THREE.Line per `/api/architecture` edge (source→target box); on each explode change the lines are re-routed to the boxes' new world positions (getWorldPosition after updateMatrixWorld). - `#axon-explode` slider (bottom-right, shown only in 3D), wired to setExplode; localized label (en/es/zh-CN). Verified on Sentinel: cranking the slider separates the 7 floors and the dependency lines stretch across the gaps. 719 Rust tests unaffected; tsc + vite build clean. No tag — loom-0.6.0 rides A3.3. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8c39899 commit 6ad22fb

5 files changed

Lines changed: 137 additions & 36 deletions

File tree

experimento/specs/002-architecture-plan/tasks.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,13 @@
347347
`/api/architecture` (auto-laid-out per floor — the 3D view doesn't need the human DrawIO
348348
geometry). Verified on Sentinel (7 floors stacked) + the demo (all 5 state colors). `tsc` +
349349
`vite build` clean.
350-
- [ ] **A3.1 — Explode.** A slider that separates the floors vertically (the BIM peel-apart) +
351-
cross-layer dependency connectors (the `links` as 3D lines between boxes).
350+
- [x] **A3.1 — Explode.** `axon.ts` refactored so each layer is a `THREE.Group` (a "floor")
351+
movable on `y`; an **Explode slider** (`#axon-explode`, bottom-right, localized) drives
352+
`setExplode(0..1)` → floor gap `MIN_FLOOR_GAP..MAX_FLOOR_GAP` (the BIM peel-apart). Each model
353+
edge (`/api/architecture` `edges`) is a `THREE.Line` between the source/target boxes; on every
354+
explode change the lines are re-routed to the boxes' new world positions
355+
(`getWorldPosition` after `updateMatrixWorld`). Verified on Sentinel (7 floors separate, the
356+
dependency lines stretch across the gaps). `tsc` + `vite build` clean.
352357
- [ ] **A3.2 — Interaction & labels.** Floor labels (layer names) + component labels (sprites),
353358
click a box → the A2.4 component detail panel, hover highlight. Reuse the where/legend panels.
354359
- [ ] **A3.3 — Acceptance + release.** Dogfood; `experimento/CHANGELOG.md` + bump

experimento/web/index.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@
105105
#plan-mode button { color:var(--muted); background:none; border:1px solid var(--border); border-radius:5px; padding:3px 9px; font-size:11px; font-weight:600; }
106106
#plan-mode button:hover { color:var(--text); }
107107
#plan-mode button.active { color:#14161c; background:var(--accent); border-color:var(--accent); }
108+
#axon-explode { position:absolute; right:14px; bottom:14px; z-index:110; display:none; align-items:center; gap:9px; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:8px 13px; color:var(--muted); font-size:11px; }
109+
body.view-plan.plan-3d #axon-explode { display:flex; }
110+
#axon-explode input { width:130px; accent-color:var(--accent); cursor:pointer; }
108111
body.view-plan #plan-legend { display:block; }
109112
body.view-plan #graph, body.view-plan .kg-only, body.view-plan #stats, body.view-plan #legend, body.view-plan #selection, body.view-plan #zoom-controls { display:none !important; }
110113
#plan-legend { position:absolute; left:14px; bottom:14px; z-index:100; display:none; background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:9px 11px; font-size:11px; color:var(--muted); }
@@ -200,6 +203,10 @@ <h1><span class="brand">Loom</span> · <span id="subtitle">knowledge graph</span
200203
<button type="button" id="plan-zoom-out" title="Zoom out"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button>
201204
<button type="button" id="plan-zoom-fit" title="Fit to view"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6V3h3M13 6V3h-3M3 10v3h3M13 10v3h-3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
202205
</div>
206+
<div id="axon-explode">
207+
<span id="axon-explode-label">Explode</span>
208+
<input type="range" id="axon-explode-range" min="0" max="100" value="25" aria-label="Explode floors" />
209+
</div>
203210
<script type="module" src="/src/main.ts"></script>
204211
</body>
205212
</html>

experimento/web/src/axon.ts

Lines changed: 115 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
//! Axonometric / BIM exploded view (Loom A3, Spec 002 §12 north star): the
2-
//! architecture model rendered in 3D — each layer is a stacked "floor", each
3-
//! component a box on it, colored by the §4 "you are here" status. A real WebGL
4-
//! scene (Three.js) with an **orthographic** camera (true axonometric — parallel
5-
//! projection, no perspective) and orbit controls. Explode (separating the
6-
//! floors) and labels land in A3.1/A3.2.
2+
//! architecture model in 3D — each layer is a stacked "floor", each component a
3+
//! box on it, colored by the §4 "you are here" status. A real WebGL scene
4+
//! (Three.js) with an **orthographic** camera (true axonometric — parallel
5+
//! projection) and orbit controls.
76
//!
8-
//! Same model as the 2D plan (`/api/architecture`); this is just a second view
9-
//! of it (the BIM "one model, many views"). Auto-laid-out per floor — the 3D
10-
//! view doesn't need the human `plan.drawio` geometry.
7+
//! A3.1 adds the **explode** (a slider that separates the floors vertically —
8+
//! the BIM "peel apart") and the **dependency lines** (the model's `links` as
9+
//! 3D connectors between boxes, redrawn as floors move). Labels + click-detail
10+
//! land in A3.2.
1111

1212
import * as THREE from 'three';
1313
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -25,10 +25,15 @@ interface ArchLayer {
2525
label: string;
2626
order: number;
2727
}
28+
interface ArchEdge {
29+
source: string;
30+
target: string;
31+
}
2832
interface ArchResponse {
2933
model_present: boolean;
3034
layers: ArchLayer[];
3135
components: ArchComponent[];
36+
edges: ArchEdge[];
3237
}
3338

3439
const PRIORITY = ['active', 'in-progress', 'implemented', 'has-debt', 'wiring-gap', 'uncharted'];
@@ -38,8 +43,19 @@ const BOX_W = 4;
3843
const BOX_H = 1.4;
3944
const BOX_D = 3;
4045
const GAP_X = 2.4;
41-
const FLOOR_GAP = 6; // vertical spacing between floors (A3.1 makes this a slider)
4246
const SLAB_PAD = 2;
47+
const MIN_FLOOR_GAP = 3.2; // compact
48+
const MAX_FLOOR_GAP = 17; // fully exploded
49+
50+
interface Floor {
51+
group: THREE.Group;
52+
index: number; // 0..n-1 bottom→top
53+
}
54+
interface Conn {
55+
line: THREE.Line;
56+
a: THREE.Object3D;
57+
b: THREE.Object3D;
58+
}
4359

4460
interface Scene3D {
4561
renderer: THREE.WebGLRenderer;
@@ -48,12 +64,15 @@ interface Scene3D {
4864
controls: OrbitControls;
4965
frame: number;
5066
onResize: () => void;
67+
floors: Floor[];
68+
conns: Conn[];
69+
layerCount: number;
5170
}
5271

5372
let current: Scene3D | null = null;
5473

5574
/** (Re)render the axonometric view into `container`. Idempotent. */
56-
export async function renderAxon(container: HTMLElement): Promise<void> {
75+
export async function renderAxon(container: HTMLElement, explode = 0.25): Promise<void> {
5776
let arch: ArchResponse;
5877
try {
5978
arch = await fetch('/api/architecture').then((r) => r.json() as Promise<ArchResponse>);
@@ -75,7 +94,6 @@ export async function renderAxon(container: HTMLElement): Promise<void> {
7594
const scene = new THREE.Scene();
7695
scene.background = new THREE.Color('#14161c');
7796

78-
// Orthographic camera = true axonometric (parallel) projection.
7997
const frustum = 26;
8098
const aspect = width / height;
8199
const camera = new THREE.OrthographicCamera(
@@ -104,7 +122,8 @@ export async function renderAxon(container: HTMLElement): Promise<void> {
104122
controls.dampingFactor = 0.1;
105123
controls.target.set(0, 0, 0);
106124

107-
buildFloors(scene, arch);
125+
const { floors, boxByComp } = buildFloors(scene, arch);
126+
const conns = buildConnections(scene, arch, boxByComp);
108127

109128
const onResize = () => {
110129
const w = container.clientWidth || width;
@@ -119,32 +138,46 @@ export async function renderAxon(container: HTMLElement): Promise<void> {
119138
};
120139
window.addEventListener('resize', onResize);
121140

122-
const s: Scene3D = { renderer, scene, camera, controls, frame: 0, onResize };
141+
const s: Scene3D = {
142+
renderer,
143+
scene,
144+
camera,
145+
controls,
146+
frame: 0,
147+
onResize,
148+
floors,
149+
conns,
150+
layerCount: floors.length,
151+
};
152+
current = s;
153+
applyExplode(s, explode);
154+
123155
const tick = () => {
124156
controls.update();
125157
renderer.render(scene, camera);
126158
s.frame = requestAnimationFrame(tick);
127159
};
128160
tick();
129-
current = s;
130161
}
131162

132-
/** Build a stacked floor per layer with a box per component (centered). */
133-
function buildFloors(scene: THREE.Scene, arch: ArchResponse): void {
134-
const layers = [...arch.layers].sort((a, b) => a.order - b.order);
135-
// Center the whole stack vertically.
136-
const yOffset = ((layers.length - 1) * FLOOR_GAP) / 2;
163+
/** Build a floor group per layer with a box per component; index the boxes. */
164+
function buildFloors(
165+
scene: THREE.Scene,
166+
arch: ArchResponse,
167+
): { floors: Floor[]; boxByComp: Map<string, THREE.Object3D> } {
168+
const layers = [...arch.layers]
169+
.sort((a, b) => a.order - b.order)
170+
.filter((l) => arch.components.some((c) => c.layer === l.id));
171+
const boxByComp = new Map<string, THREE.Object3D>();
172+
const floors: Floor[] = [];
137173

138174
layers.forEach((layer, i) => {
139175
const comps = arch.components.filter((c) => c.layer === layer.id);
140-
if (comps.length === 0) return;
141-
const y = i * FLOOR_GAP - yOffset;
176+
const group = new THREE.Group();
142177

143-
// Row of boxes, centered on x.
144178
const rowWidth = comps.length * BOX_W + (comps.length - 1) * GAP_X;
145179
let x = -rowWidth / 2 + BOX_W / 2;
146180

147-
// Translucent slab = the "floor".
148181
const slab = new THREE.Mesh(
149182
new THREE.BoxGeometry(rowWidth + SLAB_PAD * 2, 0.25, BOX_D + SLAB_PAD * 2),
150183
new THREE.MeshStandardMaterial({
@@ -154,8 +187,8 @@ function buildFloors(scene: THREE.Scene, arch: ArchResponse): void {
154187
roughness: 0.9,
155188
}),
156189
);
157-
slab.position.set(0, y - BOX_H / 2 - 0.4, 0);
158-
scene.add(slab);
190+
slab.position.set(0, -BOX_H / 2 - 0.4, 0);
191+
group.add(slab);
159192

160193
for (const comp of comps) {
161194
const state = pickState(comp.states);
@@ -170,18 +203,67 @@ function buildFloors(scene: THREE.Scene, arch: ArchResponse): void {
170203
metalness: 0.1,
171204
}),
172205
);
173-
box.position.set(x, y, 0);
206+
box.position.set(x, 0, 0);
174207
box.userData = { componentId: comp.id, label: comp.label };
175-
// Crisp edge outline in the component's stroke color.
176-
const edges = new THREE.LineSegments(
177-
new THREE.EdgesGeometry(box.geometry),
178-
new THREE.LineBasicMaterial({ color: new THREE.Color(palette.stroke) }),
208+
box.add(
209+
new THREE.LineSegments(
210+
new THREE.EdgesGeometry(box.geometry),
211+
new THREE.LineBasicMaterial({ color: new THREE.Color(palette.stroke) }),
212+
),
179213
);
180-
box.add(edges);
181-
scene.add(box);
214+
group.add(box);
215+
boxByComp.set(comp.id, box);
182216
x += BOX_W + GAP_X;
183217
}
218+
219+
scene.add(group);
220+
floors.push({ group, index: i });
184221
});
222+
223+
return { floors, boxByComp };
224+
}
225+
226+
/** One line per model edge (source → target component box). */
227+
function buildConnections(
228+
scene: THREE.Scene,
229+
arch: ArchResponse,
230+
boxByComp: Map<string, THREE.Object3D>,
231+
): Conn[] {
232+
const conns: Conn[] = [];
233+
const material = new THREE.LineBasicMaterial({ color: 0x6b7280, transparent: true, opacity: 0.75 });
234+
for (const edge of arch.edges) {
235+
const a = boxByComp.get(edge.source);
236+
const b = boxByComp.get(edge.target);
237+
if (!a || !b) continue;
238+
const geom = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]);
239+
const line = new THREE.Line(geom, material);
240+
scene.add(line);
241+
conns.push({ line, a, b });
242+
}
243+
return conns;
244+
}
245+
246+
/** Position the floors at the given explode amount (0 compact … 1 exploded)
247+
* and re-route the dependency lines to the boxes' new world positions. */
248+
function applyExplode(s: Scene3D, amount: number): void {
249+
const gap = MIN_FLOOR_GAP + amount * (MAX_FLOOR_GAP - MIN_FLOOR_GAP);
250+
const yOffset = ((s.layerCount - 1) * gap) / 2;
251+
for (const f of s.floors) {
252+
f.group.position.y = f.index * gap - yOffset;
253+
}
254+
s.scene.updateMatrixWorld(true);
255+
const pa = new THREE.Vector3();
256+
const pb = new THREE.Vector3();
257+
for (const c of s.conns) {
258+
c.a.getWorldPosition(pa);
259+
c.b.getWorldPosition(pb);
260+
c.line.geometry.setFromPoints([pa.clone(), pb.clone()]);
261+
}
262+
}
263+
264+
/** Set the explode amount on the live scene (called by the slider). */
265+
export function setExplode(amount: number): void {
266+
if (current) applyExplode(current, Math.max(0, Math.min(1, amount)));
185267
}
186268

187269
function pickState(states: string[]): string {
@@ -198,7 +280,7 @@ export function dispose(): void {
198280
window.removeEventListener('resize', current.onResize);
199281
current.controls.dispose();
200282
current.scene.traverse((obj) => {
201-
if (obj instanceof THREE.Mesh || obj instanceof THREE.LineSegments) {
283+
if (obj instanceof THREE.Mesh || obj instanceof THREE.LineSegments || obj instanceof THREE.Line) {
202284
obj.geometry.dispose();
203285
const m = obj.material;
204286
if (Array.isArray(m)) m.forEach((mm) => mm.dispose());

experimento/web/src/i18n.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const STRINGS: Record<Locale, Dict> = {
1515
'tab.kg': 'Knowledge Graph',
1616
'tab.plan': 'Architecture',
1717
'plan.subtitle': 'architecture plan',
18+
'axon.explode': 'Explode',
1819
'plan.legend': 'Status',
1920
'plan.empty': 'No architecture model yet — run `straymark architecture generate` to draft one.',
2021
'plan.error': 'Could not load the architecture plan.',
@@ -93,6 +94,7 @@ const STRINGS: Record<Locale, Dict> = {
9394
'tab.kg': 'Grafo de conocimiento',
9495
'tab.plan': 'Arquitectura',
9596
'plan.subtitle': 'plano de arquitectura',
97+
'axon.explode': 'Separar capas',
9698
'plan.legend': 'Estado',
9799
'plan.empty': 'Aún no hay modelo de arquitectura — ejecuta `straymark architecture generate` para crear uno.',
98100
'plan.error': 'No se pudo cargar el plano de arquitectura.',
@@ -171,6 +173,7 @@ const STRINGS: Record<Locale, Dict> = {
171173
'tab.kg': '知识图谱',
172174
'tab.plan': '架构',
173175
'plan.subtitle': '架构平面图',
176+
'axon.explode': '分离层',
174177
'plan.legend': '状态',
175178
'plan.empty': '尚无架构模型 — 运行 `straymark architecture generate` 生成草稿。',
176179
'plan.error': '无法加载架构平面图。',

experimento/web/src/main.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import Sigma from 'sigma';
1111

1212
import { setLocale, t } from './i18n';
1313
import { renderPlan, stateColor, LEGEND_STATES, setPlanHooks } from './plan';
14-
import { renderAxon, dispose as disposeAxon } from './axon';
14+
import { renderAxon, dispose as disposeAxon, setExplode } from './axon';
1515

1616
interface ApiNode {
1717
id: string;
@@ -705,6 +705,7 @@ function applyStaticI18n(): void {
705705
document.getElementById('badge')!.textContent = t('app.experimental');
706706
document.getElementById('tab-kg')!.textContent = t('tab.kg');
707707
document.getElementById('tab-plan')!.textContent = t('tab.plan');
708+
document.getElementById('axon-explode-label')!.textContent = t('axon.explode');
708709
buildPlanLegend();
709710
searchEl.placeholder = t('search.placeholder');
710711
document.getElementById('sizing-label')!.textContent = t('size.label');
@@ -874,6 +875,9 @@ for (const b of planModeBtns) {
874875
b.addEventListener('click', () => setPlanMode((b.dataset.mode as PlanMode) ?? '2d'));
875876
}
876877

878+
const axonExplode = document.getElementById('axon-explode-range') as HTMLInputElement | null;
879+
axonExplode?.addEventListener('input', () => setExplode(Number(axonExplode.value) / 100));
880+
877881
/** The color legend for the plan's status overlay (localized). */
878882
function buildPlanLegend(): void {
879883
planLegendEl.innerHTML =

0 commit comments

Comments
 (0)