← 3D LinkerCatalogEditor

DAWA Client Guide

How to consume Spacemaker's Digital Asset Warehouse from another application or agent. Two surfaces are available:

  • REST — plain HTTPS, no SDK, no auth (currently).
  • MCP — same data via the Spacemaker MCP server, useful when your agent is already speaking MCP for shapes / furniture / etc.

Most clients want the REST surface for browsing and previewing assets, and either REST or MCP for placement on the canvas. Pick whichever matches the rest of your stack.


Quickstart

1. Browse the catalog (REST)

curl https://spacemaker.gongvue.com/api/dawa-catalog

Returns the full tab → category → asset tree plus aggregate counts (byKind, byLicense, manualSvgCount). Cheap; cache on the client side if you call it often.

2. Find a specific asset

# All sofas
curl 'https://spacemaker.gongvue.com/api/dawa-catalog' \
  | jq '[.tabs[].assets | .. | objects | select(.name | test("sofa"; "i"))]'

# Or via MCP
mcp_call dawa_search_assets '{ "query": "sofa", "license": "CC0" }'

The REST endpoint dumps everything; MCP gives you a capped search with filters and a hasManualSvg flag per result.

3. Preview thumbnails

Static assets ship at predictable paths:

| What | Path | | --------------------------------- | --------------------------------------- | | 3D perspective PNG | /dawa/thumbs/<assetId>.png | | 2D top-down SVG silhouette | /dawa/svg/<assetId>.svg | | 3D model file (curated) | /dawa/<tab>/<category>/<file> | | 3D model file (user upload) | /dawa/user/<uploadId>.<ext> | | License attributions | /dawa/ATTRIBUTIONS.md |

The PNG is always present (auto-generated). The SVG is too, but non-pre-generated SVGs may be a simple convex-hull silhouette — use the hasManualSvg flag returned by dawa_get_asset / dawa_search_assets to know when the SVG is a hand-drawn floor plan versus an auto silhouette.

4. Drop an asset onto the canvas

mcp_call place_dawa_asset '{
  "assetId": "sofa-3p",
  "x": 2.0, "z": 1.5, "floor": "1F",
  "sizeOverrideCm": { "w": 240, "d": 90, "h": 80 }
}'

The MCP server emits a create_furniture plan; apply it via your existing applyPlan pipeline (or the Spacemaker UI does it for you when called from pages/api/vibe-chat.js). The new furniture entry carries dawaUrl + dawaSvgUrl so the renderer can swap to the real GLB in 3D mode and the silhouette in 2D mode without further plumbing.

sizeOverrideCm is required for GLB-only assets (kind=gltf / obj / fbx / stl) because the catalog has no intrinsic size for those — the GLB's bounding box is what gets scaled to fit.


REST endpoint reference

| Method | Endpoint | Purpose | | ------ | ----------------------- | --------------------------------------------------------------- | | GET | /api/dawa-catalog | Whole catalog tree + counts | | GET | /api/dawa-uploads | Just user uploads (formatted as DAWA assets) | | POST | /api/dawa-upload | Upload GLB/GLTF/OBJ/FBX/STL (≤25 MB, base64 in JSON body) | | PATCH | /api/dawa-upload | Update upload metadata (name / desc / license / author) | | DELETE | /api/dawa-upload | Remove an upload (file + manifest entry + thumbnail) | | POST | /api/dawa-thumb | Persist a perspective PNG (used by the auto generator) | | POST | /api/dawa-svg | Persist a top-down SVG silhouette (used by the auto generator) |

Upload body shape

{
  "dataUrl":  "data:application/octet-stream;base64,Z2xURg...",
  "filename": "my-sofa.glb",            // metadata only; not used as a path
  "name":     "My Custom Sofa",
  "description": "Brass-trimmed loveseat for the lobby scene",
  "license":  "CC0",                     // free-form string, ≤16 chars stored
  "author":   "Studio Foo"               // ≤80 chars
}

The server picks a random u-<hex16> id; the original filename is metadata only. Extension whitelist: .glb, .gltf, .obj, .fbx, .stl. The body cap is 25 MB after base64 decode (so the JSON body should be < 35 MB or so).

Delete / Patch body shape

