Skip to content

E-commerce Integration Guide

Google Analytics & Google Tag Manager

SmartCut checkout pages support Google Analytics 4 (GA4) and Google Tag Manager (GTM) for tracking customer behaviour. Configure these in your organisation's e-commerce settings under the Analytics & Tracking tab.

Setup

Google Analytics 4 — Enter your GA Measurement ID (format: G-XXXXXXXX). SmartCut injects the gtag.js script and sends events directly to GA4.

Google Tag Manager — Enable GTM and enter your Container ID (format: GTM-XXXXXXX). SmartCut injects the GTM container script and pushes all events to the window.dataLayer. You can then configure triggers and tags within GTM as needed.

You can configure either or both. When only GA4 is configured, events are sent directly via gtag. When GTM is enabled (with or without a GA4 ID), events are pushed to the dataLayer only — to avoid duplicate events, you should forward them to GA4 via a GA4 tag within your GTM container.

Tracked Events

The following events are automatically fired on your checkout pages. These use standard GA4 event names, which GA4 recognises and reports on automatically.

EventWhenKey Parameters
page_viewCheckout page loadedstore
view_itemOptimisation completedstore
add_to_cartItem added to basketcurrency, value, items[]
begin_checkoutCustomer proceeds to order formcurrency, value, items[]
purchaseOrder confirmedtransaction_id, value, currency

dataLayer Format

Each event is pushed to window.dataLayer in the following format:

js
{
  event: 'add_to_cart',
  currency: 'GBP',
  value: 48.00,
  items: [
    { item_name: 'MDF 18mm', quantity: 1, price: 48.00 }
  ]
}

The purchase event includes a transaction_id matching the order reference:

js
{
  event: 'purchase',
  transaction_id: 'A1B2C3',
  value: 199.98,
  currency: 'GBP'
}

GTM Trigger Examples

To capture these events in Google Tag Manager, create a Custom Event trigger for each event name:

  1. Go to Triggers > New > Custom Event
  2. Set the event name (e.g. add_to_cart)
  3. Attach it to your GA4 or other tags

For the purchase event, you can access transaction_id, value, and currency as dataLayer variables.

Notes

  • Events are not fired in admin preview mode
  • The page_view and view_item events include a store parameter with the checkout slug (or custom_domain for custom domain stores)
  • The purchase event fires on the order confirmation page — it fires once per checkout redirect and will not duplicate on page refresh

Test Mode

SmartCut e-commerce supports a test/production mode system. Test mode lets you configure and preview settings (pricing, shipping, Stripe keys, email, webhooks) independently from your live store, using the same inventory.

How It Works

  • Production mode is the default. All public checkout pages use production settings.
  • Test mode maintains a separate copy of your operational settings. Inventory (stock, materials, extras, products) is shared between both modes.
  • Toggle between modes using the Production / Test buttons in the admin bar, visible on all e-commerce admin pages.

What Is Separate Per Mode

SettingSeparateNotes
Config (pricing, stock filter, part rules, custom fields, etc.)Yes
Stripe keysYesUse pk_test_ / sk_test_ keys in test mode
Email providerYesConfigure a test email provider independently
Shipping optionsYesTest different shipping rates and formulas
WebhooksYesPoint test webhooks at a different URL
Stock & materialsNoShared — import is disabled in test mode
Products & categoriesNoShared
Extras (banding, finish, etc.)NoShared (but extras config like rules/locations is per-mode)
Company profile & brandingNoShared
Slug & custom domainNoShared
Published statusNoOnly production can be published

Previewing Test Settings

Append ?mode=test to your checkout URL to view the store with test settings applied:

https://smartcut.dev/ecommerce/your-slug?mode=test

The admin settings page shows this URL automatically when in test mode. A yellow Test mode banner appears at the top of the checkout to distinguish it from production.

Orders in Test Mode

Orders are not created in test mode. The checkout flow runs normally (customer can fill in the form, select shipping, etc.) but when submitting:

  • No order is saved to the database
  • No emails are sent
  • No webhooks are fired
  • No inventory is decremented
  • No Stripe checkout session is created

