feat(diagram-engine): open shape vocabulary — catalog + pass-through, structured style merge

The expressiveness gap traced to vocabulary: draw.io has hundreds of shapes,
the declarative layer allowed six. Opening it, with the failure modes from
design review handled:

- shapes.ts: a ~20-entry curated catalog (full style fragment incl. the
  matching perimeter — required or edges connect to the bounding box; a
  text-scale factor verified in the real editor — the same sentence overflows
  a 1.0x rhombus and fits a 1.5x one; labelOutside+glyph for umlActor-style
  figures). Any other token passes through verbatim: draw.io degrades unknown
  shapes to rectangles safely (verified). Injection-capable tokens (;/=) are
  rejected outright.
- Pass-through emits a WARNING with a nearest-catalog hint (bounded edit
  distance), so a typo'd 'cyclinder' is a one-turn fix instead of a silently
  rectangular node forever. set_shape/set_role/set_group operations make the
  fix possible without remove+re-add (which would drop links).
- mergeStyle(): style fragments merge per-key (later wins, bare shape classes
  displace each other) instead of string concatenation. This is what makes
  shape and theme composable by rule — shape owns geometry keys, theme owns
  colour/type keys, and an overlap resolves by order instead of emitting
  contradictory duplicates.
- dai_shape marker carries the declared token through the round trip:
  appearance-based reverse mapping cannot distinguish aliases (diamond vs
  decision) or a rotated queue from a cylinder.
- dai_auto marker separates engine-measured size from user-fixed size: parsed
  boxes no longer freeze the first layout's numbers, so changing a label
  re-measures. Pinned nodes keep everything, as before.
- draw_graph's closed shape enum opened to match (first instance of the
  schema-drift problem the review predicted).

14 new tests: merge ownership, injection rejection, near-match hints,
alias-preserving round trip, re-measure on label change, mxgraph.* tokens
staying boxes with role/group intact. 556 unit tests green; acceptance
diagram (person/hexagon/cylinder/queue/cloud/decision/callout) verified in
the real editor.
This commit is contained in:
dayuan.jiang
2026-08-09 21:56:09 +09:00
parent db9db1ff4e
commit 50826c0ac8
11 changed files with 644 additions and 75 deletions

View File

@@ -25,7 +25,8 @@ import {
} from "./operations"
import { parseDiagram } from "./parse"
import { renderDiagram } from "./render"
import type { DiagramTree } from "./types"
import { nearestShape, resolveShape } from "./shapes"
import { type DiagramTree, walkTree } from "./types"
export interface RestructureResult {
/** New canvas XML, or null when the request could not be carried out. */
@@ -85,6 +86,24 @@ export function restructureDiagram(
)
}
// Shape tokens: an injection-capable token is an error; an unknown-but-safe one
// passes through (draw.io degrades it to a rectangle) but gets a warning, so a typo
// is a one-turn fix instead of a silently rectangular "cyclinder" forever.
for (const n of walkTree(applied.tree)) {
if (n.kind !== "box" || !n.shape || n.shape === "box") continue
const resolved = resolveShape(n.shape)
if (!resolved) {
errors.push(
`shape "${n.shape}" (node ${n.id}) contains characters that are not allowed in a shape token.`,
)
} else if (resolved.passthrough) {
const near = nearestShape(n.shape)
warnings.push(
`shape "${n.shape}" (node ${n.id}) is not in the engine's catalog — passed through to draw.io, which renders unknown shapes as rectangles.${near ? ` Did you mean "${near}"?` : ""}`,
)
}
}
if (errors.length > 0)
return { xml: null, outline: outline(applied.tree), errors, warnings }

View File

