Appearance
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.
| Event | When | Key Parameters |
|---|---|---|
page_view | Checkout page loaded | store |
view_item | Optimisation completed | store |
add_to_cart | Item added to basket | currency, value, items[] |
begin_checkout | Customer proceeds to order form | currency, value, items[] |
purchase | Order confirmed | transaction_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:
- Go to Triggers > New > Custom Event
- Set the event name (e.g.
add_to_cart) - 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_viewandview_itemevents include astoreparameter with the checkout slug (orcustom_domainfor custom domain stores) - The
purchaseevent 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
| Setting | Separate | Notes |
|---|---|---|
| Config (pricing, stock filter, part rules, custom fields, etc.) | Yes | |
| Stripe keys | Yes | Use pk_test_ / sk_test_ keys in test mode |
| Email provider | Yes | Configure a test email provider independently |
| Shipping options | Yes | Test different shipping rates and formulas |
| Webhooks | Yes | Point test webhooks at a different URL |
| Stock & materials | No | Shared — import is disabled in test mode |
| Products & categories | No | Shared |
| Extras (banding, finish, etc.) | No | Shared (but extras config like rules/locations is per-mode) |
| Company profile & branding | No | Shared |
| Slug & custom domain | No | Shared |
| Published status | No | Only 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=testThe 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:
| Header | Description |
|---|---|
Content-Type | application/json |
X-SmartCut-Timestamp | Unix timestamp (ms) of the delivery attempt |
X-SmartCut-Delivery | Unique 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
| Event | Description |
|---|---|
order.created | A new order has been placed and payment confirmed |
order.status_changed | The order status has been updated |
inventory.decremented | Stock 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
| Field | Type | Description |
|---|---|---|
event | string | One of order.created, order.status_changed |
orderId | string | Unique order identifier |
organisationId | string | Your organisation ID |
timestamp | string | ISO 8601 timestamp of when the webhook was sent |
data
| Field | Type | Required | Description |
|---|---|---|---|
status | string | Yes | Current order status |
previousStatus | string | No | Previous status (present on order.status_changed) |
paymentStatus | string | No | Payment status |
itemCount | number | Yes | Number of line items in the order |
partsCount | number | Yes | Total number of individual parts |
createdAt | string | Yes | ISO 8601 timestamp of order creation |
items | array | Yes | Per-job breakdown of parts, files, and stock references — use with the ecommerce API (see below) |
results | array | No | Full 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.
| Field | Type | Description |
|---|---|---|
itemId | string | Basket item ID — use as itemId in mark-cut / mark-complete / adjust-cut requests |
jobId | string | Optimisation job ID |
includeOffcuts | boolean | Whether the customer opted in to receive offcuts for this job (false if not opted in) |
files | object | Export file URLs (if available) |
files.pdf | string | Layout PDF URL |
files.zip | string | ZIP archive URL (contains machining PDF, CSV, PTX, DXF as configured) |
parts | array | Parts for this item (one entry per unique part, with quantity for count) |
data.items[].parts[]
| Field | Type | Description |
|---|---|---|
id | string | Part 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 |
l | number | Part length (long side) |
w | number | Part width (short side) |
quantity | number | Number of this part required |
material | string | Material name (if specified) |
thickness | number | Material thickness (if specified) |
stock | object | Stock/material reference (if available) |
stock.id | string | Stock ID — references the corresponding results[].stock[].id |
stock.code | string | Stock 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.
| Field | Type | Description |
|---|---|---|
jobId | number | Job identifier |
saw | object | Saw configuration used |
stock | array | Stock items |
parts | array | Parts with coordinates and properties |
cuts | array | Cut instructions |
offcuts | array | Remaining offcut pieces |
unusableParts | array | Parts that could not be placed |
metadata | object | Complete analysis and metrics (efficiency, waste, costs, material summary) |
data.customer
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Customer full name |
email | string | Yes | Customer email address |
phone | string | No | Customer phone number |
data.pricing
| Field | Type | Required | Description |
|---|---|---|---|
total | number | Yes | Order total |
currency | string | Yes | ISO 4217 currency code |
breakdown | array | Yes | Ordered 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.
| Field | Type | Description |
|---|---|---|
label | string | Display label (e.g. "Items Subtotal", "VAT (20%)", "Total") |
amount | number | Amount in the order currency |
type | string | One of subtotal, charge, tax, total |
Possible line items (in order, when applicable):
| Label | Type | When present |
|---|---|---|
| Items Subtotal | subtotal | Always |
| Samples Subtotal | subtotal | Material samples ordered |
| Minimum Order Fee | charge | Order below minimum and store charges the difference |
| Shipping | charge | Shipping cost > 0 |
| VAT / GST / Sales Tax (rate%) | tax | Tax enabled on the store |
| Total | total | Always |
data.shipping
Optional. Present when shipping details are available.
| Field | Type | Required | Description |
|---|---|---|---|
method | string | No | Shipping method name |
address.line1 | string | Yes | Street address |
address.line2 | string | No | Additional address line |
address.city | string | Yes | City |
address.state | string | No | State / county / region |
address.postalCode | string | Yes | Postal/ZIP code |
address.country | string | Yes | ISO 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[]
| Field | Type | Required | Description |
|---|---|---|---|
stockId | string | Yes | Stock item ID |
stockName | string | No | Human-readable stock name |
material | string | No | Material name |
code | string | No | Material code |
thickness | number | No | Material thickness |
dimensions.length | number | No | Stock length |
dimensions.width | number | No | Stock width |
previousQuantity | number | Yes | Quantity before decrement |
newQuantity | number | Yes | Quantity after decrement |
decrementedBy | number | Yes | Number of units consumed |
deleted | boolean | No | Whether 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/:idUpdates the status of an order.
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
status | string | Yes | One of pending, cut, complete, dispatched, cancelled |
updateInventory | boolean | No | Decrement inventory stock when marking as cut (default: false) |
forceOverwrite | boolean | No | Skip partial progress warning when marking as cut (default: false) |
resetCuts | boolean | No | Reset all part cut counts when reverting to pending (default: false) |
Example:
json
{ "status": "complete" }Responses:
| Status | Description |
|---|---|
200 | Order updated — returns the updated order document |
400 | Invalid status value |
403 | Order not in your organisation |
404 | Order not found |
409 | Partial 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, orcancelled. Each status has its own toggle. Defaults:complete,dispatchedandcancelledare on;pendingandcutare off. - Order webhook (optional): if an
order.status_changedwebhook is configured, a webhook fire-and-forget is dispatched on every status change.
Mark Parts Cut
PATCH /ecommerce/orders/parts/mark-cutIncrements 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 }
]
}| Field | Type | Required | Description |
|---|---|---|---|
orderId | string | Yes | Order ID |
itemId | string | Yes | Basket item ID within the order |
id | string | Yes | Part identifier — matches id from items[].parts[] |
count | number | Yes | Number of additional instances to mark as cut (minimum: 1) |
Responses:
| Status | Description |
|---|---|
200 | Success — returns per-order results and any auto-promoted order IDs |
400 | Missing or invalid request body |
403 | Order not in your organisation |
404 | Order 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-completeIncrements 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:
| Status | Description |
|---|---|
200 | Success — returns per-order results and any auto-promoted order IDs. numberComplete may be clamped to numberCut. |
400 | Missing or invalid request body |
403 | Order not in your organisation |
404 | Order or item not found |
Adjust Parts Cut
PATCH /ecommerce/orders/parts/adjust-cutDecrements 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:
| Status | Description |
|---|---|
200 | Success — returns per-order results and any orders reverted to pending |
400 | Missing or invalid request body |
403 | Order not in your organisation |
404 | Order 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-completeAdjusts 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:
| Status | Description |
|---|---|
200 | Success — returns per-order results |
400 | Missing or invalid request body |
403 | Order not in your organisation |
404 | Order 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
- Create or log in to your Stripe account at dashboard.stripe.com
- 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)
- Enter your keys in SmartCut under your e-commerce settings → Stripe tab:
- Publishable key (
pk_live_...orpk_test_...) - Secret key (
sk_live_...,sk_test_..., or a restricted keyrk_live_.../rk_test_...)
- Publishable key (
- 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:
- Customer completes the order form and clicks Pay Now
- SmartCut creates a pending order and redirects the customer to a Stripe-hosted checkout page
- After successful payment, Stripe sends a
checkout.session.completedevent to your webhook endpoint - 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
| Symptom | Cause | Fix |
|---|---|---|
Orders stay unpaid after payment | Webhook not reaching SmartCut | Check your endpoint URL in Stripe and ensure the signing secret is saved |
| "Webhook not configured" in server logs | Signing secret missing for this organisation | Add the whsec_... secret in the Stripe tab |
| "Signature verification failed" in server logs | Signing secret mismatch | Re-copy the secret from your Stripe webhook endpoint settings |
| Test connection warns about webhook | Signing secret not saved yet | Save 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 |
|---|---|---|
| x | right | left |
| y | top | bottom |
| z | front (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 allowedAnchor 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 + NEdge 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/mirrorOfreplaces the template root's anchor. Optional — if you don't supply positioning the template's own anchor stands. - Forwarded fields.
translate,rotation,whenon 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 referenceinputs.X. - Nested templates. A template can instantiate another template. Expansion iterates until no instantiations remain.
- Repeat-of-instantiate. Use
repeat-axisorrepeat-aroundto stamp out N copies of a template (each becomes its own sub-tree under id<outerId>@<n>/<localId>).repeat-betweenof aninstantiateis 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:
| Message | Cause |
|---|---|
\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 converge | A 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.