The customer sees a message confirming it was a test submission.

Stripe Keys

A warning is shown in the Stripe settings tab if there is a mismatch between the ecommerce mode and the Stripe key type:

  • Test mode with live keys — warns you to use test keys for safe testing
  • Production mode with test keys — warns that customers cannot make real payments

Copying Settings

Use the buttons in the admin bar to copy settings between modes:

  • Apply to Production — overwrites production settings with the current test settings
  • Copy to Test / Reset from Production — overwrites test settings with the current production settings

Only mode-specific settings are copied (config, stripe, email, shipping options, webhooks). Shared fields (company, branding, slug, inventory) are never affected.

First Use

When you first switch to test mode, your current production settings are automatically copied as the starting point. You can then modify test settings independently.


Webhooks

SmartCut can send webhook notifications to your server when e-commerce events occur. Configure your webhook endpoint URL and the events you want to receive in your organisation settings.

Delivery

Webhooks are sent as POST requests with a JSON body. Each request includes the following headers:

HeaderDescription
Content-Typeapplication/json
X-SmartCut-TimestampUnix timestamp (ms) of the delivery attempt
X-SmartCut-DeliveryUnique delivery ID (UUID)

Failed deliveries (5xx or network errors) are retried up to 3 times with delays of 1s, 5s, and 30s. Client errors (4xx) are not retried.

Events

EventDescription
order.createdA new order has been placed and payment confirmed
order.status_changedThe order status has been updated
inventory.decrementedStock quantities have been reduced after an order

Order Webhook

Sent for order.created and order.status_changed events.

Payload

json
{
  "event": "order.created",
  "orderId": "abc123",
  "organisationId": "org456",
  "timestamp": "2026-03-06T12:00:00.000Z",
  "data": {
    "status": "pending",
    "previousStatus": "draft",
    "paymentStatus": "paid",
    "customer": {
      "name": "Jane Smith",
      "email": "[email protected]",
      "phone": "+44 7700 900000"
    },
    "pricing": {
      "total": 199.98,
      "currency": "GBP",
      "breakdown": [
        { "label": "Items Subtotal", "amount": 129.99, "type": "subtotal" },
        { "label": "Minimum Order Fee", "amount": 20.01, "type": "charge" },
        { "label": "Shipping", "amount": 10.00, "type": "charge" },
        { "label": "VAT (20%)", "amount": 39.98, "type": "tax" },
        { "label": "Total", "amount": 199.98, "type": "total" }
      ]
    },
    "shipping": {
      "method": "standard",
      "address": {
        "line1": "123 High Street",
        "line2": "Flat 4",
        "city": "London",
        "state": "Greater London",
        "postalCode": "SW1A 1AA",
        "country": "GB"
      }
    },
    "itemCount": 3,
    "partsCount": 24,
    "createdAt": "2026-03-06T11:55:00.000Z",
    "items": [
      {
        "itemId": "item-uuid-1",
        "jobId": "12345",
        "includeOffcuts": true,
        "files": {
          "pdf": "https://example.s3.amazonaws.com/exports/12345/layout.pdf",
          "zip": "https://example.s3.amazonaws.com/exports/12345/files.zip"
        },
        "parts": [
          { "id": "1", "l": 600, "w": 400, "quantity": 2, "material": "MDF", "thickness": 18, "stock": { "id": "1.0", "code": "WM-18" } },
          { "id": "2", "l": 580, "w": 350, "quantity": 3, "material": "MDF", "thickness": 18, "stock": { "id": "1.0", "code": "WM-18" } }
        ]
      }
    ],
    "results": [
      {
        "jobId": 12345,
        "saw": {},
        "stock": [],
        "parts": [],
        "cuts": [],
        "offcuts": [],
        "unusableParts": [],
        "metadata": {}
      }
    ]
  }
}

Fields

