# E-commerce Integration Guide

URL: https://smartcut.dev/docs/ecommerce

> Analytics tracking, webhook events, and API reference for SmartCut e-commerce

:::tip[Configure your store from an LLM client]
You can manage your storefront's materials, stock, pricing and extras in natural language with the [Store config MCP](/docs/ecommerce-mcp).
:::

## 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:

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

| 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=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:

| 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": "jane@example.com",
      "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](/api-docs/v3) |

##### 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](#mark-parts-cut) 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 <a href="/api-docs/v3" target="_blank">V3 API documentation</a> 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/:id
```

Updates 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`, 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 }
  ]
}
```

| 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-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](#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-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:**

| 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-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:**

| 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

1. **Create or log in to your Stripe account** at [dashboard.stripe.com](https://dashboard.stripe.com)
2. **Get your API keys** from **Developers → API Keys**. We recommend using [Restricted Keys](https://stripe.com/docs/keys#limit-access) 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

| 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 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:

| 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.
