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
1212import * as THREE from 'three' ;
1313import { 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+ }
2832interface ArchResponse {
2933 model_present : boolean ;
3034 layers : ArchLayer [ ] ;
3135 components : ArchComponent [ ] ;
36+ edges : ArchEdge [ ] ;
3237}
3338
3439const PRIORITY = [ 'active' , 'in-progress' , 'implemented' , 'has-debt' , 'wiring-gap' , 'uncharted' ] ;
@@ -38,8 +43,19 @@ const BOX_W = 4;
3843const BOX_H = 1.4 ;
3944const BOX_D = 3 ;
4045const GAP_X = 2.4 ;
41- const FLOOR_GAP = 6 ; // vertical spacing between floors (A3.1 makes this a slider)
4246const 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
4460interface 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
5372let 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
187269function 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 ( ) ) ;
0 commit comments