Top-level
FieldTypeDescription
eventstringOne of order.created, order.status_changed
orderIdstringUnique order identifier
organisationIdstringYour organisation ID
timestampstringISO 8601 timestamp of when the webhook was sent
data
FieldTypeRequiredDescription
statusstringYesCurrent order status
previousStatusstringNoPrevious status (present on order.status_changed)
paymentStatusstringNoPayment status
itemCountnumberYesNumber of line items in the order
partsCountnumberYesTotal number of individual parts
createdAtstringYesISO 8601 timestamp of order creation
itemsarrayYesPer-job breakdown of parts, files, and stock references — use with the ecommerce API (see below)
resultsarrayNoFull V3 API optimisation results for each job, including part placement coordinates and stock references. See V3 API docs
data.items[]

Each entry corresponds to one basket item (job) in the order. Items and results share the same order — items[0] corresponds to results[0], etc. Use itemId with the ecommerce API endpoints to track cutting progress.

FieldTypeDescription
itemIdstringBasket item ID — use as itemId in mark-cut / mark-complete / adjust-cut requests
jobIdstringOptimisation job ID
includeOffcutsbooleanWhether the customer opted in to receive offcuts for this job (false if not opted in)
filesobjectExport file URLs (if available)
files.pdfstringLayout PDF URL
files.zipstringZIP archive URL (contains machining PDF, CSV, PTX, DXF as configured)
partsarrayParts for this item (one entry per unique part, with quantity for count)
data.items[].parts[]
FieldTypeDescription
idstringPart identifier (e.g. "1", "2") — derived from the integer portion of the part ID. Use to match parts across results[].parts[] and in mark-cut / mark-complete requests
lnumberPart length (long side)
wnumberPart width (short side)
quantitynumberNumber of this part required
materialstringMaterial name (if specified)
thicknessnumberMaterial thickness (if specified)
stockobjectStock/material reference (if available)
stock.idstringStock ID — references the corresponding results[].stock[].id
stock.codestringStock code
data.results[]

Each entry is a full V3 API response containing the optimisation result for one job in the order. See the V3 API documentation for the complete response schema.

FieldTypeDescription
jobIdnumberJob identifier
sawobjectSaw configuration used
stockarrayStock items
partsarrayParts with coordinates and properties
cutsarrayCut instructions
offcutsarrayRemaining offcut pieces
unusablePartsarrayParts that could not be placed
metadataobjectComplete analysis and metrics (efficiency, waste, costs, material summary)
data.customer
FieldTypeRequiredDescription
namestringYesCustomer full name
emailstringYesCustomer email address
phonestringNoCustomer phone number
data.pricing
FieldTypeRequiredDescription
totalnumberYesOrder total
currencystringYesISO 4217 currency code
breakdownarrayYesOrdered list of pricing line items (see below)
data.pricing.breakdown[]

Each entry represents a line in the pricing summary. Only non-zero charges are included. The final entry is always the total.

FieldTypeDescription
labelstringDisplay label (e.g. "Items Subtotal", "VAT (20%)", "Total")
amountnumberAmount in the order currency
typestringOne of subtotal, charge, tax, total

Possible line items (in order, when applicable):

LabelTypeWhen present
Items SubtotalsubtotalAlways
Samples SubtotalsubtotalMaterial samples ordered
Minimum Order FeechargeOrder below minimum and store charges the difference
ShippingchargeShipping cost > 0
VAT / GST / Sales Tax (rate%)taxTax enabled on the store
TotaltotalAlways
data.shipping

Optional. Present when shipping details are available.

FieldTypeRequiredDescription
methodstringNoShipping method name
address.line1stringYesStreet address
address.line2stringNoAdditional address line
address.citystringYesCity
address.statestringNoState / county / region
address.postalCodestringYesPostal/ZIP code
address.countrystringYesISO 3166-1 alpha-2 country code

Inventory Webhook

Sent when stock quantities are decremented after an order is processed.

Payload

