Files
archify-vscode-ext/src/viewer3d/svgModel.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

182 lines
5.9 KiB
TypeScript

/**
* Reads the semantic parts of a rendered Archify SVG: nodes, routed
* relationships, their labels and structural frames. Archify marks them
* with the same data attributes in all five diagram types, so the 3D view
* needs no per-type code.
*/
export interface Box {
x: number;
y: number;
w: number;
h: number;
}
export interface NodeShape extends Box {
id: string;
kind: string;
rx: number;
el: SVGGElement;
}
export interface EdgeShape {
key: string;
from: string;
to: string;
path: SVGPathElement;
/** Points along the route, every SAMPLE_STEP user units. */
points: [number, number][];
length: number;
animated: boolean;
}
export interface LabelShape extends Box {
key: string;
el: SVGGElement;
}
export interface FrameShape extends Box {
kind: string;
rx: number;
el: SVGRectElement;
}
export interface DiagramModel {
svg: SVGSVGElement;
viewBox: Box;
nodes: NodeShape[];
edges: EdgeShape[];
labels: LabelShape[];
frames: FrameShape[];
}
const SAMPLE_STEP = 3;
/** Role of an element for the 3D view; descendants share their ancestor's role. */
export type Role = 'node' | 'edge' | 'label' | 'floor' | 'overlay';
/**
* Archify's temporary viewer layers (focus pulse, intent trace, route probe,
* journey, lens, story) are marked data-…-overlay. They are animated in 3D
* from the live SVG and never baked into the textures.
*/
function isOverlay(el: Element): boolean {
for (const name of el.getAttributeNames()) if (name.startsWith('data-') && name.endsWith('-overlay')) return true;
return false;
}
/** Flow paths inside those overlays: clones of a relationship path with pathLength="1". */
export const FLOW_SELECTOR = [
'relationship-pulse',
'intent-trace',
'route-probe',
'route-journey',
'semantic-lens',
'story',
'story-carrier',
'chapter-handoff',
].map((name) => `[data-${name}-overlay] path[pathLength]`).join(', ');
export function roleOf(el: Element): Role | undefined {
if (isOverlay(el)) return 'overlay';
if (el.hasAttribute('data-node-id')) return 'node';
if (el.getAttribute('data-graph-role') === 'automatic-crossover') return 'edge';
if (el.tagName === 'path' && el.hasAttribute('data-edge-from')) return 'edge';
if (el.getAttribute('data-graph-role') === 'automatic-crossover-underlay') return 'edge';
if (el.tagName === 'g' && el.hasAttribute('data-edge-key') && el.querySelector('text')) return 'label';
return undefined;
}
function num(el: Element, name: string): number {
return Number(el.getAttribute(name) ?? 0) || 0;
}
function rectBox(rect: SVGRectElement): Box & { rx: number } {
return { x: num(rect, 'x'), y: num(rect, 'y'), w: num(rect, 'width'), h: num(rect, 'height'), rx: num(rect, 'rx') };
}
function nodeShape(el: SVGGElement): NodeShape | undefined {
const id = el.getAttribute('data-node-id');
if (!id) return undefined;
const kind = el.getAttribute('data-node-kind') ?? 'backend';
// The node body is the semantic rect (c-<kind>); c-mask sits under it.
const rect =
el.querySelector<SVGRectElement>(`:scope > rect.c-${CSS.escape(kind)}`) ??
el.querySelector<SVGRectElement>(':scope > rect:not(.c-mask)') ??
el.querySelector<SVGRectElement>(':scope > rect');
if (rect) return { id, kind, el, ...rectBox(rect) };
const bbox = el.getBBox();
return { id, kind, el, x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height, rx: 0 };
}
function samplePath(path: SVGPathElement): { points: [number, number][]; length: number } {
const length = path.getTotalLength();
const steps = Math.max(2, Math.ceil(length / SAMPLE_STEP));
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const p = path.getPointAtLength((length * i) / steps);
points.push([p.x, p.y]);
}
return { points, length };
}
export function readModel(svg: SVGSVGElement): DiagramModel {
const vb = svg.viewBox.baseVal;
const viewBox = vb && vb.width > 0 ? { x: vb.x, y: vb.y, w: vb.width, h: vb.height } : { x: 0, y: 0, w: svg.clientWidth, h: svg.clientHeight };
const nodes = [...svg.querySelectorAll<SVGGElement>('g[data-node-id]')]
.map(nodeShape)
.filter((n): n is NodeShape => !!n && n.w > 0 && n.h > 0);
const edges: EdgeShape[] = [];
svg.querySelectorAll<SVGPathElement>('path[data-edge-from]:not([data-graph-role])').forEach((path, index) => {
const from = path.getAttribute('data-edge-from') ?? '';
const to = path.getAttribute('data-edge-to') ?? '';
const group = path.parentElement;
const { points, length } = samplePath(path);
if (length <= 0) return;
edges.push({
key: path.getAttribute('data-edge-key') ?? `e${index}`,
from,
to,
path,
points,
length,
animated: path.hasAttribute('data-animate') || !!group?.hasAttribute('data-animate'),
});
});
const labels: LabelShape[] = [];
svg.querySelectorAll<SVGGElement>('g[data-edge-key]').forEach((el) => {
if (roleOf(el) !== 'label') return;
const rect = el.querySelector<SVGRectElement>(':scope > rect');
const box = rect ? rectBox(rect) : (() => {
const b = el.getBBox();
return { x: b.x, y: b.y, w: b.width, h: b.height, rx: 0 };
})();
if (box.w > 0 && box.h > 0) labels.push({ key: el.getAttribute('data-edge-key') ?? '', el, ...box });
});
const frames = [...svg.querySelectorAll<SVGRectElement>('rect[data-graph-role="structural-frame"]')].map((el) => ({
kind: el.getAttribute('data-composition-frame-kind') ?? 'region',
el,
...rectBox(el),
}));
return { svg, viewBox, nodes, edges, labels, frames };
}
/**
* Opacity of an element including its ancestors below the SVG root. The
* root is left out: the 3D view hides the 2D SVG by fading the root.
*/
export function effectiveOpacity(el: Element, root: Element): number {
let value = 1;
for (let cur: Element | null = el; cur && cur !== root; cur = cur.parentElement) {
const style = getComputedStyle(cur);
if (style.display === 'none') return 0;
value *= Number(style.opacity || 1);
}
return value;
}