Skip to content

Layout format

sunny-layout is a versioned JSON document describing one room: its shell (size, shape, doors and windows, finishes) and the furniture placed in it. Use it to move a layout between Sunny and a scan-to-plan tool, a CAD pipeline, or your own system.

In the API a room is a space, and it’s addressed by its space id (spc_…). “Room” and “space” mean the same thing on this page.

sunny-layout v1
{
"format": "sunny-layout",
"version": 1,
"room": {
"w": 6,
"d": 4,
"openings": [
{
"id": "op_V1StGXR8_Z5jdHi6B-myT",
"kind": "window",
"side": "back",
"center": 0.5,
"width": 1.2,
"sill": 0.72,
"height": 0.76
},
{
"id": "op_3ZtqT7bWq0cY8mNn1kLpA",
"kind": "door",
"side": "front",
"center": 0.15,
"width": 0.9,
"sill": 0,
"height": 2.1,
"hinge": "start"
}
],
"floorType": "oak",
"wallColor": "#c7d0c3"
},
"placements": [
{
"id": 1,
"catalogId": "conf-table",
"x": 1.5,
"y": 1.4,
"item": { "name": "Conference Table", "w": 3, "d": 1.2 }
},
{
"id": 2,
"catalogId": "dining-chair",
"x": 2.75,
"y": 2.7,
"item": { "name": "Chair", "w": 0.5, "d": 0.55 },
"config": { "rotation": 180 }
}
],
"meta": {
"name": "Boardroom",
"exportedAt": "2026-10-05T21:00:00.000Z",
"generator": "sunny-api"
}
}
  • Every length is in meters.
  • The origin is the room’s top-left interior corner: x runs right, y runs down.
  • room.w and room.d are the interior width and depth.
  • A placement’s x/y is the top-left corner of its footprint.
Field Type Description
format "sunny-layout" Required.
version number Required. 1 is the only version today.
room object Required. The shell — see Room.
placements array Required. The furniture — see Placements. May be empty.
meta object Optional: name, rationale, exportedAt (ISO 8601), generator.

meta.generator names what wrote the document — sunny-api from the REST API, sunny-mcp from the MCP server. It’s informational; don’t branch on it. On import, meta.name becomes the new version’s name.

FieldTypeDescription
architectureobjectImported building geometry bound to this room. Round-trips a model already uploaded to Sunny; not something to author by hand.
hnumberCeiling height, meters.
floorMaterialobjectCustom floor texture. Round-trips textures already uploaded to Sunny; not something to author by hand.
wrequirednumberInterior width, meters
drequirednumberInterior depth, meters
openingsobject[]Parametric openings only. Absent/empty = a plain box.
footprintobjectNon-rectangular room shape. Absent = the plain w×d rectangle.
floorType"tile" | "concrete" | "oak" | "birch" | "walnut" | "espresso" | "plush" | "oatmeal" | "sage" | "clay" | "charcoal"Present only when the finish isn't the studio's tile default
floorColorstring#rrggbb tint overriding the finish's own color
wallColorstringPresent only when the paint isn't the studio's default plaster

Fields the room doesn’t need are left out on export: floorType is omitted for the default tile, wallColor for the default plaster, footprint for a plain rectangle, openings for a plain box.

footprint describes a non-rectangular room as a set of grid cells: cellSize in meters (one of 0.1, 0.25, 0.5, 1, 2) and cells as [column, row] pairs from a 0,0 origin.

Doors, windows, open sections, and railings are always listed explicitly in room.openings.

