How to add a new shape
A “shape” is a leaf primitive — rectangle, cylinder, cloud, etc. The shape ends up rendered byiso25d.Convert2DTo25D after going through
Flatten (in flatten.go) which produces an iso25d.ConvertOpts.
-
Implement the iso geometry — create
iso25d/shape_<name>.go:- Define
Iso<Name>Opts(just the parameters the geometry needs). - Implement
RenderIso<Name>(o Iso<Name>Opts) stringreturning an SVG fragment (no<svg>wrapper — that’s added by the composite). - Implement
apply<Name>(o ConvertOpts, target *Iso<Name>Opts)that lowers the dispatcher’sConvertOptsinto your opts.
- Define
-
Register in the dispatcher — edit
iso25d/shapes.go. Add acase "<name>":arm inConvert2DTo25Dthat callsapply<Name>thenRenderIso<Name>. -
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. -
Make Validate happy —
CapabilityReport()reads the catalog, so as long as you added tod2ShapeCatalog(or to the native shapes list incapabilities.go::buildShapeCaps), Validate will accept the new shape in YAML/D2 inputs. -
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) andshapeClass(which colour controls the shape gets: faces / outline / text / fill).studio/studio.js→optGlyph(a small inline-SVG glyph keyed'shape:<name>', so the tile is illustrated). Use only tokens the dispatcher actually accepts (isotopo capabilities→ shapes →accepted_as).
-
Add a golden test — create
samples/node/<name>/input.yaml(single-primitive) orsamples/topology/<name>-demo/input.yaml(multi-element scene), thengo test -update -run TestGolden/.*<name>to snapshot the expected SVG.
How to add a new DSL primitive
A “primitive” is a composition feature likegroup, stack,
annotation, canvas.grid. Primitives don’t map to a single iso
shape — they affect how parts get assembled.
-
Declare the data — add the field(s) to the right type in
dsl.gowith yaml/json tags. Use pointer types if the field is optional and should distinguish “missing” from “zero”. -
Lower or inject — choose one:
-
Lowering primitive (transforms the parts tree before
rendering — like
stackandgroup). Editlower.go: extendlowerCompositePartswith the new case. -
Layer primitive (post-rendered SVG overlay — like
canvas-gridandannotation). Editinject.go: add aninject<Name>function that splices a<g data-layer="...">into the rendered SVG.
-
Lowering primitive (transforms the parts tree before
rendering — like
-
Wire the renderer — edit
render.go::renderComposite. Decide the paint order (earlier-injected = behind everything because of how<svg>-after-tag splicing pushes prior insertions deeper). -
Describe it — edit
capabilities.go::buildPrimitiveCaps. Add aPrimitiveCapentry. This is the agent’s source of truth. -
Validate it — edit
validate.go. Add structural checks (does the anchor exist? is the side value valid? is the count positive?). Use the existingnearest()/nearestID()for “did you mean”. - 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):- Add a
schemaFieldto the relevant builder incmd/isotopo/main.go:nodeSchema(shape),edgeSchema(), orcanvasSchema(). Give it the dotted YAMLPath(e.g.style.palette.top), an EnglishLabel+Desc, aType(text|number|color|choice|icon), aGroup, andInline/Optionsas needed. - Reads and writes are automatic:
/api/fieldsreads the current value by dotted path;/api/editwrites it via the recursivesetField/setInInlineMapsurgery (creates missing intermediate maps). No per-field code. - For a
choicefield, add a glyph instudio/studio.js::optGlyph(keyed'<lastPathSegment-or-key>:<value>') and, if the stored value differs from the label, aoptLabelentry. - Keep it shape-appropriate:
nodeSchemaonly offers colour controls a shape can actually render (seeshapeClass) — don’t show a knob that does nothing.
How to add a new layout engine
d2 ships dagre and ELK. To add a third:- Implement a
d2graph.LayoutGraph(see d2 docs). - Edit
compile.go::CompileD2. ExtendlayoutResolver. - Edit
dsl.go: add aLayoutXxx LayoutEngineconstant. - Edit
cmd/isotopo/main.go::parseFlagsto accept the new--layout xxx. - Edit
capabilities.go::Layoutsso agents discover it. - Edit
validate.goif the layout name appears in DSL anywhere (today it’s CLI-only, so no validate change needed). - Add a golden test under
samples/topology/<name>-layout/input.d2.
The agent loop
go test -update; unintended → revert the change.
Code structure cheat sheet
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 capabilitiesemits the full supported surface as JSON;tools/gen-docsregeneratesCAPABILITIES.md,dsl.schema.json,ICONS.md,llms.txt,SAMPLES.mdand patchesPROMPT_TEMPLATE.mdfrom code. Rungo run ./tools/gen-docsafter 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.goreturns did-you-mean suggestions and CI exit codes (0/2/3).
box/sphere/polygon once leaked into the shape picker):
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
*Gradientblocks 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.
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/<name>so re-generation matches the rest of the pipeline.