json
{
  "event": "inventory.decremented",
  "orderId": "abc123",
  "organisationId": "org456",
  "timestamp": "2026-03-06T12:01:00.000Z",
  "data": {
    "changes": [
      {
        "stockId": "stock789",
        "stockName": "18mm White Melamine",
        "material": "Melamine",
        "code": "WM-18",
        "thickness": 18,
        "dimensions": {
          "length": 2440,
          "width": 1220
        },
        "previousQuantity": 50,
        "newQuantity": 47,
        "decrementedBy": 3,
        "deleted": false
      }
    ]
  }
}

data.changes[]

FieldTypeRequiredDescription
stockIdstringYesStock item ID
stockNamestringNoHuman-readable stock name
materialstringNoMaterial name
codestringNoMaterial code
thicknessnumberNoMaterial thickness
dimensions.lengthnumberNoStock length
dimensions.widthnumberNoStock width
previousQuantitynumberYesQuantity before decrement
newQuantitynumberYesQuantity after decrement
decrementedBynumberYesNumber of units consumed
deletedbooleanNoWhether the stock item was fully consumed

Testing Webhooks

Use the test endpoint to verify your webhook URL is reachable. A test webhook sends a simple payload:

json
{
  "event": "test",
  "timestamp": "2026-03-06T12:00:00.000Z",
  "message": "This is a test webhook from SmartCut"
}

Orders API

These endpoints allow you to track orders and parts through the cutting and completion workflow. All requests require your API key in the Authorization header.

Order status progression: pending → cut → complete → dispatched

Update Order Status

PATCH /ecommerce/orders/:id

Updates the status of an order.

Request body:

FieldTypeRequiredDescription
statusstringYesOne of pending, cut, complete, dispatched, cancelled
updateInventorybooleanNoDecrement inventory stock when marking as cut (default: false)
forceOverwritebooleanNoSkip partial progress warning when marking as cut (default: false)
resetCutsbooleanNoReset all part cut counts when reverting to pending (default: false)

Example:

json
{ "status": "complete" }

Responses:

StatusDescription
200Order updated — returns the updated order document
400Invalid status value
403Order not in your organisation
404Order not found
409Partial cut progress detected — pass forceOverwrite: true to proceed

When a 409 is returned, the response body contains:

json
{
  "error": "Order has parts with partial cut progress.",
  "code": "PARTIAL_PROGRESS",
  "data": {
    "partsWithProgress": [
      { "itemName": "Item abc", "partIndex": 0, "current": 2, "total": 5 }
    ]
  }
}

Side effects

  • Customer status email (optional): if email is configured and the per-status notification toggle is enabled (under email settings → Notifications → Order Status Changes), the customer is sent a short email when the status moves to pending, cut, complete, dispatched, or cancelled. Each status has its own toggle. Defaults: complete, dispatched and cancelled are on; pending and cut are off.
  • Order webhook (optional): if an order.status_changed webhook is configured, a webhook fire-and-forget is dispatched on every status change.

Mark Parts Cut

PATCH /ecommerce/orders/parts/mark-cut

Increments the numberCut count for one or more parts. When all parts in an order reach their full quantity, the order is automatically set to cut.

Request body:

json
{
  "updates": [
    { "orderId": "66abc123", "itemId": "item-uuid", "id": "1", "count": 1 }
  ]
}
FieldTypeRequiredDescription
orderIdstringYesOrder ID
itemIdstringYesBasket item ID within the order
idstringYesPart identifier — matches id from items[].parts[]
countnumberYesNumber of additional instances to mark as cut (minimum: 1)

Responses:

StatusDescription
200Success — returns per-order results and any auto-promoted order IDs
400Missing or invalid request body
403Order not in your organisation
404Order or item not found
json
{
  "success": true,
  "data": {
    "results": [{ "orderId": "66abc123", "success": true }],
    "autoMarkedOrders": ["66abc123"]
  }
}

autoMarkedOrders contains IDs of orders automatically promoted to cut because all parts reached full quantity.


Mark Parts Complete

PATCH /ecommerce/orders/parts/mark-complete

