Files
archify-vscode-ext/src/viewer3d/stage.ts
T
root-at-skic 97b3b5f685 Add a 3D view of every diagram; organise source by job; 0.6.0
3D view: a "3D" menu beside Export switches the diagram to a three.js
scene built from the live Archify SVG. Nodes become blocks topped with
the node as Archify draws it, relationships become lit pipes along
Archify's routes, and the canvas, frames, lanes and legend form the
floor. Clicks and hover go to the SVG nodes, so focus, the Semantic
Passport and the intent trace work as in 2D; Archify's own animations
(trace, node pulse, passport/path/lens flows) are mirrored live. The
menu also offers flat or stacked levels, reset view and a top view;
Export gains "3D view" (PNG). Trackpad scrolling pans, pinch and the
mouse wheel zoom, and the zoom buttons work beyond the 2D limits.
The scene draws only when something moves. New setting
archify.defaultView.

Source: src/ is split by job (commands, views, webview, archify,
sources, language, util, viewer3d); behaviour unchanged.
2026-10-11 00:54:00 +03:00

1020 lines
40 KiB
TypeScript

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import type { Raster } from './raster';
import { DiagramModel, EdgeShape, FrameShape, LabelShape, NodeShape, effectiveOpacity, FLOW_SELECTOR } from './svgModel';
/**
* The 3D scene. World units are Archify's SVG user units: x is the SVG x,
* z the SVG y, and y points up. The floor is the diagram itself; nodes rise
* from it as slabs whose top face is the node as Archify draws it.
*/
const NODE_HEIGHT = 16;
/** Pipes run at mid-height of the blocks, so they plug into their sides. */
const PIPE_Y = NODE_HEIGHT / 2;
/** Height offsets that let crossing pipes pass over each other. */
const PIPE_LANES = [0, 3.5, -3.5];
const FRAME_WALL = 12;
const FLY_MS = 650;
/** Glowing flows (focus pulse, traces) run in a slightly wider pipe over the relationship. */
const FLOW_SCALE = 1.4;
/** Height tier per semantic kind, for the stacked view. */
const KIND_TIER: Record<string, number> = { external: 3, frontend: 3, security: 2, backend: 1, cloud: 1, messagebus: 1, database: 0 };
export interface Layers {
floor: Raster;
nodes: Raster;
labels: Raster;
}
export interface Palette {
mask: string;
dark: boolean;
}
interface NodeView {
shape: NodeShape;
group: THREE.Group;
body: THREE.Mesh;
materials: THREE.Material[];
lift: number;
/** Extra height while Archify's node pulse plays. */
pulse: number;
/** Opacity at build time; live dimming (intent trace) multiplies it. */
baseOpacity: number;
dim: number;
}
interface EdgeView {
shape: EdgeShape;
tube: THREE.Mesh;
material: THREE.MeshStandardMaterial;
cap: THREE.Mesh;
arrow?: THREE.Mesh;
arrowLength: number;
radius: number;
bias: number;
/** Opacity of the path's ancestors; the path's own opacity is read live. */
baseOpacity: number;
/** Archify's dashes are sized for a thin stroke; pipes stretch them by this. */
dashScale: number;
/** The route as built, for flows that run along it. */
path?: THREE.CurvePath<THREE.Vector3>;
pipeLength: number;
/** Last mirrored stroke-dasharray, stroke-dashoffset and opacity. */
mirrored: string;
}
/** A glowing flow Archify draws over a relationship (focus pulse, trace, lens…). */
interface FlowView {
el: SVGPathElement;
edge: EdgeView;
mesh: THREE.Mesh;
material: THREE.MeshBasicMaterial;
}
interface LabelView {
shape: LabelShape;
mesh: THREE.Mesh;
edge?: EdgeView;
}
interface FrameView {
shape: FrameShape;
wall: THREE.Mesh;
rim: THREE.Line;
members: NodeShape[];
}
export interface CameraState {
position: [number, number, number];
target: [number, number, number];
}
/**
* True for a trackpad two-finger scroll, false for a mouse wheel. Trackpads
* send pixel deltas, often sideways or fractional; wheels send coarse
* vertical steps.
*/
function isTrackpadScroll(event: WheelEvent): boolean {
if (event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL) return false;
if (event.deltaX !== 0) return true;
return !Number.isInteger(event.deltaY) || Math.abs(event.deltaY) < 40;
}
const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function cssColor(value: string, fallback = '#888'): THREE.Color {
try {
return new THREE.Color().setStyle(value && value !== 'none' ? value : fallback);
} catch {
return new THREE.Color(fallback);
}
}
function canvasTexture(canvas: HTMLCanvasElement): THREE.CanvasTexture {
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 8;
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
return texture;
}
/** A soft blurred rectangle, stretched under each node as a contact shadow. */
const shadowTexture = (() => {
let texture: THREE.CanvasTexture | undefined;
return () => {
if (texture) return texture;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 128;
const ctx = canvas.getContext('2d')!;
ctx.filter = 'blur(12px)';
ctx.fillStyle = '#000';
ctx.fillRect(28, 28, 72, 72);
texture = new THREE.CanvasTexture(canvas);
return texture;
};
})();
/** Parses a computed stroke-dasharray ("10px, 8px", "none") into numbers. */
function parseDashes(value: string): number[] {
if (!value || value === 'none') return [];
return value.split(/[\s,]+/).map((v) => parseFloat(v)).filter((v) => v >= 0 && Number.isFinite(v));
}
const dashCache = new Map<string, { texture: THREE.CanvasTexture; period: number }>();
/** Alpha stripes for a dash pattern; textures are shared per pattern. */
function dashFor(dasharray: number[]): { texture: THREE.Texture; period: number } | undefined {
if (!dasharray.length || dasharray.every((d) => d === 0)) return undefined;
const key = dasharray.map((d) => d.toFixed(4)).join(',');
let entry = dashCache.get(key);
if (!entry) {
entry = dashTexture(dasharray);
dashCache.set(key, entry);
}
// A clone shares the image but has its own repeat/offset.
const texture = entry.texture.clone();
texture.needsUpdate = true;
return { texture, period: entry.period };
}
/** Alpha stripes for a dashed relationship, from its stroke-dasharray. */
function dashTexture(dasharray: number[]): { texture: THREE.CanvasTexture; period: number } {
const pattern = dasharray.length % 2 ? [...dasharray, ...dasharray] : dasharray;
const period = pattern.reduce((a, b) => a + b, 0);
const canvas = document.createElement('canvas');
// Enough pixels per period for both 10px dashes and pathLength=1 patterns.
const px = Math.min(4096, Math.max(8, 512 / Math.max(period, 1e-6))) ;
canvas.width = Math.max(2, Math.min(4096, Math.round(period * px)));
canvas.height = 1;
const ctx = canvas.getContext('2d')!;
let x = 0;
pattern.forEach((len, i) => {
ctx.fillStyle = i % 2 ? '#000' : '#fff';
ctx.fillRect(Math.round(x * px), 0, Math.ceil(len * px), 1);
x += len;
});
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.magFilter = THREE.NearestFilter;
return { texture, period };
}
function disposeObject(root: THREE.Object3D): void {
root.traverse((obj) => {
const mesh = obj as THREE.Mesh;
mesh.geometry?.dispose();
const material = mesh.material as THREE.Material | THREE.Material[] | undefined;
for (const m of Array.isArray(material) ? material : material ? [material] : []) {
for (const value of Object.values(m)) if (value instanceof THREE.Texture && value !== shadowTexture() && ![...dashCache.values()].some((d) => d.texture === value)) value.dispose();
m.dispose();
}
});
}
export class Stage {
readonly element: HTMLDivElement;
private readonly renderer: THREE.WebGLRenderer;
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(32, 1, 1, 40000);
private readonly controls: OrbitControls;
private world = new THREE.Group();
private model: DiagramModel | undefined;
private nodes = new Map<string, NodeView>();
private edges: EdgeView[] = [];
private flows = new Map<SVGPathElement, FlowView>();
/** Edge views by their SVG path data, to place Archify's flow overlays. */
private edgesByPath = new Map<string, EdgeView>();
private glowColor = new THREE.Color('#34d399');
private pulsing = new Set<NodeView>();
/** Something changed that the next frame must draw. */
private dirty = true;
private motionActive = false;
private labels: LabelView[] = [];
private frames: FrameView[] = [];
private textures: THREE.Texture[] = [];
private levels = new Map<string, number>();
private levelGap = 60;
private levelMix = 0;
private levelTarget = 0;
private hovered: string | undefined;
private frameId = 0;
private lastTime = 0;
private flight: { from: THREE.Vector3; to: THREE.Vector3; fromTarget: THREE.Vector3; toTarget: THREE.Vector3; start: number } | undefined;
private fitted = false;
private readonly raycaster = new THREE.Raycaster();
private downAt: { x: number; y: number } | undefined;
private panTimer: number | undefined;
/** Called with a node id, or undefined for empty space. */
onPick: (id: string | undefined) => void = () => {};
/** Called when the pointer moves onto another node, or off all nodes. */
onHover: (id: string | undefined) => void = () => {};
/** Called when the camera settles after the reader moves it. */
onCameraChange: () => void = () => {};
constructor() {
this.element = document.createElement('div');
this.element.className = 'archify-3d-stage';
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
// Full retina density costs 4x the pixels for little gain on a moving scene.
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
this.renderer.setClearColor(0x000000, 0);
this.element.appendChild(this.renderer.domElement);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.09;
this.controls.screenSpacePanning = false;
this.controls.maxPolarAngle = Math.PI * 0.47;
this.controls.zoomToCursor = true;
this.controls.addEventListener('end', () => this.onCameraChange());
this.controls.addEventListener('change', () => (this.dirty = true));
this.scene.add(new THREE.HemisphereLight(0xffffff, 0x8090a0, 2.2));
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
sun.position.set(-0.4, 1, 0.6);
this.scene.add(sun);
this.scene.add(this.world);
const canvas = this.renderer.domElement;
// Keep Archify's 2D pan and zoom (which start on the container) out of
// 3D input. pointermove/pointerup must still reach the document, where
// OrbitControls tracks a drag.
for (const type of ['pointerdown', 'wheel', 'dblclick']) {
canvas.addEventListener(type, (event) => event.stopPropagation());
}
canvas.addEventListener('pointerdown', (event) => (this.downAt = { x: event.clientX, y: event.clientY }));
// Archify dismisses focus on document-level clicks outside the SVG. Catch
// the canvas click at window capture, before those listeners, and hand
// Archify a click on the node instead.
window.addEventListener(
'click',
(event) => {
if (event.target !== canvas) return;
event.stopPropagation();
const still = this.downAt && Math.hypot(event.clientX - this.downAt.x, event.clientY - this.downAt.y) <= 4;
this.downAt = undefined;
if (still) this.onPick(this.pick(event));
},
true,
);
canvas.addEventListener('pointermove', (event) => {
const id = this.pick(event);
if (id !== this.hovered) {
this.dirty = true;
this.onHover(id);
}
this.hovered = id;
canvas.style.cursor = id ? 'pointer' : 'grab';
});
canvas.addEventListener('pointerleave', () => {
if (this.hovered) this.onHover(undefined);
this.hovered = undefined;
this.dirty = true;
});
canvas.addEventListener('dblclick', (event) => {
const id = this.pick(event);
if (id) this.focusNode(id);
else this.fit();
});
// Trackpad scrolling pans across the floor, in both directions, like
// scrolling over the 2D diagram. Pinch (ctrl+wheel) and mouse wheels
// fall through to OrbitControls, which zooms.
this.element.addEventListener(
'wheel',
(event) => {
if (event.ctrlKey || !isTrackpadScroll(event)) return;
event.preventDefault();
event.stopPropagation();
this.flight = undefined;
this.panBy(event.deltaX, event.deltaY);
},
{ capture: true, passive: false },
);
new ResizeObserver(() => this.resize()).observe(this.element);
}
/** Moves camera and target along the floor by a scroll delta in pixels. */
private panBy(dx: number, dy: number): void {
const distance = this.camera.position.distanceTo(this.controls.target);
const perPixel = (2 * distance * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2))) / Math.max(1, this.element.clientHeight);
const forward = this.controls.target.clone().sub(this.camera.position).setY(0);
if (forward.lengthSq() < 1e-6) forward.set(0, 0, -1).applyQuaternion(this.camera.quaternion).setY(0);
if (forward.lengthSq() < 1e-6) forward.set(0, 0, -1);
forward.normalize();
const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize();
// Scrolling down reveals what is below: move towards the reader.
const move = right.multiplyScalar(dx * perPixel).addScaledVector(forward, -dy * perPixel);
this.camera.position.add(move);
this.controls.target.add(move);
this.controls.update();
window.clearTimeout(this.panTimer);
this.panTimer = window.setTimeout(() => this.onCameraChange(), 250);
}
// ---------------------------------------------------------------- build
/** Rebuilds the scene from the current SVG state. The camera is kept. */
build(model: DiagramModel, layers: Layers, palette: Palette): void {
disposeObject(this.world);
this.scene.remove(this.world);
for (const t of this.textures) t.dispose();
this.world = new THREE.Group();
this.scene.add(this.world);
this.model = model;
this.nodes.clear();
this.edges = [];
this.edgesByPath.clear();
this.flows.clear();
this.pulsing.clear();
this.mirrored = false;
const root = getComputedStyle(document.documentElement);
this.glowColor = cssColor(root.getPropertyValue('--arrow-emphasis').trim(), '#34d399');
this.labels = [];
this.frames = [];
const floorTexture = canvasTexture(layers.floor.canvas);
const nodeTexture = canvasTexture(layers.nodes.canvas);
const labelTexture = canvasTexture(layers.labels.canvas);
this.textures = [floorTexture, nodeTexture, labelTexture];
this.computeLevels(model);
this.buildFloor(layers.floor, floorTexture);
for (const shape of model.nodes) this.buildNode(shape, layers.nodes, nodeTexture, palette);
model.edges.forEach((shape, index) => this.buildEdge(shape, index));
for (const shape of model.labels) this.buildLabel(shape, layers.labels, labelTexture);
for (const shape of model.frames) this.buildFrame(shape, model);
this.layout();
if (!this.fitted) {
this.fit(false);
this.fitted = true;
}
this.dirty = true;
}
private mirrored = false;
private computeLevels(model: DiagramModel): void {
const tiers = new Map(model.nodes.map((n) => [n.id, KIND_TIER[n.kind] ?? 1]));
const used = [...new Set(tiers.values())].sort((a, b) => a - b);
this.levels = new Map([...tiers].map(([id, tier]) => [id, used.indexOf(tier)]));
const { w, h } = model.viewBox;
this.levelGap = Math.max(48, Math.min(140, Math.max(w, h) * 0.08));
}
private levelY(id: string): number {
return (this.levels.get(id) ?? 0) * this.levelGap * this.levelMix;
}
private buildFloor(raster: Raster, texture: THREE.Texture): void {
const { area } = raster;
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(area.w, area.h).rotateX(-Math.PI / 2),
new THREE.MeshBasicMaterial({ map: texture }),
);
floor.position.set(area.x + area.w / 2, 0, area.y + area.h / 2);
floor.renderOrder = -1;
this.world.add(floor);
}
private buildNode(shape: NodeShape, raster: Raster, texture: THREE.Texture, palette: Palette): void {
const { x, y, w, h } = shape;
const r = Math.min(shape.rx, w / 2, h / 2);
const outline = new THREE.Shape();
outline.moveTo(x + r, y);
outline.lineTo(x + w - r, y);
outline.quadraticCurveTo(x + w, y, x + w, y + r);
outline.lineTo(x + w, y + h - r);
outline.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
outline.lineTo(x + r, y + h);
outline.quadraticCurveTo(x, y + h, x, y + h - r);
outline.lineTo(x, y + r);
outline.quadraticCurveTo(x, y, x + r, y);
const geometry = new THREE.ExtrudeGeometry(outline, { depth: NODE_HEIGHT, bevelEnabled: false, curveSegments: 6 });
// Map the caps onto the node layer: same SVG coordinates, same pixels.
const pos = geometry.getAttribute('position');
const uv = geometry.getAttribute('uv');
const { area } = raster;
for (let i = 0; i < pos.count; i++) {
uv.setXY(i, (pos.getX(i) - area.x) / area.w, 1 - (pos.getY(i) - area.y) / area.h);
}
// Shape y (SVG y, downwards) becomes world z; the extrusion becomes height.
geometry.rotateX(Math.PI / 2);
geometry.translate(0, NODE_HEIGHT, 0);
// Group 0 holds both caps; keep only the top one. The bottom cap carries
// the same picture and shows through when a faded block turns see-through.
const caps = geometry.groups[0];
const half = caps.count / 2;
const bottomFirst = geometry.getAttribute('position').getY(caps.start) < NODE_HEIGHT / 2;
caps.start += bottomFirst ? half : 0;
caps.count = half;
const body = this.model ? getComputedStyle(shape.el.querySelector(`rect.c-${CSS.escape(shape.kind)}`) ?? shape.el) : undefined;
const stroke = cssColor(body?.stroke ?? '', '#64748b');
const side = stroke.clone().lerp(cssColor(palette.mask), palette.dark ? 0.45 : 0.25);
const capMaterial = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide });
const sideMaterial = new THREE.MeshLambertMaterial({ color: side, transparent: true, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geometry, [capMaterial, sideMaterial]);
mesh.userData.id = shape.id;
// Contact shadow on the floor.
const shadowMaterial = new THREE.MeshBasicMaterial({
map: shadowTexture(),
transparent: true,
opacity: palette.dark ? 0.55 : 0.22,
depthWrite: false,
});
const shadow = new THREE.Mesh(new THREE.PlaneGeometry(w * 1.55, h * 1.9).rotateX(-Math.PI / 2), shadowMaterial);
shadow.position.set(x + w / 2, 0.15, y + h / 2 + 6);
shadow.renderOrder = 0;
this.world.add(shadow);
const group = new THREE.Group();
group.add(mesh);
this.world.add(group);
const opacity = effectiveOpacity(shape.el, this.model!.svg);
for (const m of [capMaterial, sideMaterial]) m.opacity = opacity;
shadowMaterial.opacity *= opacity;
this.nodes.set(shape.id, { shape, group, body: mesh, materials: [capMaterial, sideMaterial, shadowMaterial], lift: 0, pulse: 0, baseOpacity: opacity, dim: 1 });
}
private buildEdge(shape: EdgeShape, index: number): void {
const style = getComputedStyle(shape.path);
const width = parseFloat(style.strokeWidth) || 1.5;
const color = cssColor(style.stroke, '#94a3b8');
const parent: Element | null = shape.path.parentElement;
const baseOpacity = (parent && parent !== this.model!.svg ? effectiveOpacity(parent, this.model!.svg) : 1) * (Number(style.strokeOpacity) || 1);
const radius = Math.min(4.5, Math.max(2.4, width * 2));
const material = new THREE.MeshStandardMaterial({
color,
roughness: 0.32,
metalness: 0.18,
emissive: color,
emissiveIntensity: 0.14,
transparent: true,
opacity: baseOpacity * Number(style.opacity || 1),
});
const tube = new THREE.Mesh(new THREE.BufferGeometry(), material);
const cap = new THREE.Mesh(new THREE.SphereGeometry(radius, 12, 8), material);
this.world.add(tube, cap);
// Arrowhead: a cone in the colour of Archify's marker, tip at the route end.
let arrow: THREE.Mesh | undefined;
const arrowLength = radius * 4.2;
const marker = /url\(["']?#([^"')]+)["']?\)/.exec(shape.path.getAttribute('marker-end') ?? '')?.[1];
const polygon = marker ? this.model!.svg.querySelector(`marker#${CSS.escape(marker)} polygon, marker#${CSS.escape(marker)} path`) : null;
if (polygon) {
const fill = cssColor(getComputedStyle(polygon).fill, style.stroke);
const geometry = new THREE.ConeGeometry(radius * 2.1, arrowLength, 16);
geometry.rotateZ(-Math.PI / 2);
geometry.translate(-arrowLength / 2, 0, 0);
arrow = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({ color: fill, roughness: 0.32, metalness: 0.18, emissive: fill, emissiveIntensity: 0.18, transparent: true, opacity: material.opacity }),
);
this.world.add(arrow);
}
const view: EdgeView = {
shape,
tube,
material,
cap,
arrow,
arrowLength: arrow ? arrowLength : 0,
radius,
bias: PIPE_LANES[index % PIPE_LANES.length],
baseOpacity,
// Archify's dash lengths suit a thin stroke; stretch them to the pipe's
// thickness so a dashed pipe reads as separate pipe sections.
dashScale: Math.max(1, (radius * 2) / (width * 2.2)),
pipeLength: shape.length,
mirrored: '',
};
this.edges.push(view);
const d = shape.path.getAttribute('d');
if (d) this.edgesByPath.set(d.trim(), view);
this.mirrorEdge(view);
}
/**
* Copies the live dash, offset and opacity of the 2D path onto its pipe.
* Archify animates exactly these (trace draw-in, focus, Live); mirroring
* them keeps the 3D motion in step with 2D, in every preset.
*/
private mirrorEdge(view: EdgeView): boolean {
const style = getComputedStyle(view.shape.path);
const key = `${style.strokeDasharray}|${style.strokeDashoffset}|${style.opacity}`;
if (key === view.mirrored) return false;
const dashKey = view.mirrored.split('|')[0];
view.mirrored = key;
const material = view.material;
if (style.strokeDasharray !== dashKey) {
const dash = dashFor(parseDashes(style.strokeDasharray).map((d) => d * view.dashScale));
material.alphaMap?.dispose();
material.alphaMap = dash?.texture ?? null;
material.alphaTest = dash ? 0.5 : 0;
material.userData.period = dash?.period ?? 1;
material.needsUpdate = true;
}
const alpha = material.alphaMap;
if (alpha) {
const period = material.userData.period as number;
alpha.repeat.set(view.pipeLength / period, 1);
alpha.offset.x = ((parseFloat(style.strokeDashoffset) || 0) * view.dashScale) / period;
}
const opacity = view.baseOpacity * Number(style.opacity || 1);
material.opacity = opacity;
// A relationship that is animating glows a little, as Signal Flow does in 2D.
const animating = view.shape.path.getAnimations().some((anim) => anim.playState === 'running');
material.emissiveIntensity = animating ? 0.45 : 0.14;
const arrowMaterial = view.arrow?.material as THREE.MeshStandardMaterial | undefined;
if (arrowMaterial) arrowMaterial.opacity = opacity;
return true;
}
private buildLabel(shape: LabelShape, raster: Raster, texture: THREE.Texture): void {
const { area } = raster;
const map = texture.clone();
map.repeat.set(shape.w / area.w, shape.h / area.h);
map.offset.set((shape.x - area.x) / area.w, 1 - (shape.y - area.y + shape.h) / area.h);
map.needsUpdate = true;
this.textures.push(map);
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(shape.w, shape.h),
new THREE.MeshBasicMaterial({ map, transparent: true, depthWrite: false, side: THREE.DoubleSide }),
);
mesh.renderOrder = 2;
this.world.add(mesh);
this.labels.push({ shape, mesh, edge: this.edges.find((e) => e.shape.key === shape.key) });
}
private buildFrame(shape: FrameShape, model: DiagramModel): void {
if (shape.kind === 'lane') return;
const style = getComputedStyle(shape.el);
const color = cssColor(style.stroke, '#64748b');
const r = Math.min(shape.rx, shape.w / 2, shape.h / 2);
const outline = new THREE.Shape();
const { x, y, w, h } = shape;
outline.moveTo(x + r, y);
outline.lineTo(x + w - r, y);
outline.quadraticCurveTo(x + w, y, x + w, y + r);
outline.lineTo(x + w, y + h - r);
outline.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
outline.lineTo(x + r, y + h);
outline.quadraticCurveTo(x, y + h, x, y + h - r);
outline.lineTo(x, y + r);
outline.quadraticCurveTo(x, y, x + r, y);
const points = outline.getPoints(8);
// A vertical strip along the outline; its height is set in layout().
const positions: number[] = [];
for (let i = 0; i < points.length - 1; i++) {
const a = points[i];
const b = points[i + 1];
positions.push(a.x, 0, a.y, b.x, 0, b.y, b.x, 1, b.y, a.x, 0, a.y, b.x, 1, b.y, a.x, 1, a.y);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const wall = new THREE.Mesh(
geometry,
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.07, side: THREE.DoubleSide, depthWrite: false }),
);
const rim = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(points.map((p) => new THREE.Vector3(p.x, 1, p.y))),
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.55 }),
);
this.world.add(wall, rim);
const members = model.nodes.filter((n) => n.x + n.w / 2 > x && n.x + n.w / 2 < x + w && n.y + n.h / 2 > y && n.y + n.h / 2 < y + h);
this.frames.push({ shape, wall, rim, members });
}
/** Positions that depend on the level mix and hover. */
private layout(): void {
for (const view of this.nodes.values()) view.group.position.y = this.levelY(view.shape.id) + view.lift + view.pulse;
for (const view of this.edges) {
const { points, from, to } = view.shape;
const y0 = this.levelY(from);
const y1 = this.levelY(to);
const n = points.length;
const pts: THREE.Vector3[] = [];
for (let i = 0; i < n; i++) {
const s = i / (n - 1);
const k = s * s * (3 - 2 * s);
pts.push(new THREE.Vector3(points[i][0], PIPE_Y + view.bias + y0 + (y1 - y0) * k, points[i][1]));
}
const end = pts[n - 1];
// The pipe stops where the arrowhead's base begins.
let cut = n - 1;
while (cut > 1 && pts[cut].distanceTo(end) < view.arrowLength * 0.85) cut--;
const path = new THREE.CurvePath<THREE.Vector3>();
for (let i = 1; i <= cut; i++) path.add(new THREE.LineCurve3(pts[i - 1], pts[i]));
view.tube.geometry.dispose();
view.tube.geometry = new THREE.TubeGeometry(path, Math.max(2, cut), view.radius, 10, false);
view.cap.position.copy(pts[0]);
view.path = path;
view.pipeLength = path.getLength();
view.mirrored = '';
this.mirrorEdge(view);
if (view.arrow) {
const t = end.clone().sub(pts[Math.max(0, n - 4)]).normalize();
const side = new THREE.Vector3().crossVectors(t, new THREE.Vector3(0, 1, 0));
if (side.lengthSq() < 1e-6) side.set(1, 0, 0);
side.normalize();
const normal = new THREE.Vector3().crossVectors(side, t);
view.arrow.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(t, normal, side));
view.arrow.position.copy(end);
}
}
for (const view of this.labels) {
const { shape } = view;
const cx = shape.x + shape.w / 2;
const cz = shape.y + shape.h / 2;
// Labels lie on top of their pipe.
let y = PIPE_Y + (view.edge ? view.edge.bias + view.edge.radius : 4) + 1.2;
if (view.edge && this.levelMix > 0) {
const { points, from, to } = view.edge.shape;
let best = 0;
let bestD = Infinity;
points.forEach(([px, pz], i) => {
const d = (px - cx) ** 2 + (pz - cz) ** 2;
if (d < bestD) (bestD = d), (best = i);
});
const s = best / Math.max(1, points.length - 1);
const k = s * s * (3 - 2 * s);
y += this.levelY(from) + (this.levelY(to) - this.levelY(from)) * k;
}
view.mesh.position.set(cx, y + (this.levelMix > 0.5 ? shape.h / 2 : 0), cz);
view.mesh.userData.billboard = this.levelMix > 0.5;
if (!view.mesh.userData.billboard) view.mesh.rotation.set(-Math.PI / 2, 0, 0);
}
for (const view of this.frames) {
const top = view.members.length
? Math.max(...view.members.map((m) => this.levelY(m.id))) + NODE_HEIGHT + FRAME_WALL
: FRAME_WALL;
view.wall.scale.y = top;
view.rim.position.y = top - 1;
}
for (const flow of this.flows.values()) this.removeFlow(flow);
this.dirty = true;
}
// --------------------------------------------------------------- motion
/**
* Follows Archify's own animations, which keep running on the hidden 2D
* SVG: the ambient trace (relationships drawing in, nodes pulsing) and the
* flows drawn over relationships (focus pulse, intent trace, route probe,
* lens, story). Returns true while anything moves.
*/
private syncMotion(): boolean {
const svg = this.model?.svg;
if (!svg) return false;
const running = svg.getAnimations({ subtree: true }).filter((a) => a.playState === 'running');
const overlays = svg.querySelectorAll<SVGPathElement>(FLOW_SELECTOR);
if (!running.length && !overlays.length && !this.flows.size && !this.motionActive) return false;
let changed = false;
for (const view of this.edges) changed = this.mirrorEdge(view) || changed;
for (const view of this.nodes.values()) changed = this.mirrorNode(view) || changed;
// Node pulses: Archify animates glow and stroke on [data-animate="node"].
const pulsing = new Set<NodeView>();
for (const animation of running) {
const name = (animation as CSSAnimation).animationName ?? '';
if (!/node-pulse/.test(name)) continue;
const target = (animation.effect as KeyframeEffect | null)?.target as Element | null;
const id = target?.closest('[data-node-id]')?.getAttribute('data-node-id');
const view = id ? this.nodes.get(id) : undefined;
if (!view) continue;
const p = animation.effect?.getComputedTiming().progress ?? 0;
// Archify's envelope: rise to 18%, hold to 36%, fall back by 72%.
const f = p < 0.18 ? p / 0.18 : p < 0.36 ? 1 : p < 0.72 ? 1 - (p - 0.36) / 0.36 : 0;
this.setPulse(view, f);
pulsing.add(view);
}
for (const view of this.pulsing) if (!pulsing.has(view)) this.setPulse(view, 0);
this.pulsing = pulsing;
// Flows over relationships.
const seen = new Set<SVGPathElement>();
overlays.forEach((el) => {
const edge = this.edgesByPath.get((el.getAttribute('d') ?? '').trim());
if (!edge?.path) return;
seen.add(el);
let flow = this.flows.get(el);
if (!flow) {
flow = this.addFlow(el, edge);
if (!flow) return;
}
this.mirrorFlow(flow);
});
for (const flow of [...this.flows.values()]) if (!seen.has(flow.el)) this.removeFlow(flow);
const active = running.length > 0 || this.flows.size > 0;
const result = changed || active || pulsing.size > 0 || this.motionActive !== active;
this.motionActive = active;
return result;
}
/**
* Live node dimming: Archify's intent trace fades unrelated nodes with
* fill/stroke opacity, set from attributes that do not rebuild textures.
*/
private mirrorNode(view: NodeView): boolean {
const dim = Math.max(0.18, Number(getComputedStyle(view.shape.el).fillOpacity || 1));
if (Math.abs(dim - view.dim) < 0.01) return false;
view.dim = dim;
const [cap, side, shadow] = view.materials;
cap.opacity = side.opacity = view.baseOpacity * dim;
shadow.opacity = (shadow.userData.base ??= shadow.opacity) * dim;
return true;
}
private setPulse(view: NodeView, f: number): void {
const side = view.materials[1] as THREE.MeshLambertMaterial;
side.emissive.copy(this.glowColor);
side.emissiveIntensity = f * 0.9;
view.pulse = f * 3;
view.group.position.y = this.levelY(view.shape.id) + view.lift + view.pulse;
}
private addFlow(el: SVGPathElement, edge: EdgeView): FlowView | undefined {
if (!edge.path) return undefined;
const material = new THREE.MeshBasicMaterial({ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending });
const mesh = new THREE.Mesh(new THREE.TubeGeometry(edge.path, Math.max(2, Math.round(edge.pipeLength / 3)), edge.radius * FLOW_SCALE, 10, false), material);
mesh.renderOrder = 3;
this.world.add(mesh);
const flow: FlowView = { el, edge, mesh, material };
this.flows.set(el, flow);
return flow;
}
/** Flows use pathLength="1": their dashes are fractions of the route. */
private mirrorFlow(flow: FlowView): void {
const style = getComputedStyle(flow.el);
const material = flow.material;
material.color = cssColor(style.stroke, '#34d399');
material.opacity = effectiveOpacity(flow.el, this.model!.svg) * Number(style.strokeOpacity || 1);
const pattern = parseDashes(style.strokeDasharray);
const key = pattern.join(',');
if (material.userData.dashKey !== key) {
material.alphaMap?.dispose();
const dash = dashFor(pattern);
material.alphaMap = dash?.texture ?? null;
material.alphaTest = dash ? 0.5 : 0;
material.userData = { dashKey: key, period: dash?.period ?? 1 };
material.needsUpdate = true;
}
if (material.alphaMap) {
const period = material.userData.period as number;
material.alphaMap.repeat.set(1 / period, 1);
material.alphaMap.offset.x = (parseFloat(style.strokeDashoffset) || 0) / period;
}
}
private removeFlow(flow: FlowView): void {
this.world.remove(flow.mesh);
flow.mesh.geometry.dispose();
flow.material.alphaMap?.dispose();
flow.material.dispose();
this.flows.delete(flow.el);
}
// --------------------------------------------------------------- camera
private bounds(): THREE.Box3 {
const box = new THREE.Box3();
if (!this.model) return box;
const { x, y, w, h } = this.model.viewBox;
box.expandByPoint(new THREE.Vector3(x, 0, y));
box.expandByPoint(new THREE.Vector3(x + w, NODE_HEIGHT + this.levelGap * this.levelMix * 3, y + h));
return box;
}
private flyTo(position: THREE.Vector3, target: THREE.Vector3, animate = true): void {
if (!animate || reducedMotion()) {
this.flight = undefined;
this.camera.position.copy(position);
this.controls.target.copy(target);
this.controls.update();
this.dirty = true;
return;
}
this.flight = { from: this.camera.position.clone(), to: position, fromTarget: this.controls.target.clone(), toTarget: target, start: performance.now() };
}
/**
* Camera position looking at the box centre from `direction`, close enough
* that every corner of the box projects inside the view (with a margin).
*/
private framing(box: THREE.Box3, direction: THREE.Vector3, margin = 0.9): { position: THREE.Vector3; target: THREE.Vector3 } {
const target = box.getCenter(new THREE.Vector3());
const corners: THREE.Vector3[] = [];
for (const x of [box.min.x, box.max.x]) for (const y of [box.min.y, box.max.y]) for (const z of [box.min.z, box.max.z]) corners.push(new THREE.Vector3(x, y, z));
const probe = this.camera.clone();
const fits = (distance: number) => {
probe.position.copy(target).addScaledVector(direction, distance);
probe.up.set(0, 1, 0);
probe.lookAt(target);
probe.updateMatrixWorld();
probe.updateProjectionMatrix();
return corners.every((c) => {
const p = c.clone().project(probe);
return Math.abs(p.x) <= margin && Math.abs(p.y) <= margin && p.z < 1;
});
};
let lo = 1;
let hi = box.getSize(new THREE.Vector3()).length() * 4 + 100;
while (!fits(hi)) hi *= 2;
for (let i = 0; i < 32; i++) {
const mid = (lo + hi) / 2;
if (fits(mid)) hi = mid;
else lo = mid;
}
return { position: target.clone().addScaledVector(direction, hi), target };
}
/** Frames the whole diagram from the front, raised like a reading desk. */
fit(animate = true): void {
const { position, target } = this.framing(this.bounds(), new THREE.Vector3(-0.16, 1.25, 1).normalize());
this.flyTo(position, target, animate);
}
/** Straight down, like the 2D view. */
top(animate = true): void {
const { position, target } = this.framing(this.bounds(), new THREE.Vector3(0, 1, 0.0001).normalize(), 0.95);
this.flyTo(position, target, animate);
}
focusNode(id: string): void {
const view = this.nodes.get(id);
if (!view) return;
const { x, y, w, h } = view.shape;
const target = new THREE.Vector3(x + w / 2, this.levelY(id) + NODE_HEIGHT, y + h / 2);
const offset = this.camera.position.clone().sub(this.controls.target).setLength(Math.max(w, h) * 4.5 + 120);
this.flyTo(target.clone().add(offset), target);
}
zoom(factor: number): void {
const offset = this.camera.position.clone().sub(this.controls.target).multiplyScalar(1 / factor);
this.flyTo(this.controls.target.clone().add(offset), this.controls.target.clone());
}
/** The camera, once the scene has been framed. */
cameraState(): CameraState | undefined {
if (!this.fitted) return undefined;
return { position: this.camera.position.toArray() as CameraState['position'], target: this.controls.target.toArray() as CameraState['target'] };
}
restoreCamera(state: CameraState): void {
this.flyTo(new THREE.Vector3(...state.position), new THREE.Vector3(...state.target), false);
this.fitted = true;
}
get layered(): boolean {
return this.levelTarget === 1;
}
setLayered(on: boolean, animate = true): void {
this.levelTarget = on ? 1 : 0;
this.dirty = true;
if (!animate || reducedMotion()) {
this.levelMix = this.levelTarget;
this.layout();
}
}
// -------------------------------------------------------------- picking
private pick(event: MouseEvent): string | undefined {
const rect = this.renderer.domElement.getBoundingClientRect();
const pointer = new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
this.raycaster.setFromCamera(pointer, this.camera);
const hit = this.raycaster.intersectObjects([...this.nodes.values()].map((v) => v.body), false)[0];
return hit?.object.userData.id;
}
// ---------------------------------------------------------------- loop
private resize(): void {
const { clientWidth: w, clientHeight: h } = this.element;
if (!w || !h) return;
this.renderer.setSize(w, h);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
this.dirty = true;
}
start(): void {
if (this.frameId) return;
this.resize();
this.lastTime = performance.now();
const tick = (now: number) => {
this.frameId = requestAnimationFrame(tick);
this.frame(now);
};
this.frameId = requestAnimationFrame(tick);
}
stop(): void {
cancelAnimationFrame(this.frameId);
this.frameId = 0;
}
/**
* One animation frame. The scene is drawn only when something changed:
* the camera, a transition, hover, or one of Archify's animations. An idle
* diagram costs no GPU time.
*/
private frame(now: number): void {
const dt = Math.min(0.1, (now - this.lastTime) / 1000);
this.lastTime = now;
let draw = this.dirty;
this.dirty = false;
if (this.levelMix !== this.levelTarget) {
const step = dt / 0.7;
this.levelMix = this.levelTarget > this.levelMix ? Math.min(1, this.levelMix + step) : Math.max(0, this.levelMix - step);
this.layout();
draw = true;
}
for (const view of this.nodes.values()) {
const goal = view.shape.id === this.hovered ? 4 : 0;
if (Math.abs(view.lift - goal) > 0.01) {
view.lift += (goal - view.lift) * Math.min(1, dt * 14);
view.group.position.y = this.levelY(view.shape.id) + view.lift + view.pulse;
draw = true;
}
}
if (this.flight) {
const t = Math.min(1, (now - this.flight.start) / FLY_MS);
const k = 1 - Math.pow(1 - t, 3);
this.camera.position.lerpVectors(this.flight.from, this.flight.to, k);
this.controls.target.lerpVectors(this.flight.fromTarget, this.flight.toTarget, k);
draw = true;
if (t === 1) {
this.flight = undefined;
this.onCameraChange();
}
}
if (this.syncMotion()) draw = true;
// update() returns true while the camera moves (including damping).
if (this.controls.update()) draw = true;
if (!draw) return;
for (const view of this.labels) {
if (view.mesh.userData.billboard) view.mesh.quaternion.copy(this.camera.quaternion);
}
this.renderer.render(this.scene, this.camera);
}
/** PNG of the current view. */
snapshot(): string {
this.renderer.render(this.scene, this.camera);
return this.renderer.domElement.toDataURL('image/png');
}
dispose(): void {
this.stop();
disposeObject(this.world);
for (const t of this.textures) t.dispose();
this.controls.dispose();
this.renderer.dispose();
this.element.remove();
}
}