Skip to content

Design Solutions ​

Bake a @snaptrude/plugin-toolkit packer output onto the canvas as real, tagged Snaptrude components. Accessed via snaptrude.design.solutions.

A solution is not a preview. create runs the packer once, trims its cells against the optional envelope, and extrudes them as spaces through the same seam design.create.spaces uses — but the whole bake (cells, spine reference lines, and the hidden flag) rides one history entry, so a single undo removes the lot. Because the output is real geometry, everything else already measures it: design.query, the adjacency graph, ray casts, area schedules.

Membership is carried on the components in their persisted metadata, so the solution registry is rebuilt from the canvas on demand and survives save/reload. There is no in-memory bookkeeping to go stale, and no ghost canvas to keep in step.

Concepts ​

One plate, one storey. A solution is storey-agnostic in exactly the way the module it bakes is: the module runs once with spec.input in spec.unit, its cells are trimmed once against the optional spec.envelope (geom.polygonBoolean.intersectPolygons), and the result is baked onto the one storey named by spec.storey. A region already wholly inside that envelope skips the boolean entirely — the common case of spec.envelope equal to input.envelope, where the clip is a no-op. A clipped piece smaller than 1 m² is dropped and counted in audit.notes. A clip that leaves nothing at all is an error, not a quiet empty floor. Every surviving ring is sanitised before anything is extruded: finite numbers, no repeated closing point, no consecutive duplicates, three points or more, non-zero area — holes likewise.

Stacking is the copy tools, not the solution. To put the same plate on floors 3 to 6, bake it once on floor 2 and copy it up with core.storeys.copy or design.create.copy. The copies carry the solution's membership stamp along with the rest of their persisted metadata, so they stay members of the solution, getForComponent resolves them, and the backend layout readout measures them as part of it.

A floor that needs a different plan is a second solution. A podium plate and a tower plate, or a setback that moves the corridor, are different module runs — bake each as its own solution on its own storey, and copy each up over the storeys it governs.

All or nothing. create either bakes the whole solution or leaves the canvas exactly as it found it. Validation and planning happen before any geometry exists; if something still fails once masses have been built but before the history entry is written, every mesh built is disposed. There is no partial bake, and no orphan cell that undo cannot reach. Failures carry the real message too — a solution write never reaches the caller as a bare INTERNAL error id.

One history entry. create, update, delete and apply each commit a single CommandManager.execute. update puts the delete of the old components and the create of the new ones in the same batch, so undo restores the previous cells.

Hidden means invisible to every read. A hidden solution's components have isHidden === true, leave the adjacency graph and the spatial index, and are not pickable — so rays skip them and the backend layout readout ignores them. That is how option sets stay out of the way until one is chosen.

Options. Solutions that share an optionSet are alternatives. apply keeps one and deletes the rest, in one history entry.

Types ​

PluginDesignSolutionSpec ​

The recipe. Every length in it — input, envelope, cells.height — is in unit; nothing is assumed to be feet or metres. Plan points are { x, z } throughout — the same convention as modules.geom and every packer input.