Increments the numberComplete count for one or more parts. When all parts in an order reach their full quantity and the order is in cut status, the order is automatically set to complete.

Accepts the same request body format as Mark Parts Cut.

Invariant: numberComplete ≤ numberCut ≤ partQuantity. If a request would cause numberComplete to exceed numberCut, the value is silently clamped to numberCut rather than rejected. This means parts must be cut before they can be completed.

Responses:

StatusDescription
200Success — returns per-order results and any auto-promoted order IDs. numberComplete may be clamped to numberCut.
400Missing or invalid request body
403Order not in your organisation
404Order or item not found

Adjust Parts Cut

PATCH /ecommerce/orders/parts/adjust-cut

Decrements cut counts for individual parts, or resets all cut counts for a basket item to zero. When a decrement causes any part to fall below its full quantity on an order with status cut, the order is automatically reverted to pending.

When numberCut is reduced, numberComplete is automatically clamped down to maintain the invariant numberComplete ≤ numberCut. The resetAll mode resets both numberCut and numberComplete to zero.

Request body — decrement:

json
{
  "updates": [
    { "orderId": "66abc123", "itemId": "item-uuid", "id": "1", "count": -1 }
  ]
}

Request body — reset all cuts for an item:

json
{
  "resetAll": { "orderId": "66abc123", "itemId": "item-uuid" }
}

Responses:

StatusDescription
200Success — returns per-order results and any orders reverted to pending
400Missing or invalid request body
403Order not in your organisation
404Order or item not found
json
{
  "success": true,
  "data": {
    "results": [{ "orderId": "66abc123", "success": true }],
    "revertedOrders": ["66abc123"]
  }
}

revertedOrders contains IDs of orders automatically reverted to pending because one or more parts fell below full quantity.


Adjust Parts Complete

PATCH /ecommerce/orders/parts/adjust-complete

Adjusts completion counts for individual parts (increment or decrement). Each part's completion count is clamped between 0 and min( partQuantity, numberCut ). The invariant numberComplete ≤ numberCut ≤ partQuantity is enforced — you cannot complete a part that has not been cut.

Request body:

json
{
  "updates": [
    { "orderId": "66abc123", "itemId": "item-uuid", "id": "1", "count": 1 }
  ]
}

Use a positive count to increment and a negative count to decrement. The value is clamped to the valid range [0, partQuantity].

Responses:

StatusDescription
200Success — returns per-order results
400Missing or invalid request body
403Order not in your organisation
404Order or item not found
json
{
  "success": true,
  "data": {
    "results": [{ "orderId": "66abc123", "success": true }]
  }
}

Stripe Payments

SmartCut checkout pages can accept payments via Stripe. Each organisation connects their own Stripe account — SmartCut never handles funds directly.

Setup

  1. Create or log in to your Stripe account at dashboard.stripe.com
  2. Get your API keys from Developers → API Keys. We recommend using Restricted Keys with the following permissions:
    • Checkout Sessions — Write
    • Webhook Endpoints — Write (for automatic webhook setup)
    • Account — Read (optional, used for Test Connection)
  3. Enter your keys in SmartCut under your e-commerce settings → Stripe tab:
    • Publishable key (pk_live_... or pk_test_...)
    • Secret key (sk_live_..., sk_test_..., or a restricted key rk_live_... / rk_test_...)
  4. Save — SmartCut will automatically create the webhook endpoint in your Stripe account and store the signing secret

