feat: 3D renderer of plantuml diagram

Adds initial conversation with Claude along with the .html diagram
renderer.
This commit is contained in:
Luís Murta 2026-03-14 10:22:58 +00:00
commit 8d3b5cccf9
Signed by: satprog
GPG Key ID: 169EF1BBD7049F94
2 changed files with 712 additions and 0 deletions

118
conversation-summary.md Normal file
View File

@ -0,0 +1,118 @@
# Conversation summary — PlantUML isometric 3D renderer
## Context
This conversation explored generating interactive 3D/isometric renderings of embedded system architecture diagrams, with a specific focus on an automotive ECU use case. The team uses PlantUML and self-hosts the PlantUML server.
---
## System being modelled
Three top-level ECUs: **A**, **B**, and **C**.
**ECU B** contains:
- A **Connector** and an **Adapter** for each of ECU A and ECU C (two parallel lanes)
- Both adapters feed into a single **Logic** component
- Inside Logic, a sequential sub-flow: **a → b → c**
PlantUML source:
```plantuml
@startuml
component "ECU A" as A
component "ECU C" as C
component "ECU B" as B {
component "Connector A" as ConA
component "Adapter A" as AdpA
component "Connector C" as ConC
component "Adapter C" as AdpC
component "Logic" as L {
component "a" as La
component "b" as Lb
component "c" as Lc
La --> Lb
Lb --> Lc
}
ConA --> AdpA
ConC --> AdpC
AdpA --> L
AdpC --> L
}
A --> ConA
C --> ConC
@enduml
```
---
## What was built
An interactive isometric 3D renderer that:
1. **Parses PlantUML component diagram syntax** client-side (no server required for the parser itself)
2. **Renders an isometric SVG** — each component is a 3-faced isometric block with color coding by component type
3. **Supports drill-down zoom** — clicking a component with nested children transitions to an internal view, with a breadcrumb trail for navigation
4. **Color scheme by component type:**
- Gray → ECU / top-level components
- Teal → Connectors
- Amber → Adapters
- Purple → Logic components
- Coral → Sequence steps (short labels like a, b, c)
A standalone `plantuml-3d-renderer.html` file was produced — open it directly in any browser or VSCode Live Preview.
---
## Architecture decisions
### Parser approach
The client-side parser handles:
- `component "Label" as alias { ... }` — with or without braces
- `component alias { ... }` — unlabelled alias form
- `alias --> alias : optional label` — directed connections
- Arbitrary nesting depth
**For production:** POST the `.puml` source to your self-hosted PlantUML server's `/json` endpoint and drive the renderer from the returned AST instead. This handles edge cases (stereotypes, notes, skinparam, grouped interfaces) that the lightweight client-side parser skips.
### Rendering
- Pure SVG, no dependencies
- Isometric projection: each block has top/left/right faces using CSS custom properties for colors
- Dark mode automatic via `prefers-color-scheme`
- Zoom levels are discrete view transitions (not CSS transforms) — each level is a full redraw of the SVG for the selected node's children
---
## DSL alternatives considered
| Tool | License | Notes |
|---|---|---|
| **PlantUML** | GPL / LGPL (jar) | Team already uses it. Self-hosting fine for commercial internal use. Get a free Professional Edition key to suppress ads. |
| **Mermaid** | MIT | Simplest license, no restrictions. Less expressive for deep nesting. |
| **D2** | MPL 2.0 | Clean syntax, good nesting. TALA layout engine requires paid license for commercial use. |
| **Structurizr DSL** | Apache 2.0 | C4-model based. Server prebuilt binaries need a license; build from source is free. |
| **ArchiMate** | Non-commercial free / annual commercial license | The Archi tool (MIT), but the language spec requires a commercial license from The Open Group for commercial use. |
| **SysML** | Open source (OMG derived) | Semantically correct for ECU/embedded systems. Heavy tooling (Cameo, Papyrus). Eclipse SysON is open source. |
| **AADL** | SAE standard (spec costs money; OSATE tooling is open source) | Best fit for automotive embedded. Steep learning curve. |
**Recommendation:** Stay on PlantUML. The team's existing knowledge compounds in value faster than any migration would justify — unless moving toward full MBSE (model-based systems engineering) with SysML or AADL.
---
## Next steps / open threads
- [ ] Wire the renderer to the self-hosted PlantUML `/json` endpoint instead of the client-side parser
- [ ] Add animated connection flows (data flowing between components)
- [ ] Support PlantUML stereotypes (`<<ECU>>`, `<<connector>>`) as an explicit color override mechanism, removing the label-inference heuristic
- [ ] Consider a VSCode extension wrapper: watch `.puml` files, re-render on save, show isometric view in a side panel
- [ ] Explore Three.js for true 3D with orbit/zoom controls, for stakeholder presentation use cases
---
## How to continue in VSCode
1. Open `plantuml-3d-renderer.html` with Live Preview or any browser
2. Start a new Claude chat (Claude Code CLI or Copilot+Claude) and paste this summary
3. The renderer source is self-contained — all logic is in the single HTML file
4. Key function to extend: `parsePuml(src)` for parser changes, `drawLevel(node)` for rendering changes, `guessColor(node)` for color mapping changes