PropertyTypeDescription
module"pack.corridorRuns" | "pack.corridorBand" | "pack.rooms" | "pack.footprintPlacement"Which toolkit packer to run
inputRecord<string, unknown>The module's own input, in unit. Passed through verbatim with unit folded in — the toolkit validates it
unit"mm" | "cm" | "m" | "ft" | "in"The caller's length unit
storeynumber (int)The ONE storey this solution bakes onto. Stack it afterwards with core.storeys.copy
envelopePolygon?Trim region applied ONCE to the module's cells; omit it for no clip
cells{ kinds: Record<string, CellKind>; height?: number }How each module kind becomes a component, and the extrude height (default: the target storey's own height)
spineboolean?Corridor modules only: also bake a reference line per input.paths[i] and the corridor itself as a space — see The corridor
optionSetstring?Solutions in one set are alternatives
labelstring?Human label for the solution
hiddenboolean?Bake straight into the hidden state (default false)

PluginDesignSolutionCellKind ​

One entry of cells.kinds, keyed by the kind string the module emits (cells[].kind for corridorRuns, the cell label / room id for rooms, the footprintId for footprintPlacement, the piece id for corridorBand).

PropertyTypeDescription
spaceTypePluginSpaceType?Space type applied to the created mass
massTypePluginMassType?Mass type applied to the created mass
departmentIdPluginDepartmentId?Department assignment
labelstring?Space label — defaults to the kind name

A cell whose kind has no entry is a hard VALIDATION error: nothing is created. The corridor baked by spine uses kinds.Corridor when it is present and falls back to a plain Corridor-labelled space otherwise.

The corridor ​

With spine: true, the solution bakes the circulation strip itself as a space, under the kind Corridor. Where that strip comes from depends on the module:

ModuleThe corridor is
pack.corridorBandthe module's own band — that output is the circulation strip
pack.corridorRunsderived: each input.paths[i] stroked to input.corridorWidth with geom.polygonBoolean.bandFromPolyline (centred, flat caps), which is exactly the width the packer held clear between its bands

pack.corridorRuns returns no corridor of its own. What it calls bands are the two unit bands either side of the spine — the very plates its cells tile — so they are not baked; doing so laid a "Corridor" on top of every unit at 100% overlap. input.corridorWidth must be positive for spine: true on this module.

The corridor is trimmed against spec.envelope like any other cell and counted in audit.counts.corridors (and under audit.counts.Corridor, its kind). audit.counts.cells counts the module's cells, corridors excluded.

PluginDesignSolution ​

PropertyTypeDescription
idstringsol_… — the id every component of the solution is tagged with
specPluginDesignSolutionSpec | nullThe recipe. null only when the solution's anchor component was deleted (see Limits)
inputHashstringStable, key-order-independent hash of the spec
hiddenbooleanWhether the solution is currently hidden
components{ storey: number; ids: ComponentHandle[] }One object, not an array. storey is the storey the solution was baked on; ids is every cell still carrying its stamp — including copies made with core.storeys.copy, which sit on the storeys above
referenceLinesComponentHandle[]Spine reference lines (empty without spine)
auditPluginDesignSolutionAudit | null{ module, ms, notes, unfilled?, composition?, counts } — counts by kind plus cells/corridors/referenceLines
createdAtstringISO timestamp of the original bake

Functions ​

create(spec) ​

Run the module once, trim it against spec.envelope, and bake every cell as a real space on spec.storey in ONE history entry.

All-or-nothing. Either the whole solution appears, or the canvas is untouched. Nothing is committed until every cell has been planned and built, and geometry built before a late failure is disposed — a failed create never leaves cells behind, and never leaves cells outside history.

  • Parameters:
    • spec: PluginDesignSolutionSpec
  • Returns: Promise<PluginDesignSolution> — one object, not a wrapper
  • Throws: VALIDATION for a malformed spec, an unmapped cell kind, or spine: true on pack.corridorRuns without a positive input.corridorWidth; PRECONDITION_FAILED for a missing storey, a module (or intersectPolygons / bandFromPolyline) the served bundle does not carry, a module that produced no regions, or a clip that left nothing; OPERATION_FAILED when the module rejected its own input, when a clip failed, or for any other host failure — the message always carries the underlying complaint

Example prompt: "Pack studios and one-beds either side of that corridor on floors 2 to 6."

ts
const solution = await snaptrude.design.solutions.create({
  module: "pack.corridorRuns",
  unit: "m",
  input: {
    paths: [
      {
        id: "spine",
        pts: [
          { x: 0, z: 0 },
          { x: 48, z: 0 }
        ]
      }
    ],
    envelope: {
      ring: [
        /* … */
      ]
    },
    corridorWidth: 1.8,
    bandDepth: 9,
    cellKinds: [{ kind: "Studio", widthRange: [6, 8] }]
  },
  storey: 2,
  cells: { kinds: { Studio: { label: "Studio", spaceType: "Room" } } }
});

update(id, patch) ​

Re-bake a solution from a merged spec. The delete of the old components and the create of the new ones share ONE batch, so undo restores the previous cells. hidden and optionSet carry over unless the patch names them. The re-bake happens before the delete is queued, so a module or clip failure leaves the old solution standing.

  • Parameters:
    • id: string — the solution id
    • patch: Partial<PluginDesignSolutionSpec> — merged shallowly over the stored spec
  • Returns: Promise<PluginDesignSolution> — one object: the same id, a new inputHash
  • Throws: PRECONDITION_FAILED for an unknown id or a solution whose spec is no longer stored; everything create throws

Cells deleted by hand. update re-bakes from the stored spec, so it replaces whatever components still carry the solution's stamp — a cell someone removed with design.delete.entities simply comes back. If the anchor (the first component, which carries the spec) was among them, the solution reports spec: null and update refuses: delete the solution and create it again.

Example prompt: "Make the corridor 2.1 m wide and rebuild that layout."

ts
await snaptrude.design.solutions.update(solution.id, {
  input: { ...solution.spec.input, corridorWidth: 2.1 }
});

delete(id) ​

Delete every component of a solution — cells and spine — in one history entry.

  • Parameters: id: string
  • Returns: { deleted: number } — how many components were removed
  • Throws: PRECONDITION_FAILED for an unknown id

delete removes what is stamped now, so cells a person already removed by hand are simply not counted; deleted is the number that were actually still there. A solution whose cells were all hand-deleted still exists as long as something carries its stamp — typically only its spine reference lines, with spec: null once the anchor went with the cells (seen live as sol_5becf7f9cb). delete is what clears those remains.

Example prompt: "Throw away the corridor scheme."

ts
const { deleted } = await snaptrude.design.solutions.delete(solution.id);

apply(id) ​

Choose one option: unhide id and delete every other solution sharing its optionSet, in ONE history entry. (apply rather than "commit": the naming constitution, src/app/plugins/api-naming-constitution.md §4, has no commit verb.)

  • Parameters: id: string
  • Returns: { kept: string; deleted: string[] }
  • Throws: PRECONDITION_FAILED for an unknown id

Example prompt: "Go with option B and drop the others."

ts
const { kept, deleted } = await snaptrude.design.solutions.apply(optionB.id);

show(id) ​

Reveal a hidden solution. Visibility only — nothing is created or deleted.

  • Parameters: id: string
  • Returns: PluginDesignSolution — with hidden: false
  • Throws: PRECONDITION_FAILED for an unknown id

Example prompt: "Show me option C on the canvas."

ts
await snaptrude.design.solutions.show(optionC.id);

hide(id) ​

Hide a solution from every read: isHidden goes true, the cells leave the adjacency graph and the spatial index, and rays skip them.

  • Parameters: id: string
  • Returns: PluginDesignSolution — with hidden: true
  • Throws: PRECONDITION_FAILED for an unknown id

Example prompt: "Put option C away for now."

ts
await snaptrude.design.solutions.hide(optionC.id);

list(filter?) ​

Every solution on the canvas, rebuilt from component tags.

  • Parameters: filter: { optionSet?: string; storey?: number }?
  • Returns: PluginDesignSolution[] — a plain array, newest last. Not { solutions: [...] }: there is no wrapper object, so result.length and result[0] are the reads, and an empty canvas gives [].

Example prompt: "What layout options are on floor 4?"

ts
const onFour = await snaptrude.design.solutions.list({ storey: 4 });
console.log(onFour.length, onFour[0]?.id); // an array — NOT `onFour.solutions`

get(id) ​

One solution by id. Never throws — a solution id is not a handle.

  • Parameters: id: string
  • Returns: PluginDesignSolution | null — the solution object itself, or null when no such solution is on the canvas. No wrapper.

Example prompt: "How many cells did that scheme bake?"

ts
const cells = (await snaptrude.design.solutions.get(id))?.audit?.counts.cells ?? 0;

getForComponent(component) ​

The solution a component belongs to, read from its persisted stamp.

  • Parameters: component: ComponentHandle
  • Returns: PluginDesignSolution | null — the solution object itself, or null for an untagged or unresolvable component. No wrapper.

Example prompt: "Which layout produced this room?"

ts
const [room] = await snaptrude.design.selection.get();
const owner = await snaptrude.design.solutions.getForComponent(room);

open({ solutionIds, timeoutMs? }) ​

Put baked solutions in front of the person as options, in the same "Choose a layout" bar the arrange solver uses — and return immediately.

Nothing waits. The options are real components that already exist on the canvas, so there is nothing for the caller to block on: open hides all but the first, arms the overlay, and hands back a reviewId. The worker finishes its turn; the person browses in their own time. Whatever they decide arrives later, as a new user turn in the agent chat, not as this call's return value.

  • Parameters:
    • solutionIds: string[] — the options, in overlay order. Index 0 is the first thumbnail and the one shown when the review opens. Duplicates are collapsed. Every id must already exist; open never bakes anything.
    • timeoutMs: number (optional) — how long an unattended review waits before applying the active option. Default 600000 (10 minutes).
  • Returns: { reviewId: string }, synchronously
  • Throws: PRECONDITION_FAILED for an unknown id, or when a review is already open (finish it first)

The three endings

The person doesWhat happens on the canvasWhat the agent receives
Doneapply(active) — the pick is unhidden, its optionSet siblings are deleted; any listed option that survived is deleted too. One history entry.Option 2 of 3 applied (solution sol_ab12cd34): 20 cells on storey 2.
CancelEvery listed solution is deleted.Option review cancelled; all 3 solutions removed.
NothingAfter timeoutMs, the active option is applied exactly as Done does.Option 1 of 3 applied on timeout (solution sol_ab12cd34): 20 cells on storey 2.

Cancel deleting everything is deliberate: the options were baked for this review and nothing on the canvas predates them, so backing out restores what the person started with. "Keep the one I am looking at" is Done.

Browsing between options is visibility only (show/hide) — no geometry is created or destroyed until one of the three endings.

Example prompt: "Give me three corridor options and let me pick."

ts
const options = [];
for (const bandDepth of [10, 12, 14]) {
  options.push(
    await snaptrude.design.solutions.create({
      ...base,
      input: { ...base.input, bandDepth },
      optionSet: "towerLayout",
      label: `Band ${bandDepth} m`,
      hidden: true
    })
  );
}

// Returns at once — do NOT await a decision here.
const { reviewId } = await snaptrude.design.solutions.open({
  solutionIds: options.map((o) => o.id)
});

Notes

  • Thumbnails are not captured for baked solutions yet; the bar shows the option number instead.
  • Regenerate and the preserve-shape/location buttons are arrange-solver features and do nothing in a solutions review — rebuild an option with update instead.
  • With no live agent chat session the review still works end to end; the outcome is logged rather than posted.

Worked example — one typical floor, copied up to 6 ​

ts
const envelope = {
  ring: [
    { x: 0, z: 0 },
    { x: 60, z: 0 },
    { x: 60, z: 24 },
    { x: 0, z: 24 }
  ]
};

// 1. Bake the typical floor ONCE, on storey 2.
const scheme = await snaptrude.design.solutions.create({
  module: "pack.corridorRuns",
  unit: "m",
  input: {
    paths: [
      {
        id: "spine",
        pts: [
          { x: 2, z: 12 },
          { x: 58, z: 12 }
        ]
      }
    ],
    envelope,
    corridorWidth: 1.8,
    bandDepth: 10,
    cellKinds: [
      { kind: "Studio", widthRange: [5.5, 7] },
      { kind: "OneBed", widthRange: [7.5, 9] }
    ],
    composition: [
      { kind: "Studio", shareRatio: 0.4 },
      { kind: "OneBed", shareRatio: 0.6 }
    ]
  },
  storey: 2,
  envelope, // the trim; here identical to input.envelope, so the clip is a no-op
  cells: {
    kinds: {
      Studio: { label: "Studio", spaceType: "Room" },
      OneBed: { label: "1 Bed", spaceType: "Room" },
      Corridor: { label: "Corridor", spaceType: "Walkway" }
    }
  },
  // One reference line per path on storey 2, plus the 1.8 m corridor itself,
  // stroked from that path, as a space.
  spine: true,
  optionSet: "towerLayout",
  label: "Double-loaded, 40/60"
});

// { Studio: …, OneBed: …, Corridor: 1, cells: …, corridors: 1, referenceLines: 1 }
const counts = scheme.audit.counts;

// 2. Stack it: copy the plate up to storeys 3, 4, 5 and 6.
//    `copy(direction, options?)` moves each handed component up ONE level from
//    the storey it is on, so feed each round's `created` into the next.
await snaptrude.core.storeys.setActive(2);
let plate = scheme.components.ids;
for (const _ of [3, 4, 5, 6]) {
  const { created } = await snaptrude.core.storeys.copy("up", {
    components: plate,
    unique: true
  });
  plate = created;
}
// Every copy carries the solution stamp, so all five floors read back as
// members of `scheme.id` and the layout readout measures the whole stack.

A floor that needs a different plan — a podium, or a setback that moves the corridor — is a second create with its own storey, its own input.paths and an envelope that lies inside the podium plate. Never a second entry in the same solution.

ts
// Bake a second option hidden, compare, then keep one.
const alt = await snaptrude.design.solutions.create({
  ...scheme.spec,
  input: { ...scheme.spec.input, bandDepth: 12 },
  label: "Deeper bands",
  hidden: true
});

await snaptrude.design.solutions.apply(alt.id); // unhides alt, deletes `scheme`

Limits ​

  • update rebuilds only the storey the solution was baked on; copied storeys are not re-copied yet — copy again after an update. update removes everything carrying the solution's stamp (the copies included, since they inherit it) and re-bakes the plate on spec.storey alone. The stack above it is gone until you run the copy loop again. Same for a spine drag, which goes through update.
  • Copies inherit the stamp. The membership stamp lives in the component's persisted mesh metadata, which Snaptrude copies along with the component. A user who copy/pastes a baked space produces a component that claims membership; delete and apply act on what is stamped now, so a stray copy is removed with the solution.
  • The spec lives on the first component. update needs it; if that anchor is deleted by hand, get reports spec: null and update refuses. Delete the solution and create it again. A solution whose cells were all removed with design.delete.entities typically survives exactly like that: spec: null, no components, and only its spine reference lines left — delete clears it.
  • design.query.getProperties does not expose the stamp. It is a fixed whitelist; getForComponent is the sanctioned read.
  • rooms bakes the best option only. pack.rooms returns ranked options; a solution bakes options[0].

Errors ​

CodeWhen
VALIDATIONMalformed spec (unknown module, a missing or non-integer storey, non-positive cells.height); a module kind with no cells.kinds mapping; a cell height that resolved non-positive; spine: true on pack.corridorRuns without a positive input.corridorWidth
PRECONDITION_FAILEDA target storey does not exist; the served bundle does not carry the module, geom.polygonBoolean.intersectPolygons or geom.polygonBoolean.bandFromPolyline; the module produced no regions; the clip left no cells; unknown solution id on a write; update on a solution with no stored spec
OPERATION_FAILEDThe module rejected its own input; a clip failed against spec.envelope (the message names the region); the corridor could not be stroked; any other host failure — the message always carries the underlying complaint

Every failure is raised before anything is committed — there is never a partial bake, and geometry built before a late failure is disposed rather than left on the canvas outside history.

A solution write never surfaces as INTERNAL. The router sanitises uncaught host exceptions into Internal error. Reference: <errorId>, which tells a caller nothing; these paths catch what they did not anticipate and re-raise it as OPERATION_FAILED with the real message.

Editing the spine ​

A solution baked with spine: true puts one reference line per input.paths[i] on the canvas, on spec.storey. That line is editable geometry: drag an endpoint in the editor and the solution re-bakes itself.

A watcher (solutions/spineWatcher.ts, installed once with the design host) listens on the command stream for editReferenceLine commands. When the edited line belongs to a solution, it waits 250 ms for the drag to settle, reads the line's new world geometry, converts it back to spec.unit, writes it into spec.input.paths[i].pts and calls design.solutions.update. So the whole plate — every cell, the corridor strip itself — is re-derived from the moved centreline, as one history entry. Undo (unexecute) of a drag re-bakes the same way, so the cells never lag behind the line.

Nothing about this is per-storey: a solution is one plate on one storey, so there is exactly one line per path and nothing to reconcile. Storeys copied from that plate are not re-copied by the rebuild — copy the plate up again after a spine edit, as under Limits.

The watcher stays out of its own way — a command batch that arrives while a re-bake is in flight is ignored — and it does nothing at all when:

  • the moved line is not stamped with a solution;
  • the solution's spec is null (its anchor component was deleted by hand — see Limits);
  • input.paths and the solution's referenceLines are not 1:1 (a polyline path can bake into several line meshes, and index-matching would then be a guess);
  • the points did not actually move.

Editing a cell (a baked space) does not re-bake anything: cells are output, the spine and spec.input are the input. Use design.solutions.update for everything else.