# Capabilities Source: https://iso-topology.mintlify.app/agent/CAPABILITIES The generated contract for supported inputs, shapes, primitives, and styles. Capability report version: **0.4.5**. Generated from `CapabilityReport()`. Do not edit by hand — run `go run ./tools/gen-docs` to regenerate after a code change. Same content as `isotopo capabilities` JSON, but markdown for skim-reading. ## Input formats | Extension | Layout | Description | | - | - | - | | `.yaml` | manual | hand-authored iso composite with precise placement | | `.json` | manual | same shape as .yaml but JSON-encoded | | `.d2` | auto | d2 graph source; isotopo runs auto-layout then translates | ## Layout engines (.d2 input) | Name | Edges | Description | | - | - | - | | `dagre` | polyline | default; fast, natural-bend polyline edges | | `elk` | orthogonal | ELK; orthogonal right-angle edges with obstacle avoidance | ## Shapes Each row is one iso shape with every DSL alias accepted. The height hint is the default extrusion multiplier — agents can override per-part via `geom.h`. | Iso shape | Accepted aliases | Height hint | Notes | | - | - | - | - | | **array1d** | `array1d` | 1.0 | v3.11 — linear row of N identical cells. params: countX (default 3), gap (default 6). Semantic: pipeline stages, replica group. | | **array2d** | `array2d` | 1.0 | v3.11 — N×M grid of cells on the ground plane. params: countX, countY (default 3×3), gap. Semantic: shard matrix, node pool, partition table. | | **array3d** | `array3d` | 1.0 | v3.11 — N×M×K volumetric grid. params: countX, countY, countZ (default 3×3×3), gap. Semantic: GPU cluster, tensor, embedding matrix. | | **boundary** | `boundary` | 1.0 | v3.5 — a group whose substrate is a dashed OUTLINE-ONLY flat region (VPC / subnet / trust zone). Same nesting/layout/autosize as group; style.stroke restyles the dashes | | **circle** | `circle`, `oval` | 1.0 | | | **cloud** | `cloud` | 0.8 | free-form rounded outline; styles via palette top/left (+ topGradient/leftGradient) — top paints the crown, left the wrap-around side band; right is unused (no right face, linted). Top-face icon + label supported with box-family anchor/scale semantics on the cloud's inscribed content rect | | **composite** | `composite` | 1.0 | container — holds parts: \[] of CompositePart entries | | **custom\_path** | `custom_path` | 1.0 | v3.10 — arbitrary polygon base extruded vertically. Provide path: in geom.params (M/L/Z SVG commands). Semantic: any brand or domain-specific shape. | | **cylinder** | `cylinder`, `queue`, `stored-data`, `stored_data` | 1.0 | | | **group** | `group` | 1.0 | v2 primitive — translucent labeled substrate wrapping nested parts | | **hexprism** | `hexagon` | 0.7 | v3.2 — 6-gon prism: API gateway / middleware semantics. v3.12 — palette left/rightGradient shade the visible walls by normal (box left/right semantics); projected face fills pin to each wall's iso plane | | **iso\_text** | `text` | 0.3 | flat text panel (low extrusion) | | **octprism** | `octprism` | 1.0 | v3.2 — 8-gon prism: firewall (stop-sign) / decision-gate semantics. v3.12 — palette left/rightGradient shade the visible walls by normal (box left/right semantics); projected face fills pin to each wall's iso plane | | **person** | `c4-person`, `c4_person`, `person` | 1.2 | human actor: sphere head fused onto a dome torso. Body halves take palette.left/right; palette.leftGradient/rightGradient shade the matching half, palette.topGradient shades BOTH halves as one body gradient (no drawn top face). effects.outline / dropShadow / backglow follow the true head+dome silhouette. style.faces and effects.pattern are ignored (validator warns) | | **rack** | `rack` | 1.0 | v3.11 — server rack with slot shelves. params: slots (default 4). Semantic: physical server rack, blade chassis, equipment cabinet. | | **rectangle** | `callout`, `class`, `code`, `document`, `hierarchy`, `image`, `package`, `page`, `rectangle`, `sequence-diagram`, `sequence_diagram`, `sql-table`, `sql_table`, `square`, `step` | 1.0 | | | **triprism** | `triprism` | 1.0 | v3.2 — 3-gon prism: alert / one-way fan-out semantics. v3.12 — palette left/rightGradient shade the visible walls by normal (box left/right semantics); projected face fills pin to each wall's iso plane | | **wedge** | `wedge` | 0.7 | v3.10 — sloped prism: back edge raised to full height, front edge at z=0. Semantic: ramp, data ingestion pipeline, traffic escalation. | ## Composition primitives ### `group` **Where:** `node.parts[*]` **Syntax:** `{shape: group, layout: {mode: row|column|grid|ring}, parts: [...], label: "…", geom: {h}}` Wrap N parts in a translucent labeled iso substrate. Position children with layout (or per-child place) — the substrate then auto-sizes around them, so geom.w/d may be omitted. Child offsets are fine-tune deltas relative to the group. | Field | Meaning | | - | - | | `geom` | h = substrate thickness (low, default 6); w/d only when overriding the auto-sized footprint | | `label` | rendered on the top face of the substrate (or screen-space if style.text.orient=screen) | | `layout` | auto-arrangement of the children — preferred over per-child coordinates | | `parts` | list of CompositePart (other groups OK — supports unlimited nesting) | ### `stack` **Where:** `node.parts[*].stack` **Syntax:** `stack: {count: N, gap: g}` Replicate a part N times vertically with a Z-gap. Bottom copy keeps the id; the rest are suffixed "\~k" so connectors can target a specific layer. | Field | Meaning | | - | - | | `count` | total number of layers (1 is a no-op) | | `gap` | world Z step between layers (default = part.H + 4 if omitted) | ### `canvas-grid` **Where:** `document.canvas` **Syntax:** `canvas: {background: "#FAFBFC", grid: iso, gridColor: "#E2E6EE", gridStep: 36}` Paint an iso-aware backdrop under the scene. grid: iso draws a diamond rhombus lattice that aligns with the part projection. | Field | Meaning | | - | - | | `background` | solid background color (also bleed color behind patterns) | | `depthSort` | v5.2 — true / false (default false). Painter's-algorithm depth sort: parts paint back-to-front by world depth (corner sum x+y+z), merged across groups, instead of author order; connector routes layer by their endpoints' depth instead of sitting under every body. Group slabs/platforms still paint at the very back. Turn on when parts from different groups overlap in projection or when author order would paint a back part over a front one. Iso projection only (no effect with projection: top). | | `grid` | iso \| dots \| hatch \| solid \| none | | `gridColor` | pattern stroke / dot color | | `gridStep` | tile size in world units (default 40) | | `padding` | v3.1 — uniform breathing margin in px around the final composition; use 40-80 for sparse hero shots | | `projection` | v0.8 — iso (default) \| top. top renders a flat top-down plan view (footprints + orthogonal edges, height dropped) for reading the layout/flow | ### `annotation` **Where:** `document.annotations[*] | node.annotations[*] (v3.0)` **Syntax:** `{anchor: , text: "…", side: top|right|bottom|left, distance: 60}` Screen-space callout pinned to a composite part. Multi-line text is supported via \n. Placement is collision-managed: the box never crosses or touches any part projection (6px clearance) and never overlaps other text; side/distance are the FIRST candidate, and the box slides to the nearest free peripheral position when occupied. | Field | Meaning | | - | - | | `anchor` | id of the part to point at | | `bg` | fill color (default white) | | `border` | stroke color (default near-black) | | `distance` | world-unit gap from part silhouette to box (default 60) | | `side` | which direction the box sits relative to the part | | `text` | multi-line text (split on \n) | ### `connector` **Where:** `node.connectors[*]` **Syntax:** `{from: , to: , routing: straight|orthogonal|orthogonal2|bezier, arrow: none|triangle, label: "…"}` Directed line between two parts, optionally labeled and orthogonal-routed. Coplanarity contract: the two endpoints should share the same base height (wz / tier) — orthogonal connectors hug the ground plane, so same-tier links render as clean 2D L-shapes. Connecting endpoints at DIFFERENT heights forces a vertical drop segment (riser); do that only to express a deliberate cross-tier call (e.g. gateway layer → data layer), never as a side effect of giving peer nodes different wz. | Field | Meaning | | - | - | | `arrow` | none = no head; triangle = filled arrowhead at the dst | | `bend` | v4.4 — \{wx, wy} world offset on the route's interior so a Studio edge-drag shifts the line while both endpoints stay docked | | `elbow` | v3.1 — orthogonal elbow bias: xFirst \| yFirst (default: the axis the source face exits along). orthogonal2 auto-routes and ignores elbow. | | `from` | source part id; "id.anchor" picks a specific face-centre (e.g. central.right-mid). Use id.port-left/port-back/port-right/port-front for a fixed visible contour port; Studio uses these and retains stack instance IDs (id\~N). Legacy anchors keep their existing semantics. Bare ids auto-pick the face FACING the other endpoint. STRONGLY PREFER a target at the SAME height (wz/tier) as this source — same-height pairs route as clean flat 2D lines; pick the coplanar candidate when several would work. | | `labelBg` | pill background (default #FFFFFFEE) | | `labelColor` | v3.1 — pill ink (default #1F2433); dim it for dark scenes | | `labelFontFamily` | Optional font family. Source Sans Pro binds supported complete text blocks to an embedded resource with measured multiline geometry and continuous inline placement. Unset keeps legacy output; unsupported text/fonts remain estimated. | | `labelFontSize` | Pill font size (default 13 in iso, 10 in top). Explicit sizes are honored in both projections. | | `labelFontWeight` | Optional label weight (default 600). Embedded Source Sans Pro supports 400/600/700, normal/bold aliases. Measured placements declare their body/route/arrow/pill envelope scope; blocked labels remain visible and produce render-report warnings. | | `routing` | ALWAYS use orthogonal — every segment rides the iso ground axes, flush with the 2.5D grid (collinear endpoints collapse to one on-axis segment). orthogonal2 (opt-in v2) keeps the same iso-axis discipline but searches a multi-segment route that detours around node footprints and avoids crossing already-placed edges — reach for it when a dense scene makes plain orthogonal tunnel a node or cross another line. straight/bezier cut across the grid and are reserved for non-iso freeform sketches. Endpoints should be coplanar (same wz); see Purpose for cross-tier risers. | | `to` | destination part id (same anchor syntax). Choose an endpoint coplanar with `from` (same wz/tier) whenever possible; only span heights for an intentional cross-tier link, which renders a vertical drop segment. | | `waypoints` | v4.6 — explicit interior corner list \[\{wx, wy}, …] (world coords, source→target) the route threads through, so a Studio edge-drag can move ONE segment independently. Supersedes bend + the auto route; endpoints stay docked. Auto-kept iso-axis-aligned. | | `waypoints3d` | Explicit interior XYZ world corners \[\{wx, wy, wz}, ...] for orthogonal/orthogonal2; omitted coordinates are absolute zero. Nonempty values override waypoints, bend and automatic routing. Studio segment dragging writes this field to preserve risers and endpoint heights. Axis bridges use X, Y, then Z; endpoints remain resolved from from/to. Other routing modes leave it inactive and report a warning. Remove the field to resume previous routing intent. At most 512 corners; this does not guarantee obstacle clearance. | ### `layout` **Where:** `node.layout | node.parts[*].layout (groups)` **Syntax:** `layout: {mode: row|column|grid|ring|auto, engine: elk|dagre, cols: N, gap: 1, padding: 1, align: start|center|end}` Auto-arrange a container's parts along the iso ground axes — the preferred way to position parts. No hand-computed coordinates: row marches along world +x, column along +y, grid wraps row-major after cols, ring puts the FIRST child at the centre and distributes the rest on a circle around it (hub-and-spoke in one rule). A layout group's geom.w/d may be omitted; the substrate auto-sizes around the arranged content. | Field | Meaning | | - | - | | `align` | cross-axis alignment within each track (default center) | | `cols` | grid only; default ceil(sqrt(n)) | | `engine` | v5 — scene-root mode auto only: elk \| dagre delegates the connector-graph arrangement to d2's embedded layered engines (offline, deterministic; results grid-snapped). Prefer elk for dense DAGs with heavy fan-in/fan-out — it cuts crossings/tunnels the built-in longest-path solver leaves. Omit for the built-in solver (default, unchanged). The engine uses its own spacing; gap is ignored. On failure the built-in solver runs with a Warning. | | `gap` | space between children (ring: hub-to-satellite clearance), in CELLS (1 cell = gridStep, default 40 world units); default 1 | | `mode` | row \| column \| grid \| ring \| auto. auto (v4.2) is connector-driven: declare parts + connectors with NO place/coords and the engine layers the graph into a balanced iso flow — per-part style (faces/prisms/effects) is untouched. The other modes arrange a chosen structure. | | `padding` | content inset from the container edge, in cells; defaults to gap | ### `place` **Where:** `node.parts[*].place` **Syntax:** `place: {rightOf: , inFrontOf: , above: , gap: 1, gapX: 2, gapY: 0, align: start|center|end}` Position a part relative to a SIBLING — the preferred way to compose free-standing scenes. One constraint per axis: rightOf/leftOf pins world x, inFrontOf/behind pins world y (front = toward viewer), above pins world z. `above` is for DECORATIVE stacking — plinths, ghost volumes, toppers (the part sits flush ON TOP of the sibling, centred on its footprint). If an elevated part also needs a connector, keep it on the same tier (wz) as what it connects to, or the link will hang in a vertical drop; reserve `above` for parts with no connectors. Unpinned ground axes align to the referenced sibling per align. Chains are solved topologically (a stair = each tile rightOf+inFrontOf its predecessor). offset degrades to a fine-tune delta. | Field | Meaning | | - | - | | `above` | sibling id — flush on its top face (z = its top), x/y centred unless also pinned | | `align` | alignment along the unconstrained axis (default center) | | `behind` | sibling id — -y side (mutually exclusive with inFrontOf) | | `gap` | distance from the sibling's footprint, in cells; default 1 | | `gapX` | overrides gap on the x axis only | | `gapY` | overrides gap on the y axis only | | `inFrontOf` | sibling id — +y side, toward the viewer | | `leftOf` | sibling id — -x side (mutually exclusive with rightOf) | | `rightOf` | sibling id — this part sits on its +x side | ### `preset` **Where:** `theme.presets + node.parts[*].preset` **Syntax:** `theme: {presets: {: ``` ## Slides Most slide tools accept SVG drag-and-drop: * Keynote, PowerPoint, Google Slides: drag the `.svg` from a Finder/Explorer window onto the slide * Figma: same — paste from clipboard works too Native vector means infinite zoom without pixelation. ## Docs sites (MkDocs / Docusaurus / Mintlify) Drop `topology.svg` next to your `.md` and reference relatively. No special config; the SVG is self-contained. ## LLM context For text-mode LLMs: ```bash theme={null} # Send the SVG as text cat out/topology.svg | # Or send just the source DSL — model will reason about structure cat services.d2 | ``` For multimodal LLMs that want a bitmap, rasterize via headless Chrome or `rsvg-convert`: ```bash theme={null} rsvg-convert -w 1600 out/topology.svg -o topology.png ``` ## Per-element embeds Each element in `nodes/` is a self-contained iso sticker. Drop one inline: ```markdown theme={null} The ![queue](./out/nodes/queue.svg) holds pending jobs. ``` Useful for blog posts where you want a small icon, not the whole scene. ## Re-rendering after editing `out/topology.` is a byte-exact copy of your source DSL. If you or a reviewer edit it directly: ```bash theme={null} isotopo render out/topology.d2 ./out ``` The same output structure is regenerated. You can wire this into a file watcher for live preview. ## Next You've covered the full loop — install, write, render, publish. Common follow-ups: * [`reference/dsl-yaml.md`](../reference/dsl-yaml.md) — every YAML field, every option * [`reference/dsl-d2.md`](../reference/dsl-d2.md) — every d2 shape and how it lowers * [`reference/dsl-theme.md`](../reference/dsl-theme.md) — palette / stroke / text / effects + theme cascade * [`agent/RECIPES.md`](../agent/RECIPES.md) — task → DSL primitive speed-dial (useful for both humans and agents) * [`guides/troubleshooting.md`](../guides/troubleshooting.md) — when something doesn't render the way you expect # Extend the renderer Source: https://iso-topology.mintlify.app/guides/extending Add shapes, DSL primitives, layout engines, and editing controls. This is the agent-targeted recipe doc. Each recipe answers "I want to add X — where do I touch?". Pair every code change with a golden-file test so drift is caught automatically. ## How to add a new shape A "shape" is a leaf primitive — rectangle, cylinder, cloud, etc. The shape ends up rendered by `iso25d.Convert2DTo25D` after going through `Flatten` (in `flatten.go`) which produces an `iso25d.ConvertOpts`. 1. **Implement the iso geometry** — create `iso25d/shape_.go`: * Define `IsoOpts` (just the parameters the geometry needs). * Implement `RenderIso(o IsoOpts) string` returning an SVG fragment (no `` wrapper — that's added by the composite). * Implement `apply(o ConvertOpts, target *IsoOpts)` that lowers the dispatcher's `ConvertOpts` into your opts. 2. **Register in the dispatcher** — edit `iso25d/shapes.go`. Add a `case "":` arm in `Convert2DTo25D` that calls `apply` then `RenderIso`. 3. **Wire d2 input** — edit `translate.go::d2ShapeCatalog`. Add every d2 shape string that should lower to this iso shape (with its height multiplier hint). If the shape is iso-native and has no d2 counterpart, skip this step — agents will reach it via YAML only. 4. **Make Validate happy** — `CapabilityReport()` reads the catalog, so as long as you added to `d2ShapeCatalog` (or to the native shapes list in `capabilities.go::buildShapeCaps`), Validate will accept the new shape in YAML/D2 inputs. 5. **Make it editable in Studio** *(easy to forget — keeps the GUI in sync with the renderer)*. The shape token also lives in three non-renderer places; all must agree or the Studio "Edit details" shape picker offers a token that silently falls back to rectangle: * `cmd/isotopo/main.go` → `shapeOptions` (the tile list) and `shapeClass` (which colour controls the shape gets: faces / outline / text / fill). * `studio/studio.js` → `optGlyph` (a small inline-SVG glyph keyed `'shape:'`, so the tile is illustrated). Use only tokens the dispatcher actually accepts (`isotopo capabilities` → shapes → `accepted_as`). 6. **Add a golden test** — create `samples/node//input.yaml` (single-primitive) or `samples/topology/-demo/input.yaml` (multi-element scene), then `go test -update -run TestGolden/.*` to snapshot the expected SVG. ## How to add a new DSL primitive A "primitive" is a composition feature like `group`, `stack`, `annotation`, `canvas.grid`. Primitives don't map to a single iso shape — they affect how parts get assembled. 1. **Declare the data** — add the field(s) to the right type in `dsl.go` with yaml/json tags. Use pointer types if the field is optional and should distinguish "missing" from "zero". 2. **Lower or inject** — choose one: * **Lowering primitive** (transforms the parts tree before rendering — like `stack` and `group`). Edit `lower.go`: extend `lowerCompositeParts` with the new case. * **Layer primitive** (post-rendered SVG overlay — like `canvas-grid` and `annotation`). Edit `inject.go`: add an `inject` function that splices a `` into the rendered SVG. 3. **Wire the renderer** — edit `render.go::renderComposite`. Decide the paint order (earlier-injected = behind everything because of how ``-after-tag splicing pushes prior insertions deeper). 4. **Describe it** — edit `capabilities.go::buildPrimitiveCaps`. Add a `PrimitiveCap` entry. This is the agent's source of truth. 5. **Validate it** — edit `validate.go`. Add structural checks (does the anchor exist? is the side value valid? is the count positive?). Use the existing `nearest()` / `nearestID()` for "did you mean". 6. **Add a golden test** — same as for shapes. ## How to expose a field in the Studio "Edit details" editor The right-click detail modal is **schema-driven**. To let users edit a DSL field through the GUI (and have it write back, comment-preserving): 1. Add a `schemaField` to the relevant builder in `cmd/isotopo/main.go`: `nodeSchema(shape)`, `edgeSchema()`, or `canvasSchema()`. Give it the dotted YAML `Path` (e.g. `style.palette.top`), an English `Label` + `Desc`, a `Type` (`text` | `number` | `color` | `choice` | `icon`), a `Group`, and `Inline`/`Options` as needed. 2. Reads and writes are automatic: `/api/fields` reads the current value by dotted path; `/api/edit` writes it via the recursive `setField`/`setInInlineMap` surgery (creates missing intermediate maps). No per-field code. 3. For a `choice` field, add a glyph in `studio/studio.js::optGlyph` (keyed `':'`) and, if the stored value differs from the label, a `optLabel` entry. 4. Keep it shape-appropriate: `nodeSchema` only offers colour controls a shape can actually render (see `shapeClass`) — don't show a knob that does nothing. ## How to add a new layout engine d2 ships dagre and ELK. To add a third: 1. Implement a `d2graph.LayoutGraph` (see d2 docs). 2. Edit `compile.go::CompileD2`. Extend `layoutResolver`. 3. Edit `dsl.go`: add a `LayoutXxx LayoutEngine` constant. 4. Edit `cmd/isotopo/main.go::parseFlags` to accept the new `--layout xxx`. 5. Edit `capabilities.go::Layouts` so agents discover it. 6. Edit `validate.go` if the layout name appears in DSL anywhere (today it's CLI-only, so no validate change needed). 7. Add a golden test under `samples/topology/-layout/input.d2`. ## The agent loop ``` 1. read → isotopo capabilities (learn what's available) 2. write → emit DSL 3. check → isotopo validate (catch typos before render) 4. render → isotopo render (produce SVG) 5. test → go test (./...) (regression check) ``` If step 3 returns suggestions, apply them and re-run step 3. If step 5 fails with golden drift, decide per-case: intended → update golden with `go test -update`; unintended → revert the change. ## Code structure cheat sheet ``` dsl.go every public DSL type parse.go yaml/json → Document translate.go d2target.Diagram → Document flatten.go Node + Theme → iso25d.ConvertOpts theme.go 3-layer style merge render.go public API: Render / RenderDocument / RenderParts lower.go group + stack expansion inject.go canvas-bg / screen-label / connector / annotation svgutil.go SVG string parse/edit output.go //go:embed studio/* → Studio page; per-part fragments capabilities.go introspection — what does this build support? validate.go structural validation with suggestions compile.go d2 → laid-out diagram studio/ the Studio front-end (compiled into the binary) ├── studio.html page shell ({{CSS}}/{{JS}} + data placeholders) ├── studio.css all styles └── studio.js all behaviour — zoom/pan, code mapping, drag-to-edit, detail editor, context menu, undo/redo cmd/isotopo/main.go serve handlers: /api/render, /api/move (drag), /api/fields + /api/edit (detail editor), /api/op (delete/duplicate) — all comment-preserving text surgery on the POSTed source; disk never written tools/viewer-test/cdp_test.py headless-Chrome regression suite for Studio iso25d/ low-level iso geometry, one shape per file ├── composite.go iso z-order painter ├── iso.go IsoBox renderer + shared types ├── rounded.go rounded-corner panel renderer ├── shapes.go Convert2DTo25D dispatcher + ConvertOpts type ├── shape_box.go (lives inside iso.go for historical reasons) ├── shape_cylinder.go ├── shape_sphere.go ├── shape_person.go ├── shape_text.go └── shape_cloud.go cmd/isotopo/ the CLI ├── main.go subcommand routing + flag parsing ``` ## Agent self-evolution: contracts vs. manual sync points What makes this repo safe for an agent (including a future you) to evolve: * **Machine-readable contract.** `isotopo capabilities` emits the full supported surface as JSON; `tools/gen-docs` regenerates `CAPABILITIES.md`, `dsl.schema.json`, `ICONS.md`, `llms.txt`, `SAMPLES.md` and patches `PROMPT_TEMPLATE.md` from code. **Run `go run ./tools/gen-docs` after any capability change** so the docs can't drift. * **Deterministic regression.** Golden SVGs (`go test`) catch any render drift byte-for-byte; the Studio has a CDP suite (`cdp_test.py`). * **Validation teaches.** `validate.go` returns did-you-mean suggestions and CI exit codes (0/2/3). The real hazard is **parallel lists that must stay in sync** — an agent that edits one and forgets the others ships a silent desync (this is how `box`/`sphere`/`polygon` once leaked into the shape picker): | Concept | Source of truth | Also hand-maintained in | | - | - | - | | Shape tokens | `iso25d/shapes.go` (dispatch + `accepted_as`) | `capabilities.go`, `cmd/isotopo/main.go` (`shapeOptions`/`shapeClass`), `studio/studio.js` (`optGlyph`) | | Detail-editor fields | DSL structs in `dsl.go` | `cmd/isotopo/main.go` schema builders, `studio/studio.js` glyphs | | Icon value forms | `iso25d/brand_icons.go` + `flatten.go` | `capabilities.go` note, gen-docs | When you add a DSL capability, walk the whole row. The long-term fix is to **derive** the detail schema and shape options from the capability report / struct tags instead of hand-listing them — until then, this table is the blast-radius checklist. ### Deferred roadmap (known, intentionally not yet built) * Studio: **multi-select + marquee drag** (group move); **gradient editing** in the detail modal (palette `*Gradient` blocks are hand-edited today); a wider edge hit-area / grab-anywhere for edges fully occluded by their nodes (inherent geometry — partial mitigation only). * Split `studio.js` (\~45 KB, one file) into feature modules concatenated via embed; add JS lint/format in CI. Pure internal restructuring — deferred for its global-order risk vs. zero user-facing value. *Done since this list was first written:* snap-to-grid, place-aware delete, add-node, freeze bakes `wz`, NodeHTML/index extracted to `studio/`, and a capability-coupled guard test for the shape picker. ## Don'ts * **Don't bake icons / fonts into the binary** — SVG embeds them by URL. The default font stack ("Inter, sans-serif") relies on the browser/embedder. * **Don't add new top-level export to the library API casually** — every export is part of the public contract. Prefer extending an existing function over adding a new one. * **Don't modify a samples/ golden by hand** — always use `go test -update -run TestGolden/` so re-generation matches the rest of the pipeline. # Design a scene Source: https://iso-topology.mintlify.app/guides/scene-design Choose layouts, hierarchy, colors, and connector styles for readable architectures. This is the visual style guide behind the [showcase gallery](https://github.com/MishaBear94/iso-topology/blob/main/samples/topology). The DSL reference tells you what every field does; this guide tells you which fields to reach for so the output looks like a launch-post illustration instead of a clip-art collage. Humans and agents follow the same rules — the [agent skill](https://github.com/MishaBear94/iso-topology/blob/main/skills/draw-iso-diagram/SKILL.md) is a condensed version of this page. ## The five rules **1. One hero.** Every scene has exactly one visually dominant element: bigger footprint (150–220 vs 90–120), taller or haloed, gradient flanks. Everything else stays quiet. Two heroes read as noise; zero heroes read as a parts catalog. **2. One accent.** Pick a single accent hue per scene and spend it on: the hero's flanks, the connectors, and icon tints. Neutrals (white tops, grey or near-black sides) carry everything else. Reference palettes: | Scene mood | Canvas | Neutral sides | Accent | | - | - | - | - | | light / product | `#F5F6F8` | `#E2E4EA` / `#EEF0F4` | violet `#7C3AED` | | light / data | `#FFFFFF` | `#D8DAE3` / `#E8EAF1` | indigo `#4F46E5` | | dark / infra | `#0A0A0B` + iso grid `#161619` | `#0E0E11` / `#141417` | cyan `#22D3EE` | | dark / green | `#0B0D0C` + iso grid `#151A17` | `#0D0F10` / `#111416` | emerald `#10B981` | **3. Top faces are canvases.** A glyph plus a 1–3 word caption, nothing more (`icon: "iso://glyph/gpu"` + `label: "GPU Pool"` — the renderer centres them as one block, auto-wraps and auto-shrinks so nothing ever overflows the face). Paragraphs go to screen space: `style.text.orient: screen` puts the label under the part; `annotations` add callouts with leader lines. **4. Effects are a budget, not a default.** `backglow` — once per scene, on the hero. `dropShadow` — heroes and floating tiles, soft and colored with the accent at \~20% alpha. `pattern` — one texture panel or one data surface. `cornerRadius` — everywhere (8–14), it's what makes tiles read as designed objects. **5. Never compute coordinates.** Pick the composition pattern, let the solver do geometry (all gaps in cells): | Composition | One-liner | Worked example | | - | - | - | | hub-and-spoke | `layout: {mode: ring, gap: 2.6}` — first child is the hub | [ai-platform](https://github.com/MishaBear94/iso-topology/blob/main/samples/topology/ai-platform/input.yaml) | | dashboard / board | `group` + `layout: {mode: grid, cols: N}` — substrate auto-sizes | [langchain-app](https://github.com/MishaBear94/iso-topology/blob/main/samples/topology/langchain-app/input.yaml) | | layered flow | anchor part + `place` chains (`rightOf`, `behind`) | [lakehouse-agent](https://github.com/MishaBear94/iso-topology/blob/main/samples/topology/lakehouse-agent/input.yaml) | | dual plane | two `layout: row` groups, the second `place: {inFrontOf: …}` | [rag-pipeline](https://github.com/MishaBear94/iso-topology/blob/main/samples/topology/rag-pipeline/input.yaml) | | metric bars + ghosts | bars in a `place` chain, ghosts `place: {above: }` | [training-compute](https://github.com/MishaBear94/iso-topology/blob/main/samples/topology/training-compute/input.yaml) | | exploded plate stack (PCB) | plates chained with `above` + a `wz` lift each | [platform-board](https://github.com/MishaBear94/iso-topology/blob/main/samples/topology/platform-board/input.yaml) | | stair | each tile `{rightOf: prev, inFrontOf: prev, gap: 0}` | [RECIPES § stair](../agent/RECIPES.md#i-want-a-staircase-climbing-the-iso-axis) | ## Ship a design system with `theme.presets` Repeated style blocks are a smell. Name them once in the theme and reference by name — presets merge between the per-shape defaults and the part's own `style`, work in JSON as well as YAML (unlike `&` anchors), and validate with "did you mean" suggestions: ```yaml theme={null} theme: presets: satellite: palette: { top: "#FFFFFF", left: "#E2E4EA", right: "#EEF0F4" } stroke: { color: "#D6D9E0", width: 1 } effects: cornerRadius: 12 dropShadow: { dx: 0, dy: 7, blur: 10, color: "rgba(63,63,70,0.14)" } nodes: scene: shape: composite layout: { mode: ring, gap: 2.6 } parts: - { id: core, ... } # the hero, styled inline - { id: llm, preset: satellite, icon: "iso://glyph/chat", label: "LLM Gateway" } - { id: gpu, preset: satellite, icon: "iso://glyph/gpu", label: "GPU Pool" } - { id: lake, preset: satellite, icon: "iso://glyph/lake", label: "Data Lake" } ``` A part can still override any field on top of its preset (`preset: satellite` + `style: {stroke: {color: "#F00"}}`). Typical systems need three presets: `hero`, `satellite`/`tile`, and `ghost` or `pedestal`. ## Connector grammar * **`routing: orthogonal`, always.** Every segment must ride the iso grid (±30° on screen) — a line that cuts across the 2.5D lattice instantly breaks the illusion. When two anchors already share an iso axis the route collapses to a single on-axis segment, so "straight-looking" lines come from ALIGNED LAYOUT, not from `routing: straight`. * Async / replication / background links: same orthogonal routing, distinguished by `dash` — never by bezier curves. * Hairline widths: 1–1.2 for relationship spokes, 1.6–2 for primary flow. Solid = synchronous path, dashed = async. * Arrows (`arrow: triangle`) only where direction carries meaning. * Pill labels sit at the route's midpoint and paint UNDER parts — leave enough gap between connected parts for the label to land in open canvas. ## Depth tricks > These tricks are for **decorative** elements — ghosts, texture > panels, pedestals, backdrops — that carry **no connectors**. > Functional nodes that are wired together must share one ground > plane (tier): give a whole tier a single `wz`, never scatter peer > nodes at different heights, or their orthogonal connectors will > sprout vertical drop segments. Elevate by tier, not per node. * **Texture panel**: a large thin tile with `pattern: {kind: dots}` floating `behind` the hero adds depth for one part's worth of YAML. * **Replica stacks**: `stack: {count: 3}` on a cylinder or thin tile reads instantly as "replicated". * **Ghost volumes**: a `place: {above: …}` box with `palette: none` + dashed stroke shows capacity/budget headroom. * **Pedestals**: small flat neutral tiles scattered with `place` diagonals fill negative space in marketing shots. * **Hatch-filled accent tiles**: `pattern: {kind: hatch}` in the accent color on a near-white top reads as "circuit / blueprint" (see platform-board's chips). * **Wireframe ghosts**: `effects.wireframe` + dashed stroke for floating frames and dashed inset borders — exempt from overlap warnings by design. * **Film grain**: `effects.grain` for the monochrome editorial register — one strong texture style per scene, don't mix with neon glows. ## Pre-ship checklist 1. `isotopo validate` exits 0 — no overlap warnings you haven't consciously accepted. 2. Exactly one hero, one accent. Count your backglows: ≤ 1. Adjacent nodes must be tonally separable — never the same fill on touching or peer objects (use a tonal ladder in mono scenes). 3. Every top face: glyph + short caption, long text in screen space. 4. Connectors all orthogonal or deliberately bezier — no default `straight` cutting across the grid by accident. 5. Render and look once at `out/topology.html`: nothing clipped, labels legible at 100% zoom, scene reads left-to-right or hub-outward in under three seconds. # Studio Source: https://iso-topology.mintlify.app/guides/studio Edit diagrams in a local browser workbench with live source and canvas interactions. Studio is the interactive page you get from `isotopo serve` (and, in a degraded static form, from every `isotopo render` output as `topology.html`). One file, one canvas, sub-second feedback: the YAML on the right, the rendered isometric scene on the left, and a live bidirectional mapping between them — now with direct manipulation (drag to move, right-click to edit) on top. ```bash theme={null} isotopo serve path/to/input.yaml # → http://localhost:8731 ISOTOPO_PORT=9000 isotopo serve … # pick a port ``` isotopo Studio — the rendered isometric scene on the left, its YAML source on the right, SVG/PNG/YAML export and a live render badge in the toolbar; the footer spells out the hover / right-click / zoom gestures The canvas is on the left, the YAML editor on the right. Hover a node to highlight its source; double-click (or right-click) to edit it; drag to move it. Everything below is that surface in detail. ## Why it exists iso-topology is agent-first: most diagrams here are *written by a coding agent* against the DSL contract. But the loop only closes when a human can look at the result, point at a node, and say "make this one violet". Studio is that pointing device. It grew out of three design positions: 1. **Accepted source is the persistent document.** In `serve` mode, accepted edits auto-save to the bound local file; external file changes reload in the editor. A segment-drag preview does not change the editor source, disk file or undo history. Static `topology.html` keeps a local draft and requires a running server for direct manipulation. 2. **Source and canvas are the same thing seen twice.** Hover a node or edge → its YAML block highlights and scrolls into view. Click it → the highlight pins (click again, or the empty canvas, to unpin). Put the caret inside a block → the node glows. Click a validation error → the offending line flashes. If you can see it, you can find it — in either direction. 3. **Local-first, zero cloud.** Studio is a single self-contained HTML page served by the same static binary that renders your diagrams. No accounts, no telemetry, no build step. ## Direct manipulation Studio is not just a viewer — you can lay the diagram out and restyle it by hand, drawio-style, and every gesture writes back to the YAML (comment- and format-preserving) and re-renders. ### Move a node Drag any node. The cursor reads `move` (4-way) on hover and `grabbing` while dragging; connectors docked to that node **follow it live** so the line never lags behind the gesture. On release the node lands exactly under the cursor. The **first** manual drag freezes the scene into explicit coordinates and drops auto-layout — the drawio model. From then on the engine never re-decides positions: every node sits where you put it, and you own the layout. (Until that first drag, the diagram is whatever the layout engine produced.) ### Connect specific ports Hover a node to reveal its connection ports. Drag a port to another node; the target ports appear and the preview snaps to the nearest one. Release to create the connection. The selected displayed instance is retained, including an upper stack replica, and one undo removes the new edge. These gestures write explicit `port-left`, `port-back`, `port-right`, or `port-front` references with `orthogonal2` routing. They work in isometric and plan views; switching projection keeps the source reference. Fixed ports use the body contour in isometric view and footprint sides in plan view. Dashed port outlines mark the approximate array/rack envelope. Text-only parts retain legacy anchor behavior. Existing authored face anchors are not rewritten. Plan connection creation is supported; plan segment dragging is still pending. ### Move an edge — one segment at a time In isometric view, drag a visible segment or its circular handle. Horizontal legs move along their perpendicular world axis; vertical risers move in the XY plane. Heights and docked endpoints are preserved. Dragging an endpoint leg retains a short lead at the port. The saved `waypoints3d` field records absolute world coordinates, and subsequent edits preserve this 3-D intent. The server computes the complete preview. Release commits that exact result and creates one undo step; requests are coalesced so a late response cannot replace a newer preview. Escape, switching projection, changing the source, or losing window focus cancels the gesture. A failed request preserves the source and both undo and redo histories. Preview requests do not save files; the normal `serve` auto-save follows a successful commit. A transparent hit corridor keeps thin lines easy to grab without changing their visible width; it stays in the line’s painter layer. Handles appear only on mapped, uncovered portions of the actual path. Ambiguous projected overlaps are not independently editable. At a crossing or an equally close pair of segments, move the pointer along the intended segment until it is distinct; zooming in also helps separate close paths. Handles only appear where their own midpoint identifies a unique visible segment. Clipped away portions do not block editing the remaining path. A stale diagram must be rendered before editing its segments. Curved/straight edges retain bend editing, and plan segment dragging remains pending. Manual corners can still route through a body; this gesture does not promise obstacle avoidance. ### Cursors & affordances | Cursor / cue | Meaning | | - | - | | open hand (`grab`) | empty canvas — drag to pan | | 4-way (`move`) + glow | over a movable node or edge | | closed hand (`grabbing`) | a drag is in progress | Toggle **Snap** in the toolbar to round dragged nodes to the grid (one cell). Every structural edit — drag, detail edit, add/duplicate/delete — is undoable with **⌘Z** / **⌘⇧Z** (when focus isn't in the editor). ## Right-click menu Right-click an element for a context menu: * **node** → Edit details · Duplicate (clone with a fresh id) · Delete (also removes its connectors and frees any sibling `place:`d against it) * **container** (a group/lane with nested parts) → Edit details only. Duplicate/Delete are hidden: cloning would collide the children's ids, and deleting would wipe the whole lane. Edit those in the YAML pane. * **edge** → Edit details · Delete * **empty canvas** → + Add node (a default rectangle) · Edit details (the canvas background/grid) The same guards hold server-side (`ApplyOp` refuses a container duplicate/delete), so embedders get them too. **Drag a node between groups.** Dropping a node onto a group's slab moves it INTO that group; dropping it on the bare canvas moves it OUT to the scene root. The re-home rewrites the DSL hierarchy and drops the node's old offset so the new parent lays it out — an in-group nudge (same parent) is unaffected. ## Edit details **Edit details** — double-click an element (or right-click → Edit details) — opens a modal of its visually-relevant properties. Change them, hit **Update**, and both the canvas and the YAML update for that element only. The form is **schema-driven**: each editable DSL key is declared once with an English label, a one-line description, an input type, and the raw dotted key (shown in muted monospace beside the label, so the friendly field stays anchored to the source). Highlights: * **Tabbed, master-detail layout** — fields are grouped and split into tabs down the left rail; the selected tab's fields show on the right, so you see one small group at a time instead of one long scroll. A node's tabs read *Content* → *Size* → *Position* → *Layout* (containers only) → *Fill* → *Gradients* → *Border* → *Label* → *Effects*. Switching tabs **keeps unsaved edits** in the others — **Update** writes every change across all tabs at once. * **Shape-aware sections** — the colour groups depend on what the shape can render: the box family gets *Fill — faces* (top/left/right) plus per-face *Gradients*; a flat *text* node gets only its label; a *boundary* gets its dashed border; containers (group/composite) gain a *Layout — children* group (mode / gap / columns). * **Empty fields show their effective value, not a bare blank** — an unset field is hinted with what the canvas actually uses, so it never reads as "missing". The verb tells you where the value comes from: `inherits …` (a colour/size/weight from a preset or the theme), `auto …` (a width/depth the layout solver computed for a container), or `default …` (a connector's built-in routing/stroke). The editable value stays empty, so an untouched field keeps deferring; type a value to override. Fields that are genuinely *off* (no glow, no pattern, opacity 1) stay blank with no hint — empty there means empty. * **Icon color** — a tint picker for `iso://` glyph and logo icons (the color lives in the ref suffix, e.g. `iso://glyph/cpu/7C3AED`; the picker writes it for you, blank reverts to default ink). It is a no-op for image-URL / local-file icons, which carry their own colors. * **Illustrated choices** — enums render as tiles with a glyph, not bare words: node **shape** (cube / cylinder / circle / cloud / person / hexprism / octprism / …), canvas **grid** (iso / dots / hatch / solid), edge **arrowhead**, **routing** (orthogonal / straight / bezier), and edge **line style** (solid / dashed / dotted). * **Colors** get a native picker plus a free-text field (any CSS color). * **Icon** takes an `iso://…` ref, an image URL, or a local file via **Browse…** — the picked file is embedded as a data URI so it renders on the canvas and exports standalone. A local file *path* in the YAML is also inlined at render time. Editing the canvas covers the whole image: **Fill color**, **Grid pattern**, **Grid color**, **Grid step**, **Padding** (it creates the `canvas:` block if your file doesn't have one yet). ## The rest of the surface | Area | What it does | | - | - | | **Canvas** | Scroll to zoom, drag to pan, double-click resets, `⌘0` fits. The backdrop tints itself after `canvas.background`. Zoom controls bottom-right. | | **Editor** | Syntax-highlighted YAML over a line-number gutter. Tab indents. Drag-resizable splitter (remembers its width). | | **Render** | `Auto` re-renders (debounced) as you type; `⌘↵` forces it. A failed edit keeps the last good render with a "showing last good render" badge — the canvas never goes blank. | | **View toggle** | **◳ Iso / ◰ Plan** — flip between the 2.5D isometric view and a flat top-down **plan view** (footprints + orthogonal edges) to read the layout/flow. Preview-only: it sends `?projection=top` on the render round-trip and never rewrites your source. (Persist it with `canvas.projection: top`.) | | **Status** | One indicator, top-right: `Rendered HH:MM:SS` (green) stamps the last successful render so you can see how current the canvas is; `Render failed` (red) when the source has errors; `Offline` when the renderer is unreachable (re-checked every 5 s, so it re-enables itself when `isotopo serve` is back). | | **Issues** | Validation problems under the editor with did-you-mean suggestions. Click one to jump to (and flash) the line. | | **Export** | `↓ SVG` / `↓ PNG` (2×) / `↓ YAML` — capture exactly what the canvas shows (unsaved edits included). | | **Browse nodes** | Per-part gallery — every node as a standalone SVG/YAML fragment, served live from the current file. | Keyboard map lives behind `?` (or the "all shortcuts" footer link). ## Static degradation `isotopo render out/` writes the same page as `out/topology.html`. Opened as a file it keeps everything except live operations — the status reads "Static file" and Render/move/edit are disabled until you point `isotopo serve` at the source. Exports still work. ## Endpoints (for tooling) | Route | Meaning | | - | - | | `GET /` | Studio, freshly rendered from the file on disk | | `GET /topology.svg` | current render of the file on disk | | `GET /nodes/_index.html`, `/nodes/.svg\|.yaml\|.html` | per-part gallery | | `POST /api/render?format=yaml` | render the posted source; returns `{"svg","issues","model","routes","screenLabels"}` | | `POST /api/move?kind=node\|edge&…` | text-edit a node offset / edge route in the posted source; add `segment=` for a 3-D segment delta; returns `{"yaml","svg","issues","model","routes","screenLabels"}` | | `POST /api/fields?kind=node\|edge\|canvas&…` | the detail-editor schema for an element, with current values | | `POST /api/edit?kind=…&f=` | apply detail-editor changes to the posted source; returns `{"yaml","svg","issues","model","routes","screenLabels"}` | | `POST /api/op?op=add\|delete\|duplicate\|reparent&kind=node\|edge&…` | structural ops (add / delete / duplicate; `reparent&id=&target=` moves a node into a group or to root); returns `{"yaml","svg","issues","model","routes","screenLabels"}` | | `GET /api/ping` | health | `screenLabels` comes from the same emission as `svg`; its record fields follow the [emitted scene contract](../reference/api.md#consume-the-emitted-scene). Studio uses the recorded pointer rectangles for bound-font label selection, applying each group's current screen transform so zoom, pan and live drag remain aligned. It caches the identity mapping per committed SVG. Unknown fonts, older servers and mismatched records retain the browser geometry fallback. An empty array means that this render has no isometric screen-label layer; switching projection clears prior records. The edit endpoints listed above do comment-preserving text surgery on the YAML you POST and leave the file on disk untouched. They are a thin HTTP layer over the stateless Go edit contract (`ApplyOp` / `EditOp` / `RenderSource`): an embedder building a non-HTTP UI (desktop, WASM) can call those directly instead of re-serializing JSON — see the **[API reference](../reference/api.md#direct-manipulation-editing-for-embedders)**. Studio separately uses `POST /api/save` to persist its current source, including automatic saving after a successful UI edit. Calling a render/edit endpoint alone does not invoke that save path. ## How it's built The page is assembled at compile time from three embedded assets, so the binary stays single-file while the front-end is ordinary, lintable code: ``` studio/studio.html page shell ({{CSS}}/{{JS}} + data placeholders) studio/studio.css all styles studio/studio.js all behaviour (zoom/pan, render, code mapping, drag-to-edit, detail editor, context menu) output.go //go:embed + a two-pass replacer → the final page ``` ## How it's tested `tools/viewer-test/cdp_test.py` drives headless Chrome over CDP and asserts the surface end-to-end. Run it whenever the `studio/` assets or the serve handlers change: ```bash theme={null} python3 tools/viewer-test/cdp_test.py ``` # Troubleshooting Source: https://iso-topology.mintlify.app/guides/troubleshooting Resolve installation, rendering, validation, and output-quality issues. Quick answers to the failure modes people hit. Search by symptom. ## Install / build ### `go install` says "module found, but does not contain package github.com/MarkovWangRR/iso-topology/cmd/isotopo" Stop using `@v0.2.0` — that release was broken (`.gitignore` excluded the CLI source from the zip). Use `@v0.2.1` or later: ```bash theme={null} go install github.com/MarkovWangRR/iso-topology/cmd/isotopo@v0.2.1 ``` ### `go install` says "command not found: isotopo" The binary went into `$GOBIN` (default `$HOME/go/bin`) but that's not on your `PATH`. Add this to `~/.zshrc` or `~/.bashrc`: ```bash theme={null} export PATH="$HOME/go/bin:$PATH" ``` Verify with `echo $PATH | tr ':' '\n' | grep go`. ### `go install` says "go: requires Go 1.25 or later" iso-topology builds against Go 1.25+. Either upgrade Go or pin to an older release if one exists for your toolchain. ### `pkg.go.dev` shows 404 for a fresh release The proxy → pkg.go.dev pipeline is async; the page renders 5–15 minutes after `proxy.golang.org` caches the release. If you need faster: visit `https://pkg.go.dev/github.com/MarkovWangRR/iso-topology@vX.Y.Z` directly — that forces an on-demand fetch. ## Render failures ### `isotopo render` says "isotopo: d2lib compile: ... missing slog.Logger in context" That's not an error — it's a debug log from d2lib. Your render succeeded if you see `rendered N node(s) into ` after it. The log is harmless and silenced in v0.2.1+; if you still see it, you're on an older binary. ### `isotopo render` says "image shape must include an icon field" The d2 source has `shape: image` without an `icon: ` field. d2 itself rejects this. Either supply an icon URL or pick another shape: ```d2 theme={null} icon_node: My Service { shape: image; icon: "https://example.com/icon.svg" } ``` ### Output dir contains topology.svg but it's near-empty / 0 bytes Two common causes: 1. The input had no `nodes:` block at the top level (YAML/JSON). 2. The input's `scene` composite has no `parts:`. Run `isotopo validate ` — it'll flag the structural problem. ### My DSL renders but layout looks wrong Most "wrong layout" complaints come from one of: | Symptom | Likely cause | Fix | | - | - | - | | Parts overlap inside a group | hand-written offsets too tight | switch the group to `layout: {mode: row\|grid}` or `place` the children — the solver spaces them; `validate` warns with the exact colliding pair | | Connector skips a face | default anchor is centroid | use `from: api.right-mid` to pin an anchor face | | Stack only shows one layer | `stack.count` is ≤ 1 (no-op) | set `count: 3, gap: 10` | | Annotation invisible | anchor id doesn't resolve | run `validate` — it shows "did you mean" | | Iso grid not drawn | `canvas:` block missing or `grid: none` | add `canvas: {grid: iso, gridColor: "#EEE"}` | ### `.d2` input layout is rigid / vertical column dagre defaults to top-down. Try ELK for orthogonal routing with obstacle avoidance: ```bash theme={null} isotopo render --layout elk scene.d2 ./out ``` For more layout control, switch to YAML — declarative `layout`/`place` composition without hand-computed coordinates; see [`../reference/dsl-yaml.md`](../reference/dsl-yaml.md). ## Validation messages ### "annotation anchor "X" does not match any part id" The anchor refers to an id that doesn't exist after stack expansion. Common pitfall: you wrote `anchor: pod` but the stack has 3 layers — all renderable ids are `pod`, `pod~1`, `pod~2`. The bottom layer is `pod` (no suffix), top is `pod~`. The `suggest` field in the JSON error tells you the closest valid id. ### "unknown shape "X"" You typo'd a shape name. The `suggest` field carries the nearest known shape via Levenshtein distance. ```json theme={null} {"path": "nodes.scene.parts[0].shape", "message": "unknown shape \"cilinder\"", "suggest": "cylinder"} ``` Full shape inventory: `isotopo capabilities | jq '.shapes[].iso_name'`. ### "unknown grid mode "X"" `canvas.grid` accepts `iso | dots | hatch | solid | none`. Most typos hit `square` (use `iso`) or `cross` (use `iso`). ### "connector references unknown part id "X"" Same as the annotation anchor case. Common: you nested the part inside a group and forgot the dot-path: ```d2 theme={null} cluster: Cluster { api: API } edge -> api # ❌ no top-level `api` edge -> cluster.api # ✅ ``` ## Library use ### `import "github.com/MarkovWangRR/iso-topology"` doesn't resolve If you're behind a corporate proxy or running `GOPROXY=off`, the public proxy lookup fails. Set `GOPROXY=https://proxy.golang.org,direct` or add the module to your private mirror. ### My Go code doesn't see the new `Document.Scene()` helper The method was added in v0.2.1. Bump your `require` and run `go mod tidy`: ```go.mod theme={null} require github.com/MarkovWangRR/iso-topology v0.2.1 ``` ## Output quality ### SVG opens fine but PNG export looks blurry Don't rasterize the SVG with `qlmanage` or screenshot tools — they use 1× DPI. Use `rsvg-convert -w 1600` or headless Chrome with `--force-device-scale-factor=2`. ### Text labels are clipped by the iso transform The iso transform tilts the top face, which means long labels sometimes spill off. Fix options: * Switch the label to screen-orient (renders as a horizontal box under the part): ```yaml theme={null} style: {text: {orient: screen, boxBg: "#FFFFFF", boxBorder: "#1F2937"}} ``` * Make the part bigger (`geom.w` × `geom.d` both up) * Shorten the label ## Still stuck? `isotopo validate` catches \~80% of structural problems before render. For the remaining 20%, file an issue with: * Your DSL input (yaml or d2) * The `isotopo --version` / commit * What you expected vs what rendered `isotopo capabilities` output as an attachment never hurts. # Architecture, with another dimension. Source: https://iso-topology.mintlify.app/index Build isometric architecture diagrams from text. Refine them in Studio. Keep the source alongside your code. An isometric microservice architecture with services, a database, and connecting arrows iso-topology is an open-source Go CLI and library for **2.5D isometric diagrams**. Describe nodes and connections in D2, or compose a scene in YAML or JSON. The renderer produces SVG you can use in documentation, design reviews, and READMEs. Install one Go binary and turn a few lines of D2 into an SVG. Browse real scenes and open the source behind each one. ## From source to diagram Use D2 for automatic graph layout. Use YAML for explicit groups, replicas, annotations, geometry, and styling. Generate an SVG with the CLI. Open Studio to edit the source and adjust the diagram visually. Validate the result before sharing it. Commit your diagram source with your project. Re-render it as the architecture changes, or call the same renderer from your Go application. ## Choose your workflow Edit source, move nodes, adjust connections, and export from a local browser workbench. Build grouped architectures with relative placement, icons, and reusable styles. Parse, validate, render, and edit diagrams through the library API. Discover capabilities, validate sources, evaluate layouts, and render through MCP. SVG rendering runs locally. PNG and PDF export need an additional rasterizer; see the [publishing guide](/getting-started/05-publishing). # Your first diagram Source: https://iso-topology.mintlify.app/quickstart Install iso-topology, render a D2 diagram, and open the same source in Studio. You need **Go 1.25 or later** on macOS, Linux, or Windows. For the library install and build-from-source options, see [Installation](/getting-started/01-install). ## 1. Install the CLI ```bash theme={null} go install github.com/MarkovWangRR/iso-topology/cmd/isotopo@main ``` Make sure your Go binary directory (`$GOBIN`, or `$(go env GOPATH)/bin` by default) is on your `PATH`. Run `isotopo capabilities` to verify the installation. These docs track the `main` branch. Use `@main` for the features documented here, or `@latest` for the newest tagged release, which may have fewer features. The Go module path is still `github.com/MarkovWangRR/iso-topology`. The source repository now lives at [MishaBear94/iso-topology](https://github.com/MishaBear94/iso-topology). ## 2. Describe a small architecture Create a file called `services.d2`: ```d2 services.d2 theme={null} user: User { shape: person } api: API Gateway worker: Worker db: Database { shape: cylinder } user -> api: HTTPS api -> worker: enqueue worker -> db: write ``` D2 computes the layout from the graph. The renderer turns the nodes into isometric shapes and draws their connections. ## 3. Render it ```bash theme={null} isotopo render services.d2 ./out ``` Open `out/topology.html` in your browser to inspect the diagram. Use `out/topology.svg` wherever you need a vector image. To inspect the same architecture from above, run `isotopo render --projection top services.d2 ./out-plan`. ## 4. Refine it in Studio ```bash theme={null} isotopo serve services.d2 ``` Open [http://localhost:8731](http://localhost:8731). Studio provides a live source editor, canvas interactions, and export tools. See the [Studio guide](/guides/studio) for the supported editing operations and save behavior. ## 5. Check and share ```bash theme={null} isotopo validate services.d2 ``` Validation returns structured issues with paths and suggestions. Exit code `0` means clean, `2` means warnings, and `3` means errors. A diagram can render with warnings; inspect those warnings before publishing it. Keep `services.d2` in version control. Embed the generated SVG in your README: ```markdown theme={null} ![Service architecture](./out/topology.svg) ``` Continue the tutorial with nested services and labeled containers. Start from an existing scene, then adapt it to your architecture. # Go library and APIs Source: https://iso-topology.mintlify.app/reference/api Parse, render, validate, and edit diagrams through the programmable interfaces. The single entry point for every programmable surface of iso-topology. Pick the surface you need — each row links to its authoritative contract; nothing is documented in two places. | Surface | What it is | Contract | | - | - | - | | **DSL** (input format) | the YAML / JSON / d2 you author or an agent generates | [CAPABILITIES.md](../agent/CAPABILITIES.md) (generated) · [dsl.schema.json](../agent/schema/dsl.schema.json) (generated) · [YAML](./dsl-yaml.md) · [d2](./dsl-d2.md) · [Style/Theme](./dsl-theme.md) | | **Go library** | import the engine — parse, render, validate, **and edit** DSL | **this page (below)** · generated godoc on [pkg.go.dev](https://pkg.go.dev/github.com/MarkovWangRR/iso-topology) | | **HTTP API** | the Studio server's `/api/*` endpoints | [studio.md → Endpoints](../guides/studio.md#endpoints-for-tooling) | | **CLI** | the `isotopo` command | [cli.md](./cli.md) | | **MCP** | tool server for Claude / Cursor / any MCP client | [MCP.md](../agent/MCP.md) | > The **canonical, always-current** Go API is the godoc on > [pkg.go.dev/github.com/MarkovWangRR/iso-topology](https://pkg.go.dev/github.com/MarkovWangRR/iso-topology) > — it is generated from the source and never drifts. The sections below are the > hand-written tour. *** ## Go library Module path: `github.com/MarkovWangRR/iso-topology` ```bash theme={null} go get github.com/MarkovWangRR/iso-topology@latest ``` Three things you can do with the library: 1. **Render** a DSL document to SVG — [Quick example](#quick-example) 2. **Edit** a DSL document by applying direct-manipulation ops (drag, set field, add/delete/duplicate) while preserving comments — [Editing for embedders](#direct-manipulation-editing-for-embedders) 3. **Introspect** the DSL contract — `CapabilityReport()` ### Quick example ```go theme={null} package main import ( "fmt" isotopo "github.com/MarkovWangRR/iso-topology" ) func main() { yaml := []byte(` canvas: {background: "#FAFBFC", grid: iso} nodes: scene: shape: composite parts: - id: api shape: rectangle geom: {w: 120, d: 80, h: 30} label: API - id: db shape: cylinder offset: {wx: 200, wy: 0} geom: {w: 100, d: 100, h: 36} label: DB connectors: - {from: api, to: db, arrow: triangle} `) // One-shot: validate + render any DSL text (yaml | json | d2) to one SVG. svg, issues, _ := isotopo.RenderSource("yaml", yaml) for _, i := range issues { fmt.Println(i.Severity, i.Path, i.Message) } fmt.Print(svg) } ``` `RenderSource` is the convenience entry. For finer control (intercept a stage, render per-part, pick the layout engine) use the pipeline functions in [Public API surface](#public-api-surface). ### Consume the emitted scene `RenderScene(doc)` returns a `SceneResult` with `SVG`, `Routes` and `ScreenLabels`. Connector records use the contract below. Screen-label records are collected during the same render, after obstacle placement, using the emitter's decimal precision. Collection does not change the SVG bytes or rerun label placement. This is a partial shared result: isometric body geometry and top-face budgets still have separate resolvers. `RenderSceneWithInteraction(doc)` additionally populates `SceneResult.Model` (`model`, omitted from ordinary renders). Use this entry when displaying an editable preview. In top projection, resolved footprints, accepted routes, the coordinate frame and final title/connector-pill arrangement are shared by SVG emission and the world interaction model. A content arrangement already certified by route refinement is reused. The isometric interaction model retains its established resolver. Studio render/edit responses use this combined entry without changing their HTTP fields. Reuse is confined to a single request, with no cache keyed by a mutable input document. Separate calls own their models and nested selection/port slices; an edited document is resolved anew. `BuildInteractionModel(doc)` remains available for geometry-only callers and preserves the fast path for plan scenes without bound titles. Plain `RenderScene` does not compute an extra interaction model. `RenderSceneWithReport(doc)` returns both the scene and a `RenderReport`, sharing the emitted screen-label records. `BuildRenderReport(doc)` uses that entry point when only the report is needed; other diagnostics retain their existing resolvers and score definitions. Caption repair simulation is requested only when a reported caption occlusion needs a padding patch. The CLI applies `--projection` before rendering and diagnostics, and `--report` writes labels from the same render as `topology.svg`. Each `ScreenLabel` describes one emitted label in layer order: * `PartID` is the emitted instance identity, including any replica suffix. Anonymous labels retain an empty ID. `PartIndex` is the lowered geometry index, not an authored array index or a stable identifier across edits. * `Bounds` is the submitted transparent pointer rectangle, in final SVG user coordinates before browser viewBox/CSS transforms and live drag. It does not include the chip's border stroke. Consumers must apply the current label group's screen transform when working in CSS pixels. * `FontFamily`, `FontWeight` and `FontSize` describe the emitted font request. A fully bound block has `TextMetrics: "shaped-ink"` and a `FontHash` matching the embedded resource. Its `Lines` contain the emitted text, horizontal anchor, baseline, advance and optional glyph `InkBounds`. An empty line has zero advance and no ink bounds. Glyph boxes describe geometry, not a visibility mask or a readability guarantee. * An unbound or unsupported block retains `TextMetrics: "estimated-advance"`. Its legacy single text node is recorded without invented advance, ink or font hash; normal SVG whitespace collapsing still applies. The browser's actual glyph envelope may exceed the estimated pointer rectangle. The Go JSON field is `screen_labels`; `SceneResult` emits an authoritative empty array for an absent isometric screen-label layer, including the top projection. This does not mean that other text layers are absent. The report omits `screen_labels` when empty. Studio's HTTP responses use `screenLabels` and always include the array on a successful render. Separate renders own their records; the scene and report returned together share the label slice. ### Observe content fitting `ContentFits(doc)` observes the final top-face content layout after resolving layout, groups, replicas, style, wrapping and row/column selection. It returns deterministically ordered `ContentFitObservation` records without mutating the input. `Path` locates the authored part when its ID is unambiguous; `PartID` retains the expanded instance ID, and `PartIndex` is the index after lowering (not the source array index). Anonymous or ambiguous instances fall back to the owning node path and do not imply an automatic source patch. Each record's `Fit` has status `estimated-fit`, `estimated-overflow`, `unsupported`, or `empty`. Empty observations are omitted from `ContentFits`. The fit describes the preferred padding and supported ink-separation budgets in local face coordinates. `Items` contain final text, font size, text/image viewport bounds, and overflow on the left, top, right and bottom. `Manual` identifies retained authored icon placement. A budget violation can mean insufficient padding even when the content remains inside the physical face. It is not a proof of glyph clipping, readability, opaque pixels, or correct font fallback. For top-face labels explicitly using `Source Sans Pro` at weight `400`, `600`, or `700`, `text_metrics: shaped-ink` identifies glyph ink bounds from pure Go shaping of the exact embedded font; `font_hash` is its SHA-256. Horizontal Latin/Greek/Cyrillic and supported punctuation/combining marks are measured as complete runs, including kerning and ligatures. A line with missing glyphs, unsupported script/control characters, or an unbound font retains `text_metrics: estimated-advance`. `ProjectedContent.EstimatedText` is true if any emitted text line is estimated. Bounds remain bounding boxes, not glyph masks; SVG rounding, transforms and raster antialiasing still apply. The existing fit status names remain conservative and unchanged. Composite rendering records embedded fonts at their first actual use and shares each immutable resource across subsequent parts in that render. This avoids generating a full font payload for every part before removing duplicates. Resource collection is local to the render; independent and concurrent renders do not share emission state. Public standalone shape renderers and `Convert2DTo25D` still return self-contained SVGs. Resource collection preserves the first definition's position, geometry, typography, and serialized output. For bound-font text, `Gaps` records unmet separation constraints. Each entry contains `before` and `after` element indexes, `kind` (`text:text` or `icon:text`), and `required`, `actual`, and `shortfall` distances. Adjacent visible text lines require a vertical ink gap of 0.12 times their larger font size; icon/text boxes require at least 4 local units along either axis. Negative `actual` means overlap along the measured separation direction. Shortfalls over 0.01 local units produce `estimated-overflow`, including when all items satisfy the outer padding. Image bounds describe the viewport, not opaque icon pixels. These are local layout preferences, not guarantees of readability at a particular zoom. Unbound text does not certify ink gaps. `iso25d.ShapeContent(shape, options).Fit` exposes the same observation next to the projected elements; item indexes refer to those elements. Free captions, spheres and other layouts without a bounded top-face budget remain explicitly unsupported. The separate top projection has no isometric observations. Screen-oriented labels are removed before observing their composite part's icon-only face layout. Known fully transparent text is omitted from fit items. Screen-label rendering separately uses bound glyph geometry when the entire label supports the selected built-in font. It shares that geometry between text baselines, background, pointer hit rectangle, obstacle reservation and export bounds, and embeds the same font once per weight. This does not add screen labels to `ContentFits`: its public records still describe bounded top-face budgets. Unknown/mixed fallback labels retain their estimated path. After placing all screen labels, a bounded compaction pass can move displaced captions closer to their owners. It retains the chosen side and alignment, reserves every other label, and checks the serialized rectangle against body, content, submitted stroke and arrow envelopes. A move must improve the owner's box distance by more than one SVG unit and cannot reduce any peer's relative association margin (distance to peer minus distance to owner), allowing 0.01 SVG unit for coordinate rounding. It stays inside the prior label/owner envelope with the same tolerance. The untouched axis retains its original text anchor, avoiding a one-decimal-step shift from rounded rectangles. This is a geometric association heuristic, not a proof of semantic clarity. The pass makes at most four deterministic sweeps. Unknown screen blocks, unmeasured top-face or connector text, annotations, structural text, face surface layers and blur/shadow/glow effects retain the established arrangement until their full envelopes are available. Clean preferred captions keep their position. Rectangle caching and reusable scratch space bound allocation; the pass does not rearrange nodes, change routes, resize text or displace a peer to make room. It is a local refinement, not a global label assignment. `ContentFitIssues(doc)` reports estimated overflow as warnings and is included in `Validate`. `BuildRenderReport` adds `content_fits` and corresponding `content-fit` defects without suggesting an unverified automatic patch. The shared visual candidate guard protects each instance, text/icon kind, violated side, and indexed gap separately, so reducing one violation cannot pay for another. Gap indexes describe the final layout's elements; they are not stable authored line identities across arbitrary reflow. These observations are separate from the legacy scalar readability score; a high score does not clear a reported content-fit defect or certify readability. ### Observe connector geometry `RenderWithRouteInfo(scene, theme, canvas, annotations)` returns the same SVG as `RenderWithCanvas`, plus per-connector records for an isometric composite. It does not change the input document. Non-composites and the top projection return no isometric route records. * `World` and `Screen` retain the existing **pre-clip** edit/scorecard tracks. `World3D` adds the corresponding heights. For Bezier samples, the height interpolates between anchors; this is a declared lifting convention, not a physically solved cable. * `Drawing.Points` contains the **submitted** path after endpoint trimming, clipping and coincident-path reuse, at SVG decimal precision. `Kind` is `polyline` or `quadratic`; the latter has start, control and end points. * `Drawing.Provenance` records the source of each submitted segment as the emitter transforms it. `SourceSpace` is `preclip-polyline` (segment indexes into `World3D`) or `preclip-quadratic` (the original analytic curve, segment zero, **not** the sampled `World3D` polyline). It does not index authored waypoints. For `Kind: source`, final parameter `t` maps to `Source.T0 + t * (Source.T1 - Source.T0)` on `Source.Segment`. `World` contains two endpoints or three quadratic controls. The curve uses the same linear-height convention as the pre-clip samples. * A provenance segment with `Kind: derived` records a new terminal shortcut chord and its input `Ancestors`; those inputs are ancestry, not an exact parameter mapping. `Kind: unresolved` retains ancestry and a `Reason` but omits world geometry, for example when coincidence reuse displaces the submitted path. Identical rounded projections retain their own heights. For segments with `World`, `ProjectionError` bounds the difference from the rounded submitted path in SVG user units. It is not a physical routing or pixel-visibility guarantee. * `SourceRef` / `TargetRef` retain logical endpoints; `ResolvedSource` / `ResolvedTarget` retain the attachment references actually used. These may differ for promoted cross-group orthogonal connections. * `Drawing.Attachments.Source` / `.Target` make the endpoint contract explicit: logical reference and instance, resolved reference/instance/anchor, submitted `Point`, final `RouteSegment` and `RouteT`. `VisualRule` is `fixed-contour` or `legacy-silhouette-trim`. `World` has the `producer-route-lift` convention, a `known` point or an `unresolved` status with a reason. This world point belongs to the final route, after trimming; it does not certify a physical interface on the owner's surface. Returned points do not alias the source. * `PaintBeforePart` is the actual insertion position among rendered parts. `Arrow` is the submitted overlay polygon. `Label` records the pill rectangle and text anchor, not measured glyph bounds. * `Drawing.Coverage` partitions each submitted segment by later opaque body fill coverage. `OpaqueParts` lists covering instance IDs in painter order (`#part:N` identifies an unnamed rendered part). `UnresolvedParts` records uncertain coverage, including unsupported envelopes or materials. Coverage uses the scope `opaque-body-fill-centerline`. Empty coverage does **not** certify pixel visibility: stroke width, dashed gaps, text, icons, filters, translucent compositing and later overlays are outside that scope. Curved body polygons carry `BoundaryError` in SVG user units, including a 0.03-unit coordinate-rounding allowance. The observation follows the current painter order; it does not prove that order is physically correct in 3D. Keep the pre-clip track when editing a route. `Drawing.Editable` obtains its source indexes from `Drawing.Provenance` and excludes overlap with other submitted segments. Removed pre-clip legs cannot veto an edit. Missing, derived, unresolved or inconsistent source mappings are not editable. Provenance alone does not make an overlapped segment visually distinguishable. The internal depth experiment also records `Spatial.SubmittedCollision` for supported polylines and opaque box scenes. Its scope is `submitted-world-centerline`, using the `producer-route-lift` convention: final source segments and derived chords are tested in their own world geometry, after clipping and simplification. `Segment` indexes the final polyline and `T0`/`T1` bound its interval inside an open box. Face contact and isolated tangency do not count; entering an endpoint's own body does count. Only `Status: complete` with empty `Intervals` means no interior occupancy in the queried boxes. Missing or unsupported provenance is unresolved, never equivalent to clear. The compositor publishes observations atomically with the SVG; a legacy fallback omits this report. Historical `Spatial.Penetrations` still indexes the pre-clip `World3D` track, so the two results may differ. This does not certify physical surface attachments, cable thickness, curves, or pixel visibility. Fixed contour ports retain their screen attachment and base-height lift. The experiment remains disabled in public rendering. Within that experiment, `Spatial.Arrow` gives a direction marker's owner, terminal segment, depth plane and guarded clip region. The convention is `terminal-tangent-billboard`: depth follows the last segment's tangent and is constant across it. The plane uses coordinates relative to `ProjectionOrigin`. Ownership does not exempt the endpoint's body from occlusion. Emitter-owned markup offsets associate each arrow with its edge, including identical polygons at different world heights; the original polygon, alpha and gradient are drawn once. Missing/degenerate terminal geometry or inconsistent markup makes the whole experiment fall back atomically. Labels retain their existing overlay policy. Round stroke caps occupy only their outside half-disks: constant endpoint depth must not leak back over the sloping segment interior. Surface antialiasing at mixed-depth interfaces, general body ordering and mixed-shape locality still require further work before public enablement. `Spatial.Compositing` reports how the internal experiment submitted each edge: `painter` uses an overlap-constrained primitive order, `hybrid` retains clips against the listed `LineClipOwners` / `ArrowClipOwners`, and `clipped` retains the prior full depth clip with a reason. Uniformly nearer bodies paint over the primitive once, avoiding the second antialias coverage multiplication from a complementary clip. Mixed-depth bodies must paint before that primitive and contribute its local clip. The graph preserves overlapping connector stacking. Body groups are ordered by their nearest surface depths; projected separation removes unnecessary constraints. Emitted text/image content retains its established body order until its full ink and depth are certified. Mixed body depth, a cycle or a work budget limit retains the prior full clip. Unverified structural identities instead roll back the entire depth submission. `Drawing.PaintBeforePart` and `Spatial.Arrow.PaintBeforePart` report actual submitted layers. Internal body groups retain their original `data-part` geometry identity and publish a separate `data-paint-order`. The collector commits that body order before computing painter coverage; native Studio hit corridors remain beside their paths in the same DOM order. Full geometric `ClipRegion` and hit clipping remain distinct from the local paint clip. The four SVG-unit guard is tested at 0.25/1/4 scales, not certified for arbitrary device-pixel footprints. This policy is internal and does not enable depth compositing in public rendering. Connector export bounds include the actual rounded path's full round stroke and arrow polygon, in addition to the established layout margin. Quadratic bounds use analytic extrema. The extra one SVG-unit guard covers decimal rounding; it is not a display-pixel antialiasing guarantee at arbitrary scales. ### Direct-manipulation editing (for embedders) This is the contract a cloud service or a WASM client uses to turn a canvas gesture into a new DSL document — the same logic that powers Studio's drag/edit, exposed as **pure, stateless functions**: * The transform is a pure function of `(format, src, op)` — **no server state**. (Studio works by POSTing the whole DSL each time; the text is the only source of truth.) * The rewrite **preserves the user's comments and formatting** — it is line/regex text surgery, not a reformatting YAML round-trip. ```go theme={null} src := []byte(`# my diagram nodes: scene: shape: composite layout: {mode: row, gap: 1} parts: - {id: api, shape: rectangle, geom: {w: 120, d: 80, h: 30}, label: API} - {id: db, shape: cylinder, geom: {w: 100, d: 100, h: 36}, label: DB} `) // Drag "api" by (+50, 0) world units. ApplyOp returns the rewritten DSL, // a fresh SVG and validation issues; ApplyOpText returns only the new text. op := isotopo.EditOp{Kind: "move", Target: "node", ID: "api", DWX: 50, DWY: 0} newSrc, svg, issues, err := isotopo.ApplyOp("yaml", src, op) _ = svg; _ = issues; _ = err // newSrc still starts with "# my diagram" — comments survive. ``` **`EditOp` kinds** (the field set read depends on `Kind`): | Kind | Fields used | Effect | | - | - | - | | `move` | `Target` (`node`\|`edge`), `ID`/`CI`, `DWX`,`DWY`, `Snap`, `Waypoints` | nudge a node offset, or move an edge (waypoints / bend). First move on an auto-layout scene freezes it to explicit coordinates. | | `move-segment` | `CI`, `Segment`, `DWX`, `DWY` | move one pre-clip orthogonal 3-D segment; horizontal legs move perpendicularly, vertical legs move in XY. Writes `waypoints3d` and keeps resolved endpoints fixed. YAML/JSON explicit scenes only. | | `set-field` | `Target` (`node`\|`edge`\|`canvas`), `ID`/`CI`, `Fields` (dotted path → value) | write any DSL field, e.g. `{"style.palette.top": "#FFF"}` | | `add` | — | append a default rectangle to the scene | | `delete` | `Target`, `ID`/`CI` | remove a node (and its connectors) or an edge. Refused for a **container** (group/lane) — empty it first | | `duplicate` | `Target: node`, `ID` | clone a node with a fresh id, offset down-right. Refused for a **container** (cloning would collide its nested ids) | | `reparent` | `ID` (node), `Target` (group id, or `""` for the scene root) | move a node into another group / out to the root; its stale offset is dropped so the new parent lays it out. No-op when the parent is unchanged; refused into its own descendant | ```go theme={null} // Apply only the text transform (e.g. you render separately, or with ELK): newSrc, err := isotopo.ApplyOpText("yaml", src, op) ``` `move-segment` indexes `ConnectorRoute.World3D` from a render of the **same source**. A UI should hit-test `Drawing.Editable` intervals, which map final polyline segment parameters back to `RouteSegment` using the producer's exact source record. Curves, ambiguous final projected overlaps and displaced coincident paths have no edit mapping. These intervals are not proven pixel visibility: intersect them with the active coverage result and native hit testing. Studio also checks pointer ambiguity in client coordinates: a different visible leg within one CSS pixel of the best hit distance prevents selection, including a visible leg that has no edit mapping. Covered geometry does not compete in that pointer-distance check. The operation leaves input unchanged on rejection or a zero effective delta. It rejects unsupported routes, invalid indices/coordinates, oversized results and aliased connector mappings. YAML surgery targets the selected scene and connector, preserving surrounding source and comments; the selected flow mapping or waypoint field may be reformatted. JSON keeps fields and values, with normalized indentation. Undo belongs to the caller: record history only after accepting a valid source **and** SVG response. For the lowest level — the individual comment-preserving text operations — import the [`yamledit` sub-package](#sub-package-yamledit). ### Public API surface Pipeline (each step is a separate function so you can intercept any stage): ```go theme={null} // 1. Parse input isotopo.Parse(data []byte) (*Document, error) // YAML or JSON auto-sniffed isotopo.ParseYAML(data []byte) (*Document, error) isotopo.ParseJSON(data []byte) (*Document, error) isotopo.LoadInput(ctx, format string, data []byte, engine LayoutEngine) (*Document, error) // yaml|json|d2 // 2. For .d2 input: compile then translate isotopo.CompileD2(ctx, src string, engine LayoutEngine) (*d2target.Diagram, error) isotopo.Translate(diag *d2target.Diagram, opts *RenderOpts) *Document // 3. Validate (optional) isotopo.Validate(doc *Document) []Issue isotopo.UnknownKeyIssues(data []byte) []Issue // misspelled/unknown DSL keys // 4. Render isotopo.Render(n *Node, theme *Theme) string // single node, no canvas isotopo.RenderWithCanvas(n, theme, canvas, anns) string // scene with backdrop + callouts // (canvas.Projection == "top" → flat plan view) isotopo.RenderDocument(doc *Document) map[string]string // every node → SVG isotopo.RenderScene(doc *Document) SceneResult // SVG + routes + final screen labels isotopo.RenderSceneWithInteraction(doc *Document) SceneResult // same submitted scene + world interaction model isotopo.RenderSceneWithReport(doc *Document) (SceneResult, RenderReport) isotopo.RenderParts(doc *Document) map[string]string // atomic per-part SVGs isotopo.NodeHTML(id, svg, yamlFragment string) string // self-contained viewer page for ONE node (svg + source) isotopo.NodesIndexHTML(ids []string) string // gallery index page linking every per-node view isotopo.RenderPlan(n, theme, canvas, anns) string // flat top-down plan view directly ``` One-shot render + the stateless edit contract (the embedding surface): ```go theme={null} isotopo.RenderSource(format string, src []byte) (svg string, issues []Issue, err error) isotopo.ApplyOp(format string, src []byte, op EditOp) (newSrc []byte, svg string, issues []Issue, err error) isotopo.ApplyOpText(format string, src []byte, op EditOp) (newSrc []byte, err error) ``` Layout evaluation — score the connection quality from the flat geometry (see [`isotopo evaluate`](./cli.md)): ```go theme={null} isotopo.EvaluatePlan(n, theme, canvas) *PlanReport // plan-router routes isotopo.EvaluateIso(n, theme, canvas) *PlanReport // the engine's REAL routes isotopo.EvaluatePlanText(format string, src []byte) (*PlanReport, error) isotopo.EvaluateIsoText(format string, src []byte) (*PlanReport, error) isotopo.RenderPlanAnnotated(n, theme, canvas) (svg string, report *PlanReport) // plan view + red problem marks ``` Auxiliary: ```go theme={null} // Document model doc.Scene() *Node // resolves the topology-level composite isotopo.PartIDs(doc) []string // every renderable part id isotopo.PartFragments(doc) map[string]*Document // each part as a standalone doc isotopo.MarshalFragmentYAML(d) ([]byte, error) // Resolve what the renderer actually uses (cascade + layout) for a part — // the basis of Studio's "effective value" hints. isotopo.ResolvePartStyle(doc *Document, id string) *Style isotopo.ResolvePartGeom(doc *Document, id string) (w, d, h float64, ok bool) isotopo.ResolvePartOffset(doc *Document, id string) (wx, wy, wz float64, ok bool) // Theme cascade isotopo.ResolveStyle(theme, shape, preset string, override *Style) *Style isotopo.MergeStyle(base, over *Style) *Style // Iso lowering isotopo.Flatten(n *Node, theme *Theme) (shape string, opts iso25d.ConvertOpts) // Introspection isotopo.CapabilityReport() Capabilities ``` ### Types worth knowing | Type | What it is | | - | - | | `Document` | top-level parsed DSL (Canvas + Theme + Nodes + Annotations) | | `Node` | one element. `Shape == "composite"` makes it a scene; nested `Parts` allowed | | `CompositePart` | one sub-element inside a composite | | `EditOp` | one direct-manipulation edit for `ApplyOp`/`ApplyOpText` (see table above) | | `Stack` | replicate a part N times vertically | | `Connector` | directed line between parts | | `Annotation` | screen-space callout with a leader line | | `Canvas` | document-level backdrop (`background`, `grid`, `gridColor`, `gridStep`, `projection`) | | `Theme` | document-level default Style with optional per-shape overrides | | `Style` | `{palette, stroke, text, effects}` — see [Style/Theme](./dsl-theme.md) | | `Issue` | a validation finding: `{Severity, Path, Message, Suggest}` | | `PlanReport` | layout-quality scorecard from `EvaluatePlan`/`EvaluateIso` (crossings, edges-through-nodes, backward edges, bends, length, flow axis) | | `LayoutEngine` | `LayoutDagre` (default) or `LayoutELK` | ### Sub-package: `yamledit` `github.com/MarkovWangRR/iso-topology/yamledit` — comment- and format-preserving text surgery on DSL source. `ApplyOp` is built on it; import it directly only if you need a single operation (e.g. just `SetField`). Every function takes the source string and returns the edited string, touching only the lines it must: ```go theme={null} import "github.com/MarkovWangRR/iso-topology/yamledit" out, ok := yamledit.SetField(src, line, []string{"style","palette","top"}, "#FFF") ``` Notable functions: `SetField`, `UpsertInlineKey`, `UpsertInlineList`, `FreezeLayoutText`, `AddPart`, `DeletePart`, `DuplicatePart`, `FindPartIDLine`, `FindConnectorLine`, `FindCanvasLine`, `ReadPath`. Full list on [pkg.go.dev](https://pkg.go.dev/github.com/MarkovWangRR/iso-topology/yamledit). ### Sub-package: `iso25d` The low-level iso renderer, if you want geometry without the DSL: ```go theme={null} import "github.com/MarkovWangRR/iso-topology/iso25d" svg := iso25d.Convert2DTo25D("cylinder", iso25d.ConvertOpts{ Width: 100, Depth: 100, Height: 40, TopFill: "#FFF", LeftFill: "#888", RightFill: "#BBB", }) ``` ### Browser / WASM The engine compiles for `GOOS=js GOARCH=wasm`, so `RenderSource` and `ApplyOp`/`ApplyOpText` can run **client-side** (zero-latency edit + preview). The only filesystem touch — inlining a local-file icon — is build-tagged off in the js build and degrades to passing the reference through, so a wasm build never fails on a local icon. Inline local icons as `data:` URIs before handing the DSL to the module if you need them in the browser. ### Measured connector labels `SceneResult.ConnectorLabels` and `RenderReport.ConnectorLabels` use `connector_labels` (omitted when empty). Each record has `connector_index` and `label`; the index refers to the scene's connector array in both iso and top projections. Only fully bound labels populate this collection. Legacy or unsupported-font labels are not certified by an absent record. Iso `ConnectorDrawing.Label.Typography` references the same submitted geometry; top still has no iso `Routes` or `ScreenLabels`. `label.rect` is the final rounded pill box, and `label.anchor` is its centre, not a text baseline. Optional `typography` contains font family/weight/size, resource hash, final per-line baselines, advances, and ink bounds. Its `placement` is `clear-within-scope` or `blocked`, with a machine-readable `scope` naming body/arrow/pill AABBs and other-route stroke envelopes. The latter contain real stroke width, round caps/joins and the declared quadratic approximation error, rather than filling diagonal AABB corners. An unchanged position's existing exact curve-bounds certificate is retained if the new approximation extends beyond those bounds. Own route ink is intentionally covered by an inline pill. This default scope excludes substrate content, arbitrary-font ink, effects, and full pixel visibility. In iso scenes with fully bounded body/content geometry, a still-blocked label can be refined again without moving any already-clear label. A successful refinement declares the more specific scope `body-region,content-quad,other-route-stroke-envelope,arrow-aabb,pill-aabb`. It protects separate filled faces, their stroke/curve-error envelopes, measured text quads and icon viewports, including content outside a body and on substrates. Substrate fills remain available for labels. Concave faces retain their notches; a custom path's convex outline accent is independently protected as strokes. The 6-unit gap applies to these bounds. This path is conservative about all submitted faces, irrespective of opacity or occlusion; it does not claim complete pixel visibility. The entire extra pass falls back to the previous arrangement if any node content lacks known metrics, a shape has no supported body envelope, or a scene contains screen labels, annotations, legacy connector pills, structural rows/cells, custom face surfaces, backgrounds, blur, shadows, glow, grain or patterns. Currently supported bodies are box/rectangle/square, cylinder aliases, circle, person, triangular/hexagonal/octagonal prisms, wedge, cloud and custom paths; support still depends on the actual content. Person/circle captions and cloud labels without icons bind the embedded Source Sans Pro resource when the entire block has supported glyphs and weight (400, 600 or 700). These captions preserve authored newline boundaries and the requested size (serialized to two decimals), reserve real ink spacing, and expand the export frame without moving the body in world coordinates. Their content-fit status remains `unsupported`: measurable ink is not a claim that the caption fits a bounded face or avoids other scene objects. Cloud captions without icons retain their previous centroid; clouds with icons retain the existing inscribed face layout and fitting report. Unknown fonts, weights or partially unsupported blocks retain their previous metrics/path. Unchanged labels keep their existing scope; a blocked result is never promoted merely because geometry is unavailable. Top projection retains its footprint routing path. Fully measured plan node titles are also protected by their per-line ink AABBs (scope adds `plan-title-ink-aabb`). Unknown title metrics retain the conservative joint-placement fallback. This synchronous optional pass has a shared 1,024 support-line/route-segment search-work budget and a 16,384 region-point storage limit. It first screens for route/pill-only blockage. Reaching a limit preserves prior positions/status and is not proof that no solution exists; these deterministic budgets are not a wall-clock latency guarantee. Coordinates are root SVG user coordinates before browser transforms. Collection does not modify the drawing or rerun placement. `RenderSceneWithReport` adds `connector-label-placement` warnings from this same render; existing `Validate` and readability scoring retain their separate diagnostics. Report repair simulations copy connector records, so tentative route upgrades cannot change the caller's authored routing or a subsequent render. After the complete individual placement and eligible body refinement, a bounded joint pass can arrange still-blocked labels together along their existing routes. It fixes every already-clear label, offers safe interval-boundary/interior choices, and compares connected groups with each label's exact old position as a fallback. It favors more clear labels, then less movement within its finite candidate set. An unresolved label never moves; a moved label must clear even its unresolved peers. Independent components can improve separately. Search exhaustion retains the best fully checked assignment and is not a proof that the scene is infeasible. This pass requires reliable body/content bounds: unknown iso content/effects and legacy pills keep the previous layout. Plan node titles still use ambient font metrics, so plan scenes containing such titles retain their previous arrangement. No node, route, arrow, font size or authored text is changed by joint placement. `RenderSceneWithReport` also collects `typography.conflicts` for blocked measured labels, shared by its returned scene and report. Plain `RenderScene` keeps these detailed diagnostics absent so interactive previews do not build or transfer a potentially quadratic conflict list. Both paths emit the same SVG, label positions, text and placement status. Candidate repair gates request the detailed observations on their own render pass; no diagnostic re-layout is performed. Each conflict identifies a `kind` (`body`, `route`, `arrow`, or `label`). Bodies carry `part_id` and a flattened `part_index`; other counterparts carry the scene's `connector_index`, including zero. Parallel connections remain distinct. An unnamed body receives `#part:`. Its `clearance_shortfall` is in root SVG units and includes the declared 6-unit gap. For a rectangle it is the minimum L-infinity translation out of the forbidden centre region. For a route it is the maximum such penetration over its swept segments/chords; connected collinear pieces of equal width are coalesced first. This is a per-primitive envelope severity, not pixel overlap area, hidden route length, or the minimum move that resolves the union of all conflicts. Body/arrow/pill bounds retain their existing conservative scope. Own route ink is excluded; own arrow ink is protected. Legacy peer pills retain an identity without claiming measured legacy glyph ink. Rich body-region certificates are not reinterpreted as body-AABB conflicts. Conflict lists are deterministic and complete within this diagnostic scope; report warnings name up to three counterparts while the structured list keeps the rest. Clear labels keep their existing certificates and have no list. An absent list in a plain preview, an unknown-font label, or an unsupported scope must not be interpreted as a general visibility guarantee. Measured connector presence and clear/blocked state also enter the per-edge scene candidate defects (`connector-label-missing` / `connector-label-blocked`). Already-blocked labels also contribute counterpart keys and measured clearance shortfalls (`connector-label-body`, `-route`, `-arrow`, `-label`). Routing upgrades now reject every new or worsened defect in the existing per-object/per-edge scene map, including transferred crossings/tunnels and new blockage or disappearance of a measured label. Existing body-repair gates consume the same label keys. Aggregate improvement cannot buy a new pair or increase an already-blocked label's measured shortfall. Other repair actuators and unbound fonts retain their existing checks. ### Top projection node titles `SceneResult.PlanLabels` and `RenderReport.PlanLabels` (`plan_labels`, omitted when empty) describe titles in the submitted plan SVG. Studio render/edit responses expose the same collection as `planLabels`. `part_index` is the preorder footprint index, including containers and collapsed stacks; records are returned in paint order. `part_id` preserves the emitted identity. An explicit `Source Sans Pro` family with supported glyphs and static weight 400, 600 or 700 binds the same font for shaping and SVG embedding. The resolved requested size is preserved to two decimal places. Leaf titles use a centred budget with 8 units of horizontal and 6 units of vertical inset. Container titles align left in the band above their first direct child's footprint, reserving its outline and a 6-unit gap. Footprints are not resized or moved to accommodate a title. Automatic plan routes may use the guarded refinement described below when title translation alone cannot resolve an obstruction. Wrapping preserves every grapheme, space and authored newline. It prefers spaces, then permits grapheme-boundary breaks. `lines[].source_line` identifies the authored newline index; concatenating that line's fragments reconstructs its original text. Width wrapping is accepted only when its block fits the available height at the requested size. Otherwise the authored lines remain. The extra wrapping search stops after 65,536 measured input bytes and retains remaining text; this limit does not cap the initial full-text shaping cost. Records include the original `text`, actual SVG font request, resource hash, `budget`, per-line baselines/advances/ink bounds and `overflow` in left, top, right, bottom order. `status: within-budget` means ink lies in that title's budget; it does not certify whole-scene visibility. `overflow` retains the full text and adds a located `plan-label-fit` warning. Candidate guards preserve per-part, per-side overflow severity. Export bounds and approximate world selection regions include all bound ink, including overhangs. Selection adds 0.01 units to cover final SVG anchor rounding after canvas translation. Unknown families, unsupported weights/scripts, and partially unsupported blocks retain the original ambient-font SVG path: 11-unit centred leaf titles and 13-unit, weight-600 container titles. Their records say `unmeasured`, list the legacy SVG font request, and omit lines/hash rather than inventing bounds. An unmeasured result's zero overflow array does not mean the text fits. `ContentFits` remains the separate isometric top-face contract; plan titles are not added to `ScreenLabels` or iso `Routes`. Title-to-title and route-to-title visibility, arbitrary system fonts, complex scripts, global space allocation, and target-display-scale readability remain outside this title-fit contract. ### Plan route separation Detailed `RenderSceneWithReport` results expose `plan_route_crowding` on both the scene and report. `EvaluatePlan` publishes the same observation under `route_crowding`. Ordinary previews omit this diagnostic payload. It describes the final submitted plan paths; the older `crossings` score keeps its existing centreline definition and does not count every overlap or insufficient gap. `status: complete` with an empty `conflicts` array establishes clearance only within the declared `scope`. Each conflict identifies `connector_index`, `peer_index`, the primitive pairing (`stroke-stroke`, `stroke-arrow`, `arrow-stroke`, or `arrow-arrow`), a screen-space bounding rectangle and union `area` in squared SVG units. This area measures intersections of clearance envelopes, not painted overlap pixels. Stroke envelopes contain round caps, joins, casing and continuous coverage of dash gaps; each route contributes half the six-unit reading gap. Markers use the emitted triangle and its final direction. Opaque leaf cores mask hidden regions; transparent/unknown fills and container fills do not establish occlusion. Shared endpoints receive only a local neighbourhood exemption, with radius `12 + (widthA + widthB)/2 + 6` in SVG units. Both routes must dock at the same serialized point on the same node. Long coincident runs and remote crossings remain observable. Separate edges connecting the same pair of nodes receive no such exemption, including short or bidirectional parallel edges. Observations stop explicitly with `status: unresolved` and a reason when geometry is non-finite or work limits are exceeded. Limits include 512 points per route, 8192 scene route primitives, 262144 route pairs, a bounded clipping work counter, and 256 region pieces per primitive-pair intersection. Partial conflicts remain available but are not a clearance certificate. Reports add located `plan-route-clearance` warnings and, when needed, `plan-route-clearance-unresolved`. After title-driven routing, automatic plan routes may also separate observed route conflicts without requiring a title obstruction. This phase uses at most 128 geometric probes and eight complete scene trials. A candidate must remove a conflict, preserve every other counterpart's severity and existing clear text positions, and satisfy the complete stroke/body/arrow/border guards. Unknown text and legacy pills prevent changes; explicit paths and fixed contour ports remain protected. Unresolved observations never authorize a route change. Final export bounds include accepted detours even when the scene contains no text. ### Plan title clearance and local movement Bound titles additionally publish `placement` with a `status` of `clear-within-scope` or `blocked`, an `offset` from the preferred layout, and an explicit `scope`. Fit status remains independent: a title may fit its own budget while a route crosses it. Detailed reports include conflicts identified by counterpart kind, part index/ID or connector index, with the maximum L-infinity clearance shortfall over its primitives. They use the same six-unit gap as the final placement predicate. Ordinary previews omit the detailed conflict list. `plan-label-placement` warnings locate unresolved titles; `plan-title-blocked` and counterpart/severity keys protect subsequent scene candidates against new or worsened conflicts. The scope covers later-painted body AABBs, known title ink AABBs, route stroke and casing envelopes, arrow AABBs and connector pill AABBs. Boundary interiors remain empty; their dashed borders are conservatively treated as continuous strips. A fully opaque rounded footprint's inner rectangular core can hide older title or route ink according to the actual paint order. Container fills paint before routes, so they cannot hide a later route. A leaf's own fill can hide route ink beneath its internal title. Transparent, unknown CSS fills and rounded corners outside the proven core never establish that exemption. The envelopes retain conservative false positives; this is not a per-glyph pixel visibility or full scene compositing certificate. The plan content resolver freezes the complete connector-pill arrangement before trying title moves. Only blocked, fully measured titles that already fit their budgets may move, by rigid translation inside the same budget. Text, size, wrapping, nodes, ports and routes stay fixed. Previously clear titles stay at their exact anchors. A move is accepted only when every title line clears all protected counterparts, including still-blocked peer titles and all pills. Clearing a title can improve a fixed pill's final status; it cannot move that pill or make a clear pill blocked. Render output, report and interaction selection all use the resolved placement algorithm. Any unmeasured nonempty title or legacy connector pill disables automatic title moves for the scene; known geometry can still be observed. Unresolved titles retain their original positions and report `retained_reason` such as `unmeasured-text`, `infeasible-budget`, `no-safe-candidate` or `search-budget`. Search is deterministic: at most 255 boundary hints per axis are reduced to 32 positions each, then at most 512 translations are checked by distance. All titles share 65,536 line/obstacle predicate checks for candidate verification. Only fully checked moves commit. These bounds do not cap initial/final observation or typography work and do not prove that an unexamined feasible position cannot exist. General visibility, arbitrary fonts and global layout optimization remain separate work. ### Automatic plan routes around unresolved titles After title translation, a residual route/arrow conflict may trigger a second pass over automatic orthogonal routes. It tries alternative node-side and title-side corridors with a scene-wide limit of 32 candidate evaluations. Only a candidate that clears at least one previously blocked title can commit. The entire resulting title and connector-pill arrangement is observed again; no counterpart may acquire a new conflict or a greater clearance shortfall. Previously clear titles keep their exact anchors. Clear pills on other edges keep their boxes; the changed edge's own pill may follow its new route but must retain clearance. Typography and node footprints remain fixed. A detour must clear unrelated leaf footprints, including footprints on other height levels, with its complete stroke/casing and a six-unit gap. Other routes, arrows, nonterminal contacts with its own endpoints and self-crossings are also checked. The last leg must fit the marker's full rear extent, casing and reading gap before its preceding elbow. Arrow bounds are conservative. Congested shared-port hubs may therefore retain the old route rather than accept coincident segments. Unknown nonempty title ink or a legacy connector pill disables the pass. The candidate limit does not cap initial observation, typography, or all work inside one complete content evaluation; it is not a global optimum proof. Explicit straight routes, bends, either waypoint form, elbow requests and legacy named anchors retain the original route. Fixed contour ports support detours that preserve both endpoint coordinates and outward directions. Accepted route ink and arrows enlarge the final export bounds with a 16-unit margin while retaining the coordinate origin used for verification, including fixed-port routes. The input document is not rewritten. Plan rendering, evaluation and interaction use the same deterministic routing algorithm; isometric routing is unchanged by this pass. ### Isometric route clearance and conservative repair Detailed `RenderSceneWithReport` results expose `iso_route_crowding` in both `SceneResult` and `RenderReport`. Ordinary previews omit the diagnostic; their SVG and submitted route geometry are identical. The observer consumes `Drawing`, including final coordinate/stroke serialization, and the same render's body paint order. It does not measure the editable, pre-trim `World` route as screen ink. The public painter subtracts later known opaque **fill cores**, inset by the provider's polygon error plus 0.02 SVG units. Earlier fills cannot hide a later stroke. Arrows remain in the public overlay after body fills. An explicit internal spatial-render experiment instead uses its own body-depth clip regions; this does not enable that experiment for public rendering. Non-convex or uncertain opaque coverage, unsupported curves, invalid geometry and exhausted budgets yield `status: "unresolved"` with a reason. Only `complete` with no conflicts establishes clearance **within the declared scope**. Translucent fill accumulation, strokes on bodies, dash gaps, text, effects and connector-to-connector compositing are outside that scope; the result is not a pixel visibility certificate. Visible stroke/triangle regions are expanded by three units per side, giving a six-unit reading gap with a conservative square envelope. Stroke pairs are recorded only when their direction cosine magnitude is at least 0.98 and their connected projected overlap exceeds 12 units. Ordinary transverse stroke crossings remain outside the public warning. Their pairwise contact areas (and short parallel contacts) are retained privately as candidate guards: replacing a long overlap with a new remote X crossing cannot qualify as an improvement, even when the edges share a node. Arrow contacts are recorded separately. Conflict records retain `connector_index`, `peer_index`, `kind`, root SVG `bounds` and the union `area` of clearance intersections. Kinds are `parallel-stroke`, `stroke-arrow`, `arrow-stroke`, and `arrow-arrow`. Area is not a rendered pixel count. Shared endpoint masks require the same instance and the same known world and submitted point, have radius `12 + (widthA + widthB)/2 + 6`, and never exempt parallel semantic edges connecting the same unordered node pair. Limits are 512 routes, 512 points per route, 65,536 counted stroke/body/clip vertices (arrows separately require three points), one million clipping work units and 256 intersection pieces per pair/category. Processing occurs in the common projection frame to avoid numeric cancellation when translating a diagram. Legacy `Readability` and `EvaluateIso.Crossings` keep their previous meanings; new report warnings are `iso-route-clearance` and `iso-route-clearance-unresolved`. `RepairScene` can select `xFirst` or `yFirst` for an unconstrained `orthogonal` connector implicated in a complete observation. It tries at most eight alternatives per iteration. Acceptance requires fewer conflict identities, no increase in any remaining pair's area or existing per-object visual defect, unchanged terminals, arrows and label records, non-increasing world length/bends and final screen length/vertical variation (0.03 serialization tolerance), non-decreasing visible stroke-ink fraction, no export dimension growth beyond a two-unit integer-bound rounding tolerance, and non-increasing legacy readability cost. Failed trials restore the source. Explicit elbow, bend, waypoints, 3-D waypoints and anchor/port references are preserved; unmeasured screen labels or legacy connector pills stop this additional repair. Plain render and `--no-repair` never change source routes. This bounded local choice does not solve global route allocation or establish arbitrary-scene absence of visual defects. # CLI reference Source: https://iso-topology.mintlify.app/reference/cli Commands, flags, output files, and exit codes for isotopo. iso-topology has two faces: a CLI (`isotopo`) and a Go library (`github.com/MarkovWangRR/iso-topology`). Both expose the same rendering pipeline. ## CLI ### `isotopo render` — produce SVG + embed assets ``` isotopo render [--layout dagre|elk] [--projection iso|top] [--readable] [--theme ] [--no-repair] [--report] ``` | Argument | Meaning | | - | - | | `--layout dagre` | default; natural-bend polyline edges | | `--layout elk` | orthogonal right-angle edges with obstacle avoidance | | `--projection iso` | default; 2.5D isometric view | | `--projection top` | flat top-down **plan view** — footprints + orthogonal edges, height dropped (good for reading the layout/flow). Overrides `canvas.projection`. | | `--readable` | use upright screen labels, contrast chips, and a padding floor where the document has not supplied its own settings | | `--theme ` | apply a built-in design system; inspect available names under `themes` in `isotopo capabilities` | | `--no-repair` | skip the automatic projection-repair pass | | `--report` | write the repair report to `report.json` in the output directory | | `` | path to `.yaml`, `.json`, or `.d2` (or `-` for stdin) | | `` | directory to write `topology.{svg,html,…}` and `nodes/.{svg,html,yaml}` | The `--layout` flag is consulted only for `.d2` input (YAML/JSON already encode placement). The projection can also be set per-document with `canvas.projection` (see [DSL/YAML](./dsl-yaml.md)). Output structure is documented in [OUTPUTS.md](../reference/output-layout.md). `render` runs the full validator first (issues go to stderr) and returns an exit code that mirrors `validate`, so an agent can gate on `render` alone without a separate `validate` step: | Exit code | Meaning | | - | - | | 0 | rendered a non-empty scene with no validation errors (warnings may have printed) | | 3 | validation **errors** were present, **or** the document produced no scene (empty `topology.svg`) | | 1 | I/O or parse failure (message on stderr) | Warnings alone do **not** fail the render. On a code-3 result the (partial or empty) output is still written for inspection — it is simply no longer reported as success. Examples: ```bash theme={null} # .d2 with dagre auto-layout isotopo render scene.d2 ./out # .d2 with ELK orthogonal routing isotopo render --layout elk scene.d2 ./out-elk # YAML composite isotopo render scene.yaml ./out # Stream a generated DSL through stdin gen-dsl | isotopo render - ./out ``` ### `isotopo capabilities` — discover supported features ``` isotopo capabilities ``` Emits a JSON document describing the live feature contract, including: | Key | Contents | | - | - | | `version` | semantic version of this build | | `inputs` | accepted file extensions and what they mean | | `layouts` | layout engines for `.d2` | | `shapes` | every shape name and its aliases | | `primitives` | composition primitives (group, stack, annotation, …) with one-line syntax | | `style_keys` | every field inside `palette` / `stroke` / `text` / `effects` | | `themes` | available built-in design systems | | `enums` | accepted values for fields with a closed vocabulary | Designed for agents to read once at startup so they can generate valid DSL without consulting source code. Example: ```bash theme={null} isotopo capabilities | jq '.shapes[].iso_name' # "circle", "cloud", "composite", "cylinder", "group", … isotopo capabilities | jq '.primitives[] | {name, syntax}' ``` ### `isotopo validate` — structural check before render ``` isotopo validate ``` Parses the input and runs structural validation: unknown shape names, annotation anchors that don't resolve, connector references to missing parts, unknown canvas grid modes, etc. It also runs geometry/quality lints, including a **plan-view footprint collision** check: after solving layout, every node and group is projected to its top-down (x, y, w, d) footprint, and any two that **touch, cross, or cover** one another are warned — except a child sitting inside its own group (legitimate containment) and stack replicas. This catches `place`/`offset` values that make boxes or group slabs collide in the flat plan view, which the 3-D cross-container lint (it requires height overlap too) and the same-container sibling check (it ignores mere touching) would miss. Two more geometry lints: * **label occlusion** — warns when a node sits over a group label or `iso_text` title in the rendered view. The renderer silently lifts an occluded group label on top, so this surfaces the defect as feedback instead of swallowing it. * **container fit** — warns when a child's effective footprint exceeds 90% of a **fixed-size** group/boundary on either axis (it bursts the slab). Auto-sized containers grow to wrap their children, so they're exempt. Effective dims honour per-shape render floors (e.g. a cloud is measured at its drawn 200×140), so a cloud crammed into a small fixed group is caught. * **rounded side band** — warns when a solid `palette.right` that is clearly different from `palette.left` is set on a **rounded** part (cornerRadius ≥ 6, including the default-rounded group slab). The rounded renderer collapses the two side walls into one band driven by top + left and never reads the solid right, so that colour is silently dropped. Normal same-hue iso shading is below the distance threshold and stays quiet; set `cornerRadius: 0` for distinct left/right faces. Emits JSON of issues: ```json theme={null} { "issues": [ { "severity": "error", "path": "nodes.scene.parts[2].shape", "message": "unknown shape \"cilinder\"", "suggest": "cylinder" } ] } ``` | Exit code | Meaning | | - | - | | 0 | clean — no issues | | 2 | warnings only — will render but probably not as intended | | 3 | errors — render will likely fail or produce broken output | In an agent loop: ```bash theme={null} isotopo validate scene.yaml > /tmp/issues.json if [ "$?" = "3" ]; then # Apply suggestions, re-emit DSL, re-validate ... fi isotopo render scene.yaml ./out ``` ### `isotopo evaluate` — score the auto-layout connection quality ```bash theme={null} isotopo evaluate [output-dir] ``` Measures how reasonable the layout's wiring is, from the flat top-down geometry (true world x,y — the iso projection skews distance/angle, so crossings and lengths can't be measured there). Prints a JSON scorecard to stdout; with an `output-dir`, also writes `plan.svg` with problems marked in **red**. The report has two halves — `plan` (the plan view's preview router) and `iso` (the **engine's real routes**, parsed from the rendered SVG) — so you can see both the achievable and the actual quality: | Metric | Meaning (lower is better) | | - | - | | `crossings` | edge–edge crossings between non-adjacent edges | | `edges_through_nodes` | edges tunnelling an unrelated node footprint | | `backward_edges` | edges running against the dominant flow | | `node_overlaps` | overlapping node footprints | | `total_bends` / `total_edge_len` / `max_edge_len` | route complexity | | `flow_axis` | inferred dominant flow (`vertical`\|`horizontal`\|`none`) | z-stacked scenes are handled: a part on another z-floor, a shared substrate both endpoints sit on, or a flat decoration (`h ≤ 2`) is not counted as an obstacle. ```bash theme={null} # scorecard only isotopo evaluate scene.yaml | jq '.iso' # scorecard + annotated plan.svg with crossings/tunnelling in red isotopo evaluate scene.yaml ./out ``` Library equivalents: `EvaluatePlan` / `EvaluateIso` / `RenderPlanAnnotated` (see [API reference](./api.md)). ### `isotopo repair` — inspect or persist automatic fixes ```bash theme={null} isotopo repair scene.yaml isotopo repair scene.yaml --write isotopo repair scene.yaml --compose --write ``` Without `--write`, this prints a fix report without modifying the input. With `--write`, it persists supported fixes while preserving comments. `--compose` also enables bounded alignment snapping. Exit code `0` means no repairs were found; `2` means repairs were found, including when they were written. `render` applies its automatic repair pass to the rendered document by default. Use `--no-repair` to inspect the authored scene without that pass, or `--report` to save a report of what changed. Review the output: automatic repair does not guarantee that every possible layout problem has been resolved. ### `isotopo snapshot` — rasterize a scene ```bash theme={null} isotopo snapshot scene.yaml ./out ``` Writes `topology.png` using the SVG viewBox as its viewport. This requires `resvg` or ImageMagick. It accepts `--no-repair` and `--report`, like `render`. ### `isotopo preview` — render a subset ```bash theme={null} isotopo preview scene.yaml ./selection.svg api db ``` Select nodes by ID, a container and its children, or a connector such as `edge:0`. The subset is laid out and cropped independently. Use `-` as the output path to write SVG to stdout. ## `isotopo serve` — the Studio The interactive page has a name and its own guide: **[isotopo Studio](../guides/studio.md)** — feature tour, design rationale, endpoints, and how it is tested. Quick reference below. ```bash theme={null} isotopo serve scene.yaml # default port 8731 ISOTOPO_PORT=9000 isotopo serve scene.yaml ``` Serves the interactive viewer at `http://localhost:8731`: * the YAML editor (line numbers, Tab inserts indent, drag the splitter to resize the pane) edits an **in-browser copy — the input file on disk is never written**; unsaved edits persist across a reload as a local draft, with a one-click `revert` * hovering a node in the SVG highlights its source block; the filetab shows the absolute source path with a copy-path button * scroll to zoom, drag to pan, double-click to reset * edits re-render automatically (debounced) or with Cmd/Ctrl+Enter; validation errors show under the editor with fix suggestions, and the canvas keeps the last good render (with a badge) while the source is broken * `↓ SVG` / `↓ PNG` export whatever the canvas currently shows — unsaved edits included — as `.edited.svg` / `.png` (2x); `Download` saves the edited YAML copy * `Browse nodes` opens the per-part gallery, served live from the current file content Endpoints, for tooling: `GET /` viewer · `GET /topology.svg` fresh render of the file on disk · `GET /nodes/_index.html` and `/nodes/.svg|.yaml|.html` gallery · `POST /api/render` renders the posted source, returns `{"svg", "issues"}` · `GET /api/ping` health. The same interactive page is written to `out/topology.html` by `isotopo render`; opened as a static file it keeps everything except live re-render (the toolbar tells you to start `isotopo serve`). ## Go library The CLI is a thin wrapper over the Go engine. The full library API — parsing, rendering, the stateless **edit** contract (`ApplyOp` / `EditOp`), types, and the `yamledit` / `iso25d` sub-packages — lives in the single **[API reference](./api.md)**. # D2 Source: https://iso-topology.mintlify.app/reference/dsl-d2 Use D2 graph syntax with automatic layout and isometric rendering. `.d2` is the [terrastruct/d2](https://github.com/terrastruct/d2) graph DSL. iso-topology accepts `.d2` files as input and runs the d2 auto-layout pipeline, then translates the laid-out diagram into our iso composite model. **Why this path exists**: agents and code generators usually output graph structure (nodes + edges), not pixel-precise layouts. The `.d2` path lets them stay in their comfort zone and get an iso scene for free. For pixel-precise authoring, use the YAML path instead: [DSL\_YAML.md](../reference/dsl-yaml.md). ## Quick example ```d2 theme={null} user: User { shape: person } api: API Gateway queue: Job Queue { shape: queue } worker: Worker db: Database { shape: cylinder } cache: Cache { shape: cylinder } user -> api: request api -> queue: enqueue api -> cache: lookup queue -> worker worker -> db: write worker -> cache: update ``` ```bash theme={null} isotopo render scene.d2 ./out ``` Output: a `topology.svg` with all six elements iso-projected, dagre auto-layout, an iso grid backdrop, and labeled connectors. ## What translates and how ### Shapes The `mapD2Shape` catalog turns every d2 shape string into an iso shape plus a height multiplier. Discover the full list with: ```bash theme={null} isotopo capabilities | jq '.shapes[]' ``` Highlights: | d2 shape | Iso lowering | Height hint | | - | - | - | | `rectangle`, `square`, `package`, `sequence_diagram`, `hierarchy` | `rectangle` | 1.0 | | `cylinder`, `queue`, `stored_data` | `cylinder` | 1.0 | | `circle` | `circle` | 1.0 | | `person`, `c4-person` | `person` | 1.2 (taller) | | `cloud` | `cloud` | 0.8 | | `class`, `sql_table` | `rectangle` | 1.4 (taller — shows rows on side face) | | `text`, `code`, `image` | low rectangle / iso\_text | 0.3 | | `document`, `page` | flat rectangle | 0.4–0.5 | | `hexagon`, `step` | rectangle (no native polygon yet) | 0.6–0.7 | | `callout` | flat rectangle | 0.5 | Unknown d2 shape names fall back to `rectangle` with the default height. Skipping `mapD2Shape` is the right move for prototyping; you can fill in better lowerings later without touching anything else. ### Connections d2 connections become iso `Connector` entries: ```d2 theme={null} api -> db: query ``` → a connector with `arrow: triangle` and the label `"query"`. Stroke color and dash come from the d2 style (or theme defaults if neither side specified one). ### Nested containers → iso groups d2 uses dot-notation OR brace-blocks to express containers: ```d2 theme={null} cluster: Kubernetes Cluster { wn1: Worker Node 1 { proxy1: LiteLLM Proxy cache1: Cache { shape: cylinder } } ingress: Ingress Controller } users: Users { shape: person } users -> cluster.ingress cluster.ingress -> cluster.wn1.proxy1 ``` iso-topology detects the hierarchy from the dot-separated shape ids and emits one iso `group` per container level. The above produces: * Kubernetes Cluster (outer translucent panel) * Worker Node 1 (inner panel) * LiteLLM Proxy + Cache (leaves) * Ingress Controller (leaf) * Users (sibling of Cluster) Nesting depth is unlimited. ### Connector targets after nesting Use the full dot-path: ```d2 theme={null} cluster.ingress -> cluster.wn1.proxy1 ``` This translates verbatim — the iso connector's `From`/`To` keep the full `cluster.wn1.proxy1` id. Validation and the per-part output use that same id space. ## Layout engine ```bash theme={null} isotopo render --layout dagre scene.d2 ./out # default isotopo render --layout elk scene.d2 ./out ``` | Engine | Best when | | - | - | | `dagre` | small/medium graphs; natural-bend polyline edges read as flowy | | `elk` | denser graphs; orthogonal right-angle routing with obstacle avoidance | Both engines are built into the binary; no extra install needed. ## What doesn't translate (yet) These d2 features are silently dropped or simplified: * **Icons**: d2's `icon: ` field is ignored. The iso primitive for the shape renders without an embedded image. * **Shadow / 3D / multiple / double-border**: d2 has these as visual modifiers; iso renders the base shape only. * **Image shapes**: require an `icon:` field; without one d2 itself rejects the source, so the case never reaches our translator. * **Class / SQL Table rows**: d2 paints them on the front face; iso renders the rectangle envelope only (the row content is dropped). Workaround: use the YAML path and set `content.rows: [...]`. If you hit one of these and want it lowered properly, see [EXTENDING.md](../guides/extending.md) for the recipe — these are exactly the gaps the catalog/translator architecture is designed to absorb. ## Inspecting the translation `out/topology.d2` is a copy of the source DSL (so re-rendering later doesn't need the original). Past that, the layout is opaque — d2 computes positions, we project them. If you need to debug layout, run the standalone d2 CLI on the same `.d2` file and inspect its 2D output first. ## Round-trip back to YAML Each part also gets emitted as a per-part YAML fragment: ``` out/nodes/cluster.wn1.proxy1.yaml ``` That fragment is a self-contained iso-topology Document — you can feed it to `isotopo render` to get the standalone iso of just that proxy. Useful for building icon libraries from a generated topology. See [OUTPUTS.md](../reference/output-layout.md) for the full output layout. # Styles and themes Source: https://iso-topology.mintlify.app/reference/dsl-theme Configure colors, strokes, typography, effects, and reusable presets. Every iso part has a `Style` that controls its visual presentation — fills, stroke, label typography, opacity, etc. The `Theme` block on a Document provides defaults; per-part `style:` overrides them. ## The four style sub-blocks ```yaml theme={null} style: palette: {...} # face fills (top / left / right) stroke: {...} # silhouette stroke text: {...} # label typography effects: {...} # opacity, margin, corner radius ``` Each sub-block is optional and merges independently — set just the fields you want to change. ### `palette` Iso parts have three visible faces (top, left, right): ```yaml theme={null} palette: top: "#FFFFFF" # top face fill (lit-from-above) left: "#7E94B5" # left face (in shadow) right: "#A6B7D2" # right face (mid-tone) ``` A clean palette uses three values on a single hue at different lightness. Skip the fields you don't want to set; the theme defaults fill in. For shapes without three distinct faces (cylinder, circle, cloud, person), `top` and `left`/`right` cover the lit and shadowed sides respectively. Each face can also take a **linear gradient** instead of a solid fill — the gradient wins when both are set: ```yaml theme={null} palette: topGradient: {from: "#FFE188", to: "#F59E0B", dir: down} leftGradient: {from: "#F4F4F8", to: "#D8DAE3", dir: down} rightGradient: {from: "#FFFFFF", to: "#E8E8EE", dir: down} ``` `dir` is the gradient direction across the face (`down` is the common choice — lit top edge falling to a darker base). On `cornerRadius` parts, the rounded silhouette has one continuous side band: it runs `rightGradient.from` at the top to `leftGradient.to` at the bottom. ### `stroke` ```yaml theme={null} stroke: color: "#1F2937" # CSS color width: 1.2 # SVG stroke-width dash: "4 3" # SVG stroke-dasharray syntax (or omit for solid) ``` Set `width: 0` to hide the silhouette entirely (useful for soft cards). ### `text` Controls the label rendered on the top face (or screen-space, depending on `orient`): ```yaml theme={null} text: family: "Inter, sans-serif" size: 12 # px weight: "600" # CSS font-weight string color: "#1F2937" orient: iso # iso (default) | screen boxBg: "#FFFFFF" # used only with orient: screen boxBorder: "#1F2937" ``` Adaptive top-face layouts estimate label widths, wrap at word boundaries, and may reduce the font down to 5px and shrink the icon to make room. For these layouts, `size` is an upper bound; content that already fits keeps its existing placement. When a long word must break, wrapping and last-line balancing preserve whole grapheme clusters, including combining accents and emoji sequences. Balancing never moves letters across an original word boundary. For system fonts, width estimates are not measurements of the selected font. Fixed face dimensions can still be too small at the minimum font size: an oversized cluster remains intact and may overflow. Increase the face size or use a screen-space caption when the text needs more room. Explicit icon anchors and offsets retain the author's placement instead of auto-fitting. Validation reports a warning when the final estimated content bounds exceed the preferred top-face padding budget. This may indicate lost breathing room even when the content is still inside the physical face. Render reports expose the final lines, font sizes, image viewports and affected sides as `content_fits`. An extremely small face retains a positive icon viewport inside the face when the padding itself is infeasible; that fallback still reports the unmet budget. An icon never grows merely because the label needs more space. For reproducible top-face or screen-label text, opt into the bundled Source Sans Pro font: ```yaml theme={null} style: text: {family: "Source Sans Pro", weight: "600", size: 18} ``` The bounded top-face renderer embeds the same immutable font resource that it uses for shaping and fitting. Weights `400`/`normal`, `600`, and `700`/`bold` are supported; use the exact family name rather than a CSS fallback list. Latin, Greek, Cyrillic and supported punctuation/combining marks use shaped advances and ink bounds. Missing glyphs, other scripts and system-font fallback remain explicitly estimated in `content_fits`; this does not bind a CJK or emoji font. Sphere labels, free isometric text, connector labels and the separate top projection retain their existing measurement paths. For automatically positioned content whose entire label has bound metrics, the renderer tests rows and wrapped columns using actual glyph heights and ink gaps. It preserves an already feasible full-size layout; otherwise it prefers a larger feasible font, then a larger icon, then fewer layout items. The search has a bounded candidate budget and does not claim a globally optimal layout. Existing minimum-font and icon-reduction policies remain in force, with two-decimal SVG placement rounding. Explicit blank lines remain. Manual icon placement and unsupported-font labels retain their existing layout policy. When no tested layout satisfies the budget, the full text is retained and the unmet constraints are reported instead of hiding content. For bound text, `content_fits[].fit.gaps` also reports insufficient ink separation: adjacent visible lines prefer 0.12 em and icon/text boxes prefer 4 local units along either axis. This can produce a warning even when no outer padding is exceeded. The icon box is its viewport; the measurement does not certify opaque pixels or readability after shrinking the diagram. With `orient: screen`, a label fully supported by the bound font uses its shaped widths and heights to size the chip, pointer hit rectangle and obstacle reservation together. Explicit newlines, blank lines and spaces are preserved; text remains at the requested size, rounded to two decimals for SVG. The chip keeps at least 8 horizontal and 5 vertical units around the ink, with extra height for tall marks or multiple lines. Bound-font chips also avoid their own projected body. Unknown-font or mixed fallback labels retain the existing estimated single-line layout; they are not certified as shaped blocks. Screen labels and annotation boxes first try the existing preferred and peripheral positions. If all fixed candidates collide, a bounded interval search along four outward rays finds a free position among finite obstacles. It uses the nearest such position to the preferred location, retaining the existing direction order for ties, and expands the export bounds as needed. Dense scenes can therefore place a label farther away; compact global label assignment and target-scale readability remain separate concerns. SVG text stays selectable and editable. A composite embeds each used font weight once; the full TTF resource adds about 330–340 KB per weight to the SVG. No network font request is needed. Built-in `Rasterize`/`snapshot` loads the same resource into resvg, or uses Chrome/Chromium when resvg is unavailable. ImageMagick delegates and external/custom rasterizers must be independently configured for embedded fonts; an explicit `ISOTOPO_RASTERIZER` keeps its existing two-argument contract. Default font selection is unchanged. `orient: iso` paints the label on the iso-tilted top face. `orient: screen` paints a horizontal label box BELOW the part's projected bounding box — useful for parts that need a readable caption even when the iso angle would make on-face text hard to read. ### `effects` ```yaml theme={null} effects: opacity: 0.9 # 0-1, multiplied across the whole part margin: 4 # padding inside the iso box (for content shapes) cornerRadius: 8 # rounded corners on box-family parts dropShadow: {dx: 0, dy: 10, blur: 16, color: "rgba(45,10,100,0.25)"} backglow: {color: "#FFC857", radius: 80, opacity: 0.55} pattern: {kind: hatch, color: "#FFFFFF", spacing: 8, angle: 30} ``` * `dropShadow` — Gaussian-blurred offset shadow under the part (screen units; positive dy = down). Use to lift heroes off the canvas. * `backglow` — a blurred halo behind the silhouette. ONE per scene: it marks "the" central element. * `pattern` — `hatch` or `dots` texture overlaid on box top faces, cylinder top ellipses, and circle discs. * `wireframe: true` — line-art mode: strokes only, no fills. Pair with a dashed stroke for ghost volumes and floating frames; wireframe/none-filled parts are exempt from overlap warnings. * `grain: {intensity: 0.45, scale: 0.8}` — monochrome film-grain noise on the faces (the "print / risograph" texture). Intensity 0..1; \~0.4–0.5 keeps dark fills dark. Box, rounded box, cylinder. ## The Theme block A `Theme` is a `Style` plus optional per-shape-type overrides: ```yaml theme={null} theme: # Defaults for every part: palette: {top: "#FFFFFF", left: "#7E94B5", right: "#A6B7D2"} stroke: {color: "#1F2937", width: 1.2} text: {family: Inter, size: 12, weight: "600", color: "#1F2937"} # Per-shape-type overrides (only when shape matches): shapes: cylinder: palette: {top: "#DEE1EB", left: "#0D32B2", right: "#1641C5"} person: palette: {top: "#EBC8A9", left: "#8E673E", right: "#A77F58"} cloud: palette: {top: "#FFFFFF", left: "#A5B8DC", right: "#C2CFE6"} stroke: {color: "#5E6791"} ``` ## Three-layer cascade For any part, the renderer computes its final style by merging three sources in order. Later layers override earlier ones; a missing field inherits from the layer above: ``` 1. theme (document-level defaults) 2. theme.shapes[s] (shape-specific defaults — optional) 3. part.style (per-part overrides — optional) ``` Each merge is field-level. For example, if the theme says `palette.top: "#FFF"` and the part says only `palette.left: "#888"`, the final part keeps the white top and gains the gray left. ### Worked example ```yaml theme={null} theme: palette: {top: "#FFFFFF", left: "#7E94B5", right: "#A6B7D2"} shapes: cylinder: palette: {top: "#DEE1EB", left: "#0D32B2", right: "#1641C5"} nodes: scene: shape: composite parts: - id: db shape: cylinder # picks up the cylinder shape defaults style: palette: {top: "#FCD34D"} # ⇐ only override top; keep blue sides ... ``` Final palette for `db`: * top: `#FCD34D` (from part.style — wins) * left: `#0D32B2` (from theme.shapes.cylinder — wins over theme) * right: `#1641C5` (from theme.shapes.cylinder) ## Stroke and effects cascade the same way Every sub-block follows the same rule. You can layer a theme that sets only `stroke.color` with a per-shape default that sets only `effects.cornerRadius` with a per-part override of `palette.top` — all four sub-blocks merge independently. ## When you don't set a theme Skip `theme:` entirely and parts use built-in defaults (white top, gray sides, dark stroke, Inter font). This is fine for prototyping; introduce a theme block when you want brand-consistent output. ## When you want NO style at all Set `style: null` on a part to start from raw defaults, ignoring the theme. Rarely needed — usually you want some inheritance. ## See also * [DSL\_YAML.md](../reference/dsl-yaml.md) — where `style` blocks slot into a Document * [DSL\_D2.md](../reference/dsl-d2.md) — how d2 styles translate (d2 has its own theme system; we map a subset of its fill/stroke into our cascade) * `isotopo capabilities | jq '.style_keys'` — programmatic source of truth for every field name listed above # YAML and JSON Source: https://iso-topology.mintlify.app/reference/dsl-yaml The composition language for shapes, groups, placement, and connections. The YAML DSL gives you **designer-grade iso composition** — styled boards, hero scenes, stairs, infographics — without hand-computed coordinates: parts are positioned **declaratively** with `layout` (container arrangement) and `place` (sibling relations), and the solver turns relations into world coordinates. Explicit `(wx, wy, wz)` offsets remain available as a fine-tune escape hatch. For auto-layout from a textual graph, see [DSL\_D2.md](../reference/dsl-d2.md) instead. JSON is also accepted (same shape, JSON encoding); the parser sniffs the leading byte. We document YAML below. ## Document structure ```yaml theme={null} canvas: # optional — page-level backdrop ... theme: # optional — document-wide default style ... nodes: # required — map of node id → Node scene: shape: composite parts: - ... connectors: - ... annotations: # optional — screen-space callouts - ... ``` Every full scene typically has exactly one node named **`scene`** with `shape: composite`. That node holds all the visible parts. The library also accepts a single-node document with any name — see [USAGE.md](../reference/cli.md) for `doc.Scene()`. ## Canvas ```yaml theme={null} canvas: background: "#FAFBFC" # solid color behind the scene grid: iso # iso | dots | hatch | solid | none gridColor: "#E2E6EE" # pattern stroke / dot color gridStep: 40 # tile size in world units (default 40) projection: iso # iso (default) | top ``` `grid: iso` draws a dashed-diamond ground plane that aligns with the iso projection — gives the scene a sense of place. Skip the field entirely to get a transparent background. `projection: top` renders a flat **top-down plan view** instead of the 2.5D isometric one — each part as its footprint rectangle with orthogonal connectors, height dropped. Use it to read the layout/flow as a normal 2D diagram. Default (`iso`, or omitting the key) is unchanged. The CLI `--projection` flag and the Studio view toggle override it per-render without touching the source. `gridStep` doubles as the **cell** unit used by `layout`/`place` gaps, so arranged parts and orthogonal connectors land exactly on the grid texture. ## Theme A theme is a default `Style` plus optional per-shape overrides: ```yaml theme={null} theme: palette: # default face colors for every part top: "#FFFFFF" left: "#7E94B5" right: "#A6B7D2" stroke: {color: "#1F2937", width: 1.2} text: {family: Inter, size: 12, weight: "600", color: "#1F2937"} shapes: # per-shape-type overrides (optional) cylinder: palette: {top: "#FFC", left: "#A82", right: "#D04"} person: palette: {top: "#EBC8A9", left: "#8E673E", right: "#A77F58"} ``` Resolution order for any part: **theme.Style → theme.shapes\[shape] → part.Style** (each step overrides matching fields, nils inherit). Full Style field reference: [DSL\_THEME.md](../reference/dsl-theme.md). ## Node A `Node` is the top-level element. The most useful shape is `composite` (a container holding `Parts` + optional `Connectors`): ```yaml theme={null} nodes: scene: shape: composite parts: - {id: a, shape: rectangle, geom: {w: 100, d: 100, h: 30}, label: A} connectors: - {from: a, to: b, arrow: triangle} ``` A non-composite Node (just `shape: cylinder` etc.) renders a single iso element — convenient for sticker-sheet style outputs. ## CompositePart One entry inside `nodes.scene.parts`: ```yaml theme={null} - id: api shape: rectangle geom: w: 140 # iso world X extent d: 90 # iso world Y extent (depth into screen) h: 30 # iso world Z extent (extrusion height) place: rightOf: gateway # position = solved from a sibling relation gap: 2 # in cells label: "API Gateway" icon: "iso://brand/kafka" # iso:// ref, http(s) URL, data: URI, or a # local image path (./logo.svg — inlined at render) style: palette: {top: "#3B82F6"} text: {color: "#FFFFFF"} ``` Sizes (`geom`) are explicit and semantic — heroes big, tiles small. Positions come from `place`/`layout` (next section); an `offset: {wx, wy, wz}` field is applied as a **delta on top** of the solved position (or as the absolute position when no relation is given — legacy documents render unchanged). The world axes form a left-handed iso frame: * **wx** runs to the right and down (after projection) — `rightOf` moves along +wx, * **wy** runs to the left and down — `inFrontOf` moves along +wy (toward the viewer), * **wz** is vertical screen up. For a beginner, just remember: `behind` = away from the viewer (smaller wy), `inFrontOf` = toward the viewer (bigger wy), and bigger wz = higher off the ground. ## Shapes Native iso shapes (see `isotopo capabilities`): | Shape | Looks like | | - | - | | `rectangle` | iso box with top/left/right faces | | `cylinder` | iso cylinder; also covers `queue`, `stored_data` | | `circle` | round disc with depth | | `cloud` | rounded blob; flat top with depth | | `person` | cylinder body + round head | | `iso_text` | low-extrusion label panel | | `composite` | container; needs `parts` | | `group` | composition primitive — see below | ## Composition primitives ### `place` — position relative to a sibling The default way to position free-standing parts. One constraint per ground axis; the solver resolves chains topologically: ```yaml theme={null} - id: gateway shape: rectangle # no place → anchors the scene geom: {w: 100, d: 100, h: 90} - id: api shape: rectangle place: {rightOf: gateway, gap: 2} # 2 cells to gateway's +x side geom: {w: 120, d: 80, h: 30} - id: s2 shape: rectangle place: {rightOf: api, inFrontOf: api, gap: 0} # corner-to-corner stair geom: {w: 120, d: 80, h: 30} ``` | Field | Meaning | | - | - | | `rightOf` / `leftOf` | pin world x to the sibling's +x / −x side (mutually exclusive) | | `inFrontOf` / `behind` | pin world y toward / away from the viewer (mutually exclusive) | | `above` | pin world z — sit flush ON TOP of the sibling, centred on its footprint unless x/y are also pinned | | `gap` | distance from the sibling's footprint, in cells (default 1) | | `gapX` / `gapY` | override `gap` per ground axis | | `align` | `start` \| `center` \| `end` — alignment along the unconstrained axis (default center) | References must be **siblings** (same `parts:` list). Dangling refs and cycles are validation errors; if the solved scene still has overlapping siblings you get a warning naming the exact pair. ### `layout` — auto-arrange a container's children On a `group` (or the root composite). Children need no positions at all; a layout group's substrate **auto-sizes** around the content, so its `geom.w/d` may be omitted: ```yaml theme={null} - id: board shape: group label: "Dashboard" geom: {h: 20} # thickness only layout: {mode: grid, cols: 3, gap: 0.5, padding: 0.7} parts: - {id: c1, shape: rectangle, geom: {w: 90, d: 90, h: 4}, label: test} - {id: c2, shape: rectangle, geom: {w: 90, d: 90, h: 4}, label: dev} # … ``` | Field | Meaning | | - | - | | `mode` | `row` (world +x) \| `column` (world +y) \| `grid` (row-major wrap) \| `ring` (first child = hub at the centre, rest on a circle) | | `cols` | grid only; default ceil(√n) | | `gap` | space between children, in cells (default 1) | | `padding` | content inset from the container edge, in cells (defaults to gap) | | `align` | cross-axis alignment within each track (default center) | ### `group` — labeled translucent container Wrap N parts in a translucent rounded substrate with a corner label. Position children with `layout` (above) or per-child `place`; the substrate auto-sizes around the solved content: ```yaml theme={null} - id: cluster shape: group label: "Kubernetes Cluster" layout: {mode: row, gap: 1.5} parts: - {id: api, shape: rectangle, geom: {w: 120, d: 80, h: 30}} - {id: worker, shape: rectangle, geom: {w: 120, d: 80, h: 30}} ``` Groups nest unboundedly — a group inside a group inside a group works; inner groups are solved first so outer layouts see their final footprint. Hand-positioned children (`offset` relative to the group, no auto-size) remain supported for legacy documents. ### `stack` — auto-replicate N times Stamp a part N times vertically with a Z-gap. The bottom copy keeps the declared id; layers above are suffixed `~1`, `~2`, …: ```yaml theme={null} - id: pods shape: rectangle geom: {w: 110, d: 110, h: 36} stack: {count: 3, gap: 10} # 3 layers, 10 units apart in Z label: "Pods" ``` Connectors and annotations can target a specific layer: ```yaml theme={null} - {from: ingress, to: pods} # bottom layer - {from: ingress, to: pods~2} # top layer ``` If `gap` is omitted, the renderer defaults to `part.geom.h + 4` so layers visually sit atop each other. ### `connectors` — directed lines between parts ```yaml theme={null} nodes: scene: shape: composite parts: [...] connectors: - from: api # part id (or "id.anchor" — see below) to: db routing: orthogonal # straight (default) | orthogonal | bezier arrow: triangle # none (default) | triangle label: "query" stroke: {color: "#1F2937", width: 1.2, dash: "4 3"} ``` For a measured multiline label, set `labelFontFamily: Source Sans Pro`. `labelFontWeight` supports `400`, `600` (default), `700`, `normal`, and `bold`; `labelFontSize` defaults to 13 in iso and 10 in top. The embedded resource is shared by measurement and SVG/PNG output. Explicit newlines, blank lines and spaces are preserved when the entire block is supported. Other families or unsupported scripts retain estimated browser-font geometry. Unset font fields preserve legacy output; an explicit size is now also honored in top projection. Measured pills are placed after all routes and arrows are known. The whole previous AABB arrangement is retained first. Already-clear labels keep their exact anchors; blocked labels search continuous intervals on their own path while protecting every other label's current box. Body and arrow bounding boxes remain conservative. Other routes use swept stroke envelopes that include round caps/joins and a bounded quadratic approximation error, releasing the empty corners of diagonal route boxes. An already-safe position keeps its existing exact curve-bounds certificate, including when the curve subdivision cap makes the new envelope more conservative. Clearance is 6 SVG units. Substrates are excluded as body obstacles; their content, effects and arbitrary-font ink are outside this clearance scope. Quadratic labels stay on the submitted curve. Initial placement is deterministic and greedy. If individual refinement finds no feasible interval, the full text keeps its baseline anchor (the path midpoint when the baseline search itself failed). Final status is checked against the whole arrangement; `render --report` records a `connector-label-placement` warning for every affected measured label. Increase spacing or adjust the route; the renderer never silently shrinks or truncates the text to make it fit. For eligible iso scenes, an additional pass can release empty corners of body bounding boxes. It combines filled shape faces, actual stroke widths and curve error with measured node text and icon bounds; concave notches remain usable where no real outline crosses them. Only still-blocked labels move, and every peer pill, foreign route and arrow remains protected. Successful labels declare a `body-region,content-quad,...` scope in the report. This also protects measured substrate content, while allowing labels on substrate fills. The extra pass requires bounded content throughout the scene; unknown fonts or shapes, screen labels, annotations, legacy connector pills and unsupported effects retain the previous layout. Person/circle captions and cloud labels without icons now use embedded Source Sans Pro metrics for complete supported blocks at weights 400, 600 and 700. These captions preserve authored newlines and font size, expanding the export canvas when needed; this does not certify that a long caption fits inside its node or avoids other objects. Cloud captions without icons keep their original centroid; clouds with icons retain the existing bounded face layout. A partially unsupported block still falls back. See the [API clearance contract](./api.md#measured-connector-labels) for the exact scope and fallbacks. No route, font size or authored text is changed by this refinement. The optional pass has bounded search work; if its budget runs out, unresolved labels retain their prior position and status. A subsequent bounded joint pass can release labels that block each other's available slots. Already-clear labels stay fixed. Still-blocked labels may share a corridor by choosing different safe positions on their existing routes; every changed label must clear all final peer positions, including labels that remain unresolved. It preserves node/route geometry and full text, and retains only fully checked assignments if the work budget runs out. Its finite choices do not guarantee a globally optimal packing. Unknown iso content/effects, legacy pills, and plan node titles using ambient fonts retain the previous arrangement. `render --report` identifies the body, route, arrow or peer label involved in each measured blockage. Its structured `typography.conflicts` list retains connection indexes (including parallel edges) and a conservative `clearance_shortfall` in SVG units. This measures intrusion into the declared 6-unit safety gap; it is not pixel overlap area or a complete relocation solution. Routing repair cannot trade an existing blockage for a new counterpart or deepen its measured shortfall merely because total readability cost decreases. Detailed lists are collected for reports and repair candidates; ordinary `RenderScene` previews keep the same geometry without that payload. `orthogonal` bends along the iso ground axes (every segment projects to exactly ±30°, in register with `canvas.grid: iso`) — prefer it for architecture flows. `bezier` draws a single soft quadratic arc — reads as async/replication traffic. **Manual routing** (usually written by the Studio edge-drag, but valid to hand-author): an orthogonal connector may carry explicit interior corners so the line takes a path you choose while both endpoints stay docked to their nodes: ```yaml theme={null} - from: api to: db routing: orthogonal waypoints: [{wx: 120, wy: -40}, {wx: 120, wy: 60}] # world corners, source→target ``` `waypoints` is a list of interior corner points in world coordinates (source → target); the route threads through them and is kept iso-axis-aligned. `bend: {wx, wy}` is the older single-corner form (a world offset applied to the auto route's interior); `waypoints` supersedes it. For a route that preserves **height**, use `waypoints3d` (Studio segment drags now write this field): ```yaml theme={null} - from: api.port-right to: db.port-left routing: orthogonal2 waypoints3d: - {wx: 120, wy: 40, wz: 0} - {wx: 120, wy: 40, wz: 80} - {wx: 200, wy: 40, wz: 80} ``` These are interior **absolute world XYZ** positions, in source-to-target order; an omitted coordinate means zero, including `wz`. Endpoints are resolved from `from` and `to` at their current heights. When consecutive points differ on multiple axes, the route inserts bridges in X, then Y, then Z order. Authored corners are retained. Plan view uses the XY projection of the same intent and collapses vertical legs. A nonempty `waypoints3d` overrides `waypoints`, `bend` and automatic routing. The old fields retain their original semantics and can remain in the source; removing `waypoints3d` restores that earlier intent. This field is active with `orthogonal` or `orthogonal2`; switching to straight or Bezier keeps the saved corners inactive and reports a warning, so switching back restores them. It allows at most 512 interior corners and finite coordinates within the geometry bounds. Explicit routes can cross bodies; choosing corners is not an obstacle-clearance guarantee. Legacy anchor selectors include `api.right-mid`, `api.top`, and `api.front`. Orthogonal routes choose a facing side for bare IDs; straight and bezier routes start from the default top centre. For a fixed visible attachment, use `api.port-left`, `api.port-back`, `api.port-right`, or `api.port-front`. Studio creates these references with `orthogonal2` routing. The renderer and editor resolve the same body boundary, and fan-out never moves the selected endpoint. In plan view, the same names attach to footprint-side midpoints. A stack instance remains addressable, for example `workers~2.port-right`; plan view shows its connection on the collapsed stack footprint. Fixed ports describe projected contour attachments, not physical 3D face centres. Array/rack shapes currently use a semantic envelope; text-only parts use legacy anchors. A forced straight line, curve, bend or waypoint can still cross a body: preserving an endpoint does not prove route visibility or obstacle clearance. Multiple edges on one fixed port share a junction; use bare IDs when automatic endpoint distribution is desired. ### `annotations` — screen-space callouts Document-level field (NOT inside a node): ```yaml theme={null} annotations: - anchor: api # id of a part to point at side: right # top | right | bottom | left distance: 60 # world-unit gap from part silhouette text: "p95 < 100ms\nregion edge" bg: "#FFFFFF" border: "#1F2937" fontSize: 11 ``` The renderer draws a rounded text box with a dashed leader line back to the anchor part. Multi-line text is supported via `\n`. Annotation boxes and `orient: screen` labels are **collision-managed**: they never cross or touch a part's projected silhouette (6px clearance), never overlap each other, and when the requested spot is occupied the search escalates distance WITHIN the preferred direction first (so sibling labels keep a consistent relative position) before falling to the next side, peripheral sides first. The leader line starts on the silhouette edge FACING the box — never across the node's own body. `side`/`distance` are treated as the preferred candidate, not an absolute position. ## Worked example Note: not a single hand-written coordinate. ```yaml theme={null} canvas: background: "#FAFBFC" grid: iso gridColor: "#E2E6EE" gridStep: 40 theme: palette: {top: "#FFFFFF", left: "#7E94B5", right: "#A6B7D2"} stroke: {color: "#1F2937", width: 1.2} text: {family: Inter, size: 12, weight: "600", color: "#1F2937"} nodes: scene: shape: composite parts: - id: cluster shape: group label: "Cluster" layout: {mode: row, gap: 1.5} parts: - id: api shape: rectangle geom: {w: 120, d: 80, h: 30} label: "API" - id: pods shape: rectangle geom: {w: 100, d: 100, h: 30} label: "Pods" stack: {count: 3, gap: 10} - id: ingress shape: cloud place: {behind: cluster, gap: 2} geom: {w: 120, d: 80, h: 24} label: "Ingress" connectors: - {from: ingress, to: cluster, routing: orthogonal, arrow: triangle} - {from: api, to: pods, routing: orthogonal, arrow: triangle} annotations: - anchor: pods~2 side: right text: "3 replicas" fontSize: 11 ``` Render with: ```bash theme={null} isotopo render scene.yaml ./out open ./out/topology.html ``` ## Round-trip-able per-part fragments For every CompositePart with an `id`, the CLI emits `out/nodes/.yaml` — a self-contained Document with just that one part. Drop it back into `isotopo render` to get the standalone iso of that single element. Useful for building icon libraries from a scene. See [OUTPUTS.md](../reference/output-layout.md) for the full output structure. ## Validating before render ```bash theme={null} isotopo validate scene.yaml ``` Catches: unknown shape names ("did you mean cylinder?"), annotation anchors that don't resolve, missing connector targets, bad grid mode names — plus the layout solver's dry run: dangling `place` refs (with nearest-id suggestion), `place` cycles, conflicting constraints, and post-solve sibling overlaps (warning naming the exact pair and overlap size). JSON output with paths is ready for agent self-correction. Exit codes: 0 clean, 2 warnings only, 3 errors. # Output files Source: https://iso-topology.mintlify.app/reference/output-layout Understand topology files, per-node assets, and export formats. `isotopo render ` produces a two-tier output tree: 1. **Topology level** — the whole scene as one SVG + an editable HTML page 2. **Node level** — each individual element as a standalone SVG + an embed-ready HTML page + a self-contained YAML fragment ## Directory layout ``` / ├── topology.svg ← the full scene SVG (drop into anywhere) ├── topology.html ← embed snippet + editable DSL source ├── topology.{yaml,d2,json} ← source copy (rename + edit + re-render) └── nodes/ ├── _index.html ← gallery of every per-element SVG ├── .svg ← standalone SVG of one element ├── .html ← embed snippet + per-element YAML fragment └── .yaml ← per-element DSL fragment (re-renderable) ``` For a scene with N atomic elements, you get `1 + (3 × N) + 2` files. ## Topology-level files ### `topology.svg` The whole scene as a single self-contained SVG. Drop it into any markdown / Notion / Confluence / Figma — they all accept SVG. The SVG carries: * viewBox sized to fit the scene + a margin * defs block with any iso patterns (ground grid, etc.) * one `` for the background * one `` for arrows * one `` per iso element * one `` for screen-oriented labels * one `` for callouts You can post-process by selecting those layers in CSS or another tool. ### `topology.html` A self-contained HTML page that: * shows `topology.svg` in a stage * shows the embed snippet (``) with a copy button * shows the original DSL source in an editable `