Every change Wellknown observed on this agent, newest first, with what it was before and what it became. Tool-surface changes carry the definition diff. Nothing here is edited after the fact.
Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops"
before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: METHOD (every build, indoors or out: a room, a shop, a park, a product, a diagram. The recipes after it are worked examples with proven numbers, not the only things to build): 1) The person's own content first: fetch their page, take every real picture (photographs and artwork; never a logo, favicon, icon, or a generic share card used on several items) and upload_asset {url} them all in one parallel round before create_scene. Each image answer carries its width, height and aspect: cut every picture plane to that aspect. No address given? Ask for it once. 2) Library before placing anything: list_assets with kind and a short q for every surface and prop. A full set (baseColor + normal + ormh, matched by setId and mapRole) bound with worldScale on every ground, path, wall and trim; models by name. No flat colour on anything larger than a sign face. 3) Measure one, then repeat: place one of each model and one complete unit (frame + mat + picture + posts; sign + board + caption), read get_scene bounds and measure openings, screenshot the unit close up, then add the rest in one batch as duplicate ops off a tempId so they instance. 4) Lay out from a rule, not by eye: a half circle from a radius and a step angle (spread -75 to +75 degrees, never 180), a path as a sine centreline sampled every 1.5 m, trees by spacing with rejection near the path, every sign and picture turned to face the line of approach. Keep the walkway clear. 5) Nothing floats: posts under frames, a plinth under an object, a board behind a picture, a post or a bracket under a sign; real geometry, so the sun throws real shadows. 6) Pictures matted and matte: the image at its own aspect inside a mat (or a dark frame box 0.2 m larger, the picture 0.035 m proud of it), centre at 1.5 to 1.6 m, with materialMaps.roughness 1 (a number) beside its baseColor so a low sun cannot glare off it. Never stretch a frame model (no non-uniform scale): fill its opening with a mat and put the picture inside the mat. TAPS (item 13): frame, mat and picture go in one PRINT group with data.tap {action: \"inspect\"} and data.inspectLabel = the picture's title (the lightbox shows the framed print at its own aspect, titled); the stand around it (posts plus the print, or a sign board) is a STAND group with data.tap {action: \"moveTo\"}. The innermost tapped group wins, so a tap on the picture inspects and a tap on a post or board travels. The picture is a plane with materialMaps, not a webImage (a webImage keeps its own click). Pages and signs keep their link taps. 7) Light the place, not the object. OUTDOORS: settings lighting {timeOfDay: {source: \"fixed\", preset: \"sunset\"}, env: {intensity: 1.1}} (a preset stays sunset all year; never pin minutes), viewport {toneMapping: \"aces\"}, a pale warm fog matched to the sky (environment fogNear 30, fogFar 140), no lamps; another time only when the user asks or the scene calls for it (a night scene, a noon product shot). INDOORS: a closed room lit by its own key light, 2 to 4 times the fill; probe_lighting to check it rakes the texture. One accent colour, on the primary action only. 8) Frame the arrangement, larger and closer: the arrival eye 7 to 9 m from the middle of the arrangement, at 1.6 to 1.8 m, fov 50, lookAt the middle a little below the picture centres; at most five pictures in the arrival shot (more go further round, for the visitor to turn to); with an even count the middle of the arrangement is the GAP between the two middle pictures, so aim there. fitWidth only to the real width of the arrangement, never to squeeze a wide arc onto a phone. The way out is a small sign (scale 0.45 to 0.7) on its own post, low and to the side, at least 1 m from every stand, post and frame, aimed at the camera, and NOT the primaryActionId unless the scene exists to convert. For a set of equal things set settings.design.primaryActionId to the middle one, or call probe_framing with subject \"set\". 9) Look at the image: get_scene_screenshot, one view at the default 640 px; read every caption in it; fix glare, overlap, cut edges and covered subjects; one close-up of the most text-heavy thing; two fix rounds, then report. Use probe_framing's numbers (cut on 9:16, no exit within 12 m, eye too high) and reject a suggested pose aimed at one member of a set or at the ground. 10) Text that reads: webText draws WHITE (#ffffff) unless data.color is set and is anchored at its LEFT edge; on a light surface give it a dark data.color, size it by role (h1 0.5 m letters, h2 0.36, h3 0.26, body 0.17, caption 0.12), never by node scale (the margin pass sets maxWidth in the node's own units and does not count its scale) and not by a fontSize of your own (it did not take effect in testing); or use centred text3d. SURFACES AND TRIM: no bare flat colour on a floor, wall or ceiling. Every large surface wears a library set (materialMaps baseColor + normal + ormh with worldScale, the size of ONE TILE of the set, not of one board) or, with no set that fits, a materialPreset, with a visible roughness and colour step between floor and wall. Sets that fit, by list_assets q: \"white oak wide plank\" (floor boards, 5 boards a tile: worldScale 1.0; \"plank\" alone lists 60 distressed sets first), \"skim coat plaster pale gray\" (walls, worldScale about 1), \"dense green grass\" (lawn, worldScale 2.5), \"pea gravel\" (paths), \"flagstone\" (paving; tile ground with BOXES or planes, since worldScale on a cylinder streaks its cap). Finish a room the way a builder would: a white ceiling (a thin box, depth 0.05, matte white), glossy white crown moulding (roughness 0.15, metalness 0, about 0.08 m tall and 0.05 m proud) along every wall at the ceiling, a baseboard (a 0.1 m by 0.015 m box) along every wall at the floor, door and window casings, a frame around every picture. A plinth has a top edge, a shelf a lip, a sign a post or a bracket. BUILD RECIPES (worked examples; their numbers are measured): YARD GALLERY (an open-air gallery of the user's pictures; the first build for \"a gallery of the pictures on my site\"): a 80 x 80 m ground box 0.1 m deep with the grass set at worldScale 2.5; the arrival eye at the CENTRE of the arc, (0, 1.7, 0), and one stand group per picture on the arc around it, radius 8 m, so every picture is 8 m from the eye and faces it. Five pictures (an odd count puts one dead centre): angles t = -30, -15, 0, 15, 30 degrees; with an even count the angles straddle 0 (for six: -37.5, -22.5, -7.5, 7.5, 22.5, 37.5) and the camera aims at the gap. Each group at (8 sin t, 0, -8 cos t) with rotation [0, -t, 0] (t in radians) so its local +Z faces the eye. Each stand group has data.tap {action: \"moveTo\"}; inside it, a print group (data.tap {action: \"inspect\"}, data.inspectLabel = the picture's title) holds the frame, the mat and the picture, and the two posts sit beside it in the stand. In local coordinates: the frame \"frame-contemporary-1\" (list_assets q \"frame contemporary\", 2.6 MB, faces +X, portrait 0.735 x 1.0 m) at [0, 1.55, 0], rotation [1.5708, 0, -1.5708] (landscape, facing +Z), uniform scale 1.7 (1.7 x 1.25 m, opening 1.59 x 1.15 m); a mat box 1.62 x 1.17 x 0.02 m (#f4f1ea, roughness 0.9) just inside it; the picture plane at its own aspect inside the mat, 1.5 m wide (or 1.05 m tall when that is the tighter), materialMaps {baseColor, roughness: 1}, 0.02 m in front of the mat; two dark posts (cylinder r 0.04) from the ground into the frame's lower edge. Build the first stand, screenshot it close up, then duplicate it. Entry camera at (0, 1.7, 0), data.fov 50, lookAt (0, 1.4, -8) (the middle picture, or the middle gap), no fitWidth; a showPanel:false webpage whose arrivalCameraId is that camera; settings.design primaryActionId = the middle frame. The sign back (webLink href = their site) at scale 0.5 on its own post, about 4 m from the eye at 35 degrees to the right, its top under 1.1 m so it sits below the frames in the shot, at least 1 m from every stand, aimed at the camera. Sunset, env about 1.1, fog, ACES (METHOD 7). PARK WALK (a path past the pictures): gravel discs (cylinder r 1.5, h 0.04) every 1.5 m along x = 5 sin(2 pi z / 44), each with a random Y turn and y from 0.02 to 0.032 against z-fighting, all duplicates of one; library trees (oak, pine, birch; scale 7 to 12, y = scale / 2) every 4.5 m per side at 6.5 to 13 m out, none within 5.5 m of the path; picture signs (a sign group with data.tap {action: \"moveTo\"}: two posts, a text3d caption, and a print group with data.tap {action: \"inspect\"} and its inspectLabel holding the 2.7 x 1.9 m board and the picture plane at its aspect 0.045 m in front) 2.9 m off the path, alternating sides, 11 m apart, each turned to face a point 7 m before it; a trailhead page with \"Walk the path\" (a camera riding a nurbs curve along the path at 1.65 m, 70 s, loop once, rideBreak \"move\") and \"Skip to the end\"; an end page with the sign back; entry eye 1.6 m. WALKTHROUGH (a site with sections): one showPanel:false webpage per section, 45 m apart; each with a pale wall box 2.4 m behind its origin, framed pictures at their aspect (each frame box and picture plane in a print group with data.tap {action: \"inspect\"} and its inspectLabel), dark h1 and body webText, a stone plaza box; a back sign and a next sign (webLink scale 0.7 on posts, within 12 m of the arrival eye, aimed at it); its own data.primaryActionId (the next sign) and arrivalCameraId (eye 2.0 to 2.3 m, 12 to 16 m back, fitWidth = the wall plus both signs). ROOM GALLERY (only when the user asks for a room, a hall or an exhibition room): walls, floor and ceiling as thin boxes; walls the off-white plaster set at worldScale about 1, floor the oak boards, the white ceiling, the glossy crown and the baseboard of SURFACES AND TRIM; pictures 1.6 to 1.8 m wide, centre at 1.5 to 1.6 m, on the wall the entry camera faces, the arrival framed on them: the arrival eye 7 to 9 m from the picture wall, 1.6 to 1.7 m up, fov 50, so the room is about 10 m deep; captions as centred text3d about 0.12 m tall (data.fontSize 0.12), dark, under each picture, readable from the arrival; each frame, mat and picture in a print group with data.tap {action: \"inspect\"} and the caption as its inspectLabel, and a picture on a stand or easel sits in a stand group with data.tap {action: \"moveTo\"} as in YARD GALLERY; the sign is the way out, smaller than a picture, centred on the wall's axis or a doorway, at least 1 m from every frame; never a logo on a wall; the page showPanel false with its anchor just behind the back wall; one key spot raking the pictures. FACTS THE BUILDERS FOUND BY PROBING: library GLBs arrive normalised to 1 m on their longest side and centred, so a model at uniform scale s stands on the ground at y = s x (its height) / 2 (get_scene reports the bounds); the low-poly picture frames face +X and are portrait 0.82 x 1.0 m with a 0.724 x 0.904 opening (roll a frame [1.5708, 0, -1.5708] for landscape facing +Z, never scale it unevenly); worldScale on a cylinder streaks its cap, so tile ground with boxes or planes; webImage takes data.width and data.height; the \"no backdrop\" and spacing warnings repeat on every batch and lintMute does not silence scene-wide ones: read them once, then go on. OP REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\"; a row marked shared:true is the public library, copy it in first with upload_asset {assetId} and place the url that returns), from upload_asset {url} (a .glb link up to 36 MB) or from get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. A GLB's own animation plays: the first clip loops on the published page (data.animationClip picks another clip by name or 0-based index, from the names in data.animationNames; data.animationPlay:false holds it still; a model swap keeps animationClip and the first clip plays when the new file lacks it). Animated or rigged models render individually, not instanced. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth; it draws WHITE unless data.color is set, is anchored at its LEFT edge, sizes by role (a fontSize of your own did not take effect in testing), and the margin pass rewrites data.maxWidth in the node's own units without counting node scale, so scale a text node and it overflows its panel), webImage (data.src + data.alt; a src on another website is EMPTY for every visitor, so bring it in with upload_asset {url} first and use the /assets url it returns), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted ANYWHERE in the same batch works as linkedPageId (a sign may come before the page it links to), so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. A tap on a GROUP catches taps on every mesh under it, and the innermost tapped node wins: a stand group with moveTo around a print group with inspect (frame, mat, picture plane) inspects the picture and travels from a post. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt} (src an /assets url: an off-site picture is EMPTY for visitors, upload_asset {url} copies it in); webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `roughness` takes a map url, or a NUMBER from 0 (glossy) to 1 (matte) for the whole surface: a picture with {baseColor, roughness: 1} is a matte print a low sun cannot glare off. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD sca…after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: METHOD (every build, indoors or out: a room, a shop, a park, a product, a diagram. The recipes after it are worked examples with proven numbers, not the only things to build): 1) The person's own content first: fetch their page, take every real picture (photographs and artwork; never a logo, favicon, icon, or a generic share card used on several items) and upload_asset {url} them all in one parallel round before create_scene. Each image answer carries its width, height and aspect: cut every picture plane to that aspect. No address given? Ask for it once. 2) Library before placing anything: list_assets with kind and a short q for every surface and prop. A full set (baseColor + normal + ormh, matched by setId and mapRole) bound with worldScale on every ground, path, wall and trim; models by name. No flat colour on anything larger than a sign face. 3) Measure one, then repeat: place one of each model and one complete unit (frame + mat + picture + posts; sign + board + caption), read get_scene bounds and measure openings, screenshot the unit close up, then add the rest in one batch as duplicate ops off a tempId so they instance. 4) Lay out from a rule, not by eye: a half circle from a radius and a step angle (spread -75 to +75 degrees, never 180), a path as a sine centreline sampled every 1.5 m, trees by spacing with rejection near the path, every sign and picture turned to face the line of approach. Keep the walkway clear. 5) Nothing floats: posts under frames, a plinth under an object, a board behind a picture, a post or a bracket under a sign; real geometry, so the sun throws real shadows. 6) Pictures matted and matte: the image at its own aspect inside a mat (or a dark frame box 0.2 m larger, the picture 0.035 m proud of it), centre at 1.5 to 1.6 m, with materialMaps.roughness 1 (a number) beside its baseColor so a low sun cannot glare off it. Never stretch a frame model (no non-uniform scale): fill its opening with a mat and put the picture inside the mat. TAPS (item 13): frame, mat and picture go in one PRINT group with data.tap {action: \"inspect\"} and data.inspectLabel = the picture's title (the lightbox shows the framed print at its own aspect, titled); the stand around it (posts plus the print, or a sign board) is a STAND group with data.tap {action: \"moveTo\"}. The innermost tapped group wins, so a tap on the picture inspects and a tap on a post or board travels. The picture is a plane with materialMaps, not a webImage (a webImage keeps its own click). Pages and signs keep their link taps. 7) Light the place, not the object. OUTDOORS: settings lighting {timeOfDay: {source: \"fixed\", preset: \"sunset\"}, env: {intensity: 1.1}} (a preset stays sunset all year; never pin minutes), viewport {toneMapping: \"aces\"}, a pale warm fog matched to the sky (environment fogNear 30, fogFar 140), no lamps; another time only when the user asks or the scene calls for it (a night scene, a noon product shot). INDOORS: a closed room lit by its own key light, 2 to 4 times the fill; probe_lighting to check it rakes the texture. One accent colour, on the primary action only. 8) Frame the arrangement, larger and closer: the arrival eye 7 to 9 m from the middle of the arrangement, at 1.6 to 1.8 m, fov 50, lookAt the middle a little below the picture centres; at most five pictures in the arrival shot (more go further round, for the visitor to turn to); with an even count the middle of the arrangement is the GAP between the two middle pictures, so aim there. fitWidth only to the real width of the arrangement, never to squeeze a wide arc onto a phone. The way out is a small sign (scale 0.45 to 0.7) on its own post, low and to the side, at least 1 m from every stand, post and frame, aimed at the camera, and NOT the primaryActionId unless the scene exists to convert. For a set of equal things set settings.design.primaryActionId to the middle one, or call probe_framing with subject \"set\". 9) Look at the image: get_scene_screenshot, one view at the default 640 px; read every caption in it; fix glare, overlap, cut edges and covered subjects; one close-up of the most text-heavy thing; two fix rounds, then report: the editorUrl, and that the scene is private until the person says \"publish it\", which gives a public link to send (publish_scene after their yes). Use probe_framing's numbers (cut on 9:16, no exit within 12 m, eye too high) and reject a suggested pose aimed at one member of a set or at the ground. 10) Text that reads: webText draws WHITE (#ffffff) unless data.color is set and is anchored at its LEFT edge; on a light surface give it a dark data.color, size it by role (h1 0.5 m letters, h2 0.36, h3 0.26, body 0.17, caption 0.12), never by node scale (the margin pass sets maxWidth in the node's own units and does not count its scale) and not by a fontSize of your own (it did not take effect in testing); or use centred text3d. SURFACES AND TRIM: no bare flat colour on a floor, wall or ceiling. Every large surface wears a library set (materialMaps baseColor + normal + ormh with worldScale, the size of ONE TILE of the set, not of one board) or, with no set that fits, a materialPreset, with a visible roughness and colour step between floor and wall. Sets that fit, by list_assets q: \"white oak wide plank\" (floor boards, 5 boards a tile: worldScale 1.0; \"plank\" alone lists 60 distressed sets first), \"skim coat plaster pale gray\" (walls, worldScale about 1), \"dense green grass\" (lawn, worldScale 2.5), \"pea gravel\" (paths), \"flagstone\" (paving; tile ground with BOXES or planes, since worldScale on a cylinder streaks its cap). Finish a room the way a builder would: a white ceiling (a thin box, depth 0.05, matte white), glossy white crown moulding (roughness 0.15, metalness 0, about 0.08 m tall and 0.05 m proud) along every wall at the ceiling, a baseboard (a 0.1 m by 0.015 m box) along every wall at the floor, door and window casings, a frame around every picture. A plinth has a top edge, a shelf a lip, a sign a post or a bracket. BUILD RECIPES (worked examples; their numbers are measured): YARD GALLERY (an open-air gallery of the user's pictures; the first build for \"a gallery of the pictures on my site\"): a 80 x 80 m ground box 0.1 m deep with the grass set at worldScale 2.5; the arrival eye at the CENTRE of the arc, (0, 1.7, 0), and one stand group per picture on the arc around it, radius 8 m, so every picture is 8 m from the eye and faces it. Five pictures (an odd count puts one dead centre): angles t = -30, -15, 0, 15, 30 degrees; with an even count the angles straddle 0 (for six: -37.5, -22.5, -7.5, 7.5, 22.5, 37.5) and the camera aims at the gap. Each group at (8 sin t, 0, -8 cos t) with rotation [0, -t, 0] (t in radians) so its local +Z faces the eye. Each stand group has data.tap {action: \"moveTo\"}; inside it, a print group (data.tap {action: \"inspect\"}, data.inspectLabel = the picture's title) holds the frame, the mat and the picture, and the two posts sit beside it in the stand. In local coordinates: the frame \"frame-contemporary-1\" (list_assets q \"frame contemporary\", 2.6 MB, faces +X, portrait 0.735 x 1.0 m) at [0, 1.55, 0], rotation [1.5708, 0, -1.5708] (landscape, facing +Z), uniform scale 1.7 (1.7 x 1.25 m, opening 1.59 x 1.15 m); a mat box 1.62 x 1.17 x 0.02 m (#f4f1ea, roughness 0.9) just inside it; the picture plane at its own aspect inside the mat, 1.5 m wide (or 1.05 m tall when that is the tighter), materialMaps {baseColor, roughness: 1}, 0.02 m in front of the mat; two dark posts (cylinder r 0.04) from the ground into the frame's lower edge. Build the first stand, screenshot it close up, then duplicate it. Entry camera at (0, 1.7, 0), data.fov 50, lookAt (0, 1.4, -8) (the middle picture, or the middle gap), no fitWidth; a showPanel:false webpage whose arrivalCameraId is that camera; settings.design primaryActionId = the middle frame. The sign back (webLink href = their site) at scale 0.5 on its own post, about 4 m from the eye at 35 degrees to the right, its top under 1.1 m so it sits below the frames in the shot, at least 1 m from every stand, aimed at the camera. Sunset, env about 1.1, fog, ACES (METHOD 7). PARK WALK (a path past the pictures): gravel discs (cylinder r 1.5, h 0.04) every 1.5 m along x = 5 sin(2 pi z / 44), each with a random Y turn and y from 0.02 to 0.032 against z-fighting, all duplicates of one; library trees (oak, pine, birch; scale 7 to 12, y = scale / 2) every 4.5 m per side at 6.5 to 13 m out, none within 5.5 m of the path; picture signs (a sign group with data.tap {action: \"moveTo\"}: two posts, a text3d caption, and a print group with data.tap {action: \"inspect\"} and its inspectLabel holding the 2.7 x 1.9 m board and the picture plane at its aspect 0.045 m in front) 2.9 m off the path, alternating sides, 11 m apart, each turned to face a point 7 m before it; a trailhead page with \"Walk the path\" (a camera riding a nurbs curve along the path at 1.65 m, 70 s, loop once, rideBreak \"move\") and \"Skip to the end\"; an end page with the sign back; entry eye 1.6 m. WALKTHROUGH (a site with sections): one showPanel:false webpage per section, 45 m apart; each with a pale wall box 2.4 m behind its origin, framed pictures at their aspect (each frame box and picture plane in a print group with data.tap {action: \"inspect\"} and its inspectLabel), dark h1 and body webText, a stone plaza box; a back sign and a next sign (webLink scale 0.7 on posts, within 12 m of the arrival eye, aimed at it); its own data.primaryActionId (the next sign) and arrivalCameraId (eye 2.0 to 2.3 m, 12 to 16 m back, fitWidth = the wall plus both signs). ROOM GALLERY (only when the user asks for a room, a hall or an exhibition room): walls, floor and ceiling as thin boxes; walls the off-white plaster set at worldScale about 1, floor the oak boards, the white ceiling, the glossy crown and the baseboard of SURFACES AND TRIM; pictures 1.6 to 1.8 m wide, centre at 1.5 to 1.6 m, on the wall the entry camera faces, the arrival framed on them: the arrival eye 7 to 9 m from the picture wall, 1.6 to 1.7 m up, fov 50, so the room is about 10 m deep; captions as centred text3d about 0.12 m tall (data.fontSize 0.12), dark, under each picture, readable from the arrival; each frame, mat and picture in a print group with data.tap {action: \"inspect\"} and the caption as its inspectLabel, and a picture on a stand or easel sits in a stand group with data.tap {action: \"moveTo\"} as in YARD GALLERY; the sign is the way out, smaller than a picture, centred on the wall's axis or a doorway, at least 1 m from every frame; never a logo on a wall; the page showPanel false with its anchor just behind the back wall; one key spot raking the pictures. FACTS THE BUILDERS FOUND BY PROBING: library GLBs arrive normalised to 1 m on their longest side and centred, so a model at uniform scale s stands on the ground at y = s x (its height) / 2 (get_scene reports the bounds); the low-poly picture frames face +X and are portrait 0.82 x 1.0 m with a 0.724 x 0.904 opening (roll a frame [1.5708, 0, -1.5708] for landscape facing +Z, never scale it unevenly); worldScale on a cylinder streaks its cap, so tile ground with boxes or planes; webImage takes data.width and data.height; the \"no backdrop\" and spacing warnings repeat on every batch and lintMute does not silence scene-wide ones: read them once, then go on. OP REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\"; a row marked shared:true is the public library, copy it in first with upload_asset {assetId} and place the url that returns), from upload_asset {url} (a .glb link up to 36 MB) or from get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. A GLB's own animation plays: the first clip loops on the published page (data.animationClip picks another clip by name or 0-based index, from the names in data.animationNames; data.animationPlay:false holds it still; a model swap keeps animationClip and the first clip plays when the new file lacks it). Animated or rigged models render individually, not instanced. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth; it draws WHITE unless data.color is set, is anchored at its LEFT edge, sizes by role (a fontSize of your own did not take effect in testing), and the margin pass rewrites data.maxWidth in the node's own units without counting node scale, so scale a text node and it overflows its panel), webImage (data.src + data.alt; a src on another website is EMPTY for every visitor, so bring it in with upload_asset {url} first and use the /assets url it returns), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted ANYWHERE in the same batch works as linkedPageId (a sign may come before the page it links to), so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. A tap on a GROUP catches taps on every mesh under it, and the innermost tapped node wins: a stand group with moveTo around a print group with inspect (frame, mat, picture plane) inspects the picture and travels from a post. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt} (src an /assets url: an off-site picture is EMPTY for visitors, upload_asset {url} copies it in); webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `roughness` takes a map url, or a NUMBER from 0 (glossy) to 1 (matte) for the whole surface: a picture with {baseColor, roughness: 1} is a matte print a low sun cannot glare off. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of …Changed the definition of "apply_ops"
before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: METHOD (every build, indoors or out: a room, a shop, a park, a product, a diagram. The recipes after it are worked examples with proven numbers, not the only things to build): 1) The person's own content first: fetch their page, take every real picture (photographs and artwork; never a logo, favicon, icon, or a generic share card used on several items) and upload_asset {url} them all in one parallel round before create_scene. Each image answer carries its width, height and aspect: cut every picture plane to that aspect. No address given? Ask for it once. 2) Library before placing anything: list_assets with kind and a short q for every surface and prop. A full set (baseColor + normal + ormh, matched by setId and mapRole) bound with worldScale on every ground, path, wall and trim; models by name. No flat colour on anything larger than a sign face. 3) Measure one, then repeat: place one of each model and one complete unit (frame + mat + picture + posts; sign + board + caption), read get_scene bounds and measure openings, screenshot the unit close up, then add the rest in one batch as duplicate ops off a tempId so they instance. 4) Lay out from a rule, not by eye: a half circle from a radius and a step angle (spread -75 to +75 degrees, never 180), a path as a sine centreline sampled every 1.5 m, trees by spacing with rejection near the path, every sign and picture turned to face the line of approach. Keep the walkway clear. 5) Nothing floats: posts under frames, a plinth under an object, a board behind a picture, a post or a bracket under a sign; real geometry, so the sun throws real shadows. 6) Pictures matted and matte: the image at its own aspect inside a mat (or a dark frame box 0.2 m larger, the picture 0.035 m proud of it), centre at 1.5 to 1.6 m, with materialMaps.roughness 1 (a number) beside its baseColor so a low sun cannot glare off it. Never stretch a frame model (no non-uniform scale): fill its opening with a mat and put the picture inside the mat. 7) Light the place, not the object. OUTDOORS: settings lighting {timeOfDay: {source: \"fixed\", preset: \"sunset\"}, env: {intensity: 1.1}} (a preset stays sunset all year; never pin minutes), viewport {toneMapping: \"aces\"}, a pale warm fog matched to the sky (environment fogNear 30, fogFar 140), no lamps; another time only when the user asks or the scene calls for it (a night scene, a noon product shot). INDOORS: a closed room lit by its own key light, 2 to 4 times the fill; probe_lighting to check it rakes the texture. One accent colour, on the primary action only. 8) Frame the arrangement: the entry camera at 1.6 m (a walk) to 2.0-2.3 m (a set in depth), 11 to 16 m back, lookAt the middle of the arrangement a little below its centre, every item inside 16:9; fitWidth only to the real width of the arrangement, never to squeeze a wide arc onto a phone. The way out is a small sign (scale 0.45 to 0.7) on a post, low and to the side, aimed at the camera, and NOT the primaryActionId unless the scene exists to convert. For a set of equal things set settings.design.primaryActionId to the middle one, or call probe_framing with subject \"set\". 9) Look at the image: get_scene_screenshot, one view at the default 640 px; read every caption in it; fix glare, overlap, cut edges and covered subjects; one close-up of the most text-heavy thing; two fix rounds, then report. Use probe_framing's numbers (cut on 9:16, no exit within 12 m, eye too high) and reject a suggested pose aimed at one member of a set or at the ground. 10) Text that reads: webText draws WHITE (#ffffff) unless data.color is set and is anchored at its LEFT edge; on a light surface give it a dark data.color, size it by role (h1 0.5 m letters, h2 0.36, h3 0.26, body 0.17, caption 0.12), never by node scale (the margin pass sets maxWidth in the node's own units and does not count its scale) and not by a fontSize of your own (it did not take effect in testing); or use centred text3d. SURFACES AND TRIM: no bare flat colour on a floor, wall or ceiling. Every large surface wears a library set (materialMaps baseColor + normal + ormh with worldScale, the size of ONE TILE of the set, not of one board) or, with no set that fits, a materialPreset, with a visible roughness and colour step between floor and wall. Sets that fit, by list_assets q: \"white oak wide plank\" (floor boards, 5 boards a tile: worldScale 1.0; \"plank\" alone lists 60 distressed sets first), \"skim coat plaster pale gray\" (walls, worldScale about 1), \"dense green grass\" (lawn, worldScale 2.5), \"pea gravel\" (paths), \"flagstone\" (paving; tile ground with BOXES or planes, since worldScale on a cylinder streaks its cap). Finish a room the way a builder would: a white ceiling (a thin box, depth 0.05, matte white), glossy white crown moulding (roughness 0.15, metalness 0, about 0.08 m tall and 0.05 m proud) along every wall at the ceiling, a baseboard (a 0.1 m by 0.015 m box) along every wall at the floor, door and window casings, a frame around every picture. A plinth has a top edge, a shelf a lip, a sign a post or a bracket. BUILD RECIPES (worked examples; their numbers are measured): YARD GALLERY (an open-air gallery of the user's pictures; the first build for \"a gallery of the pictures on my site\"): a 80 x 80 m ground box 0.1 m deep with the grass set at worldScale 2.5; 5 to 7 pictures, an odd count so one stands dead centre; one stand group per picture on a half circle of radius 7 m, angle t from -75 to +75 degrees in 25 degree steps, the group at (7 sin t, 0, -7 cos t) with rotation [0, -t, 0] (t in radians) so its local +Z faces the centre. Inside each stand, in local coordinates: the frame \"frame-contemporary-1\" (list_assets q \"frame contemporary\", 2.6 MB, faces +X, portrait 0.735 x 1.0 m) at [0, 1.6, 0], rotation [1.5708, 0, -1.5708] (a landscape frame facing +Z), uniform scale 2 (2.0 x 1.47 m, opening 1.87 x 1.35 m); a mat box 1.9 x 1.37 x 0.02 m (#f4f1ea, roughness 0.9) just inside it; the picture plane at its own aspect, width 1.8 m, materialMaps {baseColor, roughness: 1}, 0.02 m in front of the mat; two dark posts (cylinder r 0.04) from the ground into the frame's lower edge. Build the first stand, screenshot it close up, then duplicate it. Entry camera eye (0, 2.0, 11.5) lookAt (0, 1.35, -3), default fov, no fitWidth; a showPanel:false webpage whose arrivalCameraId is that camera; settings.design primaryActionId = the middle frame; the sign back (webLink href = their site) at scale 0.45 on its own post at the front right, aimed at the camera. Sunset, env about 1.1, fog, ACES (METHOD 7). PARK WALK (a path past the pictures): gravel discs (cylinder r 1.5, h 0.04) every 1.5 m along x = 5 sin(2 pi z / 44), each with a random Y turn and y from 0.02 to 0.032 against z-fighting, all duplicates of one; library trees (oak, pine, birch; scale 7 to 12, y = scale / 2) every 4.5 m per side at 6.5 to 13 m out, none within 5.5 m of the path; picture signs (two posts, a 2.7 x 1.9 m board, the picture plane at its aspect 0.045 m in front, a text3d caption) 2.9 m off the path, alternating sides, 11 m apart, each turned to face a point 7 m before it; a trailhead page with \"Walk the path\" (a camera riding a nurbs curve along the path at 1.65 m, 70 s, loop once, rideBreak \"move\") and \"Skip to the end\"; an end page with the sign back; entry eye 1.6 m. WALKTHROUGH (a site with sections): one showPanel:false webpage per section, 45 m apart; each with a pale wall box 2.4 m behind its origin, framed pictures at their aspect, dark h1 and body webText, a stone plaza box; a back sign and a next sign (webLink scale 0.7 on posts, within 12 m of the arrival eye, aimed at it); its own data.primaryActionId (the next sign) and arrivalCameraId (eye 2.0 to 2.3 m, 12 to 16 m back, fitWidth = the wall plus both signs). ROOM GALLERY (only when the user asks for a room, a hall or an exhibition room): walls, floor and ceiling as thin boxes; walls the off-white plaster set at worldScale about 1, floor the oak boards, the white ceiling, the glossy crown and the baseboard of SURFACES AND TRIM; pictures 1.2 to 1.8 m wide, centre at 1.5 to 1.6 m, on the wall the entry camera faces, the arrival framed on them; the sign is the way out, smaller than a picture, centred on the wall's axis or a doorway; never a logo on a wall; the page showPanel false with its anchor just behind the back wall; one key spot raking the pictures. FACTS THE BUILDERS FOUND BY PROBING: library GLBs arrive normalised to 1 m on their longest side and centred, so a model at uniform scale s stands on the ground at y = s x (its height) / 2 (get_scene reports the bounds); the low-poly picture frames face +X and are portrait 0.82 x 1.0 m with a 0.724 x 0.904 opening (roll a frame [1.5708, 0, -1.5708] for landscape facing +Z, never scale it unevenly); worldScale on a cylinder streaks its cap, so tile ground with boxes or planes; webImage takes data.width and data.height; the \"no backdrop\" and spacing warnings repeat on every batch and lintMute does not silence scene-wide ones: read them once, then go on. OP REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\"; a row marked shared:true is the public library, copy it in first with upload_asset {assetId} and place the url that returns), from upload_asset {url} (a .glb link up to 36 MB) or from get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. A GLB's own animation plays: the first clip loops on the published page (data.animationClip picks another clip by name or 0-based index, from the names in data.animationNames; data.animationPlay:false holds it still; a model swap keeps animationClip and the first clip plays when the new file lacks it). Animated or rigged models render individually, not instanced. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth; it draws WHITE unless data.color is set, is anchored at its LEFT edge, sizes by role (a fontSize of your own did not take effect in testing), and the margin pass rewrites data.maxWidth in the node's own units without counting node scale, so scale a text node and it overflows its panel), webImage (data.src + data.alt; a src on another website is EMPTY for every visitor, so bring it in with upload_asset {url} first and use the /assets url it returns), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted ANYWHERE in the same batch works as linkedPageId (a sign may come before the page it links to), so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt} (src an /assets url: an off-site picture is EMPTY for visitors, upload_asset {url} copies it in); webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `roughness` takes a map url, or a NUMBER from 0 (glossy) to 1 (matte) for the whole surface: a picture with {baseColor, roughness: 1} is a matte print a low sun cannot glare off. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. `alphaMode` mask turns a baseColor with a transparent background (a cut-out gravestone, cat or ghost PNG) into a cut-out on a plane, with a shaped shadow; blend fades soft edges; the default opaque draws the empty pixels as a solid card. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"alphaMode":{"enum":["opaque","mask","blend"],"type":"string"},"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":["string","number"]},"uvAni…Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops", "create_scene", "get_scene_screenshot" and 4 more
Use this to build or change a 3D scene: add rooms, text, pictures, links, models, lights, pages. Edit a scene by applying a batch of ⟨133 unchanged words⟩ batch. READ FIRST: the `operations` field descriptionisopens with METHOD (ten steps every build follows, indoors and out: thefullperson'sreferenceown(spacingpictures first,materialslibrary sets before flat colour,importedmeasuremodelsone then repeat
Authorization not required, issuer https://3dcms.info
Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops"
⟨6490 unchanged words⟩ Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops"
before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\"; a row marked shared:true is the public library, copy it in first with upload_asset {assetId} and place the url that returns), from upload_asset {url} (a .glb link up to 36 MB) or from get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. A GLB's own animation plays: the first clip loops on the published page (data.animationClip picks another clip by name or 0-based index, from the names in data.animationNames; data.animationPlay:false holds it still; a model swap keeps animationClip and the first clip plays when the new file lacks it). Animated or rigged models render individually, not instanced. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth), webImage (data.src + data.alt; a src on another website is EMPTY for every visitor, so bring it in with upload_asset {url} first and use the /assets url it returns), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted ANYWHERE in the same batch works as linkedPageId (a sign may come before the page it links to), so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt} (src an /assets url: an off-site picture is EMPTY for visitors, upload_asset {url} copies it in); webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0. Outside 0 to 1 is refused.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `camera` is a saved viewpoint: data.isEntry true makes it the one visitors ARRIVE at on the published site (aim it with lookAt), and a page names its own arrival view with data.arrivalCameraId. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness (1 or more, default 5). `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters; directional, ambient and hemisphere are refused: the sun is the `settings` op, lighting.timeOfDay or lighting.sun), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024, and above it is refused): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent).","type":["string","null"]},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, lookAt or lookAtNodeId (aim instead of a rotation, from the position the patch sets), plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's). To AIM a camera or a lamp, or turn a sign to face a point, pass lookAt instead.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5. Outside 0 to 1 is refused.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\"…Changed the definition of "apply_ops", "get_scene_screenshot", "list_assets" and 2 more
before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\") or get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth), webImage (data.src + data.alt), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted in the SAME batch works as linkedPageId, so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0. Outside 0 to 1 is refused.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `camera` is a saved viewpoint: data.isEntry true makes it the one visitors ARRIVE at on the published site (aim it with lookAt), and a page names its own arrival view with data.arrivalCameraId. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness (1 or more, default 5). `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters; directional, ambient and hemisphere are refused: the sun is the `settings` op, lighting.timeOfDay or lighting.sun), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024, and above it is refused): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent).","type":["string","null"]},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, lookAt or lookAtNodeId (aim instead of a rotation, from the position the patch sets), plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's). To AIM a camera or a lamp, or turn a sign to face a point, pass lookAt instead.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5. Outside 0 to 1 is refused.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness D…Authorization not required, issuer https://3dcms.info
Changed the definition of "publish_scene"
⟨89 unchanged words⟩ id from list_scenes.","type":"string"},"shortLink":{"description":"true also returns shortUrl, a short 3dcms.info/s/<code> link to the site (one stable code per site), for sharing and QR codes.","type":"boolean"},"siteSlug":{"description":"Publish onto one of YOUR existing sites by its slug (the part after /sites/ in its URL) instead of this scene's own. When that site shows another scene, the first call answers willReplace: name the site to the user and call again with confirmReplace:true on a yes. Later publishes of this scene go to that site.","type":"string"}},"required":["sceneId"],"type":"object"}
Changed the definition of "apply_ops", "create_scene", "publish_scene" and 1 more
⟨6463 unchanged words⟩ {"destructiveHint":falsetrue,"idempotentHint":false,"openWorldHint":false,"readOnlyHint" ⟨6 unchanged words⟩
Create a new,empty
Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops", "compose_surface", "create_scene" and 10 more
before
—after
{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Edit a 3dCMS scene"}before
—after
{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Build a printed surface"}Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops" and "list_assets"
Edit a scene by applying a batch of operations ATOMICALLY (all-or-nothing; one undo step in the editorall-or-nothing). Call this for any add / move ⟨50 unchanged words⟩ same string as the id/parentId of later ops.LARGEEachSCENEScall is atomic and checkpointed:~40-50theobjectsownerpercancallroll back in the editor (eachFile
Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops"
⟨5481 unchanged words⟩ ,"type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for ⟨11 unchanged words⟩ the source, under the same parent).","type":["string","null"]},"patch":{"additionalProperties":{},"description ⟨814 unchanged words⟩ default to 500); pass where.limit to delete…more on purpose. `s…
Changed the definition of "apply_ops", "compose_surface", "get_scene" and 3 more
⟨75 unchanged words⟩ same string as the id/parentId of later ops.SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree.LARGECoordinatesSCENES:Y up, units ≈ meters, ground at y≈0 — place~40-50 objectsso they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind "glb") or get_generation goes on a geometry node as data: {primitive:"imported", assetUrl:"<url>"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth), webImage (data.src + data.alt), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud. PAGES & NEIGHBORHOODS: add a `webpage` node
Certificate changed, valid to 2026-12-27
Authorization not required, issuer https://3dcms.info
Authorization not required, issuer https://3dcms.info
Added "compose_surface" (12 tools before, 13 now)
Build a printed surface — Make a flat surface that looks PRINTED ON a real material: a quote on a stretched canvas, a poster, a shop sign, a label, a plaque, a book cover. Start from a `base`: a texture set from the library (`setId` from list_assets; tiled at `tileSize` meters, or `mode` "stretch" for a whole-sheet scan), explicit `maps` {baseColor, normal, ormh} urls, or a flat `color`; `tint` re-colours the base and keeps its detail. Then `layers`, painted in order: text {text (use \n or | for line breaks), font typewriter|sans|horror, weight, size (letter height in meters), color, align, x/y (the block centre, 0..1…
Added "measure" and "upload_asset" (10 tools before, 12 now)
Measure things in a scene — Measure nodes in a saved scene (analytic, instant, no render). For each node in nodeIds: its world centre, its size along its own axes after every scale above it, its world bounding box, and `clearance`: the nearest visible thing in each world direction (+x, -x, +y, -y, +z, -z) that overlaps it across that axis, with the gap in meters (how far a frame hangs off its wall, how much room a camera has before it meets a side wall). An IMPORTED MODEL also gets `opening`: the hole through it (a picture frame, a doorway, a window, an arch), measured from the model's own triangles: its size in meters …
Add a file to the asset library — Put a file into the account's asset library and get back its /assets url to use in apply_ops (a glb on a geometry node as data {primitive:"imported", assetUrl}, an image in materialMaps or a webImage src, an mp3 in an audioClip). Give ONE source: `url` (a public http(s) link, fetched by the server), `dataBase64` with `filename` (the file itself, up to about 35 MB), or (`path` is only available when the MCP runs on your own computer). A model gets its phone-sized copy made automatically, the same as an editor upload. Label it and give keywords so list_assets finds it again.
Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops"
⟨526 unchanged words⟩ page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; ⟨3655 unchanged words⟩
Changed the definition of "apply_ops"
⟨527 unchanged words⟩ | linkedCameraId (jump to a viewpoint), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the ⟨3629 unchanged words⟩
Added "design_review" and "probe_framing"; changed the definition of "apply_ops", "get_scene" and "get_scene_screenshot" (8 tools before, 10 now)
before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent)."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's).","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update, assignMaterial and delete: act on MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId (direct children only), underId (the WHOLE subtree of a node: every wall in a room group, however deep; a same-batch tempId works), materialAssetId (every node wearing that material, for a global restyle), limit} - every field given must match. Pass either `id` or `where`, never both. delete removes each match WITH its subtree and defaults to a limit of 100 (update and assignMaterial default to 500); pass where.limit to delete more on purpose. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}Changed the definition of "apply_ops"
⟨75 unchanged words⟩ same string as the id/parentId of later ops. SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground ⟨1227 unchanged words⟩ pose and the visitor's camera then follows thecurve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the
Changed the definition of "list_scenes"
⟨5 unchanged words⟩ in your 3dCMS account (editable "sources")., newest first, 40 at a time. Returns each scene's id, name, page count, last-updated time andlast-updated(whentime.published) its site slug. Pass `q` to search by name or slug instead of paging through a big account; page with the `nextOffset` the previous reply gave you. Use a scene id with get_scene and apply_ops.
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{
Changed the definition of "apply_ops" and "get_scene"
⟨96 unchanged words⟩ they rest sensibly. Geometry size lives in `data`,(under the keys get_scene reports as `size`: box {width,height,depth};,sphere/cylinder/torusplane {width,height}, sphere {radius};, cylinder {radius,height}, torus {radius,tube}); the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). MATERIALS: to color an object pass `color` ( ⟨1222 unchanged words⟩ an OPEN curve loop cuts back to thestart each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean fo
Authorization not required, issuer https://3dcms.info
Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops"
⟨906 unchanged words⟩ nodes cost scene weight and cannot be GPU-instanced—: animate a few focal objects, not everything. ⟨418 unchanged words⟩ loop and pingpong ride until the visitor takesover),over;easing,onpath.offsetanandOPENpath.reversecurvemeanloopwhatcutsthey
Changed the definition of "apply_ops"
⟨797 unchanged words⟩ in the published scene. ANIMATION: any nodeexcept a camera(geometry, text3d, group, cable,andthe web blocks and lights,tooand a camera, which makes it a RIDE, below) takes data.animation to move on a loop ⟨424 unchanged words⟩ draw it anyway. The editor always shows it.FAST PATHCAMERA—RIDEmake_page(1026):forapage that isCAMERAanode
Added "probe_lighting"; changed the definition of "apply_ops", "get_scene" and "get_scene_screenshot" (7 tools before, 8 now)
⟨693 unchanged words⟩ arrival camera with data.viaCameraIds ["door"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, ⟨454 unchanged words⟩ get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. FAST PATH — make_page: for a page that ⟨105 unchanged words⟩ result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch — each call is its own atomic Undo and stays well within limits; call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings` — advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pag
Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops"
⟨1115 unchanged words⟩ emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying ⟨138 unchanged words⟩ material lands on from that primitive's dimensions andnodeits WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the ⟨182 unchanged words⟩ one material. Each distinct phase is its own material (one textureinstanceuploadatisruntimeshared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city.
Changed the definition of "apply_ops" and "list_assets"
⟨264 unchanged words⟩ rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText ⟨183 unchanged words⟩ and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId "door", then the arrival camera with data.viaCameraIds ["door"]. TAP ACTIONS: object nodes (geometry, text3d, ⟨658 unchanged words⟩
Changed the definition of "apply_ops"
⟨756 unchanged words⟩ with different offsets); reverse:true rides itbackwards.backwards;EasingfacingEasedefaults(seconds, default 1, 0 snaps) is how long the rider takes tolinearTURNonfromathe way it was placed into the facing of the pathsowhen the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on
Changed the definition of "apply_ops"
⟨637 unchanged words⟩ animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {"duration":12,"loop":"loop","path":{"curveId":"<a nurbs node id, or its same-batch tempId>","facing":"tangent","forward":"z","up":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass "target":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants "-z"); up is the world up the roll is kept against; offset 0..1 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards. Easing defaults to linear on a path so a loop never brakes at the seam. While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {"op":"add","nodeType":"nurbs","tempId":"track","data":{"controlPoints":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],"closed":true}} then {"op":"add","nodeType":"geometry","primitive":"box","data":{"animation":{"duration":10,"path":{"curveId":"track"}}}}. get_scene reads a rider back as motionPath:<curveId>. FAST PATH — make_page: for a page that ⟨246 unchanged words⟩
Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops" and "list_assets"
⟨1044 unchanged words⟩ size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material.","properties":{"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"} ⟨1611 unchanged words⟩
⟨170 unchanged words⟩ normal | roughness | metalness | height | emissive |
Changed the definition of "apply_ops", "get_scene", "get_scene_screenshot" and 1 more
⟨136 unchanged words⟩ `materialMaps` (the map urls from get_generation) onadd/assignMaterialadd/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) —bothall three mint-or-reuse a material and assign it, no pre-existing library entry needed. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind "glb") or get_generation goes on a geometry node as data: {primitive:"imported", assetUrl:"<url>"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. WEB BLOCKS (responsive site building-blocks): add webText ⟨244 unchanged words⟩ for visitors in the published scene. ANIMATION:objectany
Changed the definition of "apply_ops"
⟨1839 unchanged words⟩ their own numeric params; camera flights {cameraEasing:linear|ease-in-out|ease-out|ease-inout|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1 ⟨198 unchanged words⟩
Authorization not required, issuer https://3dcms.info
Changed the definition of "apply_ops"
before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed.","type":"string"},"count":{"description":"For scatter: how many copies to make (1–200).","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry {width,height,depth} (box, default 1.5 each), {radius} (sphere/cylinder/torus), {tube} (torus); text {text,color,fontSize, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel}. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for update/delete/duplicate/reparent/assignMaterial.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight, minutes 0-1439}} — source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device; {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {env: {intensity 0-4}} is the IBL fill, and the control to reach for after pinning a low sun, since at sunset almost nothing is lit directly and a dark palette renders black (0.55 default, ~1.25 lifts a charcoal set back).","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently.","properties":{"baseColor":{"type":"string"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps (e.g. the generation label like \"Lush Grass\").","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename.","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra; data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself); data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0.","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale.","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled, lensFlareEnabled} each with their own numeric params; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update and assignMaterial: edit MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId, limit} - every field given must match. Pass either `id` or `where`, never both. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}Authorization not required, issuer https://3dcms.info
Certificate recorded, valid to 2026-10-29
Authorization not required, issuer https://3dcms.info
First tool surface recorded: 7 tools (server version 1.0.0)
Showing the latest 53 events. The API returns up to 500 and filters by kind: ?kind=tool_surface_changed
after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: METHOD (every build, indoors or out: a room, a shop, a park, a product, a diagram. The recipes after it are worked examples with proven numbers, not the only things to build): 1) The person's own content first: fetch their page, take every real picture (photographs and artwork; never a logo, favicon, icon, or a generic share card used on several items) and upload_asset {url} them all in one parallel round before create_scene. Each image answer carries its width, height and aspect: cut every picture plane to that aspect. No address given? Ask for it once. 2) Library before placing anything: list_assets with kind and a short q for every surface and prop. A full set (baseColor + normal + ormh, matched by setId and mapRole) bound with worldScale on every ground, path, wall and trim; models by name. No flat colour on anything larger than a sign face. 3) Measure one, then repeat: place one of each model and one complete unit (frame + mat + picture + posts; sign + board + caption), read get_scene bounds and measure openings, screenshot the unit close up, then add the rest in one batch as duplicate ops off a tempId so they instance. 4) Lay out from a rule, not by eye: a half circle from a radius and a step angle (spread -75 to +75 degrees, never 180), a path as a sine centreline sampled every 1.5 m, trees by spacing with rejection near the path, every sign and picture turned to face the line of approach. Keep the walkway clear. 5) Nothing floats: posts under frames, a plinth under an object, a board behind a picture, a post or a bracket under a sign; real geometry, so the sun throws real shadows. 6) Pictures matted and matte: the image at its own aspect inside a mat (or a dark frame box 0.2 m larger, the picture 0.035 m proud of it), centre at 1.5 to 1.6 m, with materialMaps.roughness 1 (a number) beside its baseColor so a low sun cannot glare off it. Never stretch a frame model (no non-uniform scale): fill its opening with a mat and put the picture inside the mat. TAPS (item 13): frame, mat and picture go in one PRINT group with data.tap {action: \"inspect\"} and data.inspectLabel = the picture's title (the lightbox shows the framed print at its own aspect, titled); the stand around it (posts plus the print, or a sign board) is a STAND group with data.tap {action: \"moveTo\"}. The innermost tapped group wins, so a tap on the picture inspects and a tap on a post or board travels. The picture is a plane with materialMaps, not a webImage (a webImage keeps its own click). Pages and signs keep their link taps. 7) Light the place, not the object. OUTDOORS: settings lighting {timeOfDay: {source: \"fixed\", preset: \"sunset\"}, env: {intensity: 1.1}} (a preset stays sunset all year; never pin minutes), viewport {toneMapping: \"aces\"}, a pale warm fog matched to the sky (environment fogNear 30, fogFar 140), no lamps; another time only when the user asks or the scene calls for it (a night scene, a noon product shot). INDOORS: a closed room lit by its own key light, 2 to 4 times the fill; probe_lighting to check it rakes the texture. One accent colour, on the primary action only. 8) Frame the arrangement, larger and closer: the arrival eye 7 to 9 m from the middle of the arrangement, at 1.6 to 1.8 m, fov 50, lookAt the middle a little below the picture centres; at most five pictures in the arrival shot (more go further round, for the visitor to turn to); with an even count the middle of the arrangement is the GAP between the two middle pictures, so aim there. fitWidth only to the real width of the arrangement, never to squeeze a wide arc onto a phone. The way out is a small sign (scale 0.45 to 0.7) on its own post, low and to the side, at least 1 m from every stand, post and frame, aimed at the camera, and NOT the primaryActionId unless the scene exists to convert. For a set of equal things set settings.design.primaryActionId to the middle one, or call probe_framing with subject \"set\". 9) Look at the image: get_scene_screenshot, one view at the default 640 px; read every caption in it; fix glare, overlap, cut edges and covered subjects; one close-up of the most text-heavy thing; two fix rounds, then report. Use probe_framing's numbers (cut on 9:16, no exit within 12 m, eye too high) and reject a suggested pose aimed at one member of a set or at the ground. 10) Text that reads: webText draws WHITE (#ffffff) unless data.color is set and is anchored at its LEFT edge; on a light surface give it a dark data.color, size it by role (h1 0.5 m letters, h2 0.36, h3 0.26, body 0.17, caption 0.12), never by node scale (the margin pass sets maxWidth in the node's own units and does not count its scale) and not by a fontSize of your own (it did not take effect in testing); or use centred text3d. SURFACES AND TRIM: no bare flat colour on a floor, wall or ceiling. Every large surface wears a library set (materialMaps baseColor + normal + ormh with worldScale, the size of ONE TILE of the set, not of one board) or, with no set that fits, a materialPreset, with a visible roughness and colour step between floor and wall. Sets that fit, by list_assets q: \"white oak wide plank\" (floor boards, 5 boards a tile: worldScale 1.0; \"plank\" alone lists 60 distressed sets first), \"skim coat plaster pale gray\" (walls, worldScale about 1), \"dense green grass\" (lawn, worldScale 2.5), \"pea gravel\" (paths), \"flagstone\" (paving; tile ground with BOXES or planes, since worldScale on a cylinder streaks its cap). Finish a room the way a builder would: a white ceiling (a thin box, depth 0.05, matte white), glossy white crown moulding (roughness 0.15, metalness 0, about 0.08 m tall and 0.05 m proud) along every wall at the ceiling, a baseboard (a 0.1 m by 0.015 m box) along every wall at the floor, door and window casings, a frame around every picture. A plinth has a top edge, a shelf a lip, a sign a post or a bracket. BUILD RECIPES (worked examples; their numbers are measured): YARD GALLERY (an open-air gallery of the user's pictures; the first build for \"a gallery of the pictures on my site\"): a 80 x 80 m ground box 0.1 m deep with the grass set at worldScale 2.5; the arrival eye at the CENTRE of the arc, (0, 1.7, 0), and one stand group per picture on the arc around it, radius 8 m, so every picture is 8 m from the eye and faces it. Five pictures (an odd count puts one dead centre): angles t = -30, -15, 0, 15, 30 degrees; with an even count the angles straddle 0 (for six: -37.5, -22.5, -7.5, 7.5, 22.5, 37.5) and the camera aims at the gap. Each group at (8 sin t, 0, -8 cos t) with rotation [0, -t, 0] (t in radians) so its local +Z faces the eye. Each stand group has data.tap {action: \"moveTo\"}; inside it, a print group (data.tap {action: \"inspect\"}, data.inspectLabel = the picture's title) holds the frame, the mat and the picture, and the two posts sit beside it in the stand. In local coordinates: the frame \"frame-contemporary-1\" (list_assets q \"frame contemporary\", 2.6 MB, faces +X, portrait 0.735 x 1.0 m) at [0, 1.55, 0], rotation [1.5708, 0, -1.5708] (landscape, facing +Z), uniform scale 1.7 (1.7 x 1.25 m, opening 1.59 x 1.15 m); a mat box 1.62 x 1.17 x 0.02 m (#f4f1ea, roughness 0.9) just inside it; the picture plane at its own aspect inside the mat, 1.5 m wide (or 1.05 m tall when that is the tighter), materialMaps {baseColor, roughness: 1}, 0.02 m in front of the mat; two dark posts (cylinder r 0.04) from the ground into the frame's lower edge. Build the first stand, screenshot it close up, then duplicate it. Entry camera at (0, 1.7, 0), data.fov 50, lookAt (0, 1.4, -8) (the middle picture, or the middle gap), no fitWidth; a showPanel:false webpage whose arrivalCameraId is that camera; settings.design primaryActionId = the middle frame. The sign back (webLink href = their site) at scale 0.5 on its own post, about 4 m from the eye at 35 degrees to the right, its top under 1.1 m so it sits below the frames in the shot, at least 1 m from every stand, aimed at the camera. Sunset, env about 1.1, fog, ACES (METHOD 7). PARK WALK (a path past the pictures): gravel discs (cylinder r 1.5, h 0.04) every 1.5 m along x = 5 sin(2 pi z / 44), each with a random Y turn and y from 0.02 to 0.032 against z-fighting, all duplicates of one; library trees (oak, pine, birch; scale 7 to 12, y = scale / 2) every 4.5 m per side at 6.5 to 13 m out, none within 5.5 m of the path; picture signs (a sign group with data.tap {action: \"moveTo\"}: two posts, a text3d caption, and a print group with data.tap {action: \"inspect\"} and its inspectLabel holding the 2.7 x 1.9 m board and the picture plane at its aspect 0.045 m in front) 2.9 m off the path, alternating sides, 11 m apart, each turned to face a point 7 m before it; a trailhead page with \"Walk the path\" (a camera riding a nurbs curve along the path at 1.65 m, 70 s, loop once, rideBreak \"move\") and \"Skip to the end\"; an end page with the sign back; entry eye 1.6 m. WALKTHROUGH (a site with sections): one showPanel:false webpage per section, 45 m apart; each with a pale wall box 2.4 m behind its origin, framed pictures at their aspect (each frame box and picture plane in a print group with data.tap {action: \"inspect\"} and its inspectLabel), dark h1 and body webText, a stone plaza box; a back sign and a next sign (webLink scale 0.7 on posts, within 12 m of the arrival eye, aimed at it); its own data.primaryActionId (the next sign) and arrivalCameraId (eye 2.0 to 2.3 m, 12 to 16 m back, fitWidth = the wall plus both signs). ROOM GALLERY (only when the user asks for a room, a hall or an exhibition room): walls, floor and ceiling as thin boxes; walls the off-white plaster set at worldScale about 1, floor the oak boards, the white ceiling, the glossy crown and the baseboard of SURFACES AND TRIM; pictures 1.6 to 1.8 m wide, centre at 1.5 to 1.6 m, on the wall the entry camera faces, the arrival framed on them: the arrival eye 7 to 9 m from the picture wall, 1.6 to 1.7 m up, fov 50, so the room is about 10 m deep; captions as centred text3d about 0.12 m tall (data.fontSize 0.12), dark, under each picture, readable from the arrival; each frame, mat and picture in a print group with data.tap {action: \"inspect\"} and the caption as its inspectLabel, and a picture on a stand or easel sits in a stand group with data.tap {action: \"moveTo\"} as in YARD GALLERY; the sign is the way out, smaller than a picture, centred on the wall's axis or a doorway, at least 1 m from every frame; never a logo on a wall; the page showPanel false with its anchor just behind the back wall; one key spot raking the pictures. FACTS THE BUILDERS FOUND BY PROBING: library GLBs arrive normalised to 1 m on their longest side and centred, so a model at uniform scale s stands on the ground at y = s x (its height) / 2 (get_scene reports the bounds); the low-poly picture frames face +X and are portrait 0.82 x 1.0 m with a 0.724 x 0.904 opening (roll a frame [1.5708, 0, -1.5708] for landscape facing +Z, never scale it unevenly); worldScale on a cylinder streaks its cap, so tile ground with boxes or planes; webImage takes data.width and data.height; the \"no backdrop\" and spacing warnings repeat on every batch and lintMute does not silence scene-wide ones: read them once, then go on. OP REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\"; a row marked shared:true is the public library, copy it in first with upload_asset {assetId} and place the url that returns), from upload_asset {url} (a .glb link up to 36 MB) or from get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. A GLB's own animation plays: the first clip loops on the published page (data.animationClip picks another clip by name or 0-based index, from the names in data.animationNames; data.animationPlay:false holds it still; a model swap keeps animationClip and the first clip plays when the new file lacks it). Animated or rigged models render individually, not instanced. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth; it draws WHITE unless data.color is set, is anchored at its LEFT edge, sizes by role (a fontSize of your own did not take effect in testing), and the margin pass rewrites data.maxWidth in the node's own units without counting node scale, so scale a text node and it overflows its panel), webImage (data.src + data.alt; a src on another website is EMPTY for every visitor, so bring it in with upload_asset {url} first and use the /assets url it returns), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted ANYWHERE in the same batch works as linkedPageId (a sign may come before the page it links to), so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. A tap on a GROUP catches taps on every mesh under it, and the innermost tapped node wins: a stand group with moveTo around a print group with inspect (frame, mat, picture plane) inspects the picture and travels from a post. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt} (src an /assets url: an off-site picture is EMPTY for visitors, upload_asset {url} copies it in); webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `roughness` takes a map url, or a NUMBER from 0 (glossy) to 1 (matte) for the whole surface: a picture with {baseColor, roughness: 1} is a matte print a low sun cannot glare off. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD sca…before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\"; a row marked shared:true is the public library, copy it in first with upload_asset {assetId} and place the url that returns), from upload_asset {url} (a .glb link up to 36 MB) or from get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. A GLB's own animation plays: the first clip loops on the published page (data.animationClip picks another clip by name or 0-based index, from the names in data.animationNames; data.animationPlay:false holds it still; a model swap keeps animationClip and the first clip plays when the new file lacks it). Animated or rigged models render individually, not instanced. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth), webImage (data.src + data.alt; a src on another website is EMPTY for every visitor, so bring it in with upload_asset {url} first and use the /assets url it returns), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted ANYWHERE in the same batch works as linkedPageId (a sign may come before the page it links to), so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt} (src an /assets url: an off-site picture is EMPTY for visitors, upload_asset {url} copies it in); webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. `alphaMode` mask turns a baseColor with a transparent background (a cut-out gravestone, cat or ghost PNG) into a cut-out on a plane, with a shaped shadow; blend fades soft edges; the default opaque draws the empty pixels as a solid card. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"alphaMode":{"enum":["opaque","mask","blend"],"type":"string"},"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0. Outside 0 to 1 is refused.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `camera` is a saved viewpoint: data.isEntry true makes it the one visitors ARRIVE at on the published site (aim it with lookAt), and a page names its own arrival view with data.arrivalCameraId. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness (1 or more, default 5). `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters; directional, ambient and hemisphere are refused: the sun is the `settings` op, lighting.timeOfDay or lighting.sun), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024, and above it is refused): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent).","type":["string","null"]},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, lookAt or lookAtNodeId (aim instead of a rotation, from the position the patch sets), plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's). To AIM a camera or a lamp, or turn a sign to face a point, pass lookAt instead.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5. Outside 0 to 1 is refused.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_di…after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: METHOD (every build, indoors or out: a room, a shop, a park, a product, a diagram. The recipes after it are worked examples with proven numbers, not the only things to build): 1) The person's own content first: fetch their page, take every real picture (photographs and artwork; never a logo, favicon, icon, or a generic share card used on several items) and upload_asset {url} them all in one parallel round before create_scene. Each image answer carries its width, height and aspect: cut every picture plane to that aspect. No address given? Ask for it once. 2) Library before placing anything: list_assets with kind and a short q for every surface and prop. A full set (baseColor + normal + ormh, matched by setId and mapRole) bound with worldScale on every ground, path, wall and trim; models by name. No flat colour on anything larger than a sign face. 3) Measure one, then repeat: place one of each model and one complete unit (frame + mat + picture + posts; sign + board + caption), read get_scene bounds and measure openings, screenshot the unit close up, then add the rest in one batch as duplicate ops off a tempId so they instance. 4) Lay out from a rule, not by eye: a half circle from a radius and a step angle (spread -75 to +75 degrees, never 180), a path as a sine centreline sampled every 1.5 m, trees by spacing with rejection near the path, every sign and picture turned to face the line of approach. Keep the walkway clear. 5) Nothing floats: posts under frames, a plinth under an object, a board behind a picture, a post or a bracket under a sign; real geometry, so the sun throws real shadows. 6) Pictures matted and matte: the image at its own aspect inside a mat (or a dark frame box 0.2 m larger, the picture 0.035 m proud of it), centre at 1.5 to 1.6 m, with materialMaps.roughness 1 (a number) beside its baseColor so a low sun cannot glare off it. Never stretch a frame model (no non-uniform scale): fill its opening with a mat and put the picture inside the mat. 7) Light the place, not the object. OUTDOORS: settings lighting {timeOfDay: {source: \"fixed\", preset: \"sunset\"}, env: {intensity: 1.1}} (a preset stays sunset all year; never pin minutes), viewport {toneMapping: \"aces\"}, a pale warm fog matched to the sky (environment fogNear 30, fogFar 140), no lamps; another time only when the user asks or the scene calls for it (a night scene, a noon product shot). INDOORS: a closed room lit by its own key light, 2 to 4 times the fill; probe_lighting to check it rakes the texture. One accent colour, on the primary action only. 8) Frame the arrangement: the entry camera at 1.6 m (a walk) to 2.0-2.3 m (a set in depth), 11 to 16 m back, lookAt the middle of the arrangement a little below its centre, every item inside 16:9; fitWidth only to the real width of the arrangement, never to squeeze a wide arc onto a phone. The way out is a small sign (scale 0.45 to 0.7) on a post, low and to the side, aimed at the camera, and NOT the primaryActionId unless the scene exists to convert. For a set of equal things set settings.design.primaryActionId to the middle one, or call probe_framing with subject \"set\". 9) Look at the image: get_scene_screenshot, one view at the default 640 px; read every caption in it; fix glare, overlap, cut edges and covered subjects; one close-up of the most text-heavy thing; two fix rounds, then report. Use probe_framing's numbers (cut on 9:16, no exit within 12 m, eye too high) and reject a suggested pose aimed at one member of a set or at the ground. 10) Text that reads: webText draws WHITE (#ffffff) unless data.color is set and is anchored at its LEFT edge; on a light surface give it a dark data.color, size it by role (h1 0.5 m letters, h2 0.36, h3 0.26, body 0.17, caption 0.12), never by node scale (the margin pass sets maxWidth in the node's own units and does not count its scale) and not by a fontSize of your own (it did not take effect in testing); or use centred text3d. SURFACES AND TRIM: no bare flat colour on a floor, wall or ceiling. Every large surface wears a library set (materialMaps baseColor + normal + ormh with worldScale, the size of ONE TILE of the set, not of one board) or, with no set that fits, a materialPreset, with a visible roughness and colour step between floor and wall. Sets that fit, by list_assets q: \"white oak wide plank\" (floor boards, 5 boards a tile: worldScale 1.0; \"plank\" alone lists 60 distressed sets first), \"skim coat plaster pale gray\" (walls, worldScale about 1), \"dense green grass\" (lawn, worldScale 2.5), \"pea gravel\" (paths), \"flagstone\" (paving; tile ground with BOXES or planes, since worldScale on a cylinder streaks its cap). Finish a room the way a builder would: a white ceiling (a thin box, depth 0.05, matte white), glossy white crown moulding (roughness 0.15, metalness 0, about 0.08 m tall and 0.05 m proud) along every wall at the ceiling, a baseboard (a 0.1 m by 0.015 m box) along every wall at the floor, door and window casings, a frame around every picture. A plinth has a top edge, a shelf a lip, a sign a post or a bracket. BUILD RECIPES (worked examples; their numbers are measured): YARD GALLERY (an open-air gallery of the user's pictures; the first build for \"a gallery of the pictures on my site\"): a 80 x 80 m ground box 0.1 m deep with the grass set at worldScale 2.5; 5 to 7 pictures, an odd count so one stands dead centre; one stand group per picture on a half circle of radius 7 m, angle t from -75 to +75 degrees in 25 degree steps, the group at (7 sin t, 0, -7 cos t) with rotation [0, -t, 0] (t in radians) so its local +Z faces the centre. Inside each stand, in local coordinates: the frame \"frame-contemporary-1\" (list_assets q \"frame contemporary\", 2.6 MB, faces +X, portrait 0.735 x 1.0 m) at [0, 1.6, 0], rotation [1.5708, 0, -1.5708] (a landscape frame facing +Z), uniform scale 2 (2.0 x 1.47 m, opening 1.87 x 1.35 m); a mat box 1.9 x 1.37 x 0.02 m (#f4f1ea, roughness 0.9) just inside it; the picture plane at its own aspect, width 1.8 m, materialMaps {baseColor, roughness: 1}, 0.02 m in front of the mat; two dark posts (cylinder r 0.04) from the ground into the frame's lower edge. Build the first stand, screenshot it close up, then duplicate it. Entry camera eye (0, 2.0, 11.5) lookAt (0, 1.35, -3), default fov, no fitWidth; a showPanel:false webpage whose arrivalCameraId is that camera; settings.design primaryActionId = the middle frame; the sign back (webLink href = their site) at scale 0.45 on its own post at the front right, aimed at the camera. Sunset, env about 1.1, fog, ACES (METHOD 7). PARK WALK (a path past the pictures): gravel discs (cylinder r 1.5, h 0.04) every 1.5 m along x = 5 sin(2 pi z / 44), each with a random Y turn and y from 0.02 to 0.032 against z-fighting, all duplicates of one; library trees (oak, pine, birch; scale 7 to 12, y = scale / 2) every 4.5 m per side at 6.5 to 13 m out, none within 5.5 m of the path; picture signs (two posts, a 2.7 x 1.9 m board, the picture plane at its aspect 0.045 m in front, a text3d caption) 2.9 m off the path, alternating sides, 11 m apart, each turned to face a point 7 m before it; a trailhead page with \"Walk the path\" (a camera riding a nurbs curve along the path at 1.65 m, 70 s, loop once, rideBreak \"move\") and \"Skip to the end\"; an end page with the sign back; entry eye 1.6 m. WALKTHROUGH (a site with sections): one showPanel:false webpage per section, 45 m apart; each with a pale wall box 2.4 m behind its origin, framed pictures at their aspect, dark h1 and body webText, a stone plaza box; a back sign and a next sign (webLink scale 0.7 on posts, within 12 m of the arrival eye, aimed at it); its own data.primaryActionId (the next sign) and arrivalCameraId (eye 2.0 to 2.3 m, 12 to 16 m back, fitWidth = the wall plus both signs). ROOM GALLERY (only when the user asks for a room, a hall or an exhibition room): walls, floor and ceiling as thin boxes; walls the off-white plaster set at worldScale about 1, floor the oak boards, the white ceiling, the glossy crown and the baseboard of SURFACES AND TRIM; pictures 1.2 to 1.8 m wide, centre at 1.5 to 1.6 m, on the wall the entry camera faces, the arrival framed on them; the sign is the way out, smaller than a picture, centred on the wall's axis or a doorway; never a logo on a wall; the page showPanel false with its anchor just behind the back wall; one key spot raking the pictures. FACTS THE BUILDERS FOUND BY PROBING: library GLBs arrive normalised to 1 m on their longest side and centred, so a model at uniform scale s stands on the ground at y = s x (its height) / 2 (get_scene reports the bounds); the low-poly picture frames face +X and are portrait 0.82 x 1.0 m with a 0.724 x 0.904 opening (roll a frame [1.5708, 0, -1.5708] for landscape facing +Z, never scale it unevenly); worldScale on a cylinder streaks its cap, so tile ground with boxes or planes; webImage takes data.width and data.height; the \"no backdrop\" and spacing warnings repeat on every batch and lintMute does not silence scene-wide ones: read them once, then go on. OP REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\"; a row marked shared:true is the public library, copy it in first with upload_asset {assetId} and place the url that returns), from upload_asset {url} (a .glb link up to 36 MB) or from get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. A GLB's own animation plays: the first clip loops on the published page (data.animationClip picks another clip by name or 0-based index, from the names in data.animationNames; data.animationPlay:false holds it still; a model swap keeps animationClip and the first clip plays when the new file lacks it). Animated or rigged models render individually, not instanced. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth; it draws WHITE unless data.color is set, is anchored at its LEFT edge, sizes by role (a fontSize of your own did not take effect in testing), and the margin pass rewrites data.maxWidth in the node's own units without counting node scale, so scale a text node and it overflows its panel), webImage (data.src + data.alt; a src on another website is EMPTY for every visitor, so bring it in with upload_asset {url} first and use the /assets url it returns), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted ANYWHERE in the same batch works as linkedPageId (a sign may come before the page it links to), so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt} (src an /assets url: an off-site picture is EMPTY for visitors, upload_asset {url} copies it in); webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `roughness` takes a map url, or a NUMBER from 0 (glossy) to 1 (matte) for the whole surface: a picture with {baseColor, roughness: 1} is a matte print a low sun cannot glare off. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. `alphaMode` mask turns a baseColor with a transparent background (a cut-out gravestone, cat or ghost PNG) into a cut-out on a plane, with a shaped shadow; blend fades soft edges; the default opaque draws the empty pixels as a solid card. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"alphaMode":{"enum":["opaque","mask","blend"],"type":"string"},"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":["string","number"]},"uvAni…CreateUse this when the user wants anew3D website, a walkable portfolio, a product showcase, a gallery, an exhibit, an event page or any interactive space they can share as a link. Creates the scene inyourtheir 3dCMS account andreturnreturns itsid:id;empty,thenorbuild it with apply_ops. With copyFrom it is a full COPY of one ofyourtheir scenes (Save As), the way to ⟨9 unchanged words⟩ the original and its published site stay untouched.Build it up with apply_ops.Itappears in your account andopens in the 3dCMS editor; a copy is unpublished until publish_scene.
⟨148 unchanged words⟩ , and findings in sentences. Fix with apply_ops.Discipline: atAt most 2 screenshot→fix rounds peruserrequest, thenreport what you built.report. views (max 4, default = the visitor's ⟨20 unchanged words⟩ an exact camera. Rendering is real but slow:—~10~10ss for a simplescenesscene,and45thesserverpergivesview by default (timeoutSeconds upafterto45180sforpera big textured scene). The default is ONE viewbyatdefault;640apx;400-nodelargersceneshotswithandmanyseveraltexturesviewsneedsaremorewhat timed out, sopass timeoutSecondsask(upfortoone180)viewandat asmallertime.maxDimA(acall512-wideanswersshotwithinisabouta85finescheckeitherofway:placementtheandimage,readabilityor "still rendering, retry in N s"; then call again with the SAME arguments after N s andrendersthefaster).image comes from the cache at no extra cost. A timeout comes back as an error naming ⟨22 unchanged words⟩ a fresh render counts against the daily budget(a failed one does not), and each reply says shotsLeft.Rate-limited: prefer ONE well-chosen view; only multi-view when areas are far apart (four views at 180 s can hold the renderer for twelve minutes).If it returns a busy/unavailable error, keep ⟨11 unchanged words⟩
⟨67 unchanged words⟩ of the image in pixels, 64-1920 (default400;640; height is 0.625x).Smaller640rendersreadsfastercaptions andis enough to judgerendersplacement;insidegothelargetimeonlylimit;forlargeraoftenfinaldoeslook.not.","type":"number"},"sceneId":{ ⟨121 unchanged words⟩
ListUse this first when the user mentions their 3dCMS scenes, a site they built with 3dCMS, or wants to edit, publish or check something they made. Lists the 3D scenes saved inyourtheir 3dCMS account (editable "sources"), newest ⟨52 unchanged words⟩
⟨146 unchanged words⟩ data.primaryActionId), else the page's h1, else a SET of 3+ equal things in frame (framed as one arrangement), else the biggestthingthing; subject "set" forces the set. A suggested scene pose keeps the horizon in frame. Returns, per screen shape: where ⟨93 unchanged words⟩ exits (signs and tap targets within 12 m, in frame or not). Then `findings` in plain sentences, worst first, and `suggest`: one pose that fixes the composition,judged as the viewer builds iton every shape, withthe sentence sayingwhy, and `suggest.op`, a ready apply_ops update forthis camerait (null whenthe camerait already stands at the best posethe probe can find). Call it on every entry and arrival ⟨35 unchanged words⟩
⟨100 unchanged words⟩ from list_scenes.","type":"string"},"subject":{"description":"\"set\" frames the arrangement of equal things (a row or arc of pictures, signs) as the subject instead of one of them. Default auto.","enum":["set","auto"],"type":"string"},"target":{"description":"With position: the ⟨18 unchanged words⟩
Use this when the user wants their scene live as a public link to share, or to update one already live. Publish a saved scene to a PUBLIC, shareable ⟨89 unchanged words⟩
⟨195 unchanged words⟩ the MCP runs on your own computer). An image answers its width, height and aspect in pixels: cut a picture plane to that aspect, never a guess. A model gets its phone-sized copy made automatically, ⟨16 unchanged words⟩
after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\"; a row marked shared:true is the public library, copy it in first with upload_asset {assetId} and place the url that returns), from upload_asset {url} (a .glb link up to 36 MB) or from get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. A GLB's own animation plays: the first clip loops on the published page (data.animationClip picks another clip by name or 0-based index, from the names in data.animationNames; data.animationPlay:false holds it still; a model swap keeps animationClip and the first clip plays when the new file lacks it). Animated or rigged models render individually, not instanced. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth), webImage (data.src + data.alt; a src on another website is EMPTY for every visitor, so bring it in with upload_asset {url} first and use the /assets url it returns), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted ANYWHERE in the same batch works as linkedPageId (a sign may come before the page it links to), so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt} (src an /assets url: an off-site picture is EMPTY for visitors, upload_asset {url} copies it in); webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. `alphaMode` mask turns a baseColor with a transparent background (a cut-out gravestone, cat or ghost PNG) into a cut-out on a plane, with a shaped shadow; blend fades soft edges; the default opaque draws the empty pixels as a solid card. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"alphaMode":{"enum":["opaque","mask","blend"],"type":"string"},"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0. Outside 0 to 1 is refused.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `camera` is a saved viewpoint: data.isEntry true makes it the one visitors ARRIVE at on the published site (aim it with lookAt), and a page names its own arrival view with data.arrivalCameraId. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness (1 or more, default 5). `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters; directional, ambient and hemisphere are refused: the sun is the `settings` op, lighting.timeOfDay or lighting.sun), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024, and above it is refused): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent).","type":["string","null"]},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, lookAt or lookAtNodeId (aim instead of a rotation, from the position the patch sets), plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's). To AIM a camera or a lamp, or turn a sign to face a point, pass lookAt instead.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5. Outside 0 to 1 is refused.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_di…after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\"; a row marked shared:true is the public library, copy it in first with upload_asset {assetId} and place the url that returns), from upload_asset {url} (a .glb link up to 36 MB) or from get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. A GLB's own animation plays: the first clip loops on the published page (data.animationClip picks another clip by name or 0-based index, from the names in data.animationNames; data.animationPlay:false holds it still; a model swap keeps animationClip and the first clip plays when the new file lacks it). Animated or rigged models render individually, not instanced. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth), webImage (data.src + data.alt; a src on another website is EMPTY for every visitor, so bring it in with upload_asset {url} first and use the /assets url it returns), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted ANYWHERE in the same batch works as linkedPageId (a sign may come before the page it links to), so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt} (src an /assets url: an off-site picture is EMPTY for visitors, upload_asset {url} copies it in); webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0. Outside 0 to 1 is refused.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `camera` is a saved viewpoint: data.isEntry true makes it the one visitors ARRIVE at on the published site (aim it with lookAt), and a page names its own arrival view with data.arrivalCameraId. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness (1 or more, default 5). `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters; directional, ambient and hemisphere are refused: the sun is the `settings` op, lighting.timeOfDay or lighting.sun), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024, and above it is refused): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent).","type":["string","null"]},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, lookAt or lookAtNodeId (aim instead of a rotation, from the position the patch sets), plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's). To AIM a camera or a lamp, or turn a sign to face a point, pass lookAt instead.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5. Outside 0 to 1 is refused.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\"…⟨272 unchanged words⟩ unchanged scene at the same size are cached, instant andinstant.free; only a fresh render counts against the daily budget (a failed one does not), and each reply says shotsLeft. Rate-limited: prefer ONE well-chosen view; only multi-view ⟨36 unchanged words⟩
Search the images, models and3Dsoundsmodelsinalreadythe account AND the shared public library (frames, props, texture sets; `scope` mine|shared|all, default all). A row with shared:true is copied in first with upload_asset {assetId}: place theaccount.url that returns, never the shared one. This is how you TEXTURE something with what ⟨27 unchanged words⟩ { baseColor: "<url>" }`, plus `normal`,and the set's packed `ormh` (or `orm`)file on materialMaps.ormh, or separate `roughness` / `metalness`maps when the set has those instead.maps. PBR sets are stored as SEPARATE images that ⟨145 unchanged words⟩ each url by its mapRole. orm/ormh are channel-packedfiles(R=AO, G=roughness, B=metalness, A=height): bind one on materialMaps.ormh (or .orm), never asroughness, androughness; the channels are splitoutforyou, so a library set is baseColor + normal + ormh.you. An `emissive` member goes on materialMaps.emissive. `kind` is ⟨18 unchanged words⟩ imported", assetUrl}, never as the top-levelprimitive; the low-poly picture frames face +X, rotate [0, 1.5708primitive),0] to hang one on a"audio"wallforfacingsounds+Z(data.audioClip.src).
⟨8 unchanged words⟩ " for textures, \"glb\" for 3Dmodels.models, \"audio\" for sounds. Omit forboth.all.","type":"string"},"limit":{ ⟨38 unchanged words⟩ label or keywords. Omit to list the newest.","type":"string"},"scope":{"description":"\"mine\" = this account only, \"shared\" = the public library only, \"all\" (default) = both, yours first.","enum":["mine","shared","all"],"type":"string"}},"type":"object"}
⟨27 unchanged words⟩ "Only after a reply with needsConfirm and willReplace:,exactlyandtruethereplacesuser'sthatyeslivenaming that site:withpassthiswillReplace.replaceTokenscene.exactlyAskwhentheituserhasfirst,onenaming(siteSlugthepublishes),site.otherwise true.","type":["boolean","string"]},"listed":{"description":"false publishes ⟨53 unchanged words⟩ per site), for sharing and QR codes. Without confirm, a scene whose site is already live gets its shortUrl at once, no republish.","type":"boolean"},"siteSlug":{ ⟨35 unchanged words⟩ the site to the user and call again on a yes with confirmReplace:trueonsetatoyes.willReplace.replaceToken. Later publishes of this scene go to that ⟨7 unchanged words⟩
⟨51 unchanged words⟩ any node; get_scene reports it as `audio`). A `url` may be a picture (JPEG, PNG, WebP, GIF, AVIF, up to 12 MB), a binary glTF 2.0 model (.glb, up to 36 MB) or a sound (MP3, OGG, WAV, M4A, up to 12 MB); the type is read from the bytes. `assetId` instead copies a SHARED library asset (a list_assets row marked shared:true) into this account and returns the url to place; placing the copy keeps the page whole if the sharer removes theirs, and the same asset copied twice returns the first copy. Give ONE source: `url` (a public http( ⟨73 unchanged words⟩
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"assetId":{"description":"Copy this shared library asset (a list_assets id with shared:true) into your library instead.","type":"string"},"dataBase64":{"description":"The file, base64-encoded. ⟨66 unchanged words⟩ ":"A public http(s) URL toimport.import: a picture, a .glb model or a sound.","type":"string"}},"type":"object"}
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"copyFrom":{"description":"A scene id from list_scenes to copy every node, material and setting from. Omit for an empty scene.","type":"string"},"name":{"description":"A name for the ⟨8 unchanged words⟩
⟨49 unchanged words⟩ the site.","type":"boolean"},"listed":{"description":"false publishes UNLISTED: reachable by its link, but kept off Explore and your creator profile, marked noindex and not submitted to search engines. Omit for a normal listed publish.","type":"boolean"},"sceneId":{"description":"Scene id from list_scenes. ⟨6 unchanged words⟩
{"destructiveHint":true,"idempotentHint":true,"openWorldHint":falsetrue,"readOnlyHint":false,"title":"Publish a 3dCMS scene"}
⟨64 unchanged words⟩ public http(s) link, fetched by theserverserver; use this for anything larger), `dataBase64` with `filename` (the file itself, up to about353 MB on this hosted connector; bigger files go by `url` or the editor's upload), or (`path` is only available when ⟨32 unchanged words⟩
before
—after
{"destructiveHint":false,"idempotentHint":false,"openWorldHint":false,"readOnlyHint":false,"title":"Create a 3dCMS scene"}before
—after
{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true,"title":"Design review of a scene"}before
—after
{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true,"title":"Get a 3dCMS scene"}before
—after
{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true,"title":"Screenshot a 3dCMS scene"}before
—after
{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true,"title":"Search the 3dCMS asset library"}before
—after
{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true,"title":"List 3dCMS scenes"}before
—after
{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true,"title":"Measure things in a scene"}before
—after
{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true,"title":"Is this camera a good photograph?"}before
—after
{"destructiveHint":false,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":true,"title":"What is lighting this point?"}before
—after
{"destructiveHint":true,"idempotentHint":true,"openWorldHint":false,"readOnlyHint":false,"title":"Publish a 3dCMS scene"}before
—after
{"destructiveHint":false,"idempotentHint":false,"openWorldHint":true,"readOnlyHint":false,"title":"Add a file to the asset library"}before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\") or get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth), webImage (data.src + data.alt), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted in the SAME batch works as linkedPageId, so a \"sign in the park linking to the mall\" can be built in one call. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. Runs for visitors in the published scene. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is 0-1 across duration seconds; values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier, 1 = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; optional delay seconds; max 16 keys per track. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..1 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, to: a page id/tempId | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch — each call is its own atomic Undo and stays well within limits; call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed. A webText or webLink has no material: on add, color is the colour it shows (the text, or a link chip's fill; the label on a text-variant link).","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `camera` is a saved viewpoint: data.isEntry true makes it the one visitors ARRIVE at on the published site (aim it with lookAt), and a page names its own arrival view with data.arrivalCameraId. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent).","type":["string","null"]},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, lookAt or lookAtNodeId (aim instead of a rotation, from the position the patch sets), plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's). To AIM a camera or a lamp, or turn a sign to face a point, pass lookAt instead.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 (a share of the frame height x 16:9; 0.0018 = 0.32% of screen height; above 0.002 the split is wider than thin strokes, and a pure red, green or blue stroke renders BLACK because its only channel moves off it) + chromaticAberrationRadial; colorGradingSaturation and colorGradingContrast -0.95 to 0.95, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; a resting camera {cameraIdleMotion 0-3 (slow handheld drift; 1 subtle, 3 a real sway), cameraShake 0-1 (faster, jerkier shake with a little tilt; adds to the drift)}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}; {responsive3d true: REQUIRED for any web block with data.anchor \"hud\" to fly into view, else it stays put}; {cameraWallCollision true: the orbit of a visitor stops at walls scene-wide (the arrival camera of a page can say data.wallCollision true|false)}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update, assignMaterial and delete: act on MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId (direct children only), underId (the WHOLE subtree of a node: every wall in a room group, however deep; a same-batch tempId works), materialAssetId (every node wearing that material, for a global restyle), limit} - every field given must match. Pass either `id` or `where`, never both. delete removes each match WITH its subtree and defaults to a limit of 100 (update and assignMaterial default to 500); pass where.limit to delete more on purpose. `s…after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). GROUND: {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"plane\",\"position\":[0,0,0],\"rotation\":[-1.5708,0,0],\"data\":{\"width\":40,\"height\":40}}: a plane is XY-oriented (unrotated it is a wall), and a geometry node with no position is minted 1.25 m up, above everything at y~0. MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Only a geometry node wears a material: on text3d, a light, a cable, particles, a curve, a shadowPool or a backdrop `color` sets its own data.color, and on a group, camera, image, video, page or force field every material field is refused. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\") or get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth), webImage (data.src + data.alt), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted in the SAME batch works as linkedPageId, so a \"sign in the park linking to the mall\" can be built in one call. ROOMS: data.zonePageId (a page id or same-batch tempId) on a room's GROUP binds it to that page, INHERITED by its whole subtree (duplicate and scatter copy it verbatim, so re-point a cloned room); the room switches off when the visitor walks away, and each room gets its own 4 shadow-casting lights (a fifth in one room casts nothing; the result warns). data.zoneRadius (meters) keeps a landmark on within that distance of its page. SEO arrays on a page: data.faqItems [{\"question\",\"answer\"}] and data.testimonials [{\"quote\",\"author\"?,\"role\"?}], these EXACT keys. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. Runs for visitors in the published scene. A tap with no action, a link with no target, any other key, or inspect/moveTo on another node type is REFUSED (each would do nothing); get_scene reads a working tap back as tapAction. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is a FRACTION from 0 to 1 of the duration, NOT seconds (a key 2 s into a 4 s loop is t 0.5); values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier as [x,y,z], [1,1,1] = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; easing = linear|easeIn|easeOut|easeInOut; duration 0.05-600 s; optional delay seconds; 2 to 16 keys per track, each {t, v, easing?}. Anything else (a \"rotate\" track, {time, value} keys, t in seconds) is REFUSED by name; it used to delete the whole animation on load. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..0.95 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, ONE of to: a page id/tempId | linkedCameraId: a viewpoint | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch, which stays well within limits. Each call is atomic and checkpointed: the owner can roll back in the editor (File > Version history). There is no undo over MCP; to reverse a call, apply the inverse ops. Call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, a floor above the content it should hold up, shadow-casting lights past the 4 a room gets, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial of a GEOMETRY node, mints-or-reuses a solid-color material and assigns it; no pre-existing material needed. Only geometry wears a material. A node that draws its own colour (webText, webLink, text3d, light, cable, particle, nurbs, shadowPool, backgroundLayer) takes color as that colour: data.color (a solid link chip takes it as its fill, data.bgColor; the label on a text or ghost link). On a group, camera, webImage, webVideo, webpage or forcefield, color and every material field are refused.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness, 1 or more), headingDeg, opacity 0-1}. Closed vocabularies and ranges (timeMode, goboPattern, fontWeight, anchor, kind, variant, falloff, a shadowPool's opacity and softness 0-1, volumetricDensity 0-3, goboSpin -3..3) are REFUSED by name when out of range, never quietly defaulted. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. fogFar must be greater than fogNear (judged against the stored value when you send one), or the batch is refused. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and ONE target: to (a page id/tempId to fly to; linkedPageId is the same), linkedCameraId (a viewpoint camera id/tempId) or href (a URL) }. A link with no target, two targets or any other key is refused.","items":{"additionalProperties":{},"properties":{"href":{"type":"string"},"label":{"type":"string"},"linkedCameraId":{"type":"string"},"linkedPageId":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0. Outside 0 to 1 is refused.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `camera` is a saved viewpoint: data.isEntry true makes it the one visitors ARRIVE at on the published site (aim it with lookAt), and a page names its own arrival view with data.arrivalCameraId. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness (1 or more, default 5). `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters; directional, ambient and hemisphere are refused: the sun is the `settings` op, lighting.timeOfDay or lighting.sun), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024, and above it is refused): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent).","type":["string","null"]},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, lookAt or lookAtNodeId (aim instead of a rotation, from the position the patch sets), plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's). To AIM a camera or a lamp, or turn a sign to face a point, pass lookAt instead.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5. Outside 0 to 1 is refused.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness D…⟨43 unchanged words⟩ as `materialMaps: { baseColor: "<url>" }`—, plus `normal`,/and the set's packed `ormh` (or `orm`) file on materialMaps.ormh, or separate `roughness` / `metalness` maps when the set has thosemaps.instead. PBR sets are stored as SEPARATE images that ⟨237 unchanged words⟩
before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed. A webText or webLink has no material: on add, color is the colour it shows (the text, or a link chip's fill; the label on a text-variant link).","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent)."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, lookAt or lookAtNodeId (aim instead of a rotation, from the position the patch sets), plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's). To AIM a camera or a lamp, or turn a sign to face a point, pass lookAt instead.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update, assignMaterial and delete: act on MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId (direct children only), underId (the WHOLE subtree of a node: every wall in a room group, however deep; a same-batch tempId works), materialAssetId (every node wearing that material, for a global restyle), limit} - every field given must match. Pass either `id` or `where`, never both. delete removes each match WITH its subtree and defaults to a limit of 100 (update and assignMaterial default to 500); pass where.limit to delete more on purpose. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically. REFERENCE: SPACING: every foreground element on a panel (text, a link chip, a picture) is inset from it by the same margin, and the gaps between those elements are that same margin again - this door sets the widths and centres for you (reported as marginFixes) and warns when the vertical gaps disagree. Coordinates: Y up, units ≈ meters, ground at y≈0 — place objects so they rest sensibly. Geometry size lives in `data`, under the keys get_scene reports as `size`: box {width,height,depth}, plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; the default box is 1.5×1.5×1.5. An update writes them in patch.data (never patch.size). MATERIALS: to color an object pass `color` (#rrggbb) on add/assignMaterial; to apply a GENERATED material pass `materialMaps` (the map urls from get_generation) on add/assignMaterial; for a real surface with no image at all pass `materialPreset` (brushed-metal, frosted-ice, rough-stone, ... see the field) — all three mint-or-reuse a material and assign it, no pre-existing library entry needed. Prefer a preset or a set from list_assets over a flat color for walls, floors, trim and metal, so the first pass reads as a place, not a grey box. IMPORTED MODELS: a glb from list_assets (kind \"glb\") or get_generation goes on a geometry node as data: {primitive:\"imported\", assetUrl:\"<url>\"}, NOT as the top-level `primitive` (that enum is the five built-ins). Once placed, get_scene reports the model's bounds and size. The built-in low-poly picture frames face +X: to hang one on a wall that faces +Z rotate it [0, 1.5708, 0]. INSTANCING (971): identical PRIMITIVES are drawn as one batch too, keyed on the primitive, its data dimensions and segments, its material (color, materialMaps or materialPreset) and its room: 281 boxes that share one size and one material cost one draw call, and the weight meter credits it. Vary placement with position, rotation and the node SCALE (not data.width/height/depth) to stay in the batch; a different data size or material starts another. An animated, tapped or surface-emitter host node draws on its own. WEB BLOCKS (responsive site building-blocks): add webText (data.role h1|h2|h3|body|caption + data.text + data.fontWeight 400|500|600|700 for a real weighted face (a role already picks one); glyph outline via data.outline true|false + data.outlineColor + data.outlineWidth), webImage (data.src + data.alt), webLink (data.label + data.href or data.linkedPageId), webVideo (data.src + data.poster); any web block also accepts data.anchor = world|billboard|hud (hud needs viewport.responsive3d true; apply_ops turns it on and says so). PAGES & NEIGHBORHOODS: add a `webpage` node per page (set data.pageTitle, data.slug); place pages tens of meters apart and parent each area's content under its page to build a multi-area site. Link areas with a webLink (data.linkedPageId = target page, or data.linkedCameraId = a saved viewpoint); a tempId minted in the SAME batch works as linkedPageId, so a \"sign in the park linking to the mall\" can be built in one call. Set data.arrivalCameraId on a page (a camera node id, or a tempId from the same batch) to choose the viewpoint visitors ARRIVE at for it — otherwise the runtime auto-frames the page from a generic distance. It belongs on the PAGE, not on the signs: a sign that jumps to a camera skips the page change, so the URL and title would still name the previous room. MULTI-LEG ARRIVALS (969): a flight is a straight line between two poses, which cuts through a wall to reach a room. Set data.viaCameraIds on a CAMERA node (an ordered array of camera node ids, or same-batch tempIds, up to 8) and every flight to that camera (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view deep link) passes through those viewpoints first on one smooth curve: place a via camera in the doorway, aimed into the room, and the visitor is threaded through the door. The flight takes longer in proportion to the path, the mid-flight bow is off on a legged flight, a via camera's own legs are not followed, and a via id that names no camera is skipped (get_scene warns). Wire a doorway in one batch: add the door camera with tempId \"door\", then the arrival camera with data.viaCameraIds [\"door\"]. PACING (1028): data.travelScale 0.25-4 on a camera node multiplies the viewport.cameraTravelScale of the scene for every flight that ENDS at that viewpoint (a page arrival, a sign, a #view link): 2 is a slow reveal into a hero room, 0.5 a quick hop. TAP ACTIONS: object nodes (geometry, text3d, group, cable) take data.tap = {\"action\":\"inspect\"} (fullscreen orbit lightbox on tap — products/exhibits/hero models), {\"action\":\"moveTo\"} (camera flies to frame it — tap-to-travel on landmarks), or {\"action\":\"link\", href|linkedPageId|linkedCameraId} (navigate like a webLink). inspect/moveTo take an optional \"distance\" (meters) overriding the auto camera distance. An inspect lightbox is headed by data.inspectLabel (else the node name) with data.inspectCaption under it. Runs for visitors in the published scene. ANIMATION: any node (geometry, text3d, group, cable, the web blocks and lights, and a camera, which makes it a RIDE, below) takes data.animation to move on a loop in the published scene: {\"duration\":4,\"loop\":\"loop\",\"easing\":\"easeInOut\",\"tracks\":{\"position\":[{\"t\":0,\"v\":[0,0,0]},{\"t\":0.5,\"v\":[0,0.6,0]},{\"t\":1,\"v\":[0,0,0]}]}} bobs the node half a meter. t is 0-1 across duration seconds; values are OFFSETS from the authored transform (position meters, rotation euler radians, scale a multiplier, 1 = authored size). tracks = position|rotation|scale; loop = loop|pingpong|once; optional delay seconds; max 16 keys per track. Animated nodes cost scene weight and cannot be GPU-instanced: animate a few focal objects, not everything. MOTION PATH (973): data.animation.path puts the node ON a curve instead of (or as well as) the tracks: {\"duration\":12,\"loop\":\"loop\",\"path\":{\"curveId\":\"<a nurbs node id, or its same-batch tempId>\",\"facing\":\"tangent\",\"forward\":\"z\",\"up\":[0,1,0]}}. facing = tangent (nose along the curve) | target (toward a world point, pass \"target\":[x,y,z]) | fixed (keep the authored rotation); forward = z|-z|x|-x is which local axis of the model leads (a glb whose nose points -Z wants \"-z\"); up is the world up the roll is kept against; offset 0..1 is where along the curve the ride starts (space several riders around one loop with different offsets); reverse:true rides it backwards; facingEase (seconds, default 1, 0 snaps) is how long the rider takes to TURN from the way it was placed into the facing of the path when the ride begins (and again if the heading jumps, such as the loop seam of an open curve), eased in and out. `easing` shapes the ride itself: linear is the default on a loop (constant speed, no braking at the seam) and easeInOut on once/pingpong (pull away, brake at the end). While bound, the authored position and rotation are NOT where the node is (it sits on the curve; scale still applies, tracks stack on top as offsets); a curveId that names no curve node leaves it at its authored place. The curve is a nurbs node: {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"track\",\"data\":{\"controlPoints\":[[0,0,0],[8,0,-4],[16,0,0],[8,0,4]],\"closed\":true}} then {\"op\":\"add\",\"nodeType\":\"geometry\",\"primitive\":\"box\",\"data\":{\"animation\":{\"duration\":10,\"path\":{\"curveId\":\"track\"}}}}. get_scene reads a rider back as motionPath:<curveId>. A curve something rides is a HELPER on the published site (1032): registered for the ride, not drawn, so a swing arc does not show as a green spline across the door; set data.renderCurve:true on the curve to draw it anyway. The editor always shows it. CAMERA RIDE (1026): a CAMERA node with data.animation.path is a ride: every flight that lands on it (a page arrival through arrivalCameraId, a sign or tap with linkedCameraId, a #view link) ends at the curve's start pose and the visitor's camera then follows the curve, looking along it (facing tangent), at a world point (facing target + target) or the way it arrived (facing fixed); animation.duration, loop (once = ride to the end and hand control back there; loop and pingpong ride until the visitor takes over; on an OPEN curve loop cuts back to the start each lap, so use once or pingpong there or set closed:true on the curve), easing, path.offset and path.reverse mean what they mean for any rider (a pingpong ride looks where it is going on the way back too). data.rideBreak is the author's choice of what ends the ride early: \"any\" (default: any pointer down, key, wheel or touch), \"move\" (only a drag, a wheel or a movement key, so a tap on a sign still navigates and a stray click does not stop the ride) or \"none\" (nothing; a loop rides until the visitor navigates elsewhere). A new flight always ends a ride; a reduced-motion visitor and a screenshot get the start pose and no ride; the curve is a helper (not drawn) like any ridden curve. {\"op\":\"add\",\"nodeType\":\"nurbs\",\"tempId\":\"rail\",\"data\":{\"controlPoints\":[[0,1.6,8],[0,1.6,2],[3,1.6,-2]]}},{\"op\":\"add\",\"nodeType\":\"camera\",\"tempId\":\"arrive\",\"data\":{\"animation\":{\"duration\":8,\"loop\":\"once\",\"path\":{\"curveId\":\"rail\"}},\"rideBreak\":\"move\"}} then arrivalCameraId \"arrive\" on the page. FAST PATH — make_page: for a page that is a heading + paragraph + some links, ONE `make_page` op builds the whole thing: pass name, data.pageTitle+data.slug, hero (h1 text), body (paragraph), and links [{label, to: a page id/tempId | href}], plus a tempId (which becomes the PAGE id so other signs can target it). Create link-target pages earlier in the batch so their tempIds resolve. It replaces ~6–12 primitive ops. SET-DRESSING: use the `scatter` op to make `count` jittered copies of a node across an {area:{x,z}} (random rotation + slight scale + optional per-copy `tint` for imported models) — efficient for many props, debris, foliage, a trashy parking lot. The result names what it CREATED (`created`: id, type, name) so you can edit those nodes in your next call without re-reading the scene. LARGE SCENES: build INCREMENTALLY across multiple apply_ops calls (~40–50 objects per batch) instead of one huge batch — each call is its own atomic Undo and stays well within limits; call get_scene between batches to stay aligned. Do not try to emit hundreds of operations in a single call. The result may carry `warnings`: advisory scene lint (floating content, orphan web blocks, unreadable text, crowded pages, a lamp whose range reaches the ceiling above it, a plane facing a shadow lamp with content behind it, ambient IBL in a sun-less studio scene, a page card inside a room, a camera ride looping an open curve, scene WEIGHT over the perf red line) and design fails (text that does not read against what is behind it, with the nearest shade that does; a room with no composed arrival shot; a primary action outside its arrival frame; the action's accent hue worn everywhere on its page; dead-end pages; two h1s, or body text with none). These are the fails only; the full critique, ranked for the brief with ready fixes, is design_review. A per-node warning you have read and disagree with goes quiet with data.lintMute:true on that node (every rule about one node honours it; scene-wide ones do not). When more than six fire, the last entry counts the rest. The ops APPLIED; act on warnings in your next batch unless they contradict what the user asked for. A weight warning names the heaviest categories: trim them (lower-poly models, fewer shadow lights/particles) or publish will warn and far heavier scenes are blocked.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed. A webText or webLink has no material: on add, color is the colour it shows (the text, or a link chip's fill; the label on a text-variant link).","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint) (href \"#share\" opens the share sheet of the visitor for the page they are on, the \"send this to someone\" button; a mailto may leave the recipient blank, mailto:?subject=...&body=..., so the visitor picks one), bgColor (the chip, the BUTTON colour; the accent on a primary action goes here), color (the LABEL text on the chip, not the button), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `camera` is a saved viewpoint: data.isEntry true makes it the one visitors ARRIVE at on the published site (aim it with lookAt), and a page names its own arrival view with data.arrivalCameraId. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent)."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, lookAt or lookAtNodeId (aim instead of a rotation, from the position the patch sets), plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's). To AIM a camera or a lamp, or turn a sign to face a point, pass lookAt instead.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 (a share of the frame height x 16:9; 0.0018 = 0.32% of screen height; above 0.002 the split is wider than thin strokes, and a pure red, green or blue stroke renders BLACK because its only channel moves off it) + chromaticAberrationRadial; colorGradingSaturation and colorGradingContrast -0.95 to 0.95, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; a resting camera {cameraIdleMotion 0-3 (slow handheld drift; 1 subtle, 3 a real sway), cameraShake 0-1 (faster, jerkier shake with a little tilt; adds to the drift)}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}; {responsive3d true: REQUIRED for any web block with data.anchor \"hud\" to fly into view, else it stays put}; {cameraWallCollision true: the orbit of a visitor stops at walls scene-wide (the arrival camera of a page can say data.wallCollision true|false)}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update, assignMaterial and delete: act on MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId (direct children only), underId (the WHOLE subtree of a node: every wall in a room group, however deep; a same-batch tempId works), materialAssetId (every node wearing that material, for a global restyle), limit} - every field given must match. Pass either `id` or `where`, never both. delete removes each match WITH its subtree and defaults to a limit of 100 (update and assignMaterial default to 500); pass where.limit to delete …⟨16 unchanged words⟩ {"type":"string"},"maps":{"additionalProperties":{},"properties":{"baseColor":{"type":"string" ⟨207 unchanged words⟩
⟨157 unchanged words⟩ the first three ids) or `unused:true`,so you can reason about how the scene LOOKS, not just its structure,and `settings`: the scene-level look the `settings` op ⟨59 unchanged words⟩ with every lamp deleted stays lit by it.This is the view to reason over before editing.Sizes are in scene units (≈ meters) ⟨81 unchanged words⟩
⟨5 unchanged words⟩ :{"description":"Max nodes to return,1-5001-300 (default 250).","type":"number" ⟨178 unchanged words⟩
⟨32 unchanged words⟩ rules a photographer and a web designer use.Analytic and instant: the published viewer's own camera pose, no render.A PAGE shot (a page's own camera, ⟨200 unchanged words⟩ ), anything in the lens or around it, how much is in frame, and exits (signs and tap targets within ⟨102 unchanged words⟩
⟨21 unchanged words⟩ the user.","type":"boolean"},"confirmReplace":{"description":"Only after a reply with needsConfirm and willReplace: exactly true replaces that live site with this scene. Ask the user first, naming the site.","type":"boolean"},"sceneId":{"description":"Scene id from list_scenes. ⟨6 unchanged words⟩
⟨34 unchanged words⟩ a webImage src, an mp3 in an audioClip: data.audioClip = {src, volume 0-1, loop, mode, muted} on any node; get_scene reports it as `audio`). Give ONE source: `url` (a public ⟨56 unchanged words⟩
after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed. A webText or webLink has no material: on add, color is the colour it shows (the text, or a link chip's fill; the label on a text-variant link).","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` or `d` key and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"design":{"additionalProperties":{},"description":"For settings: the DESIGN BRIEF, what the scene is FOR, which every design rule and design_review keys on. {intent: convert (get the visitor to do one thing) | inform | showcase | entertain | navigate, mood: calm|energetic|luxury|playful|serious|dramatic|natural, audience: one line (120 characters) on who it is for, primaryActionId: the node id or same-batch tempId of the one thing the page exists for, a sign or a tap target}. null (or \"\") clears a field; absent means undeclared, and the review says what it inferred instead. A webpage can carry its own data.intent and data.primaryActionId, which win for that page (add the action first, then point at it).","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}: source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year; the sun's bearing still follows the season), so pass a preset alone for that; pass minutes to pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight while the disc still draws; the sky's own faint night fill stays); {env: {intensity 0-4}} is the ambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, since almost nothing is lit directly and a dark palette renders black (~1.25 lifts a charcoal set back); 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"lookAt":{"description":"A WORLD point [x,y,z] to aim at, instead of a rotation (on add; inside patch on update). A camera looks at it with a level horizon (what the published viewer shows), a spot or area light beams at it, and a page, a web block or 3D text turns about the vertical to face it. Compose every saved viewpoint (entry, arrival, via, a sign's target) this way; never hand-compute a camera euler. With `where` on an update, every match aims at the one point: all the spots at the statue is one op.","items":{"type":"number"},"type":"array"},"lookAtNodeId":{"description":"Like lookAt, aimed at a node (its id, or a same-batch tempId): at its visible centre, so a page is aimed at its card, a pivoted door at its mesh and a room group at the middle of what is in it.","type":"string"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent)."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, lookAt or lookAtNodeId (aim instead of a rotation, from the position the patch sets), plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's). To AIM a camera or a lamp, or turn a sign to face a point, pass lookAt instead.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update, assignMaterial and delete: act on MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId (direct children only), underId (the WHOLE subtree of a node: every wall in a room group, however deep; a same-batch tempId works), materialAssetId (every node wearing that material, for a global restyle), limit} - every field given must match. Pass either `id` or `where`, never both. delete removes each match WITH its subtree and defaults to a limit of 100 (update and assignMaterial default to 500); pass where.limit to delete more on purpose. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}⟨63 unchanged words⟩ can be sized to a picture without ascreenshotscreenshot; a camera says what it SEES, from the pose the published viewer builds: lookingAt (the point it pivots on), pitchDeg, heightM, fov and fitWidth when set, looksAtNothing when nothing is ahead of it, and frames: up to six things in its picture on a square screen, biggest first, each at x and y from -1 to 1 with its share of the frame height, the floor left out) plus the material library — including each ⟨202 unchanged words⟩
⟨15 unchanged words⟩ on the scene. Use it after apply_ops batches:and after probe_framing, and judgetheeach image as a photographer and a web designer would: one clear subject, on a third (floatingforobjectsa page's own camera,unreadable/overlappingthe whole card at a readable angle); the horizon off the middle; the eye at a standing height; text legible and on even margins; a way out in view; the colours serving the brief (one accent,emptyonhorizonthe primary action); nothing floating,badclippedspacingor overlapping. Beside each image comes `composition`,wrongreadcolorsfrom its own pixels: tonalRange (p5 to p95 of lightness; under 0.35 is flat), clipping (share blown to white, crushed to black), key (low, mid, high), a 3x3 thirds grid of lightness andfixdetail with the busiest and brightest cells, the hues on screen by share, and findings in sentences. Fix with apply_ops. Discipline: at most 2 screenshot→fix ⟨165 unchanged words⟩
Design review of a scene — A designer's critique of a scene (or one page with pageId) against its BRIEF: what it is for (settings.design, or, when none is declared, what it reads as and why), its palette (dominant, secondary, accent, harmony, temperature, key, and whether that serves the intent and the mood), text contrast, heading hierarchy, every arrival and entry shot judged by probe_framing, wayfinding, the light's key to fill at the subject, and motion. `top` is the five things that matter most FOR THIS INTENT (a page meant to convert ranks the action and its accent first; one meant to inform, hierarchy and contra…
Is this camera a good photograph? — Read a saved camera (cameraId), or an exact pose, as a visitor sees it at the screen shapes they actually have (16:9 desktop and 9:16 phone by default), against the rules a photographer and a web designer use. Analytic and instant: the published viewer's own camera pose, no render. A PAGE shot (a page's own camera, its arrivalCameraId or a camera inside the page, with the card in view) should hold the whole card on every screen, most of the width, within 60 degrees of square so its text still reads: centred is right for a page. A SCENE shot (a room, an object, a place, and the entry view even…
⟨155 unchanged words⟩ "description":"Type-specific fields to merge: geometry dimensions, under the SAME keys get_scene reports as `size` (so a size you read is a size you can write back): box {width,height,depth} (box,default 1.5 each), plane {width,height}, sphere {radius}, cylinder {radius,height}, torus {radius,tube}; there is no `size`, `w`, `h` orfor`d`akey and one is REFUSED by name rather than stored. A glb model is {primitive:\"imported\", assetUrl} (see IMPORTED MODELS), {radius} (sphere/cylinder/torus), {tube} (torus). CUT a primitive (take part of ⟨3605 unchanged words⟩
⟨16 unchanged words⟩ [euler radians], scale, primitive, realsize`size` under the keys `data` takes (width/height/depth, radius, height, tube: write it back as patch.data as-is), assigned material; an imported model also carries ⟨237 unchanged words⟩
⟨818 unchanged words⟩ ; {timeOfDay: {source: live|fixed, preset:sunrise|noon|sunset|twilight|midnightsunrise|noon|sunset|twilight|midnight|null, minutes 0-1439}}—: source must be \"fixed\" or the ⟨29 unchanged words⟩ on every load (sunset stays sunset allyearyear; the sun's bearing still follows the season), so pass a preset alone for that; pass minuteswith preset nullto pin a CLOCK time instead (writing minutes clears any stored preset, or pass preset null explicitly; 18:28 is on the horizon in August ⟨33 unchanged words⟩ light after sunset, 0 = no moonlightat allwhile the disc stilldraws, so a dark scene can keepdraws; thenight sky andsky'slightownitselffaintwithnightlampsfillalonestays); {env: {intensity 0-4}} is theIBLambient fill (0.55 default): the IBL in studio mode, and in time mode the scale of the sky's own fill (hemisphere plus IBL, about 0.2 by night to 0.9 by day at 0.55; never the sun or moon key). The control to reach for after pinning a low sun, sinceat sunsetalmost nothing is lit directly and a dark palette renders black (0.55 default,~1.25 lifts a charcoal set back).; 0 leaves only the sun, the moon and the lamps, so a lamp-only night scene is a midnight pin with env 0 and moon 0.","propertyNames":{"type":"string"}, ⟨2730 unchanged words⟩
⟨215 unchanged words⟩ hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; a WALL or CEILING that must block a lamp is a THIN BOX ({width, height, depth:0.05}), not a plane: a plane shows one face and casts a shadow only from BEHIND, so a shadow-casting lamp in front of it shines straight through; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), ⟨3463 unchanged words⟩
before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry {width,height,depth} (box, default 1.5 each), or for a glb model {primitive:\"imported\", assetUrl} (see IMPORTED MODELS), {radius} (sphere/cylinder/torus), {tube} (torus). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight, minutes 0-1439}} — source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device; {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {env: {intensity 0-4}} is the IBL fill, and the control to reach for after pinning a low sun, since at sunset almost nothing is lit directly and a dark palette renders black (0.55 default, ~1.25 lifts a charcoal set back).","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat: matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra; data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent)."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's).","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update, assignMaterial and delete: act on MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId, limit} - every field given must match. Pass either `id` or `where`, never both. delete removes each match WITH its subtree and defaults to a limit of 100 (update and assignMaterial default to 500); pass where.limit to delete more on purpose. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry {width,height,depth} (box, default 1.5 each), or for a glb model {primitive:\"imported\", assetUrl} (see IMPORTED MODELS), {radius} (sphere/cylinder/torus), {tube} (torus). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"emissive":{"description":"Beside `color`: a hex that GLOWS, added as light on top of the lit colour and visible in the dark (a lampshade, a neon tube, a screen, an ember), no texture needed. #000000 glows nothing. A lit and an unlit copy of one colour are two materials. A map set glows through materialMaps.emissive instead.","type":"string"},"emissiveIntensity":{"description":"0 to 16, default 1 = the emissive colour as given; 2 to 4 reads as a lit lamp, higher blooms. Needs `emissive` beside it.","type":"number"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight, minutes 0-1439}} — source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device. A preset pins the SOLAR PHASE and is re-derived for the current date on every load (sunset stays sunset all year), so pass a preset alone for that; pass minutes with preset null to pin a CLOCK time instead (18:28 is on the horizon in August and night in December); {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {moon: {intensity 0-4, color}} is the moonlight in time mode (1 = its natural phase-scaled strength as the key light after sunset, 0 = no moonlight at all while the disc still draws, so a dark scene can keep the night sky and light itself with lamps alone); {env: {intensity 0-4}} is the IBL fill, and the control to reach for after pinning a low sun, since at sunset almost nothing is lit directly and a dark palette renders black (0.55 default, ~1.25 lifts a charcoal set back).","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. `ao` is a separate ambient-occlusion map url (list_assets mapRole \"ao\"), the crevice shading; a packed file already carries it in R, so ao beside ormh or orm is refused. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and its WORLD scale (its own times its ancestors'), so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own material (one texture upload is shared by every tiling of a file), so use it on the pieces of a seam, not on every brick of a city. BOTH worldScale AND offsetFromWorld ARE COMPUTED ONCE, WHEN THE MATERIAL IS BOUND: a node moved, resized or duplicated afterwards keeps the repeat and phase it was bound with (get_scene shows the material's repeat and offset), so re-run assignMaterial on the pieces you moved. One material source per op: materialAssetId, materialMaps, materialPreset or color, not two.","properties":{"ao":{"type":"string"},"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat (pass `color` beside it and the preset takes that colour: its tint ramp keeps the preset's own light-to-dark contrast about your hue, so brushed-metal + #8a2be2 is purple metal, neon-glass + #ff2020 glows red; without a colour a preset is exactly its stock look): matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra (a DOWNWARD-ONLY bulb, the light that fills a room without washing its ceiling, is a spot with angle 1.55 and penumbra 0.6 and NO rotation: it beams straight down over a 178 deg cone, and a point light has no such flag because this is it); data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent)."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"pivot":{"description":"For add: the HINGE. The point in the primitive's own space that sits at the node's origin, so rotation swings about it: a 1 m wide door hinged on its left edge is pivot [-0.5,0,0] with position at the hinge, and a rotation track of [0,0,0] to [0,1.4,0] swings it open. Default [0,0,0] = rotate about the centre. (update takes it in patch.)","items":{"type":"number"},"type":"array"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's).","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update, assignMaterial and delete: act on MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId (direct children only), underId (the WHOLE subtree of a node: every wall in a room group, however deep; a same-batch tempId works), materialAssetId (every node wearing that material, for a global restyle), limit} - every field given must match. Pass either `id` or `where`, never both. delete removes each match WITH its subtree and defaults to a limit of 100 (update and assignMaterial default to 500); pass where.limit to delete more on purpose. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}⟨59 unchanged words⟩ material library — including each solid material's color/roughness/metalness and `usedBy` (how many nodes wear it, the first three ids) or `unused:true`, so you can reason about how the scene LOOKS, not just itsstructure.structure, and `settings`: the scene-level look the `settings` op writes (lighting: mode, sun, timeOfDay, env IBL intensity; viewport: post effects, tone mapping, LUT, draw distance, camera flight; environment: fog; audio), every field that exists, plus `settings.sky`, one sentence on what actually lights the scene. Read it FIRST when a surface looks lit for no reason: env.intensity is ambient light with no source and no falloff, and a scene with every lamp deleted stays lit by it. This is the view to reason over before ⟨89 unchanged words⟩
⟨115 unchanged words⟩ src-1a2b3c4d\").","type":"string"},"settings":{"description":"The scene-level settings block (lighting, viewport, environment, audio, sky) rides on an unfiltered first-page call by default. Pass 1 to get it on a filtered or paged call, 0 to omit it.","type":"number"},"type":{"description":"Only nodes of this ⟨33 unchanged words⟩
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"atSeconds":{"description":"Seconds into the scene's animation the shot is taken at (0-600, default 0 = frame one). Every track and motion-path rider is frozen at that time, so a door with a 4 s swing is verified mid-swing at 2 and a rider on a 12 s loop at u=0.5 at 6. Cached per time.","type":"number"},"maxDim":{"description":"Longest side of the ⟨150 unchanged words⟩
What is lighting this point? — For one world point, the contribution of EVERY light in the scene, computed from the saved scene (no render, instant): each light node in reach with its value (three's falloff and spot cone, from the node's WORLD position and aim), the studio sun or the live sky, and `ambient` = lighting.env.intensity NAMED as a term, sorted by contribution, plus a `why` on each light that does not reach (out of range, outside the cone, behind the panel). Pass `nodeId` for the surface's albedo: a pale albedo shows the ambient term 2-3x as strongly as a dark one. Use it BEFORE deleting lights to find a phantom…
before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry {width,height,depth} (box, default 1.5 each), or for a glb model {primitive:\"imported\", assetUrl} (see IMPORTED MODELS), {radius} (sphere/cylinder/torus), {tube} (torus). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight, minutes 0-1439}} — source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device; {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {env: {intensity 0-4}} is the IBL fill, and the control to reach for after pinning a low sun, since at sunset almost nothing is lit directly and a dark palette renders black (0.55 default, ~1.25 lifts a charcoal set back).","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material.","properties":{"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat: matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra; data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent)."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's).","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update, assignMaterial and delete: act on MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId, limit} - every field given must match. Pass either `id` or `where`, never both. delete removes each match WITH its subtree and defaults to a limit of 100 (update and assignMaterial default to 500); pass where.limit to delete more on purpose. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry {width,height,depth} (box, default 1.5 each), or for a glb model {primitive:\"imported\", assetUrl} (see IMPORTED MODELS), {radius} (sphere/cylinder/torus), {tube} (torus). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, shadowMapSize:512|1024|2048, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight, minutes 0-1439}} — source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device; {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {env: {intensity 0-4}} is the IBL fill, and the control to reach for after pinning a low sun, since at sunset almost nothing is lit directly and a dark palette renders black (0.55 default, ~1.25 lifts a charcoal set back).","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1. `emissive` is a sixth map url: the pixels that GLOW (lit windows on a facade, a screen, a neon tube, a lava crack), added as light on top of the lit surface and visible in the dark; black pixels glow nothing. `emissiveIntensity` (0 to 16, default 1 = the map as painted) scales that glow; needs `emissive` beside it. A different emissive map or intensity mints a different material. PACKED SETS: `ormh` is one RGBA file carrying AO (R), roughness (G), metalness (B) and height (A), which is how every set in the shared library ships (list_assets mapRole \"ormh\"); `orm` is the glTF three-channel form (R=AO, G=roughness, B=metalness, no height). Pass the file on `ormh` (or `orm`) and each channel is split out and wired for you; it REPLACES the separate roughness/metalness (and, for ormh, height) urls, so naming both is refused, and ormh and orm together is refused. A library set is baseColor + normal + ormh: {\"baseColor\":\"<albedo url>\",\"normal\":\"<normal url>\",\"ormh\":\"<ormh url>\",\"repeat\":4}. Tiling, offset and animation apply to the split channels too. `worldScale` (meters) is the size of ONE tile of the set and REPLACES repeat: the engine turns it into the repeat for the node the material lands on from that primitive's dimensions and node scale, so brick every 0.4 m reads the same on a 2 m wall and a 12 m one ({\"baseColor\":..., \"normal\":..., \"ormh\":..., \"worldScale\":0.4}). With a `where` selector each match gets the repeat for its own size (same-sized pieces still share one material). A box has three face sizes and one repeat, so its LARGEST face is sized right and a long thin box stretches on its small faces; a sphere, cylinder and torus size by their unwrapped surface. Refused on an imported model (its UVs are whatever the file baked) and beside repeat. `offsetFromWorld: true` does the seam arithmetic for you: the offset is derived from where the node stands in the world (its face's UV origin along the face's own axes, over the tile size, modulo one tile), so two pieces of one wall bound with the same set and worldScale carry the pattern straight across their join. Box and plane only; needs worldScale or repeat beside it; refused beside an explicit offset. With `where` each match gets its own phase, and pieces a whole tile apart still share one material. Each distinct phase is its own texture instance at runtime, so use it on the pieces of a seam, not on every brick of a city.","properties":{"baseColor":{"type":"string"},"emissive":{"type":"string"},"emissiveIntensity":{"type":"number"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"offsetFromWorld":{"type":"boolean"},"orm":{"type":"string"},"ormh":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"worldScale":{"type":"number"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat: matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra; data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.shadowMapSize 512|1024|2048 sets that light's shadow map resolution (default: spot 1024, point 512 per cube face, area 1024; a point light caps at 1024): the price is the TEXELS, so a 512 spot costs a quarter of the default and a 2048 four times it, and 512 is the right call for a soft grounding shadow under a small object while 2048 is for one crisp hero shadow; data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent)."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's).","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update, assignMaterial and delete: act on MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId, limit} - every field given must match. Pass either `id` or `where`, never both. delete removes each match WITH its subtree and defaults to a limit of 100 (update and assignMaterial default to 500); pass where.limit to delete more on purpose. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}⟨211 unchanged words⟩ setId and placing each url by its mapRole.⚠orm/ormh are channel-packed files (R=AO, G=roughness, B=metalness, A=height):andbindmaterialMapsonehasonnomaterialMaps.ormhpacked(orslot:.orm),bindneveronlyas roughness, and thebaseColorchannelsandarenormalsplitmembersoutofforsuchyou, so a library set,neverisanbaseColorormh+filenormalas+roughness.ormh. An `emissive` member goes on materialMaps.emissive. `kind` is ⟨44 unchanged words⟩
before
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed.","type":"string"},"count":{"description":"For scatter: how many copies to make (1–200).","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry {width,height,depth} (box, default 1.5 each), {radius} (sphere/cylinder/torus), {tube} (torus). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel}. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for update/delete/duplicate/reparent/assignMaterial.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight, minutes 0-1439}} — source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device; {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {env: {intensity 0-4}} is the IBL fill, and the control to reach for after pinning a low sun, since at sunset almost nothing is lit directly and a dark palette renders black (0.55 default, ~1.25 lifts a charcoal set back).","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently.","properties":{"baseColor":{"type":"string"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps (e.g. the generation label like \"Lush Grass\").","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename.","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra; data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself); data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0.","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale.","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update and assignMaterial: edit MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId, limit} - every field given must match. Pass either `id` or `where`, never both. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed.","type":"string"},"count":{"description":"For scatter (1–200) or duplicate (1–64): how many copies to make. A duplicate count lays the copies out in a straight row by `step`; scatter jitters them across `area`.","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry {width,height,depth} (box, default 1.5 each), or for a glb model {primitive:\"imported\", assetUrl} (see IMPORTED MODELS), {radius} (sphere/cylinder/torus), {tube} (torus). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel, showPanel}. showPanel:false removes the page's white card (background plane, accent strip, LOD stand-in) while its text, links and children still render: use it when a page is a ROOM or an anchor rather than a billboard, instead of burying the page underground. The card is floored at 3.6 x 2.6 m otherwise. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. light {lightType:point|spot|area, color, intensity, distance, decay, angle, penumbra, width, height, castShadow, timeMode, goboPattern, goboSrc, goboAnim, goboSpeed, goboSpin, volumetric, volumetricDensity} (see the light notes on nodeType). Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for duplicate/reparent; update, assignMaterial and delete take either `id` or `where`.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight, minutes 0-1439}} — source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device; {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {env: {intensity 0-4}} is the IBL fill, and the control to reach for after pinning a low sun, since at sunset almost nothing is lit directly and a dark palette renders black (0.55 default, ~1.25 lifts a charcoal set back).","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently. `offset` [x, y] shifts the whole set in UV units (1 = one full tile) so two pieces of one wall can be phase-aligned across their seam: a piece that starts 1.2 m into a 0.5 m tile pattern wants offset [2.4, 0] (distance divided by tile size). A different offset mints a different material; every piece needs its own repeat (piece size / tile size) since a box face always maps 0..1.","properties":{"baseColor":{"type":"string"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"offset":{"items":{"type":"number"},"type":"array"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps or materialPreset (e.g. the generation label like \"Lush Grass\").","type":"string"},"materialPreset":{"description":"On add or assignMaterial: mint-or-reuse one of the fourteen curated procedural presets, the same ones the editor's Material Assets panel offers, so a first pass is textured rather than flat: matte-paper, neon-glass, brushed-metal, warm-ceramic, soft-plastic, holographic-film, dark-carbon, rough-stone, frosted-ice, worn-leather, polished-marble, lava-rock, ocean-glass, gold-leaf. Optional `color` becomes the preset's base color where the preset takes one. No image files, no weight beyond the node.","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename. On duplicate: the copy's name (default \"<source> copy\"; with a count, \"<name> 1\", \"<name> 2\", ...).","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra; data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself; ⚠ a shadow light is PRICED as if it re-drew the whole scene, one 1024 spot roughly doubles the geometry + draw score, so for an object that merely needs to look grounded use a `shadowPool` instead, which is nearly free); data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. GOBO (a projected cookie) + VOLUMETRIC lit air, on point and spot only (never area): data.goboPattern none|dapple (sun through blowing leaves)|blinds|windowpane|caustics (water light)|custom (with data.goboSrc, an image or video URL; a video makes a moving projector); data.goboAnim none|drift MOVES the pattern (slow pan + spin, the leaves stir): data.goboSpeed 0-1 uv/s (0.03), data.goboSpin -3..3 rad/s (0); data.volumetric true renders the beam as lit air (light shafts), data.volumetricDensity 0-3 (0.6). On a spot the cookie hits lit surfaces AND the cone shafts; a point light has no surface cookie, its pattern lives inside a spherical glow and shows ONLY with volumetric true. With castShadow, real geometry carves dark shafts through the beam. Dappled light through a canopy is one spot: {lightType:\"spot\", goboPattern:\"dapple\", goboAnim:\"drift\", volumetric:true}, aimed down at the floor. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root. On duplicate: where the copy goes (default: beside the source, under the same parent)."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0. On duplicate: the copy's position (default: the source's, i.e. exactly on top of it).","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians. On duplicate: the copy's rotation (default: the source's).","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale. On duplicate: the copy's scale (default: the source's).","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"step":{"description":"For duplicate with a count: [x,y,z] meters added per successive copy, so sixteen identical wall units 4.8 m apart are ONE op: {\"op\":\"duplicate\",\"id\":\"wall\",\"count\":16,\"step\":[4.8,0,0]}. Default [0,0,0] (copies stack in place).","items":{"type":"number"},"type":"array"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId. On a duplicate with a count it names the FIRST copy.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: out|inOut|gentle|linear, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update, assignMaterial and delete: act on MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId, limit} - every field given must match. Pass either `id` or `where`, never both. delete removes each match WITH its subtree and defaults to a limit of 100 (update and assignMaterial default to 500); pass where.limit to delete more on purpose. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}⟨18 unchanged words⟩ ], scale, primitive, real size, assignedmaterialmaterial; an imported model also carries its bounds {min,max}, size [w,h,d] and assetFile, the tail of its list_assets url, so a frame can be sized to a picture without a screenshot) plus the material library — including each ⟨113 unchanged words⟩
⟨87 unchanged words⟩ but slow — ~10s for simple scenes, and the server gives up after 45 s per view by default; a 400-node scene with many textures needs more, so pass timeoutSeconds (up to~2180)minutesandforaasset-heavysmalleronesmaxDim (a 512-wide shot is a fine check of placement and readability and renders faster). A timeout comes back as an error naming it, never as an empty image; repeat shots of an unchanged scene at the same size are cached andinstant).instant. Rate-limited: prefer ONE well-chosen view; only multi-view when areas are farapart.apart (four views at 180 s can hold the renderer for twelve minutes). If it returns a busy/unavailable error, keep ⟨11 unchanged words⟩
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"maxDim":{"description":"Longest side of the image in pixels, 64-1920 (default 400; height is 0.625x). Smaller renders faster and is enough to judge placement; go large only for a final look.","type":"number"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"},"timeoutSeconds":{"description":"Per-view render budget in seconds, clamped 10-180 (default 45). Raise it for a big or texture-heavy scene instead of retrying a timeout at the same budget.","type":"number"},"views":{"description":"Camera views ⟨79 unchanged words⟩
⟨60 unchanged words⟩ maps. PBR sets are stored as SEPARATE imageswhose labelsthat share aprefixsetId;andthe base color member is often unsuffixed (mapRole null) while its siblings end in the map name,e.g. "... Base Color",("... Normal", "...RoughnessOrmh"). Search the set name, then pick one url perslot;slot by mapRole; a base colour alone already reads as a ⟨37 unchanged words⟩ Returns id, kind, label, url, sizeBytes, keywords, mapRole andkeywords.setId. mapRole names the slot an image is (baseColor | normal | roughness | metalness | height | ao | orm | ormh, or null when the label does not say); setId is shared by every image of one PBR set, so bind a whole set by matching setId and placing each url by its mapRole. ⚠ orm/ormh are channel-packed files (R=AO, G=roughness, B=metalness, A=height) and materialMaps has no packed slot: bind only the baseColor and normal members of such a set, never an ormh file as roughness. `kind` is "image" for textures and "glb ⟨3 unchanged words⟩ glb url goes on a geometry node as`primitivedata: {primitive:"imported"`,+assetUrl},`assetUrl`never as the top-level primitive; the low-poly picture frames face +X, rotate [0, 1.5708, 0] to hang one on a wall facing +Z).
after
{"$schema":"http://json-schema.org/draft-07/schema#","properties":{"operations":{"description":"Ordered edit operations, applied atomically.","items":{"additionalProperties":{},"properties":{"area":{"additionalProperties":{},"description":"For scatter: half-extents (meters) of the XZ area to spread copies across, centered on the source. Default 5 each. Large = a lot strewn over a parking lot; small = a tight cluster.","properties":{"x":{"type":"number"},"z":{"type":"number"}},"type":"object"},"audio":{"additionalProperties":{},"description":"For settings: the scene audio bus. {masterLevel 0-1, ambientLevel 0-1, ambientMuted}.","propertyNames":{"type":"string"},"type":"object"},"body":{"description":"For make_page: a paragraph of body text placed under the hero.","type":"string"},"color":{"description":"Hex color like \"#ff3344\". On add or assignMaterial, mints-or-reuses a solid-color material and assigns it — no pre-existing material needed.","type":"string"},"count":{"description":"For scatter: how many copies to make (1–200).","type":"number"},"data":{"additionalProperties":{},"description":"Type-specific fields to merge: geometry {width,height,depth} (box, default 1.5 each), {radius} (sphere/cylinder/torus), {tube} (torus). CUT a primitive (take part of it), ALL ANGLES IN RADIANS: sphere {thetaStart (down from the pole, 0..PI), thetaLength (sweep, capped at PI - thetaStart), phiStart, phiLength (around, 0..2PI)}, a hemisphere/dome is {thetaStart:0, thetaLength:1.5708}; cylinder {cylinderThetaStart, cylinderThetaLength (PI = half-pipe), cylinderOpenEnded:true (a tube, no caps)}; torus {torusArc (PI = horseshoe)}. A cut renders double-sided and costs the SAME triangles as the whole (only openEnded removes faces). text {text,color,fontSize, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}. webpage (a page/neighborhood surface) {pageTitle, slug, isHomePage, accentColor, clusterLabel}. backgroundLayer (env-sphere backdrop centred on camera) {kind:gradient|color|image, color, color2, src, tint, height (world-Y offset; negative=horizon lower), coverage (band thickness), headingDeg, opacity}. Web blocks: webText {text, role:h1|h2|h3|body|caption, fontWeight:400|500|600|700, outline:true|false, outlineColor, outlineWidth}; webImage {src, alt}; webLink {label, fontWeight, href|linkedPageId (jump to a page) | linkedCameraId (jump to a viewpoint), textureUrl (a same-origin /assets image mapped onto the chip - use list_assets to find one, and bgColor tints it), castShadow (the chip joins the shadow pass so it reads as an object ON a surface rather than an overlay; needs a light with castShadow), roughness}; webVideo {src, poster}; any web block also accepts {anchor:world|billboard|hud}.","propertyNames":{"type":"string"},"type":"object"},"edges":{"description":"For make_diagram: the connectors, each { from, to } naming node ids from `nodes`, plus an optional label drawn at the midpoint. Max 128. Cycles are fine.","items":{"properties":{"from":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"type":"array"},"environment":{"additionalProperties":{},"description":"For settings: FOG and the background. {backgroundColor, fogColor, fogNear, fogFar, fogType: linear|exponential, fogDensity}. Fog is what makes distance read, so raising drawDistance without pushing fogFar out with it shows nothing new.","propertyNames":{"type":"string"},"type":"object"},"hero":{"description":"For make_page: the hero heading text — becomes an h1 webText on the page.","type":"string"},"id":{"description":"Target node id from get_scene, or a tempId minted earlier in this batch. Required for update/delete/duplicate/reparent/assignMaterial.","type":"string"},"ids":{"description":"For group: the node ids to gather under a new group.","items":{"type":"string"},"type":"array"},"layout":{"description":"For make_diagram: `layered` (default) stacks flowchart rows downward from the nodes nothing points at — use for processes, pipelines, org charts. `radial` puts the first root in the middle with rings of children around it — use for mind maps.","enum":["layered","radial"],"type":"string"},"lighting":{"additionalProperties":{},"description":"For settings: the SUN and the sky. {mode: time|studio, forceDaylight}; {timeOfDay: {source: live|fixed, preset: sunrise|noon|sunset|twilight|midnight, minutes 0-1439}} — source must be \"fixed\" or the preset is stored and ignored and the sky keeps following the clock on each viewer device; {sun: {azimuth, elevation, intensity, color, castShadow}} for studio mode; {env: {intensity 0-4}} is the IBL fill, and the control to reach for after pinning a low sun, since at sunset almost nothing is lit directly and a dark palette renders black (0.55 default, ~1.25 lifts a charcoal set back).","propertyNames":{"type":"string"},"type":"object"},"links":{"description":"For make_page: wayfinding \"sign\" buttons. Each { label, and EITHER to (a page id/tempId to fly to) OR href (external URL) }.","items":{"properties":{"href":{"type":"string"},"label":{"type":"string"},"to":{"type":"string"}},"type":"object"},"type":"array"},"materialAssetId":{"description":"A material id from the scene summary (for add geometry or assignMaterial).","type":"string"},"materialMaps":{"additionalProperties":{},"description":"Bind a GENERATED PBR material. Pass the map URLs from get_generation (its maps.<field>.url). On add or assignMaterial, mints-or-reuses a textured material from these maps and assigns it - this is how you place a generate_material result onto a node. At least one map url required. `repeat` tiles the whole set (a number, or [x, y]) - a map wraps EXACTLY ONCE without it, which is right for a prop and useless for ground, since one wrap over a 400m plane is a single smeared ripple. `uvAnimation` moves it: { mode: \"scroll\", speedX, speedY } in UV per second is the flowing-water primitive - point it at a set with a normal map and the surface ripples - or { mode: \"flipbook\", columns, rows, fps } to step a spritesheet. Both apply to every channel at once, so the albedo and the normal cannot end up tiled differently.","properties":{"baseColor":{"type":"string"},"height":{"type":"string"},"metalness":{"type":"string"},"normal":{"type":"string"},"repeat":{"anyOf":[{"type":"number"},{"items":{"type":"number"},"type":"array"}]},"roughness":{"type":"string"},"uvAnimation":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"}},"type":"object"},"materialName":{"description":"Display name for the material minted from materialMaps (e.g. the generation label like \"Lush Grass\").","type":"string"},"metalness":{"description":"Optional 0–1 metalness for the minted color material (0 = non-metal, 1 = metal). Default 0.","type":"number"},"name":{"description":"Display name for a created node, or a rename.","type":"string"},"nodeType":{"description":"For add: the kind of node to create. `cable` is a catenary wire sagging between data.pointA and data.pointB (node-local [x,y,z]; data.sag = midspan droop in meters, data.thickness, data.color) — power lines, festoon lights, rigging. `webpage` is a page / \"neighborhood\" surface (set data.pageTitle, data.slug; place pages a distance apart to make a multi-area site). `backgroundLayer` is an env-sphere backdrop centred on the camera (distant mountains, skyline, valley haze) — set data.kind gradient|color|image, colors; data.height offsets the horizon (negative=lower), data.coverage=band thickness. `light` is a placeable lamp: data.lightType point|spot|area (area = a soft glowing panel of data.width×data.height meters), data.color, data.intensity (~30), data.distance range (0=unlimited, point/spot), spot data.angle rad + data.penumbra; data.castShadow true (max 4 lights cast, scene order; on an area light it adds a soft grounding shadow since the panel casts none itself); data.timeMode always|night|day gates it by the live sky (night = streetlamp). A spot and an area panel beam down local -Y — rotate the node to aim. `shadowPool` is a fake contact shadow: a flat soft dark disc laid just above the floor (data.radius meters, data.opacity 0 to 1, data.softness, data.color) so a heavy object reads as resting on the ground rather than floating; it works where no light casts a real shadow. `forcefield` bends nearby particle motion and does nothing without particles in range: data.fieldType vortex|gravity|wind|turbulence|pointer|camera, data.radius meters of influence, data.strength, data.falloff constant|linear|inverseSquare; vortex and wind act along local +Y, so rotate the node to aim. `pointer` binds the field to the VISITOR'S cursor or finger instead of to the node: dragging is a magnet plus a wind along the direction of travel, a click or tap sends a ripple expanding outward. It uses its position only to fix the depth the cursor projects onto (data.pointerPlane camera|ground), plus data.pointerMagnet, data.pointerWind, data.rippleStrength, data.rippleSpeed, data.rippleWidth and data.pointerDamping (how fast particles settle again; at 0 the effect never reaches equilibrium). `camera` makes the CAMERA the force: particles are shoved aside as the visitor moves through them and settle again when it stops, so a parked camera costs nothing. data.cameraSweep 0 shoves straight outward from the camera, 1 sweeps along the direction of travel; 0.35 (default) reads as dodging. The web* types are responsive site building-blocks. Keep in sync with server/copilotSchema.js NODE_TYPE_ENUM.","enum":["geometry","text3d","nurbs","group","camera","particle","forcefield","light","shadowPool","cable","webpage","backgroundLayer","webText","webImage","webLink","webVideo"],"type":"string"},"nodes":{"description":"For make_diagram: the boxes. Each { id (your own short handle, referenced by edges), label (the text on the card), body (optional smaller line under it) }. Max 64.","items":{"properties":{"body":{"type":"string"},"id":{"type":"string"},"label":{"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"op":{"description":"Which kind of edit. `scatter` makes many jittered copies of a node across an area (set-dressing: debris, foliage, props). `make_page` is a COMPOSITE that builds a whole page in one op (webpage surface + hero h1 + body paragraph + link \"signs\") — prefer it over hand-assembling a page from primitives. `make_diagram` is a COMPOSITE that builds a whole flowchart / mind map from `nodes` + `edges`, laid out for you — never place diagram boxes by hand. `settings` edits the SCENE rather than its nodes — the post-processing look, the sun and time of day, fog, the audio bus, editor preferences, the scene `name` and its `activeCameraId` — via `viewport`, `lighting`, `environment`, `audio` and `ui`; it is the only way to reach any of them.","type":"string"},"parentId":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Parent node id/tempId, or null for the scene root."},"patch":{"additionalProperties":{},"description":"For update: any of name, visible, position, rotation, scale, pivot, plus a nested `data` object (shallow-merged). update reads them ONLY from here — a top-level position/rotation/scale/data on an update op is REJECTED, not applied.","propertyNames":{"type":"string"},"type":"object"},"position":{"description":"[x,y,z]. Y is up, units ≈ meters, ground at y≈0.","items":{"type":"number"},"type":"array"},"primitive":{"description":"For add of a geometry node: which primitive.","enum":["box","sphere","cylinder","plane","torus"],"type":"string"},"rotation":{"description":"[x,y,z] euler radians.","items":{"type":"number"},"type":"array"},"roughness":{"description":"Optional 0–1 roughness for the minted color material (0 = glossy, 1 = matte). Default 0.5.","type":"number"},"scale":{"description":"[x,y,z] scale.","items":{"type":"number"},"type":"array"},"seed":{"description":"For scatter: integer RNG seed for reproducible placement (optional).","type":"number"},"spacing":{"description":"For make_diagram: multiplier on the gaps between cards (default 1). Raise it for long labels, lower it for a compact board.","type":"number"},"tempId":{"description":"Batch-local handle for a node this op creates (add/duplicate/group). Later ops in the SAME batch may use it as id/parentId.","type":"string"},"tint":{"description":"Hex color. On scatter, a base per-copy tint for imported/generated (assetRef) copies, with slight random variation (rust/foliage/grime). No effect on solid-color primitives.","type":"string"},"ui":{"additionalProperties":{},"description":"For settings: editor preferences stored on the scene. {density: comfy|compact|ultra, theme: auto|light|dark, polyWheelTheme, reduceMotion, showStatusBar, showHistoryHud, showMobileViewportHints, gizmoSize/Thickness Desktop+Mobile, longPressDurationMs, longPressMoveTolerance, tapFrameWindowMs}. These travel with the scene but change nothing a visitor sees.","propertyNames":{"type":"string"},"type":"object"},"viewport":{"additionalProperties":{},"description":"For settings: the published LOOK. Post effects {bloomEnabled, dofEnabled, vignetteEnabled, colorGradingEnabled, glitchEnabled, filmGrainEnabled, lensDistortionEnabled, chromaticAberrationEnabled + chromaticAberrationOffset 0.0002-0.01 + chromaticAberrationRadial, godRaysEnabled + godRaysSamples 15-120 (the COST dial) + godRaysDensity, godRaysWeight, godRaysDecay, godRaysExposure, lensFlareEnabled} each with their own numeric params; camera flights {cameraEasing: linear|ease-in-out|ease-out|ease-in, cameraTravelScale 0.25-4 (flight length), cameraFlightArc 0-1}; neighborhood dissolve {zoneFadeMs 0-2000}; particles and cables take data.forceFieldIds: [fieldId,...] (null = every field, [] = none, the one lever that makes fields cheap) and a webpage takes data.zoneKeepDistance; {lutEnabled, lutPreset: neutral|warm|cool|teal-orange|vintage|noir|sepia|vibrant, lutIntensity}; {toneMapping: none|linear|reinhard|cineon|aces, toneMappingExposure 0.1-4}; {drawDistance 1-5000}; {showGroundPlane, showNeighborhoodPlates, showGrid}. ⚠ publishPostFx must be true or bloom/DoF/vignette/grading/glitch/grain/lens-distortion/chromatic-aberration render in the editor ONLY and every visitor sees none of them. ⚠ drawDistance is overridden by the data.far on any camera node (created at 100), so raise those too.","propertyNames":{"type":"string"},"type":"object"},"where":{"additionalProperties":{},"description":"For update and assignMaterial: edit MANY nodes at once instead of one by id. {type, name, nameStartsWith, nameContains, primitive, parentId, limit} - every field given must match. Pass either `id` or `where`, never both. `scatter` names its copies \"<source> 1\", \"<source> 2\", so nameStartsWith is how you re-edit a scattered set without listing 60 ids. A selector matching NOTHING rejects the batch rather than quietly doing nothing, and an empty selector is refused rather than matching everything.","propertyNames":{"type":"string"},"type":"object"}},"required":["op"],"type":"object"},"minItems":1,"type":"array"},"sceneId":{"description":"Scene id from list_scenes.","type":"string"}},"required":["sceneId","operations"],"type":"object"}