@@ -28,6 +28,7 @@
* `pool()` primitive; sequence and radial are original to this repository.
*/
import { resolveShape } from "./shapes"
import { type Role, roleMetrics } from "./theme"
import type {
ContainerNode,
@@ -212,7 +213,18 @@ function visibleText(label: string): string {
export function autoBoxSize(
label: string,
role?: Role,
shape?: string,
): { w: number; h: number } {
const spec = shape ? resolveShape(shape)?.spec : undefined
// A glyph shape (umlActor…) has a fixed figure with the label below it: the slot is
// the figure plus a line of text, and the text length does not scale the figure.
if (spec?.labelOutside && spec.glyph) {
const text = visibleText(label)
return {
w: Math.max(spec.glyph.w + 20, Math.min(160, text.length * 7 + 16)),
h: spec.glyph.h + 22,
}
}
const r = roleMetrics(role)
const maxW = Math.round(260 * Math.max(1, r.charScale))
const explicit = visibleText(label).split("\n")
@@ -233,7 +245,13 @@ export function autoBoxSize(
0,
)
const lineH = Math.round(r.fontSize * 1.6)
return { w, h: Math.max(r.minH, lines * lineH + 26) }
const h = Math.max(r.minH, lines * lineH + 26)
// A non-rectangular outline inscribes a smaller text area than its bounding box —
// a rhombus exactly half — so the box grows by the shape's measured factor.
// Verified in the real editor: the same sentence overflows a 1.0× rhombus and fits
// a 1.5× one.
const s = spec?.textScale ?? 1
return { w: Math.round(w * s), h: Math.round(h * s) }
}
/**
@@ -432,7 +450,7 @@ function measure(
return { node: n, rect: { x: 0, y: 0, ...s }, children: [] }
}
if (n.kind === "box") {
const auto = autoBoxSize(n.label, n.role)
const auto = autoBoxSize(n.label, n.role, n.shape)
return {
node: n,
rect: { x: 0, y: 0, w: n.w ?? auto.w, h: n.h ?? auto.h },

View File

@@ -64,6 +64,18 @@ export const MARKER = {
role: "dai_role",
/** The node's semantic zone, whose hue ramp colours it. */
group: "dai_group",
/**
* The declared shape token, verbatim. Appearance-based reverse mapping is ambiguous
* (aliases, rotated variants, styles with no unique shape= token), so the round trip
* carries the declaration itself.
*/
shape: "dai_shape",
/**
* Marks a node's size as engine-computed rather than user-fixed. Without it, the
* w/h read back from the canvas would freeze the first layout's measurement: change
* the label and the box would keep the old size instead of re-measuring.
*/
auto: "dai_auto",
/** Share of the parent's leftover flow-axis space — flex-grow. */
grow: "dai_grow",
/** Cross-axis position within the parent: "start" | "center" | "end". */
@@ -388,6 +400,21 @@ export function stampGroup(style: string, group: string): string {
return append(style, MARKER.group, encodeURIComponent(group))
}
/** Stamp the declared shape token, so the round trip carries the declaration itself. */
export function stampShape(style: string, shape: string): string {
return append(style, MARKER.shape, encodeURIComponent(shape))
}
/** Mark a node's size as engine-computed, so a re-layout re-measures it. */
export function stampAuto(style: string): string {
return append(style, MARKER.auto, 1)
}
/** Was this node's size computed by the engine (vs fixed by the user or the model)? */
export function isAutoSized(style: string): boolean {
return readMarker(style, MARKER.auto) === "1"
}
type FlexAlign = "start" | "center" | "end" | "stretch"
/** Stamp the flex fields a node carries, so a round-trip preserves them. */

View File

@@ -86,17 +86,10 @@ export const OperationSchema = z.discriminatedUnion("op", [
.optional()
.describe("Border colour; pair it with fill"),
shape: z
.enum([
"box",
"decision",
"terminator",
"round",
"data",
"document",
])
.string()
.optional()
.describe(
"Flowchart outline: decision=diamond, terminator=start/end, data=input/output, document=report. Omit for a plain rectangle",
"What the node IS, drawn as its conventional outline. Catalog: decision/diamond, terminator (start/end), round, data (input/output), document, cylinder (database), queue, person (actor/user), cloud (external system), hexagon (service), ellipse (concept), callout (note), step (pipeline stage), note, card, process, tape, cube. Any other draw.io shape token also works verbatim. Omit for a plain rectangle",
),
grow: z
.number()
@@ -261,6 +254,36 @@ export const OperationSchema = z.discriminatedUnion("op", [
id: z.string(),
label: z.string(),
}),
z.object({
op: z.literal("set_shape"),
id: z.string(),
shape: z
.string()
.describe("New shape token; 'box' resets to a plain rectangle"),
}),
z.object({
op: z.literal("set_role"),
id: z.string(),
role: z
.enum([
"banner",
"heading",
"body",
"callout",
"good",
"bad",
"metric",
"muted",
])
.describe("New information role; 'body' resets to the default"),
}),
z.object({
op: z.literal("set_group"),
id: z.string(),
group: z
.string()
.describe("New semantic zone; empty string removes the zone"),
}),
z.object({
op: z.literal("set_dir"),
id: z.string().describe("Container to re-orient"),
@@ -579,6 +602,50 @@ export function applyOperations(
break
}
case "set_shape": {
const node = findNode(tree, op.id)
if (!node || node.kind !== "box") {
errors.push(`set_shape: "${op.id}" is not a box`)
break
}
// The verbatim style is last render's composition with the OLD shape
// baked in; keeping it would override the new declaration entirely.
if (op.shape === "box") delete node.shape
else node.shape = op.shape
delete node.style
delete node.w
delete node.h
break
}
case "set_role": {
const node = findNode(tree, op.id)
if (!node || (node.kind !== "box" && node.kind !== "group")) {
errors.push(
`set_role: "${op.id}" is not a box or container`,
)
break
}
if (op.role === "body") delete node.role
else node.role = op.role
delete node.style
break
}
case "set_group": {
const node = findNode(tree, op.id)
if (!node || (node.kind !== "box" && node.kind !== "group")) {
errors.push(
`set_group: "${op.id}" is not a box or container`,
)
break
}
if (op.group === "") delete node.group
else node.group = op.group
delete node.style
break
}
case "set_dir": {
const node = findNode(tree, op.id)
if (!node || !isContainer(node)) {

View File

@@ -25,6 +25,7 @@ import { extractDiagramXML } from "@/lib/utils"
import {
type Direction,
hasMarkers,
isAutoSized,
isLaneChrome,
isPinned,
MARKER,
@@ -361,6 +362,12 @@ function zoneOf(style: string): string | undefined {
return v ? decodeURIComponent(v) : undefined
}
/** The declared shape token stamped on a cell, if any. */
function shapeOf(style: string): string | undefined {
const v = readMarker(style, MARKER.shape)
return v ? decodeURIComponent(v) : undefined
}
/** The flex fields stamped on a cell, as sparse properties ready to spread. */
function flexOf(style: string): {
grow?: number
@@ -1069,19 +1076,29 @@ export function parseDiagram(xml: string, pageIndex = 0): ParseResult {
const headH = lifeline
? Number(styleValue(c.style, "size") ?? "44")
: undefined
// An engine-measured box must NOT read its size back as a fixed size:
// that would freeze the first layout's measurement, so a later label or
// shape change would keep the stale box instead of re-measuring. A pinned
// node is the exception — the user froze it, geometry and all.
const auto = isAutoSized(c.style) && !pinned
const node: BoxNode = {
kind: "box",
id: c.id,
label: c.value,
w: c.geo ? Math.round(c.geo.w) : undefined,
w: auto ? undefined : c.geo ? Math.round(c.geo.w) : undefined,
h: lifeline
? Math.round(headH && headH > 0 ? headH : 44)
: c.geo
? Math.round(c.geo.h)
: undefined,
: auto
? undefined
: c.geo
? Math.round(c.geo.h)
: undefined,
fill: styleValue(c.style, "fillColor"),
stroke: styleValue(c.style, "strokeColor"),
shape: boxShape(c.style),
// The declared token wins: appearance-based reverse mapping cannot
// distinguish aliases (decision vs diamond) or identify a passed-through
// token whose style is just `shape=<name>`.
shape: shapeOf(c.style) ?? boxShape(c.style),
role: roleOf(c.style),
group: zoneOf(c.style),
...flexOf(c.style),

View File

@@ -23,6 +23,7 @@ import {
} from "./layout"
import {
isInvisible,
stampAuto,
stampCell,
stampContainer,
stampFlex,
@@ -34,11 +35,12 @@ import {
stampRadial,
stampRole,
stampSequence,
stampShape,
} from "./markers"
import { type RoutedEdge, routeEdges } from "./route"
import { mergeStyle, resolveShape } from "./shapes"
import { hueOf, NEUTRAL, themedStyle } from "./theme"
import {
type BoxShape,
type DiagramNode,
type DiagramTree,
isContainer,
@@ -88,27 +90,6 @@ const TITLE_STYLE =
const EDGE_STYLE =
"edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;jettySize=auto;orthogonalLoop=1;fontSize=10;fontColor=light-dark(#1B2733,#CFE0F0);strokeColor=light-dark(#1A1A1A,#E0E0E0);strokeWidth=1;"
/**
* Flowchart outlines, as mxGraph draws them.
*
* All six are core mxGraph shapes, not stencils from a shape library, so they render
* without the catalog and without any extra dependency. The notation is conventional: a
* reader takes a diamond to mean a branch and a stadium to mean a start or end point, so
* drawing every step as the same rectangle loses information the shape was carrying.
*/
const BOX_SHAPES: Record<BoxShape, string> = {
box: "rounded=0;",
round: "rounded=1;arcSize=12;",
/** Decision — a diamond. */
decision: "rhombus;",
/** Start or end — a stadium. draw.io draws `rounded=1` at arcSize 50 as a full stadium. */
terminator: "rounded=1;arcSize=50;",
/** Input or output — a parallelogram. */
data: "shape=parallelogram;perimeter=parallelogramPerimeter;fixedSize=1;size=14;",
/** A document or report — a rectangle with a wavy bottom edge. */
document: "shape=document;boundedLbl=1;",
}
// ---- swimlane pool chrome ----
/** Hairline between lane bands: present, but quieter than the shapes sitting on it. */
@@ -172,21 +153,34 @@ function styleFor(
}
if (n.kind === "box") {
let base = n.style ?? FALLBACK_BOX
if (!n.style) {
// Order matters, later keys win in draw.io: outline, then the theme's
// composition of role x hue, then explicit colours on top of everything.
if (n.shape) base += BOX_SHAPES[n.shape]
if (n.role || n.group)
base += themedStyle(n.role ?? "body", hue(n.group), "leaf")
if (n.fill) base += `fillColor=${n.fill};`
if (n.stroke) base += `strokeColor=${n.stroke};`
if (n.bold) base += "fontStyle=1;"
let base: string
if (n.style) {
base = n.style
} else {
// Structured merge, ownership by fragment order: the fallback's neutral
// look, the shape's geometry keys, the theme's colour/type keys, explicit
// colours last. Each key ends up in the style exactly once — a theme that
// says rounded=1 cannot leave a contradictory duplicate on a rhombus.
const shape = n.shape ? resolveShape(n.shape) : null
base = mergeStyle(
FALLBACK_BOX,
shape?.spec.style,
n.role || n.group
? themedStyle(n.role ?? "body", hue(n.group), "leaf")
: undefined,
n.fill ? `fillColor=${n.fill};` : undefined,
n.stroke ? `strokeColor=${n.stroke};` : undefined,
n.bold ? "fontStyle=1;" : undefined,
)
}
let stamped = stampLeaf(base, "box")
if (n.shape && n.shape !== "box") stamped = stampShape(stamped, n.shape)
if (n.role && n.role !== "body") stamped = stampRole(stamped, n.role)
if (n.group) stamped = stampGroup(stamped, n.group)
stamped = stampFlex(stamped, { grow: n.grow, align: n.align })
// Engine-measured (no explicit w/h): mark it, so the parser re-measures next
// time instead of freezing this layout's numbers as a fixed size.
if (n.w == null && n.h == null) stamped = stampAuto(stamped)
return n.cell ? stampCell(stamped, n.cell) : stamped
}

View File

@@ -0,0 +1,235 @@
/**
* The shape vocabulary: what a box can BE, beyond a labelled rectangle.
*
* draw.io ships hundreds of shapes; the engine's declarative layer used to allow six.
* That gap — not colours, not spacing — was why engine output looked flat next to
* hand-written XML: a database drawn as a grey rectangle labelled "database" instead of
* a cylinder. This module opens the vocabulary in two tiers:
*
* CATALOG — ~20 curated shapes the engine fully understands. Each entry carries the
* complete style fragment (including the matching `perimeter=`, which draw.io's own
* style reference warns is required or edges connect to the bounding box), how much
* larger the box must be for its text to fit inside the non-rectangular outline
* (verified empirically in the real editor: a rhombus needs ~1.5× the rectangle's
* size for the same text), and whether the label renders below the glyph instead of
* inside it.
*
* PASS-THROUGH — any other token that looks like a draw.io shape name is emitted
* verbatim as `shape=<token>;`. Verified in the real editor: an unknown token
* degrades to a rectangle, it does not break the page. A conservative text scale
* covers the common case that the real shape is roughly convex. The tool response
* carries a near-match hint ("cyclinder → cylinder?") so a typo is a one-turn fix,
* not a silent permanent degradation.
*
* Style strings are merged structurally, not concatenated: each fragment is parsed to
* key=value tokens and later fragments override earlier ones per key. This is what
* makes shape and theme composable by rule — the shape fragment owns geometry keys
* (shape, perimeter, rounded…), the theme owns colour and type keys, and an overlap
* (a theme that says rounded=1 on a rhombus) resolves by order instead of emitting
* two conflicting tokens.
*/
/** How a known shape renders and measures. */
export interface ShapeSpec {
/** Geometry style tokens ONLY — no colours, no fonts; those belong to the theme. */
style: string
/**
* How much larger than a rectangle the box must be for the same text to fit
* inside the outline. 1.0 for the rectangle family; ~1.5 for a rhombus, whose
* inscribed rectangle is half its bounding box.
*/
textScale: number
/** The label renders below the glyph, not inside it (umlActor and friends). */
labelOutside?: boolean
/** Fixed glyph size for labelOutside shapes, which do not scale with text. */
glyph?: { w: number; h: number }
}
/**
* The curated catalog. Keys are the vocabulary the model is taught; several are
* semantic aliases for the same geometry (decision/diamond), because the model will
* reach for both names.
*/
export const SHAPE_CATALOG: Record<string, ShapeSpec> = {
// ---- the rectangle family (the original six) ----
box: { style: "rounded=0;", textScale: 1 },
round: { style: "rounded=1;arcSize=12;", textScale: 1 },
terminator: { style: "rounded=1;arcSize=50;", textScale: 1.15 },
decision: {
style: "rhombus;perimeter=rhombusPerimeter;",
textScale: 1.5,
},
diamond: {
style: "rhombus;perimeter=rhombusPerimeter;",
textScale: 1.5,
},
data: {
style: "shape=parallelogram;perimeter=parallelogramPerimeter;fixedSize=1;size=14;",
textScale: 1.2,
},
document: { style: "shape=document;boundedLbl=1;", textScale: 1.15 },
// ---- the semantic vocabulary (D2's tier: a node that IS a thing) ----
/** A database or datastore. */
cylinder: {
style: "shape=cylinder3;boundedLbl=1;backgroundOutline=1;size=12;",
textScale: 1.3,
},
/** A message queue: a cylinder on its side. */
queue: {
style: "shape=cylinder3;direction=south;boundedLbl=1;backgroundOutline=1;size=12;",
textScale: 1.3,
},
/** An actor or user. Label below the figure. */
person: {
style: "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;outlineConnect=0;",
textScale: 1,
labelOutside: true,
glyph: { w: 40, h: 60 },
},
/** An external system, the internet. */
cloud: { style: "ellipse;shape=cloud;", textScale: 1.6 },
/** A service or process step. */
hexagon: {
style: "shape=hexagon;perimeter=hexagonPerimeter2;fixedSize=1;size=16;",
textScale: 1.25,
},
/** A concept, state or category. */
ellipse: { style: "ellipse;", textScale: 1.3 },
/** A speech-bubble annotation. */
callout: {
style: "shape=callout;perimeter=calloutPerimeter;rounded=1;size=16;position=0.5;base=24;",
textScale: 1.35,
},
/** A chevron stage in a pipeline. */
step: {
style: "shape=step;perimeter=stepPerimeter;fixedSize=1;size=16;",
textScale: 1.2,
},
/** A sticky note. */
note: { style: "shape=note;size=14;", textScale: 1.1 },
/** A card with a cut corner. */
card: { style: "shape=card;size=14;", textScale: 1.1 },
/** A process box with side bars (predefined subroutine). */
process: { style: "shape=process;size=0.1;", textScale: 1.2 },
/** Punched tape — legacy data, files. */
tape: { style: "shape=tape;size=0.2;", textScale: 1.3 },
/** A double-walled cube. */
cube: { style: "shape=cube;size=12;", textScale: 1.25 },
}
/** A shape token that may pass through unrecognised: draw.io style-key charset only. */
const SAFE_TOKEN = /^[a-zA-Z0-9._]+$/
export interface ResolvedShape {
spec: ShapeSpec
/** Set when the token was not in the catalog and passed through verbatim. */
passthrough?: boolean
}
/**
* Resolve a shape token: catalog entry, safe pass-through, or null for a token that
* could inject style keys (`;`/`=`/quotes) and must be rejected outright.
*/
export function resolveShape(token: string): ResolvedShape | null {
const known = SHAPE_CATALOG[token]
if (known) return { spec: known }
if (!SAFE_TOKEN.test(token)) return null
// Unknown but safe: emit verbatim. draw.io degrades an unregistered shape to a
// rectangle, so the worst case is a plain box — same as before the vocabulary
// existed. The conservative scale covers roughly-convex real shapes.
return {
spec: { style: `shape=${token};`, textScale: 1.25 },
passthrough: true,
}
}
/** The catalog key most similar to a token, for "did you mean" hints. */
export function nearestShape(token: string): string | null {
const t = token.toLowerCase()
let best: string | null = null
let bestD = 3 // more than 2 edits away is not a typo
for (const key of Object.keys(SHAPE_CATALOG)) {
const d = editDistance(t, key.toLowerCase(), bestD)
if (d < bestD) {
bestD = d
best = key
}
}
return best
}
/** Bounded Levenshtein distance; returns limit when the strings are further apart. */
function editDistance(a: string, b: string, limit: number): number {
if (Math.abs(a.length - b.length) >= limit) return limit
const prev = new Array(b.length + 1)
for (let j = 0; j <= b.length; j++) prev[j] = j
for (let i = 1; i <= a.length; i++) {
let diag = prev[0]
prev[0] = i
let rowMin = prev[0]
for (let j = 1; j <= b.length; j++) {
const cur = Math.min(
prev[j] + 1,
prev[j - 1] + 1,
diag + (a[i - 1] === b[j - 1] ? 0 : 1),
)
diag = prev[j]
prev[j] = cur
if (cur < rowMin) rowMin = cur
}
if (rowMin >= limit) return limit
}
return Math.min(prev[b.length], limit)
}
// ---- structured style merge ----
/**
* Merge style fragments by key, later fragments winning.
*
* A draw.io style is `tok;key=value;key=value;` — bare class tokens (rhombus, ellipse,
* text) come first and key=value pairs follow. String concatenation made every
* conflict a duplicate key resolved by draw.io's last-wins rule, which worked until
* shape fragments and theme fragments both owned geometry keys (a theme's rounded=1
* against a shape's rhombus). Merging structurally keeps exactly one token per key and
* one bare-token set, so the output is canonical and the ownership rule — theme owns
* colour and type, shape owns geometry — is enforced by fragment ORDER, not by hoping
* the keys never meet.
*
* Bare tokens are kept in first-appearance order, except that a later fragment's bare
* SHAPE CLASS (rhombus/ellipse/triangle) replaces an earlier one — two shape classes
* on one cell is a contradiction, not a union.
*/
export function mergeStyle(...fragments: (string | undefined)[]): string {
const bare: string[] = []
const kv = new Map<string, string>()
const SHAPE_CLASSES = new Set(["rhombus", "ellipse", "triangle"])
for (const f of fragments) {
if (!f) continue
for (const tok of f.split(";")) {
if (tok === "") continue
const eq = tok.indexOf("=")
if (eq < 0) {
if (SHAPE_CLASSES.has(tok)) {
const i = bare.findIndex((b) => SHAPE_CLASSES.has(b))
if (i >= 0) bare.splice(i, 1)
}
if (!bare.includes(tok)) bare.push(tok)
continue
}
const key = tok.slice(0, eq)
kv.set(key, tok.slice(eq + 1))
// An explicit shape= also displaces a bare shape class from an earlier
// fragment — same contradiction as two bare classes.
if (key === "shape") {
const i = bare.findIndex((b) => SHAPE_CLASSES.has(b))
if (i >= 0) bare.splice(i, 1)
}
}
}
let out = bare.join(";")
if (out) out += ";"
for (const [k, v] of kv) out += `${k}=${v};`
return out
}

View File

@@ -32,19 +32,14 @@ export interface PoolCell {
export type Align = "start" | "center" | "end" | "stretch"
/**
* The outline a flowchart box is drawn with.
* What a box IS, drawn as its conventional outline.
*
* Flowchart notation is conventional, not decorative: a reader takes a diamond to mean a
* branch and a stadium to mean an entry or exit point. Rendering every step as the same
* rectangle throws that away.
* Open vocabulary: catalog names ("cylinder", "decision", "person"…) get full engine
* support — correct perimeter, text sized to fit the outline. Any other draw.io shape
* token passes through verbatim and degrades to a rectangle if the editor does not
* know it. See shapes.ts.
*/
export type BoxShape =
| "box"
| "decision"
| "terminator"
| "round"
| "data"
| "document"
export type BoxShape = string
/** A catalog icon: a real stencil, drawn at a fixed glyph size with a label below. */
export interface IconNode {

View File

@@ -73,7 +73,7 @@ parameters: {
tool name: draw_graph
description: Draw a flowchart, decision tree, dependency graph, ER diagram or site map from nodes and arrows alone. You give NO positions and NO nesting; the engine works out how many rows there are, who shares a row, and who goes left of whom, so arrows do not cross or run through unrelated boxes. Replaces the whole diagram — use restructure_diagram to edit afterwards.
parameters: {
nodes: Array<{id: string, label: string, shape?: "box"|"decision"|"terminator"|"round"|"data"|"document", icon?: string, group?: string}>
nodes: Array<{id: string, label: string, shape?: string, icon?: string, group?: string}>
edges: Array<{source: string, target: string, label?: string, dashed?: boolean}>
title?: string
flow?: "col" | "row" // col (default): top to bottom. row: left to right
@@ -166,10 +166,15 @@ Mind maps and org charts (add_radial):
- spread: "radial" for a mind map (branches on both sides, compact). "down" for an org chart
(everything below its manager, which is the only way a reporting line reads correctly).
Flowchart box shapes, for both draw_graph and add_box:
- "decision" for a branch (a diamond), "terminator" for a start or end point, "data" for input or
output, "document" for a report, "round" for a soft-edged step. Use them: a reader takes a
diamond to mean a choice, so drawing every step as the same rectangle loses that.
Box shapes, for both draw_graph and add_box — a shape says what a node IS:
- Flowchart: "decision" (a diamond) for a branch, "terminator" for a start or end point, "data"
for input or output, "document" for a report, "round" for a soft-edged step.
- Semantic: "cylinder" for a database, "queue" for a message queue, "person" for an actor or
user, "cloud" for an external system, "hexagon" for a service, "ellipse" for a concept,
"callout" for a note, "step" for a pipeline stage, "note", "card", "process", "tape", "cube".
- Any other draw.io shape token also works verbatim (unknown ones render as rectangles).
Use shapes: a database drawn as a cylinder needs no "database" caption; a reader takes a
diamond to mean a choice. Drawing everything as the same rectangle throws that away.
Core capabilities:
- Create professional flowcharts, mind maps, entity diagrams, and technical illustrations