594
plantuml-3d-renderer.html Normal file
View File

@ -0,0 +1,594 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PlantUML → Isometric 3D Renderer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
--bg: #ffffff;
--bg2: #f5f4f0;
--bg3: #eeecea;
--txt: #1a1a1a;
--txts: #555550;
--txt3: #888780;
--border: rgba(0,0,0,0.12);
--border2: rgba(0,0,0,0.22);
--info-bg: #e6f1fb;
--info-txt: #0c447c;
--info-bdr: #378add;
--sk: rgba(0,0,0,0.13);
--top-gray: #D3D1C7; --left-gray: #888780; --right-gray: #5F5E5A;
--top-blue: #B5D4F4; --left-blue: #378ADD; --right-blue: #185FA5;
--top-teal: #9FE1CB; --left-teal: #1D9E75; --right-teal: #0F6E56;
--top-amber: #FAC775; --left-amber: #BA7517; --right-amber: #854F0B;
--top-purple:#CECBF6; --left-purple:#7F77DD; --right-purple:#534AB7;
--top-coral: #F5C4B3; --left-coral: #D85A30; --right-coral: #993C1D;
--radius: 8px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1e1e1e;
--bg2: #2a2a2a;
--bg3: #333333;
--txt: #e8e6dc;
--txts: #9c9a92;
--txt3: #666660;
--border: rgba(255,255,255,0.1);
--border2: rgba(255,255,255,0.2);
--info-bg: #042c53;
--info-txt: #85b7eb;
--info-bdr: #185fa5;
--sk: rgba(255,255,255,0.08);
--top-gray: #2C2C2A; --left-gray: #444441; --right-gray: #1a1a18;
--top-blue: #0C447C; --left-blue: #185FA5; --right-blue: #042C53;
--top-teal: #085041; --left-teal: #0F6E56; --right-teal: #04342C;
--top-amber: #633806; --left-amber: #854F0B; --right-amber: #412402;
--top-purple:#3C3489; --left-purple:#534AB7; --right-purple:#26215C;
--top-coral: #712B13; --left-coral: #993C1D; --right-coral: #4A1B0C;
}
}
body {
font-family: var(--font-sans);
background: var(--bg);
color: var(--txt);
padding: 24px;
min-height: 100vh;
}
h1 {
font-size: 18px;
font-weight: 500;
margin-bottom: 4px;
}
.subtitle {
font-size: 13px;
color: var(--txts);
margin-bottom: 20px;
}
.layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 20px;
align-items: start;
}
@media (max-width: 700px) {
.layout { grid-template-columns: 1fr; }
}
.panel {
background: var(--bg2);
border: 0.5px solid var(--border2);
border-radius: var(--radius);
padding: 16px;
}
.panel-label {
font-size: 11px;
font-weight: 500;
color: var(--txt3);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 10px;
}
textarea {
width: 100%;
height: 320px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
padding: 10px;
resize: vertical;
background: var(--bg);
color: var(--txt);
border: 0.5px solid var(--border2);
border-radius: var(--radius);
outline: none;
}
textarea:focus { border-color: var(--info-bdr); }
.btn-row {
display: flex;
gap: 8px;
margin-top: 10px;
align-items: center;
}
button {
font-family: var(--font-sans);
font-size: 13px;
padding: 7px 16px;
border-radius: var(--radius);
border: 0.5px solid var(--border2);
background: transparent;
color: var(--txt);
cursor: pointer;
}
button:hover { background: var(--bg3); }
button:active { transform: scale(0.98); }
button.primary {
background: var(--info-bg);
color: var(--info-txt);
border-color: var(--info-bdr);
font-weight: 500;
}
#err {
font-size: 12px;
color: #c0392b;
margin-top: 6px;
min-height: 16px;
}
.legend {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--txts);
}
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 2px;
border: 0.5px solid var(--border);
}
.hint-box {
margin-top: 12px;
padding: 8px 10px;
background: var(--info-bg);
border: 0.5px solid var(--info-bdr);
border-radius: var(--radius);
font-size: 11px;
color: var(--info-txt);
line-height: 1.5;
}
.canvas-panel {
background: var(--bg2);
border: 0.5px solid var(--border2);
border-radius: var(--radius);
overflow: hidden;
}
.canvas-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 0.5px solid var(--border);
}
#nav {
font-size: 12px;
color: var(--txts);
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
#nav .crumb {
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
color: var(--info-txt);
}
#nav .crumb:hover { opacity: 0.75; }
#nav .sep { opacity: 0.35; }
#canvas-wrap {
padding: 8px;
background: var(--bg);
}
svg { display: block; }
.iso-lbl {
font-family: var(--font-sans);
font-size: 11px;
fill: var(--txt, #1a1a1a);
}
.iso-sub {
font-family: var(--font-sans);
font-size: 9px;
fill: var(--txts, #555);
}
.zoomable { cursor: zoom-in; }
.zoomable:hover polygon:first-child { opacity: 0.82; }
.conn { stroke-width: 1; fill: none; stroke-dasharray: 4 3; }
.conn-s { stroke-width: 1.5; fill: none; }
</style>
</head>
<body>
<h1>PlantUML → Isometric 3D Renderer</h1>
<p class="subtitle">Paste a PlantUML component diagram and click Render to generate an interactive isometric view.</p>
<div class="layout">
<div class="panel">
<div class="panel-label">PlantUML source</div>
<textarea id="puml" spellcheck="false">@startuml
component "ECU A" as A
component "ECU C" as C
component "ECU B" as B {
component "Connector A" as ConA
component "Adapter A" as AdpA
component "Connector C" as ConC
component "Adapter C" as AdpC
component "Logic" as L {
component "a" as La
component "b" as Lb
component "c" as Lc
La --> Lb
Lb --> Lc
}
ConA --> AdpA
ConC --> AdpC
AdpA --> L
AdpC --> L
}
A --> ConA
C --> ConC
@enduml</textarea>
<div class="btn-row">
<button class="primary" onclick="render()">Render →</button>
<button onclick="resetSource()">Reset example</button>
</div>
<div id="err"></div>
<div class="legend">
<div class="legend-item">
<div class="legend-swatch" style="background:var(--top-gray)"></div> ECU / top-level
</div>
<div class="legend-item">
<div class="legend-swatch" style="background:var(--top-teal)"></div> Connector
</div>
<div class="legend-item">
<div class="legend-swatch" style="background:var(--top-amber)"></div> Adapter
</div>
<div class="legend-item">
<div class="legend-swatch" style="background:var(--top-purple)"></div> Logic
</div>
<div class="legend-item">
<div class="legend-swatch" style="background:var(--top-coral)"></div> Sequence step
</div>
</div>
<div class="hint-box">
Color is inferred from the component label.<br>
Components with children are clickable — drill in to explore nested structure.
<br><br>
To connect your self-hosted PlantUML server, POST the source to
<code>/json</code> and feed the response AST into the renderer instead of the client-side parser.
</div>
</div>
<div class="canvas-panel">
<div class="canvas-header">
<div id="nav"><span style="color:var(--txt3)">Overview</span></div>
</div>
<div id="canvas-wrap">
<svg id="iso" width="100%" viewBox="0 0 680 400"></svg>
</div>
</div>
</div>
<script>
const EXAMPLE = document.getElementById('puml').value;
const PALETTE = {
ecu: { t:'--top-gray', l:'--left-gray', r:'--right-gray' },
conn: { t:'--top-teal', l:'--left-teal', r:'--right-teal' },
adp: { t:'--top-amber', l:'--left-amber', r:'--right-amber' },
logic: { t:'--top-purple', l:'--left-purple', r:'--right-purple' },
seq: { t:'--top-coral', l:'--left-coral', r:'--right-coral' },
};
function cv(v) {
return getComputedStyle(document.documentElement).getPropertyValue(v).trim() || v;
}
function parsePuml(src) {
const lines = src.split('\n')
.map(s => s.trim())
.filter(s => s && !s.startsWith("'") && s !== '@startuml' && s !== '@enduml');
const root = { id: '__root__', label: 'root', children: [], conns: [] };
const stack = [root];
const byId = {};
const aliasRe = /^component\s+"([^"]+)"\s+as\s+(\w+)\s*(\{)?\s*$/;
const aliasNoLblRe = /^component\s+(\w+)\s*(\{)?\s*$/;
const connRe = /^(\w+)\s*-->\s*(\w+)(?:\s*:\s*(.*))?$/;
const closeRe = /^\}$/;
function cur() { return stack[stack.length - 1]; }
for (const line of lines) {
let m;
if ((m = aliasRe.exec(line))) {
const node = { id: m[2], label: m[1], children: [], conns: [], parent: cur() };
byId[m[2]] = node;
cur().children.push(node);
if (m[3]) stack.push(node);
} else if ((m = aliasNoLblRe.exec(line))) {
const node = { id: m[1], label: m[1], children: [], conns: [], parent: cur() };
byId[m[1]] = node;
cur().children.push(node);
if (m[2]) stack.push(node);
} else if ((m = connRe.exec(line))) {
cur().conns.push({ from: m[1], to: m[2], label: m[3] || '' });
} else if (closeRe.test(line)) {
if (stack.length > 1) stack.pop();
}
}
return { root, byId };
}
function guessColor(node) {
const l = node.label.toLowerCase();
if (l.startsWith('connector')) return PALETTE.conn;
if (l.startsWith('adapter')) return PALETTE.adp;
if (l === 'logic' || l === 'logic component') return PALETTE.logic;
if (node.label.length <= 2) return PALETTE.seq;
return PALETTE.ecu;
}
function ns(tag) {
return document.createElementNS('http://www.w3.org/2000/svg', tag);
}
function makePoly(pts, cssVar) {
const el = ns('polygon');
el.setAttribute('points', pts.map(p => p.join(',')).join(' '));
el.style.fill = `var(${cssVar})`;
el.style.stroke = 'var(--sk)';
el.setAttribute('stroke-width', '0.5');
return el;
}
function makeBlock(svg, x, y, W, H, pal, label, sublabel, clickable) {
const g = ns('g');
if (clickable) g.classList.add('zoomable');
const hw = W, hh = W * 0.47;
const top = makePoly([[x,y],[x+hw,y+hh],[x,y+hh*2],[x-hw,y+hh]], pal.t);
const left = makePoly([[x-hw,y+hh],[x,y+hh*2],[x,y+hh*2+H],[x-hw,y+hh+H]], pal.l);
const right = makePoly([[x,y+hh*2],[x+hw,y+hh],[x+hw,y+hh+H],[x,y+hh*2+H]], pal.r);
g.appendChild(top); g.appendChild(left); g.appendChild(right);
const tx = ns('text');
tx.setAttribute('x', x); tx.setAttribute('y', y + hh + 2);
tx.setAttribute('text-anchor', 'middle');
tx.setAttribute('dominant-baseline', 'central');
tx.setAttribute('class', 'iso-lbl');
tx.setAttribute('font-weight', '500');
tx.textContent = label;
g.appendChild(tx);
if (sublabel) {
const ts = ns('text');
ts.setAttribute('x', x); ts.setAttribute('y', y + hh + 15);
ts.setAttribute('text-anchor', 'middle');
ts.setAttribute('class', 'iso-sub');
ts.textContent = sublabel;
g.appendChild(ts);
}
svg.appendChild(g);
return {
g,
cx: x,
cy: y + hh,
top_y: y,
bot_y: y + hh * 2 + H,
left_x: x - hw,
right_x: x + hw,
};
}
function makeArrow(svg) {
const defs = ns('defs');
const m = ns('marker');
m.setAttribute('id', 'arr');
m.setAttribute('viewBox', '0 0 10 10');
m.setAttribute('refX', '8'); m.setAttribute('refY', '5');
m.setAttribute('markerWidth', '6'); m.setAttribute('markerHeight', '6');
m.setAttribute('orient', 'auto-start-reverse');
const p = ns('path');
p.setAttribute('d', 'M2 1L8 5L2 9');
p.setAttribute('fill', 'none');
p.style.stroke = 'var(--left-gray)';
p.setAttribute('stroke-width', '1.5');
p.setAttribute('stroke-linecap', 'round');
m.appendChild(p); defs.appendChild(m); svg.insertBefore(defs, svg.firstChild);
}
function makeLine(svg, x1, y1, x2, y2) {
const p = ns('path');
const mx = (x1 + x2) / 2, my = Math.min(y1, y2) - 18;
p.setAttribute('d', `M${x1},${y1} Q${mx},${my} ${x2},${y2}`);
p.classList.add('conn-s');
p.style.stroke = 'var(--left-gray)';
p.setAttribute('marker-end', 'url(#arr)');
svg.appendChild(p);
}
let state = { parsed: null, viewStack: [] };
function render() {
const src = document.getElementById('puml').value;
document.getElementById('err').textContent = '';
let parsed;
try { parsed = parsePuml(src); }
catch (e) { document.getElementById('err').textContent = 'Parse error: ' + e.message; return; }
state.parsed = parsed;
state.viewStack = [];
drawLevel(parsed.root);
}
function drawLevel(node) {
const svg = document.getElementById('iso');
svg.innerHTML = '';
makeArrow(svg);
const children = node.children;
const conns = node.conns;
if (children.length === 0) {
svg.setAttribute('viewBox', '0 0 680 100');
const t = ns('text');
t.setAttribute('x', '340'); t.setAttribute('y', '50');
t.setAttribute('text-anchor', 'middle'); t.setAttribute('class', 'iso-sub');
t.textContent = 'No nested components.';
svg.appendChild(t);
updateNav(); return;
}
const W = 80, H = 28;
const cols = Math.min(children.length, 4);
const rows = Math.ceil(children.length / cols);
const vbH = rows * 160 + 80;
svg.setAttribute('viewBox', `0 0 680 ${vbH}`);
const startX = 340 - (cols - 1) * 90;
const positions = {};
children.forEach((child, i) => {
const col = i % cols, row = Math.floor(i / cols);
const x = startX + col * 180;
const y = 50 + row * 155;
const pal = guessColor(child);
const canZoom = child.children.length > 0;
const sub = canZoom ? 'click to expand →' : null;
const blk = makeBlock(svg, x, y, W, H, pal, child.label, sub, canZoom);
positions[child.id] = blk;
if (canZoom) {
blk.g.addEventListener('click', () => {
state.viewStack.push(node);
drawLevel(child);
});
}
});
conns.forEach(c => {
const fp = positions[c.from], tp = positions[c.to];
if (!fp || !tp) return;
makeLine(svg, fp.cx, fp.bot_y, tp.cx, tp.top_y);
});
updateNav();
}
function updateNav() {
const nav = document.getElementById('nav');
nav.innerHTML = '';
const stack = state.viewStack;
const sep = () => {
const s = document.createElement('span');
s.className = 'sep';
s.textContent = ' ';
return s;
};
if (stack.length === 0) {
const s = document.createElement('span');
s.style.color = 'var(--txt3)';
s.textContent = 'Overview';
nav.appendChild(s);
return;
}
const overviewEl = document.createElement('span');
overviewEl.className = 'crumb';
overviewEl.textContent = 'Overview';
overviewEl.onclick = () => { state.viewStack = []; drawLevel(state.parsed.root); };
nav.appendChild(overviewEl);
stack.forEach((node, i) => {
nav.appendChild(sep());
if (i < stack.length - 1) {
const el = document.createElement('span');
el.className = 'crumb';
el.textContent = node.label || node.id;
el.onclick = () => { state.viewStack = stack.slice(0, i); drawLevel(node); };
nav.appendChild(el);
} else {
const el = document.createElement('span');
el.style.color = 'var(--txt3)';
el.textContent = node.label || node.id;
nav.appendChild(el);
}
});
nav.appendChild(sep());
const cur = stack[stack.length - 1];
const curChildren = cur.children;
if (curChildren.length > 0) {
const el = document.createElement('span');
el.style.color = 'var(--txt3)';
el.textContent = curChildren[0]?.parent?.label || cur.label;
nav.appendChild(el);
}
}
function resetSource() {
document.getElementById('puml').value = EXAMPLE;
render();
}
render();
</script>
</body>
</html>