If automatic setup fails (e.g. your restricted key doesn't have webhook permissions), you can manually add a webhook endpoint in Developers → Webhooks → Add endpoint:

  • Endpoint URL: https://api.smartcut.dev/ecommerce-stripe-webhook
  • Events to listen for: select checkout.session.completed
  • Copy the Signing Secret (whsec_...) and enter it in the Stripe tab

How It Works

When Stripe is enabled, the checkout flow changes:

  1. Customer completes the order form and clicks Pay Now
  2. SmartCut creates a pending order and redirects the customer to a Stripe-hosted checkout page
  3. After successful payment, Stripe sends a checkout.session.completed event to your webhook endpoint
  4. SmartCut verifies the webhook signature, marks the order as paid, generates the PDF, sends confirmation emails, and fires any configured order webhooks

Without the webhook, SmartCut has no way to confirm that payment succeeded — orders will remain in an unpaid state.

Troubleshooting

SymptomCauseFix
Orders stay unpaid after paymentWebhook not reaching SmartCutCheck your endpoint URL in Stripe and ensure the signing secret is saved
"Webhook not configured" in server logsSigning secret missing for this organisationAdd the whsec_... secret in the Stripe tab
"Signature verification failed" in server logsSigning secret mismatchRe-copy the secret from your Stripe webhook endpoint settings
Test connection warns about webhookSigning secret not saved yetSave your Stripe settings including the webhook signing secret

3D Product Visualiser — Schema Reference

Formula products can carry an optional assembly spec alongside their formula spec. The assembly spec describes how the formula's resolved panels are arranged in 3D space — what joins what, where mirrors live, how shelves repeat — and drives the storefront's interactive 3D preview.

The spec is JSON. Edit it via the product editor's "Edit 3D assembly" dialog, or pre-populate by clicking "Load example" to start from the bundled cabinet or shaker door.

Coordinate system

Right-handed, mm units, world origin at the centre of the product.

Axis+ direction− direction
xrightleft
ytopbottom
zfront (toward camera)back

Every part has six faces named by world-relative direction: top, bottom, left, right, front, back. The face naming is fixed regardless of the part's local orientation.

Top-level shape

jsonc
{
  "name": "Optional spec name",
  "templates": { /* … see Sub-assemblies … */ },
  "instances": [
    /* one entry per physical part */
  ]
}

An assembly spec is a flat list of instances — each represents one physical part in the resolved scene. Sub-assemblies (templates + instantiations) are flattened into instances at parse time; the resolver only ever sees concrete instances.

Instance — common fields

jsonc
{
  "id":   "leftSide",                       // unique identifier for this part
  "from": "side",                           // formula panel key — supplies l/w/t/material
  "take": 0,                                // which of the q copies (default 0)
  "axes": { "l": "y", "w": "z", "t": "x" }, // map panel dims to world axes
  /* exactly one positioning verb: */
  "anchor": "origin",
  "join":   { /* … */ },
  "mirrorOf": "leftSide",
  "repeat":   { /* … */ },
  /* optional transforms applied after positioning: */
  "rotation":  { /* … */ },
  "translate": { /* … */ },
  /* optional visibility gate: */
  "when": "= inputs.hasDoor"
}

axes maps the panel's local l (length), w (width) and t (thickness) onto world x/y/z. All three must be distinct. A back panel lying in the XY plane uses { l: "x", w: "y", t: "z" }; a vertical side panel with thickness on x uses { l: "y", w: "z", t: "x" }.

from is required for every panel-backed instance. The resolver looks up the matching panel in the formula spec; its l/w/t become the part's bounding-box extents. If the formula spec computes q: 0 for the panel, the instance is silently skipped (and dependents cascade-skip too).

when is an optional formula expression. If it evaluates to falsy (zero), the instance is skipped — same cascade as q: 0. Use it to gate hardware on a customer input independently of any panel quantity.

Positioning — anchor

jsonc
{ "anchor": "origin" }                        // centre at (0, 0, 0)
{ "anchor": { "at": [ 100, 0, 0 ] } }         // centre at (100, 0, 0)
{ "anchor": { "at": [ "= inputs.x", 0, 0 ] } } // formulas allowed

Anchor instances are roots — every other instance positions itself relative to one. There must be at least one anchor in any spec.

Positioning — join (face-to-face)

jsonc
{
  "join": {
    "to":         "leftSide",  // reference instance id (must exist)
    "myFace":     "left",      // this instance's face that touches…
    "theirFace":  "right",     // …the reference instance's face
    "align": [ /* up to 2 alignment rules — see below */ ]
  }
}

The contact face pair pins one axis (the one perpendicular to both faces). Faces must be on the same axis (e.g. left/right) and oppose (+1 and -1 signs) — my-right touches their-left, not my-right touches their-right. The remaining two axes are pinned by align rules.

Alignment rules

Three discriminated shapes:

jsonc
{ "my": "top",  "their": "top"   }        // edge-to-edge
{ "center": "y" }                          // axis centres match
{ "axis": "z", "offset": 12 }              // my centre = their centre + N

Edge alignment names two faces; the resolver pins the relevant axis so my-face meets their-face. Same-edge pairs (top/top) and cross-edge pairs (back/front) both work — the signs do the work.

If you don't supply a rule for an axis, the centres match by default.

Positioning — mirrorOf

jsonc
{
  "id":       "rightSide",
  "mirrorOf": "leftSide",
  "axis":     "x",          // reflect along this axis
  "center":   0             // optional mirror plane (default 0)
}

Reflects an already-placed instance across an axis. The mirrored part inherits the source's geometry (panel reference, rotation, translate). If the source is a repeat group (id <base>@n), the mirror produces a parallel group <mirrorId>@n.

Positioning — repeat

Three flavours:

between — even spacing between two anchors

jsonc
{
  "repeat": {
    "count":   "= inputs.numShelves",
    "between": [ "bottom.top", "top.bottom" ],
    "spacing": "even",
    "offset":  { "x": 0, "z": "= inputs.depth - 50" }, // override non-spacing axes
    "shift":   "= 0 - inputs.thickness / 2"             // additive on spacing axis
  }
}

Each anchor is <instanceId>.<face>. Copies are evenly spaced between the two anchors with count + 1 gaps (so count = 1 lands a single copy at the midpoint). Default position on non-spacing axes is the midpoint between the anchor parts.

axis — fixed spacing along one axis

jsonc
{
  "repeat": {
    "count":   3,
    "axis":    "y",
    "spacing": 200,
    "startAt": -200,
    "offset":  { "x": 0, "z": "= inputs.depth" } // optional
  }
}

Stamps count copies starting at startAt, separated by spacing. Useful for stacked drawers, fixed shelves, etc.

around — symmetric spacing around a centreline

jsonc
{
  "repeat": {
    "count":   12,
    "around":  { "axis": "x", "center": 0 },
    "spacing": 90
  }
}

Centres copies symmetrically on the centreline. Odd counts get one copy on the centre, then pairs at ±spacing, ±2·spacing, …; even counts get pairs at ±½·spacing, ±1½·spacing, … Useful for slats and balusters.

Repeated parts get ids like <id>@0, <id>@1, … etc.

Rotation

jsonc
{
  "rotation": {
    "axis":    "y",
    "degrees": "= 0 - inputs.doorOpenAngle",
    "pivot":   [ "left", "front" ]   // edge formed by two faces
  }
}

Applied after positioning. pivot defaults to the part centre. A single face name (e.g. "left") pivots around that face's centre. A pair of faces names the edge where they meet — the right model for door hinges. The two faces must lie on different non-rotation axes.

Bounding boxes use the unrotated AABB, so dimensions and click-selection use the part's pre-rotation extents.

Translation

jsonc
{
  "translate": {
    "axis":     "z",
    "distance": "= inputs.depth * inputs.drawerOpen / 100"
  }
}

Linear offset along one axis, baked into the part's world position before rotation. Use it for "drawer slid forward by N mm" effects.

Sub-assemblies (templates)

Define a reusable group of instances once, instantiate it many times.

jsonc
{
  "templates": {
    "drawerBox": {
      "params": [ "drawerHeight" ],          // optional declared param names
      "instances": [
        // The instance with `anchor` is the template's ROOT.
        { "id": "front", "from": "drawerFront", "anchor": "origin", /* … */ },
        { "id": "leftSide", "from": "drawerSide",
          "join": { "to": "front", "myFace": "front", "theirFace": "back", /* … */ } },
        { "id": "rightSide", "mirrorOf": "leftSide", "axis": "x" },
        // … back, bottom …
      ]
    }
  },
  "instances": [
    {
      "id":          "drawer",
      "instantiate": "drawerBox",
      "params":      { "drawerHeight": "= inputs.drawerHeight" },
      "join":        { /* placement of the template root */ },
      "translate":   { "axis": "z", "distance": "= inputs.depth * inputs.drawerOpen / 100" }
    }
  ]
}

Expansion rules

  • Id namespacing. Inner instances expand to <outerId>/<localId>drawer/front, drawer/leftSide, etc. Local references inside the template (join.to: "front") are rewritten to the prefixed form automatically.
  • Cross-scope refs. Inner instances can reference an outer-scope instance by prefixing with outer/: "to": "outer/leftSide". The prefix is stripped at expansion.
  • Root inheritance. The instantiation's anchor / join / mirrorOf replaces the template root's anchor. Optional — if you don't supply positioning the template's own anchor stands.
  • Forwarded fields. translate, rotation, when on the instantiate block are forwarded to the root, so the whole sub-tree slides / rotates / gates as a unit.
  • Parameters. Template formulas can reference params.<name>. The instantiator supplies values; param values can themselves be formulas that reference inputs.X.
  • Nested templates. A template can instantiate another template. Expansion iterates until no instantiations remain.
  • Repeat-of-instantiate. Use repeat-axis or repeat-around to stamp out N copies of a template (each becomes its own sub-tree under id <outerId>@<n>/<localId>). repeat-between of an instantiate is not supported — the resolver would need to interleave with expansion.

Worked example — stacked drawers

jsonc
{
  "templates": {
    "drawerBox": { /* 5 inner instances as shown above */ }
  },
  "instances": [
    /* … carcass: back, sides, top, bottom … */
    {
      "id":          "drawer",
      "instantiate": "drawerBox",
      "repeat": {
        "count":   "= inputs.numDrawers",
        "axis":    "y",
        "spacing": "= inputs.drawerHeight + 2",
        "startAt": "= inputs.thickness + inputs.drawerHeight / 2 - inputs.height / 2",
        "offset":  { "x": 0, "z": "= inputs.depth" }
      },
      "translate": { "axis": "z", "distance": "= inputs.depth * inputs.drawerOpen / 100" }
    }
  ]
}

A numDrawers: 3 cabinet expands to 15 instances (5 per drawer × 3) with ids drawer@0/front, drawer@0/leftSide, …, drawer@2/bottom. The translate is forwarded through the repeat so all three drawers respect drawerOpen together.

Hardware (pricing-only)

The assembly spec has no separate hardware kind — every instance is a panel backed by a formula entry. Pricing-only items (hinges, slides, shelf pins) live in the formula spec's hardware block and are summed into the cart total without rendering in the 3D preview. Built-in primitives weren't realistic enough to justify visualising; if you need visible fittings later, import them as separate panels with appropriate dimensions.

Validation + error surfacing

The dialog's live preview parses each edit against the schema and runs the resolver. Surface errors include:

MessageCause
\from` (formula panel key) is required …`Non-mirrored instance missing from
Edge alignment faces must share an axis …{ my: "top", their: "left" } — top is on y, left on x
Contact faces must oppose …myFace: "right" and theirFace: "right" (same sign)
No formula panel "X"from: "X" doesn't exist in the paired formula spec
Unresolved instances (cyclic or unknown reference) …Two instances joining to each other, or a reference to an unknown id
Unknown template "X"instantiate: "X" with no template by that name
Template … must have exactly one instance with \anchor``Template missing or has multiple roots
Template expansion did not convergeA template instantiates itself directly or indirectly
Repeat-between of an \instantiate` is not supported`Use repeat-axis or repeat-around

When two parts overlap, the resolver flags them in the preview (red edge outlines) without throwing — author can iterate without the preview disappearing.