FieldTypeDescription
idrequiredstringOpening id, unique within the room (op_…).
kindrequired"window" | "door" | "open" | "railing""window", "door", "open" (a stretch of wall that isn't there — a cased opening or a room open to the next), or "railing" (a half-height balustrade where a wall would be).
siderequired"back" | "front" | "left" | "right"Which wall the opening is on.
edgenumberTraced-outline edge index for shaped rooms; absent = the 4-side address
centerrequirednumberCenter as a 0–1 fraction along the wall
widthrequirednumberOpening width, meters
sillrequirednumberBottom edge above the floor, meters (0 for doors)
heightrequirednumberOpening height, meters
panesnumberWindow panes: omitted/0 = auto, 1 = single sheet, N = N panes. Doors: 0 = no leaf (cased opening)
hinge"start" | "end"Door hinge jamb; windows ignore it
swing"in" | "out"Door swing; only "out" is stored
leaf"swing" | "sliding" | "bifold" | "pocket"Door leaf type; omitted = swing, which is never stored
leafStyle"flush" | "lite" | "glass" | "panel" | "twoPanel"Door leaf face; omitted = flush, which is never stored
leafColorstringDoor leaf paint as #rrggbb
frameColorstringCasing/frame paint as #rrggbb; omitted = inherit
openbooleanDoor leaf open; only false is stored

An explicit empty openings array means “no openings”.

Each placement has:

Field Type Description
id integer Required. Positive, unique within the document. Other placements refer to it (parentId).
catalogId string Required. The catalog item. Catalog ids are opaque; use the ids the catalog API returns.
x, y number Required. Top-left of the footprint, meters.
item object Required. { name, w, d } — a snapshot so the document reads without the catalog. Informational only.
config object Optional. The fields below, each present only when set on the piece.

item is never trusted on import: the footprint is recomputed from catalogId, size, scale, and rotation.

FieldTypeDescription
rotationnumberFacing in degrees: 0 = front toward +y (into the room), 90 = left, 180 = up (−y), 270 = right. Any angle is valid.
finishstringName of one of the catalog item's finishes. Unknown names fall back to the default.
sizestringName of one of the catalog item's size presets. Unknown names fall back to the default.
mountHnumberWall-mount height above the floor, meters, for wall-snapping items such as a TV.
elevationnumberFree lift above the floor, meters, for a piece with nothing else to rest on.
onbooleanPowered on, for screens and lights. Only true is written.
powernumberDimmer for a light, 0–1. Absent means full.
openbooleanLid state for lidded items (a laptop). Only false is written.
srcstringDisplayed content for a screen, artwork, or board: an http(s) URL or a small data:image URL.
propParamsobjectParameters for a parametric prop (width, height, diagonal, ratio, and so on). Rejected on an item that has none.
pileHnumberPile height for a rug, meters.
fringedbooleanFringed ends on a rug. Only true is written.
hiddenbooleanHidden from the stage but still part of the room and its bill of materials.
lockedbooleanLocks the piece in place. true and false are both meaningful; absent uses the item's default.
selectablebooleanWhether viewers can pick the piece on share and embed surfaces. true and false are both meaningful; absent uses the item's default.
scalenumberUniform scale. Absent means 1.
orderedbooleanfalse leaves a priced piece out of the order and quote.
suggestedbooleanShows the piece as a suggestion rather than a committed choice.
raisedbooleanSit-stand desk raised to standing. Superseded by raiseT.
raiseTnumberSit-stand height, 0 (sitting) to 1 (standing).
doorsOpenstring[]Ids of the item's door leaves that are swung open.
artWnumberCustom artwork width, meters. Wins over size.
artHnumberCustom artwork height, meters. Wins over size.
slotFinishesobjectPer-part finish overrides keyed by slot (frame, base, textile, …), for manufacturer models that support them.
parentIdnumberId of another placement in this document that this piece is grouped under or attached to.
attachAtstringThe parent's attach point this piece is seated in. Only meaningful with parentId.
attachTnumberPosition along a rail attach point, 0–1.
attachmentsobject[]Every seat of a piece that spans two or more parents (a worksurface across two pedestals).
systemobjectIdentifies a manufacturer system assembly. Only meaningful on a group.
labelstringA name given to a group.

Anything not listed here is not part of the format: unknown fields anywhere in the document are ignored on import rather than rejected.

  • Within a version, only optional fields are added. A v1 reader keeps accepting every v1 document.
  • A breaking change bumps version. During a migration, Sunny may accept more than one version.
  • A document with an unsupported format or version fails with a message naming the versions that are supported.
Method and path Scope Purpose
GET /spaces/{spaceId}/layout projects:read Export the room’s current layout.
POST /spaces/{spaceId}/layout projects:write Import a document as a new version of the room.
POST /plan.svg projects:read Render a document as a 2D plan SVG. Saves nothing.

Call them on https://api.sunnysdk.com with an OAuth access token or API key carrying the scope — see OAuth & scopes and the REST API.

Export
curl https://api.sunnysdk.com/spaces/spc_8QnZr2kVtL4x/layout \
-H "Authorization: Bearer $TOKEN"
Import
curl -X POST https://api.sunnysdk.com/spaces/spc_8QnZr2kVtL4x/layout \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @layout.json

An import never edits an existing version: it saves the document as a new version of the room, and the room takes on the document’s width and depth (unless the document carries a footprint, which defines the shape instead). Each placement is resolved against the catalog; one whose catalogId doesn’t resolve is dropped and reported, and the rest still import.

Status Meaning
201 Imported; every placement resolved.
207 Imported, but some placements were dropped. itemErrors lists them.
400 Not imported: the body isn’t JSON, or the document failed validation.
404 The space doesn’t exist or isn’t visible to your workspace.
201 or 207
{
"id": "gen_h4Kd9Qe2LmXa",
"itemErrors": [
{ "index": 3, "catalogId": "itm_retired01", "error": "Unknown catalog item: itm_retired01" }
]
}

id is the new version. itemErrors[].index is the position in your placements array.

400
{
"error": "Invalid layout document",
"details": ["placements.0.x: Invalid input: expected number, received string"]
}

details lists one message per invalid field, prefixed with its path. A body that isn’t JSON at all returns { "error": "Invalid JSON body" } with no details.

The MCP server exposes the same operations as export_layout (needs projects:read) and import_layout (needs projects:write). They read and write the same format with the same validation and catalog resolution; import_layout reports dropped placements per item the same way.

The full studio embed accepts a document over postMessage with load_layout, and every embeddable room posts its current layout as a sunny-layout document. Nothing is saved to a project that way. See Embeds.

  • One room per document; there are no floor-level documents.
  • No import/export UI in the app yet — use the API, MCP, or an embed.
  • No IFC, GeoJSON, or other CAD formats.