// DELETE
{ "id": "u-<hex16>" }
// PATCH
{ "id": "u-<hex16>", "name": "...", "description": "...", "license": "...", "author": "..." }

Each PATCH field is optional and only the supplied fields get replaced.


MCP tool reference

All four tools are scope: 'public' so they're available through both the in-process server (used by Spacemaker's own UI) and the HTTP gateway (used by external agents).

dawa_list_tabs

Discovery. Returns every tab + its categories + asset counts + an aggregated byKind / byLicense / manualSvgCount block. Call this first to learn the catalog structure without enumerating every asset.

dawa_search_assets

{
  "query":         "sofa",       // substring against name / id / desc
  "tab":           "building",   // optional
  "category":      "furniture",  // optional
  "license":       "CC0",        // optional
  "kind":          "gltf",       // optional
  "manualSvgOnly": true,         // optional — limit to hand-drawn SVGs
  "limit":         20             // optional, default 20, max 100
}

Filters are AND-combined. Each result row carries id, name, kind, license, url, svgUrl, hasManualSvg, and the tab/category path — enough for most navigation flows. Use dawa_get_asset when you need the full record.

dawa_get_asset

Returns the full asset metadata for a single id plus its tab / category labels. Errors with unknown_asset if the id is not in the catalog.

place_dawa_asset

Catalog → main canvas. Emits a create_furniture plan with DAWA-specific fields attached (dawaUrl, dawaSvgUrl, dawaLicense, dawaAuthor, dawaSource, dawaAssetId, dawaKind).

| Field | Required | Notes | | ---------------- | ------------------ | ------------------------------------------------------------------------ | | assetId | yes | Catalog id (e.g. sofa-3p) or upload id (u-<hex16>). | | x, z | no (default 0) | Floor position in metres. | | rotation | no (default 0) | Radians, y-axis. | | floor | no (default 1F) | Floor id. | | spaceId | no | Associate the placement with a specific space, if you have its id. | | sizeOverrideCm | conditional | Required for GLB-only assets (kind != box/door/window/stair/...).|


Common workflows

Floor-plan-quality assets only

When the user wants assets with hand-drawn architectural floor-plan SVGs (door swing arcs, stair UP arrows, plumbing fixture icons), combine the new filter:

dawa_search_assets({
  "query": "door",
  "manualSvgOnly": true
})

Eighteen catalog assets ship with manual SVGs today (bed-double, sofa-3p, chair-task, table-dine, toilet, sink-bathroom, door-hinged-90, door-sliding-180, door-double-160, win-fixed-120, win-sash-100, win-bay-220, stair-straight, stair-L, elev-standard, elev-through, roof-flat, roof-gable). The set is the source of truth at lib/dawaManualSvgs.js.

Redistributable assets

For CC0-only:

dawa_search_assets({ "license": "CC0" })

The catalog currently has 19 CC0 assets across kinds. CC-BY assets require attribution — the original credit lives in the asset record's author and source fields, and the consolidated record is public/dawa/ATTRIBUTIONS.md.

Uploading and reusing your own model

# 1. Upload
B64=$(base64 -w0 my-table.glb)
curl -X POST -H 'Content-Type: application/json' \
  -d "{\"dataUrl\":\"data:application/octet-stream;base64,${B64}\",\"filename\":\"my-table.glb\",\"name\":\"My Custom Table\",\"license\":\"CC-BY\",\"author\":\"Me\"}" \
  https://spacemaker.gongvue.com/api/dawa-upload
# → { "ok": true, "asset": { "id": "u-abc...", ... } }

# 2. Place it
mcp_call place_dawa_asset '{
  "assetId": "u-abc...",
  "x": 1, "z": 1,
  "sizeOverrideCm": { "w": 120, "d": 60, "h": 75 }
}'

The thumbnail (/dawa/thumbs/u-abc....png) and silhouette (/dawa/svg/u-abc....svg) get generated automatically the first time a Spacemaker /dawa browser tab is open after the upload — there is no synchronous generation API yet, so external clients should treat those files as eventually-available rather than guaranteed.


Smoke test

A POSIX shell smoke test exercises every endpoint:

./scripts/dawa-smoke.sh                              # localhost
DAWA_HOST=https://spacemaker.gongvue.com \
  ./scripts/dawa-smoke.sh                            # production

Nine checks, sub-second per step, cleans up the test upload it creates regardless of how earlier steps went. Useful in CI right after a deploy and as a quick local sanity check after any DAWA change.


Authentication (Phase 3 — optional)

Set SPACEMAKER_DAWA_LINKER_KEYS on the Cloud Run service to a comma-separated list of API keys. When the env var is empty (the default), the service runs fully anonymous and behaves like Phase 1+2.

When set:

  • /dawa/3dlinker and POST/PATCH/DELETE /api/dawa-links require a valid key.
  • Three places the middleware looks for it, in order: the X-API-Key header, the dawa_linker_key cookie, or a ?key= query param.
  • All other paths — embed page, short URL, /api/dawa-catalog, static thumbnails — stay anonymous so iframe embedders never need credentials.

The editor UI at /dawa/3dlinker has a small key chip in the top bar: paste your key once, the page stores it in localStorage and drops a short-lived cookie so the middleware sees it on subsequent navigations.

Production storage (Phase 3 — optional)

By default link metadata is written to public/dawa/links/manifest.json. On Cloud Run that filesystem is read-only, so write endpoints succeed on the instance that handled the request but the change disappears on the next cold start.

Setting SPACEMAKER_DAWA_BUCKET=<bucket-name> switches to GCS:

  • Links are read from / written to gs://<bucket>/links/manifest.json (override the object path with SPACEMAKER_DAWA_LINKS_OBJECT).
  • The Cloud Run service account needs Storage Object Admin on the chosen bucket.
  • No service-account key file is shipped — Application Default Credentials are picked up automatically.

When the env var is unset, local file storage is used (current dev behavior).

Embed customization

/dawa/embed/<id> accepts query params to match the embedder's brand:

| Param | Type | Default | Notes | | ----------- | ------ | ------------ | -------------------------------------------------- | | panel | enum | on | off hides the right info panel. | | autorotate| flag | off | 1 continuously spins the camera. | | bg | enum | dark | light, trans — canvas background. | | layout | enum | split | full removes the panel entirely. | | chrome | flag | on | 0 hides the small "Powered by DAWA" footer. | | accent | hex | #7dd3fc | Links + CTA button background. #rgb or #rrggbb.| | logo | URL | none | Customer logo at top of panel. https + image only. | | cta | URL | none | Primary button URL at bottom of panel. https only. | | ctaText | string | Learn more | Button label. Capped at 30 chars. |

Anything that fails validation is silently dropped to the default — no 500 / no malformed-URL rendering. Use percent-encoding for # in the accent value: accent=%23ff6b35.

Example:

<iframe
  src="https://spacemaker.gongvue.com/dawa/embed/bed-double?bg=light&accent=%23ff6b35&logo=https%3A%2F%2Fexample.com%2Flogo.png&cta=https%3A%2F%2Fexample.com%2Fproduct%2F123&ctaText=Add+to+cart"
  width="640" height="400" frameborder="0"></iframe>

Theme stored on a Link

The four theme fields can also be saved on a Link record so embedders don't need to construct a long querystring on every iframe. The editor at /dawa/3dlinker has a "Theme (optional)" section; the API accepts a theme object on POST / PATCH:

{
  "assetId": "rack-server-eyring",
  "title": "Rack 42",
  "theme": {
    "accent":  "#ff6b35",
    "logo":    "https://example.com/logo.png",
    "cta":     "https://example.com/asset/42",
    "ctaText": "Open in CMDB"
  }
}

Precedence at render time is query param > link.theme > default, so iframes can still one-off-override individual fields without losing the rest of the stored theme.

Bulk import

POST /api/dawa-links-bulk creates many Links in one call — designed for CMDB / facility customers whose internal inventory needs hundreds of room / rack / vehicle embeds.

Body shape:

{
  "links": [
    { "assetId": "bed-double",     "title": "Room 101", "theme": { "accent": "#ff6b35" } },
    { "assetId": "sofa-3p",        "title": "Lobby" },
    { "assetId": "rack-server-eyring", "title": "Rack 42" }
    // ... up to 500 entries
  ],
  "secret": "<optional — supply an existing batch secret to extend it>"
}

Each entry takes the same shape as the single-link POST. The whole batch shares one secret: omit it to mint a new one (returned in the response), or pass an existing secret to extend the set you already own — useful when you bulk-import in batches and want to keep one secret to manage everything.

Response:

{
  "ok": true,
  "secret": "<the secret to use for PATCH/DELETE on these links>",
  "inserted": 2,
  "failed": 1,
  "results": [
    { "ok": true,  "index": 0, "id": "lk-abc..." },
    { "ok": true,  "index": 1, "id": "lk-def..." },
    { "ok": false, "index": 2, "error": "missing_asset_id" }
  ]
}

Per-row failures don't reject the batch — inspect results and retry the failed rows individually. The endpoint also rejects batches >500 rows (HTTP 400 batch_too_large) and bodies >2 MB.

The editor at /dawa/3dlinker exposes the same capability via a "Bulk import…" button: paste a CSV with columns assetId,title,desc,accent,logo,cta,ctaText (header row optional — auto-detected when the first row contains assetId).

Engagement analytics

Link-mode embeds fire three anonymous beacons to POST /api/dawa-link-view:

  • kind:"view" — once on load
  • kind:"cta" — on primary CTA button click
  • kind:"resource" — on resource row click

Bare-asset embeds (/dawa/embed/<assetId> without a link wrapping) don't beacon so shared catalog browsing doesn't pollute customer counters.

Read endpoint returns per-kind stats so dashboards can compute CTR in one call:

GET  /api/dawa-link-view?id=lk-xxx
  → { ok,
      stats:  { "lk-xxx": { view: 120, cta: 14, resource: 3 } },
      counts: { "lk-xxx": 120 }   // alias of stats[*].view, pre-M shape
    }

GET  /api/dawa-link-view?ids=lk-a,lk-b,lk-c
  → batched stats (≤50 ids/call)

POST /api/dawa-link-view
  { linkId, kind?: "view"|"cta"|"resource", assetId?, extra? }
  // kind defaults to "view". An unknown kind is silently coerced
  // to "view" rather than rejected — keeps malformed beacons from
  // landing in 4xx noise.

The beacon uses navigator.sendBeacon so it survives page unload and doesn't trigger a CORS preflight. It's fire-and-forget — embed rendering is never blocked on the beacon.

Storage is object-per-event under gs://<bucket>/views/<linkId>/<kind>/<ts-millis>-<nonce>.json (or a parallel local directory in dev). The Link DELETE handler cleans up all engagement records for the link best-effort.

Bot filter: none server-side, but Slackbot / Googlebot / Twitterbot fetch the embed without executing JS, so they never reach the beacon. The counter stays close to "humans who actually loaded the iframe."

The editor at /dawa/3dlinker renders a small "views · CTR%" chip on each link in the My Links sidebar so authors see engagement at a glance.

Embed events (window.postMessage)

The embed iframe posts events to the parent frame via window.parent.postMessage. Embedders can read them for analytics, UX, or to integrate with their own dashboards — no SDK needed:

window.addEventListener('message', (e) => {
  if (!e.data || typeof e.data.type !== 'string') return;
  if (!e.data.type.startsWith('dawa:')) return;
  console.log(e.data);   // → { type, ts, ...payload }
});

| Event | Payload | Fires when | | ------------------------- | ---------------------------------------------------- | ----------------------------------- | | dawa:loaded | assetId, name, linkId (or null) | server props arrived, once | | dawa:cta-clicked | assetId, linkId, url | the primary CTA button is clicked | | dawa:resource-clicked | assetId, linkId, label, url, index | a resource row in the panel clicked |

All payloads also carry ts (epoch ms). Frames embedded same-origin under their own parent (e.g. dev self-tests) are no-op'd so you don't see phantom events during local development.

The targetOrigin on the postMessage call is * — embedders should verify e.origin === 'https://spacemaker.gongvue.com' before trusting the payload if the embed runs alongside untrusted iframes.

Versioning

DAWA is pre-1.0 along with the rest of the public MCP surface. Breaking changes are possible until the first public release; see VERSIONING.md. The smoke test will catch removed or renamed endpoints early.