Référence technique

Public Locus Package v2 and compatibility ZIP formats

Sur cette page
  1. Complete Package v2 — canonical import format
  2. One pure-data capability contract
  3. Complete View source and midground data (Locus 1.2.1)
  4. Locus 1.3.0 View effects and orbital profiles
  5. Locus 1.4 View additions
  6. Legacy/simple flat ZIP compatibility
  7. Flat compatibility Room ZIP
  8. Room lighting (formatVersion 2)
  9. Room indirect lighting (formatVersion 3)
  10. Authored rendering and spotlight direction (formatVersion 5)
  11. Ambient animations (formatVersion 4)
  12. How desk alignment identifies a desk
  13. Lounge seats and hideable desks
  14. Seat groups (formatVersion 6)
  15. Room video surfaces (formatVersion 7)
  16. Room illuminated surfaces and view-forward yaw (formatVersion 8)
  17. Room particle effects (formatVersion 9)
  18. Room baked light layers (formatVersion 10)
  19. How Locus uses a Room
  20. Collision and quality: internal fields, not additional author inputs
  21. Model and texture budgets
  22. Flat compatibility View ZIP
  23. Son
  24. Placement and randomized playback (version: 2)
  25. provenance
  26. Pack and validate a flat compatibility ZIP
  27. Inspect the delivered model before packaging
  28. Build an independent material example

Complete Package v2 — canonical import format

Locus 1.2.1 and later accepts a complete Package v2 ZIP from Files or a direct public HTTPS URL through both Importer une Vue et Importer une Pièce. Use an ordinary .zip filename for normal public delivery. Locus accepts the same ZIP bytes with a .zip ou .locusplace Nom de fichier.

The ZIP root directly contains locusplace.json et catalog/; do not wrap them in another directory:

complete-place.zip
|-- locusplace.json
`-- catalog/
    |-- destinations/<destination-id>/destination.json
    |-- spaces/<space-id>/space.json
    `-- experiences/<experience-id>/experience.json   optional

Each declared View, Room, or Experience keeps its other assets beside its manifest under the same content-ID directory. These catalog/destinations/, catalog/spaces/, et facultatif catalog/experiences/ paths are required Package v2 structure, not forbidden nested files. Import a Package v2 ZIP from the View entrypoint when it contains a requested View, or from the Room entrypoint when it contains a requested Room. A package may bundle supported Experiences that compose its requested content.

Complete Package v2 preserves its authored stable IDs. locusplace.json declares the stable packageID, contentVersion, minimumAppVersion, content IDs, every payload file with its byte count and lowercase SHA-256, and the overall contentHash. Reimporting the same package and content is idempotent. Changed bytes for the same packageID require a strictly newer contentVersion, and an update cannot replace the package’s declared set of View, Room, or Experience IDs.

Supported complete packages preserve View media and masks, View and Room sound, Room animations, video surfaces and seat groups, provenance, and supported Experiences. They must pass manifest and file-inventory agreement, hashes, safe canonical paths, archive and decoded-resource limits, declared capabilities, and minimumAppVersion. If locusplace.json is present but the complete package is malformed, Locus rejects it; it does not reinterpret or fall back to the flat compatibility format.

One pure-data capability contract

Package v2 describes data, never executable code. Its JSON may select typed images, audio, video, USD/USDZ scenes, and renderer parameters implemented by the installed Locus binary. A package cannot carry scripts, plugins, dynamic libraries, or executable extensions. Safe paths, hashes, exact inventory and reference closure, structural decoding, resource budgets, and minimumAppVersion are enforced before an import is committed.

Within the same Locus binary, a runtime-consumed Room or View field that passes that common validation has the same content meaning whether it is bundled with the app, downloaded from the Locus Library, or installed from a complete public Package v2 ZIP. Source may still control entitlement, whether custom import is allowed, update authority, storage and removal policy, and UI labels. It does not unlock a different content schema or renderer feature set.

This is also the compatibility rule for future Room and View capabilities: add the typed field, its referenced assets and type/budget/structure rules, the runtime consumer, and an app-version floor to the common Package v2 contract. Do not add the field to a separate public-import allowlist. A reserved or retired field with no runtime consumer can still be rejected because that Locus binary cannot use it—not because the ZIP came from outside the app.

Complete View source and midground data (Locus 1.2.1)

A complete destination.json may include sourceImage et midground in addition to its panorama, thumbnail, environment, media, sound, and provenance data. sourceImage is an optional package-relative JPEG or PNG kept with the View as its source/audit image. It is decoded and counted against the same image budgets as other packaged images; it is not an executable authoring file.

midground adds View-owned 3D scenery and/or renderer-owned water between a Room and the panorama. It is supported in both Virtual Space and Room Portal, and has the same contract for bundled, Library, and complete imported packages. Any package that declares midground must set minimumAppVersion à 1.2.1 or later.

{
  "sourceImage": "original.jpg",
  "midground": {
    "path": "midground.usdz",
    "transform": {
      "translationMeters": [0, 0, 0],
      "orientationXYZW": [0, 0, 0, 1],
      "scale": [1, 1, 1]
    },
    "terrainEntities": ["Terrain"],
    "water": {
      "levelMeters": -1.2,
      "mask": "water-mask.png",
      "normalMap": "water-normal.png"
    }
  }
}

A midground object must contain path, water, or both:

The currently reserved or retired Destination fields depthLayers, splat, and legacy audio remain unsupported in complete public imports because this binary has no active runtime consumer for them. Use sound, not audio, for current View audio.

Locus 1.3.0 View effects and orbital profiles

The fields in this section belong in a complete Package v2 catalog/destinations/<destination-id>/destination.json. They are accepted from a complete imported ZIP only; a flat view.json ZIP does not grow these fields. Set the enclosing package’s minimumAppVersion à 1.3.0 and, when publishing through the Library, declare the capability named below. An older reader rejects an unsupported package instead of silently dropping an effect.

All paths are package-relative and every referenced file must appear in locusplace.json’s file inventory with its exact byte count and SHA-256. Omit an optional key to use its documented default; JSON null and unknown keys are invalid.

Water

water is available on a View and on its Room as separate, authored effects. It requires locus.view.water.v1 ou locus.room.water.v1, respectively. A water object contains one to four uniquely named regions. The View-only blend block lets a region meet the panorama and the paired Room; a Room region must not contain it. A complete package may have at most one region whose surface.motion Est "ocean" across its View and Room.

"water": {
  "preset": "clear-lake",
  "regions": [{
    "id": "lake",
    "pose": {"translationMeters": [0, -0.35, 0],
             "orientationXYZW": [0, 0, 0, 1]},
    "shape": "disc",
    "sizeMeters": [153.6, 153.6],
    "surface": {"motion": "ocean", "waveHeightMeters": 0.06,
                "waveLengthMeters": 26, "waveSpeed": 0.8,
                "directionDegrees": 0},
    "optics": {"color": [0.03, 0.18, 0.24], "reflectionBlur": 1.5,
               "sunGlint": 1},
    "bed": {"albedo": "water/bed.png", "depthMeters": 1.8,
            "reliefMeters": 0.4, "extentMeters": [-30, -30, 30, 30],
            "caustics": 0.6,
            "shoreline": {"foam": 0.8, "swashMeters": 0.04,
                          "swashPeriodSeconds": 3}},
    "blend": {"panoramaMask": "water/mask.png", "horizonSkirt": true,
              "roomKeepOut": true}
  }]
}

shape Est "rectangle" (the default) or "disc"; sizeMeters has two finite values in (0, 1000] and a disc uses its first value as diameter. pose is in the View or Room floor frame. A surface is "still" ou "ocean" (the default); legacy "lake" et "stream" remain readable aliases, but are not new authoring choices. waveHeightMeters Est 0...20, waveLengthMeters is bounded by the region’s sampling floor through 1000 m, waveSpeed Est -100...100, et directionDegrees Est -360...360.

bed.albedo is an 8-bit, non-interlaced RGBA PNG no larger than 4096 pixels on either side. Its RGB is the bed colour and alpha is height relief. depthMeters Est -50...50, reliefMeters Est 0...50, and a bed has either tileMeters (0.1...1000) or ordered extentMeters coordinates in -1000...1000. Optional normal maps, bed textures, panorama masks, and sun visibility masks are 8-bit PNGs with a 4096-pixel edge limit; a panorama mask is grayscale and 2:1. All water textures, including their required mipmaps and renderer-owned buffers, share a 64 MiB resident budget. A bed may also add shoreline foam (0...4), swash (0...0.5 m), and period (0.5...60 s).

glow is a water sub-block, not a separate profile. It can add bounded bed emission, particles, sparkle, and surface flashes. It uses the same water capability, has no image asset, and is disabled by strength: 0. A View region can additionally supply a four-number glow.mover [centreX, centreZ, radiusMeters, angularRadiansPerSecond]; its centre is within -1000...1000, radius is 0.1...25, speed is 0...1, and it also requires locus.view.water.glow-disturbance.v1.

midground.water remains a compatibility reader for previously authored lakes. Do not mix its old field table with the root water interface; new packages should author one explicit water region and retain any midground.path USDZ only for visible terrain and props.

Animated painted clouds and a timed sun

A View may animate clouds already painted in its panorama with clouds and the locus.view.clouds.v1 capability. This is a masked repair of the authored photograph, not generated weather and not a cloud profile for Rooms. The mask is an 8-bit grayscale, 2:1 PNG and the fill is an 8-bit RGB, 2:1 PNG. They must have identical dimensions and neither may be wider than 4096 pixels.

"clouds": {
  "mask": "clouds/mask.png",
  "fill": "clouds/fill.png",
  "windMetersPerSecond": 4,
  "windTowardDegrees": 25,
  "horizonDegrees": 0
}

Clouds can coexist with procedural sky, water, or a midground USDZ, but not with video media. The cloud mask should cover sky pixels only: black retains the panorama foreground while white permits the fill to move. Turning Motion off or using Reduce Motion restores the unchanged photograph. horizonDegrees Est -10...10; optional altitude is 200...12000 m, sea depth is 0...5000 m, wind is 0...30 m/s toward -360...360 degrees, cycle time is 5...120 seconds, evolution is 0...5, and breathing is 0...0.15.

environment.sunPath gives a deliberately sun-free View a 24-hour lighting path and requires locus.view.sun-path.v1. It cannot coexist with environment.directSun, a celestial profile, or a static water bed.sunVisibility bake. Both the panorama and IBL must be free of a painted sun or sun-centred glare, and provenanceDetail must include a truthful sun-free-sky component record.

"environment": {
  "sunPath": {
    "sunFreeSky": true,
    "startHour": 9,
    "dayDurationSeconds": 3600,
    "maxElevationDegrees": 60,
    "peakIlluminanceLux": 60000,
    "disc": {"skyMask": "sun/mask.png", "intensity": 1}
  }
}

startHour Est 0...24, dayDurationSeconds Est 60...86400, maximum elevation is 10...85 degrees, peak illuminance is 0...120000 lux, and night illuminance is 0...1000 lux. The optional disc needs an 8-bit grayscale 2:1 PNG no wider than 4096 pixels; its angular size is 0.1...5 degrees and intensity is 0...20. Motion off, Reduce Motion, and the Sun Position override stop the cycle at a deterministic state.

Fireflies and point lights

fireflies requires locus.view.fireflies.v1. It has one to four volumes, no asset paths, and cannot coexist with a celestial profile. Their combined count may not exceed 400.

"fireflies": [{
  "id": "shore", "centerMeters": [0, 1.4, -5],
  "sizeMeters": [8, 3, 6], "count": 120,
  "color": [0.75, 1, 0.35], "sizeMillimeters": 6,
  "driftMetersPerSecond": 0.15,
  "blink": {"periodSeconds": 2.5, "duty": 0.35, "jitter": 0.5}
}]

Centers are within -100...100 m, each size is 0.1...100 m, count is 1...400, particle size is 1...20 mm, and drift is 0...0.5 m/s. Blink period is 1.5...20 seconds, duty is 0.05...0.8, and jitter is 0...1. These are View-floor coordinates; sky pitch does not change them.

pointLights remains a complete-package View sidecar requiring locus.view.point-lights.v1 et minimumAppVersion: "1.2.5" or later. It names one strict JSON table with 1–64 groups and 1–4000 total rows:

"pointLights": {"table": "point-lights.json"}

Each row is [u, v, angularSizeDegrees, intensity]: u Est [0,1), v Est 0...1, angular size is 0.001...0.5 degrees, and intensity is 0...4. Use either a blink group (period 1...20 seconds, phase, minimum, and softness) or a twinkle group (amplitude 0...0.3, speed 0.05...2). The sidecar uses the same equirectangular seam convention as the panorama.

Procedural sky, celestial bodies, and near orbit

The existing sky profile remains the procedural-sky contract: a 2:1 grayscale sky.mask selects pixels the renderer replaces, while stars, moon, et aurora add only the declared sky layers. It requires minimumAppVersion: "1.2.4" and the relevant Library capability. Its mask remains an ordinary package asset, so it belongs in the envelope inventory. A celestial body may now share a stars-only procedural sky at 1.3.0 through locus.view.celestial-sky.v1: sky.stars is required, while sky.moon et sky.aurora must be absent. That composition excludes media, water, fireflies, and clouds.

A starting night-sky declaration is:

"sky": {
  "mask": "sky/mask.png", "defaultMode": "free",
  "stars": {"seed": 19, "twinkle": 1,
            "milkyWay": {"style": "realistic", "brightness": 1}},
  "moon": {"azimuthDegrees": 150, "elevationDegrees": 25,
           "illumination": 0.5},
  "aurora": {"northAzimuthDegrees": 0, "palette": "classic", "intensity": 1}
}

Declare locus.view.sky.stars.v1 for authored stars and locus.view.sky.aurora.v1 for aurora. The Moon adds no separate capability. An explicit defaultMode: "free" also needs locus.view.sky.default-mode.v1; "real" and omission do not. Free uses the authored arrangement; Real uses the visitor’s place and clock. A saved visitor mode takes precedence. At least one of stars, moon, ou aurora is required. Empty objects use their defaults. Moon and aurora imply a default starry sky when stars is absent. Keep painted stars out of the foreground; grey mask edges blend the live sky with the photograph.

Stars accept a seed integer 0...2147483647, twinkle 0...1.5, brightness 0.25...2, limiting magnitude 4.5...7, bright-star size 0...2, colour saturation 0...1.5, and azimuth -180...180 degrees. Milky Way style is realistic ou vivid, brightness 0...2, and free placement uses centre azimuth -180...180, centre elevation -90...90, and tilt -180...180. The shared Galactic map is bundled by Locus; an ordinary package needs no extra map asset. At 1.3.0 an author may supply stars.milkyWay.diffuseMap, requiring locus.view.sky.milky-way-map.v1: an 8-bit RGB/RGBA 2:1 PNG, 64–8192 pixels wide, Galactic longitude zero at image centre, longitude increasing left, north at top. Include its truthful source credit and rights.

For an authored Moon, position, phase, brightness and size are optional. The visitor can override them in Motion; in Real mode the Moon instead follows the visitor’s location and clock. Its azimuth is -180...180 degrees, elevation 0...85, illumination and maximum illumination 0...1, brightness 0...2, and size scale 1...3. Locus 1.4 can instead fix the drawn size with drawnDiameterDegrees; see Smaller additions. Aurora palette is classic, storm, ou low-latitude, intensity is 0...2, and north azimuth is -180...180. Its optional substorms object has periodSeconds exactly zero or 120...3600, firstOnsetSeconds 0...3600, strength 0...1.5, et raySpeedup 0...2. Optional arcs contains 1–4 objects; each may set elevation 2...178 degrees, brightness 0...2, thickness 0.8...12 km, ray speed 0...8 km/s, ray width 2...40 km, ray contrast 0...15, and waviness 0...2. Elevation runs from the northern horizon through the zenith to the southern horizon, so 145 degrees is about 35 degrees above the south. Retain a complete baked panorama as the still fallback.

celestial is one far-field body. Version 1 uses an angular diameter; version 2 offers either an angular diameter (with optional distance) or explicit distanceMeters plus radiusMeters. Both formats require a complete static panorama fallback, because Motion off and Reduce Motion show that fallback. Use the celestial v1 schema ou celestial v2 schema for exact required keys and machine-readable ranges.

"celestial": {
  "version": 2, "backgroundPanorama": "space/stars.jpg",
  "dayTexture": "earth/day.jpg", "cloudTexture": "earth/clouds.png",
  "yawDegrees": 25,
  "elevationDegrees": 12, "distanceMeters": 500,
  "radiusMeters": 100, "rotationPeriodSeconds": 1200,
  "cloudHeightRatio": 0.006, "cloudDriftRatio": 1.08,
  "cloudShadowStrength": 0.7, "cloudEvolution": 0.5,
  "cloudEvolutionPeriodSeconds": 900,
  "sunDisc": {"skyMask": "space/sun-mask.png"}
}

For v2, distance is 400...800 m, radius is 1...400 m, radius must be no more than half the distance, and distance plus radius must be at most 1100 m. The cloud shell is optional: height is 0...0.03 of body radius, with zero keeping flat compositing, drift is 0.5...2, shadow strength and evolution are 0...1, and the evolution period is 60...3600 seconds. The presence of any cloud-shell key or sunDisc requires the 1.3.0 floor. sunDisc.skyMask is a grayscale 2:1 PNG no wider than 8192 pixels. Regenerate the complete static panorama after changing body, cloud-shell, or disc parameters.

nearOrbit is an alternative whole-sphere View profile, not an effect that mixes with water, celestial, procedural sky, media, fireflies, clouds, point lights, midground, or sunPath. Version 1 is available through locus.view.near-orbit.v1; version 2 adds body radius, cloud drift, optional surface tiles, and locus.view.near-orbit.v2. Both need a fallback panorama that is exactly panorama.path, plus the profile’s required fallback proof. Use the near-orbit v1 schema ou near-orbit v2 schema.

"nearOrbit": {
  "version": 2, "bodyRadiusKilometers": 6371,
  "latitudeDegrees": 32, "longitudeDegrees": -121.5,
  "altitudeKilometers": 400, "bearingDegrees": 0,
  "pitchFromNadirDegrees": 55, "sunLatitudeDegrees": 30,
  "sunLongitudeDegrees": -70, "rotationPeriodSeconds": 3600,
  "cloudDriftRatio": 1.02, "cloudHeightKilometers": 8,
  "atmosphereHeightKilometers": 20, "dayTexture": "earth/day.jpg",
  "nightTexture": "earth/night.jpg", "cloudTexture": "earth/clouds.png",
  "fallbackPanorama": "panorama.jpg", "skyMask": "space/mask.png",
  "galaxyTexture": "space/galaxy.png", "starCatalog": "space/stars.json"
}

Version 2’s body radius is 1000...100000 km, altitude 200...1000 km, cloud drift 0.5...2, and every full-sphere colour texture is 2:1 and no wider than 8192 pixels. Optional tiles have 1–360 columns, 1–180 rows, no more than 1024 total tiles, and a 16–4096-pixel square size. Version 1 keeps its fixed 1024-square regional texture and its 4096-wide full-sphere texture limit. A horizontal locus-star-catalog v1 is required in either profile, with 1–20000 stars and a 1 MiB limit.

GPU texture sidecars

gpuTextures is the 1.3.0 View fast path (locus.view.gpu-texture.v2). It does not replace editable source images: the JPEG or PNG remains the source of truth for import, export, and fallback. Do not combine it with the earlier single gpuTexture field.

"gpuTextures": [{
  "source": "earth/day.jpg", "path": "earth/day.ktx",
  "format": "astc-6x6-srgb", "width": 8192, "height": 4096,
  "mipmapLevels": 14,
  "sourceSHA256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}]

Every entry has a unique source path and a KTX1 sidecar. Its dimensions and mip count must match both the actual source and KTX header; sourceSHA256 is the lowercase SHA-256 of that source’s raw packaged bytes. Use astc-6x6-srgb for colour images and astc-6x6-linear only for a cloud texture. Eligible sources are panorama.path, celestial background/day/night/ cloud textures, and near-orbit v2 day/night/cloud/fallback/galaxy textures and declared tiles. Other masks, cloud fills and arbitrary images do not gain sidecar support by being listed here. Full-sphere sources are 2:1 and bake with repeating edges; near-orbit tiles are square and bake with clamped edges. The renderer prefers a valid sidecar, then falls back to its JPEG/PNG unchanged if the sidecar cannot load. For near-orbit v2, every eligible global texture and every tile needs a matching sidecar entry.

Locus 1.4 View additions

Locus 1.4.0 is the first public release that reads the fields in this section. Like the 1.3.0 fields above, they belong in a complete Package v2 destination.json; a flat view.json ZIP does not grow them. Each subsection names the minimumAppVersion the field needs. These floors were set during 1.4 development, so any of them is satisfied by 1.4.0, and declaring "1.4.0" is always correct. Except where noted, Locus rejects an import whose declared version is lower than a field’s floor. The capability names are for Library publication; a complete imported ZIP declares its version, not capability tokens.

Black hole Views

blackHole is a whole-sphere View profile: a far-field, non-spinning (Schwarzschild) black hole with a thin accretion disc, lensing an authored sky. It needs minimumAppVersion 1.3.4 (Library capability locus.view.black-hole.v1). The observer and the hole are fixed, so every geometric quantity is baked offline into the lens table and disc map; Locus only animates the disc’s gas, a slow drift of the unlensed sky, short flares and a glow, and never traces a ray. Like nearOrbit, it excludes celestial, nearOrbit, sky, media, midground, water, fireflies, clouds, pointLights et environment.sunPath.

"blackHole": {
  "version": 1,
  "skyTexture": "black-hole/sky.jpg",
  "lensTable": "black-hole/lens.png",
  "discMap": "black-hole/disc-map.png",
  "discTexture": "black-hole/disc-texture.png",
  "glowTexture": "black-hole/glow.png",
  "yawDegrees": 0, "elevationDegrees": 12,
  "observerRadius": 30, "discMapMaxAngleDegrees": 30,
  "disc": {"inner": 6, "outer": 16, "spin": 1,
           "iscoPeriodSeconds": 10, "regenerationSeconds": 12,
           "profileExponent": 3, "peakTemperatureKelvin": 5200,
           "exposure": 1.1, "textureLogSpan": 0.6, "contrast": 0.75},
  "doppler": 0, "glow": 0.3,
  "drift": {"degreesPerSecond": 0.75, "axis": "up"},
  "flares": {"intervalSeconds": 5, "lifeSeconds": 8, "brightness": 4,
             "radiusRange": [1.1, 2.2], "width": 0.7, "arcLength": 2.5}
}

glowTexture et flares are optional; everything else is required, and unknown keys and nulls are rejected at every level. The complete panorama is still required: it is the View at time zero, the first live frame, and the exact image shown when Motion is off or Reduce Motion is on. In Current Place → Motion, visitors can change Sky Drift (0–2 °/s), flow speed, Doppler, glow, flares, detail and brightness for each View; the authored values are the defaults.

le black hole View tutorial walks through the whole bake with open tools: a sky composer, the lens and disc baker, and a Package v2 packer and validator. Use the black hole v1 schema for exact keys and ranges, and the tools’ README for the lens-table, disc-map and texture encodings.

Celestial advection: gas-giant flow

Celestial v1 and v2 may add advection, which carries the body’s day texture along an authored steady flow so cloud bands drift and shear and vortices turn, without the texture streaking out. It needs minimumAppVersion 1.3.2; there is no separate capability.

"advection": {
  "version": 1, "flowTexture": "celestial/advection.png",
  "ageKeyframes": 8, "maxAgeRotations": 0.84,
  "displacementRangeDegrees": 13, "regenerationRotations": 1.2,
  "crossfade": 0.2
}

All seven fields are required. flowTexture is one non-interlaced 16-bit RGB PNG, 64–1024 pixels wide (even), holding ageKeyframes + 1 (2–16) stacked 2:1 tiles, north up, tile 0 at the top. Tile j gives, for every surface point, where its colour came from after age j × maxAgeRotations / ageKeyframes rotations: red and green are the longitude and latitude displacement, encoded as 32768 + 32767 × degrees / displacementRangeDegrees (range 1...90). Tile 0 is age zero; its blue channel holds the per-pixel regeneration phase (0–65535 for one cycle). regenerationRotations Est 0.1...4, crossfade Est 0.05...0.45, et maxAgeRotations (0.05...4) must reach (0.5 + crossfade) × regenerationRotations. The flow texture is raw data: it is not a GPU-texture sidecar source, and it counts toward the image budget. advection excludes nightTexture, cloudTexture and every cloud-shell field. The complete panorama, rendered without flow, remains the Motion-off fallback.

Oblate bodies and rings

Celestial v1 and v2 may add polarRadiusRatio et rings to draw a ringed giant planet as real three-dimensional geometry rather than a halo around a sphere. Either field needs minimumAppVersion 1.3.4; there is no separate capability.

"polarRadiusRatio": 0.902,
"rings": {
  "version": 1, "profileTexture": "celestial/rings.png",
  "innerRadiusRatio": 1.239, "outerRadiusRatio": 2.27,
  "brightness": 1, "unlitBrightness": 0.6
}

polarRadiusRatio (0.85...1, default 1) is the polar radius over the equatorial radius; the existing size fields keep describing the equatorial radius, and the texture keeps its longitude/latitude layout. It excludes the cloud-shell fields. rings is one flat annulus in the body’s equatorial plane that shares the body’s position, axial tilt and size, and does not spin with it. version, profileTexture, innerRadiusRatio et outerRadiusRatio are required. Radii are multiples of the equatorial radius, with 1.05 <= inner, inner + 0.05 <= outer <= 4. The whole ring must stay between 200 m and 1100 m away: with R the outer radius in metres, distance - R >= 200 et distance + R <= 1100. profileTexture is an 8-bit RGBA PNG (colour type 6), 64–8192 pixels wide and exactly 1 pixel tall: left is the inner edge, RGB is the sRGB ring colour and straight alpha is the opacity seen face-on, from which Locus derives the ring’s transparency and the shadows between ring and planet. brightness Est 0...4 et unlitBrightness, the unlit face, is 0...2; both default to 1. rings excludes nightTexture, cloudTexture, the cloud-shell fields and advection. Bake the ring and both shadows into the complete panorama, which is shown with Motion off.

Near-orbit surface data

A near-orbit v2 profile with tiles may add per-tile surface data on the same grid: terrain slopes for relief shading and water cover for sun glint. It needs minimumAppVersion 1.3.2 (Library capability locus.view.near-orbit.surface.v1).

"surface": {"manifest": "surface/surface.json", "tileSize": 1024}

manifest is a package-relative .json path different from tiles.manifest; tileSize is an integer 16...4096. The surface manifest has the tile manifest’s shape plus "encoding": "slope-east-north-water-v1", with the same columns, rows et latitudeSouthDegrees as tiles, and every (row, column) exactly once with its sha256 et byteCount. Each surface tile is a lossless tileSize square 8-bit RGB PNG (no alpha) covering the same area as the day tile with the same (row, column): red is 0.5 + 0.5 × slopeEast, green 0.5 + 0.5 × slopeNorth (each clamped to -1...1), and blue is water cover (0 land, 1 open water), so (128, 128, 0) is flat land. Every surface tile needs a gpuTextures sidecar entry in astc-6x6-linear, square and converted with clamped edges like the day tiles. A profile with surface uses fallback-proof material revision 7: the token suffix is near-orbit-material-7, followed by the surface manifest’s SHA-256 after the tile manifest’s.

Water contact calibration

A View water region may add contactCalibration (layered-proxy-v1) so that photographed rocks and shores meet the rendered water. It supplies real dry-land geometry near the visitor and an authored background for the area that geometry uncovers. It needs minimumAppVersion 1.3.2 (Library capability locus.view.water.contact-calibration.v1).

"contactCalibration": {
  "model": "layered-proxy-v1",
  "referenceEyeHeightMeters": 1.5,
  "referenceSkyCenterMeters": [0, 0.2, 0],
  "referenceYawDegrees": 0, "referencePitchDegrees": 0,
  "sourcePanorama": "contact-source.png",
  "sourcePanoramaSHA256": "<64 lowercase hex characters>",
  "sourceMaskSHA256": "<64 lowercase hex characters>",
  "landMesh": "contact-land.locusmesh",
  "landTexture": "contact-land.png",
  "removalMask": "contact-removal.png",
  "waterCoverage": "contact-coverage.png",
  "reflectionPanorama": "contact-reflection.png"
}

The View’s panorama.path becomes the authored far background, and sourcePanorama is the original photograph at the same size. The two must be identical wherever the removal mask is zero, and sourcePanoramaSHA256 et sourceMaskSHA256 bind the source and the region’s original blend.panoramaMask. The removal and coverage masks are 8-bit grayscale 2:1 PNGs of equal size; the removal mask contains both 0 and 255, and the coverage mask covers the original water mask and the removed area. An optional sourceProjection records a latitude correction already baked into the source (latitude-mobius-v1 with one horizon elevation, or latitude-mobius-profile-v1 with 3–64 horizon samples); it is not a runtime transform.

landMesh is a .locusmesh file: UTF-8 JSON of at most 32 MiB with exactly formatVersion (1), positionsMeters, sourceUV, textureUV, indices et contacts. It has 3–250,000 vertices within ±1000 m at or above the water plane, nondegenerate triangles facing the reference eye, and contact records (vertex, distanceMeters, kind of rock ou shore) that lie on the water plane at their declared range, from 0.5 m to inside the water disc. landTexture is the land’s colour atlas, and reflectionPanorama is a 2:1 radiance-direction texture for reflections.

The contract currently accepts only a pure photographic View with one identity-rotation, floor-relative disc region, blend.horizonSkirt on, no glow, and zero authored and composed pitch; anything else is rejected at import. Contact ranges and the hidden background are authored approximations for one reference eye position, not surveyed terrain.

Smaller additions

Two-eye depth, shimmer, performance and comfort for these effects still need review on Apple Vision Pro for each new View; Simulator captures do not establish them.

Legacy/simple flat ZIP compatibility

The flat format documented below remains accepted for simple imports and for the existing public examples and tools. It contains exactly one Room or one View, has no authored package or content identity, and receives a new UUID on every import. Use complete Package v2 when stable identity, in-place updates, bundled Experiences, or the feature-complete content model matters.

In this compatibility format, v1 remains supported and immutable; v2 adds visitor-controlled Room lighting metadata and a View condition that suggests the light switch’s initial position; Room v3 adds Room-owned baked indirect light and bounded authoring limits; Room v4 adds independently controlled USDZ animations with saved playback speed and randomized replay intervals; Room v5 adds explicit rendering roles and spotlight directions; Room v6 adds authored seat groups; Room v7 adds looping video surfaces; Room v8 adds illuminated surfaces and an explicit view-forward yaw; Room v9 adds particle effects; Room v10 adds per-lamp baked light layers. All of those compatibility versions use the same flat ZIP layout.

Locus 1.1.4 adds an optional sound.json sidecar: a Room or a View may carry its own audio, independently of everything above. An older Locus refuses the extra files rather than installing a place with its sound silently missing.

A flat compatibility archive contains exactly one Room or one View. It is an ordinary .zip file with all files at the ZIP root. Unlike complete Package v2, it does not contain locusplace.json, a catalog/ tree, an Experience, or an author-chosen asset ID.

Locus assigns a new UUID when it imports an asset. displayName is user-visible copy, may repeat, and is not an identity. Importing the same ZIP twice creates two assets with different UUIDs and the same display name.

Flat compatibility Room ZIP

The five files are all required:

room.zip
|-- space.json
|-- provenance.json
|-- teleport-points.json
|-- scene.usdz
|-- thumbnail.jpg
|-- sound.json         optional, Locus 1.1.4
`-- forest-air.m4a     one or more, only with sound.json

space.json contains the Room metadata. It does not name files or contain an ID:

{
  "formatVersion": 1,
  "displayName": "My Room",
  "caption": "A quiet two-seat room.",
  "previewCamera": {
    "yawDegrees": 0,
    "pitchDegrees": -8,
    "zoom": 0.8
  },
  "seatedOrigin": {
    "translationMeters": [0, 0, 0],
    "orientationXYZW": [0, 0, 0, 1]
  },
  "safeHeadVolume": {
    "centerMeters": [0, 1.25, 0],
    "sizeMeters": [1.2, 1, 1.2]
  },
  "viewOpenings": [
    {
      "id": "surroundings",
      "transform": {
        "translationMeters": [0, 1.25, -2],
        "orientationXYZW": [0, 0, 0, 1]
      },
      "widthMeters": 4,
      "heightMeters": 2.5
    }
  ],
  "spatialAdaptation": {
    "wallEntities": ["Rear_Wall"],
    "roofEntities": ["Roof"],
    "deskEntitiesByTeleportID": {
      "seat.window": "Window_Desk_Top"
    },
    "deskGroupEntitiesByTeleportID": {
      "seat.window": "Window_Desk_Group"
    }
  }
}

caption, previewCamera, et spatialAdaptation are optional. Every Room must have exactly one View opening and at least one teleport point. Coordinates are right-handed meters with +Y up and -Z forward. Quaternions are [x, y, z, w]. anchorXZ values are normalized into the Room bounds.

teleport-points.json has this shape:

{
  "formatVersion": 1,
  "points": [
    {
      "id": "seat.window",
      "title": "Window Desk",
      "anchorXZ": [0.45, 0.55],
      "sourceFloorOffset": 0,
      "eyeHeight": 1.15,
      "yawRadians": 3.1415927
    }
  ]
}

A teleport point’s id ou title naming Left or Right names that side from the seated visitor’s own perspective, not an outside observer’s — the right seat of such a pair sits to the sitter’s right hand. This is authoring guidance, not a schema field: it only affects how you choose anchorXZ et yawRadians for the two points, so their relative positions actually match a sitter’s left and right.

scene.usdz is a self-contained, meter-scale, +Y-up USDZ. Its ZIP members are stored without compression and it must pass usdchecker --arkit. thumbnail.jpg is required and must decode as JPEG or PNG image data. Locus does not capture a missing thumbnail during import.

Room lighting (formatVersion 2)

A Room without lighting keeps formatVersion: 1 and omits lighting; Locus shows no light switch or Room lighting settings and builds no runtime rig. A Room that declares lamps uses formatVersion: 2 and adds:

"lighting": {
  "luminaireGroups": [
    {
      "id": "desk-pendant",
      "displayName": "Desk Pendant",
      "entities": ["Desk_Pendant_Glow"],
      "nearTeleportIDs": ["seat.window"],
      "authoredColor": {"mode": "temperature", "kelvin": 2700},
      "controls": {
        "brightnessEVRange": [-4, 1],
        "temperatureKelvinRange": [2000, 6500],
        "supportsFullColor": false
      },
      "proxy": {
        "type": "spot",
        "anchorEntity": "Desk_Pendant_Anchor",
        "intensityLumens": 900,
        "attenuationRadiusMeters": 3.5,
        "colorTemperatureKelvin": 2700,
        "innerAngleDegrees": 30,
        "outerAngleDegrees": 65,
        "castsShadow": false
      }
    }
  ]
}

Each group is one independently controlled logical lamp/circuit. Keep id stable across Room revisions; split separately controllable physical lamps into separate groups. Every entity and anchor must resolve in the delivered USDZ. authoredColor is either temperature plus kelvin, ou srgb plus three components. Full color and color temperature are per-light capabilities, never Room-wide controls. New public Rooms do not author condition-to-intensity tables.

nearTeleportIDs is optional. When present, it must be a non-empty unique list of authored teleport IDs and only the group’s bounded direct proxy is active at those seats. An enabled fixture’s emissive body remains visible from every seat. Omitting the field makes the direct proxy available throughout the Room.

Room indirect lighting (formatVersion 3)

A newly authored Room that simulates artificial diffuse bounce uses formatVersion: 3. It keeps the v2 luminaireGroups and adds one or more opaque receiver subtrees:

"lighting": {
  "luminaireGroups": ["... v2 group objects ..."],
  "bakedIndirect": {
    "entities": ["Locus_BakedIndirect"]
  }
}

Format v3 may replace a group’s legacy proxy object with a non-empty proxies array when one logical switch drives multiple physical emitters. For example, a paired dining pendant remains one control but authors one downward spot per shade. Do not write both spellings in the same group.

Every named subtree must resolve exactly once in scene.usdz and contain positive-emissive PBR materials. The subtree is an authored low-frequency indirect layer: include broad opaque floors and walls; exclude glass, water, luminaire glow meshes, and other transparent or transmissive materials. Also exclude a ceiling, tabletop, or other receiver when the combined atlas would paint a recognizable fixture-specific halo that cannot follow that fixture’s individual switch; bounded proxies own those local pools. A dedicated second UV atlas is recommended so indirect light does not replace the receivers’ base PBR texture coordinates. The exact delivered atlas must be denoised or deterministically low-pass filtered at its final authored emission strength; visible fireflies or high-frequency speckle are invalid for this deliberately low-frequency layer. Each Room owns that baseline strength, while the app owns only the documented EV multiplier.

The Room Lights master switch controls the indirect layer. Overall Room EV uses the app-owned -4...+4 EV range and scales it by 2^overallEV; individual-light enable, EV, temperature, and color do not recolor or double-count the shared bounce. This lets the overall control simulate changing room response while direct lamp controls remain local. Overall and per-light EV are combined and clamped to -4...+4 EV before rendering.

For v3, every proxy is at most 10,000 lumens and every authored per-light brightness range stays inside -4...+1 EV. These are hard package bounds, not recommended intensities; functional indoor proxies normally begin around 700–1,500 lumens with a 2.5–3.5 m reach. Locus still accepts v2 archives using the older wider numeric contract and safely normalizes them during import; locked v2 bytes are never rewritten. bakedIndirect requires Room v3 or later.

Authored rendering and spotlight direction (formatVersion 5)

Room v5 requires a Locus build that supports this format. Use the final exported USDZ as the material reference: Locus preserves its supported PBR values and texture/UV connections. Object names, mesh dimensions, and imported Room IDs do not select material presets. A frosted panel remains frosted, even if its name contains glass.

Authored input Supported behavior
Meshes, metric transforms, normals, UV sets Loaded from the USDZ; visitor placement applies to the Room root.
USD Preview Surface base color, roughness, metallic, specular, normal, clearcoat, clearcoat roughness, opacity Retained as exported, including supported connected textures. Export a Preview Surface network; bake procedural nodes to maps.
Emission Retained. Declared lamp groups may scale emission and apply the visitor’s color choice; color changes retain the emission map. Declared baked indirect receivers scale emission only.
Transparent surfaces Opacity blending; this is not a promise of physical transmission, refraction, caustics, or order-independent layered glass. Inspect intersecting transparent surfaces in Locus.
Reflections The selected View supplies distant environment lighting. This does not capture the Room interior or provide spatially exact mirrors.
Object animation The embedded transform clips and optional experimental controls described below. Arbitrary material animation and procedural shader nodes require target-renderer validation and are not part of this contract.

The optional rendering object contains both arrays, even when either is empty:

"rendering": {
  "softenedReflectionEntities": ["Panel_927"],
  "uiFadeEntities": ["Furniture_412"]
}

Each entry names one unique exported entity and its descendants. Names are case-sensitive, nonempty, at most 200 UTF-8 bytes, and unique within each array. Do not name both an ancestor and its descendant within the same array. A missing, ambiguous, overlapping, or geometry-free binding fails Room loading with the affected entity name.

Omitting rendering grants neither opt-in. An empty array also grants nothing. Locus does not infer permission from names or a size threshold.

Every v5 spot proxy requires direction: a finite unit vector in the Room’s exported +Y-up coordinates. For example, [0.6, -0.8, 0] points down and toward +X; [0, -1, 0] points straight down. The anchor supplies position only. Rotating or scaling its mesh does not reinterpret this vector. Derive it from the authored light’s emission axis during export. point proxies must omit direction. The existing lumens, radius, active-light, shadow, and adjustment budgets continue to apply.

Rooms v1–v4 remain importable. A legacy spot without direction keeps its specified downward default; use v5 to declare another direction. Legacy packages receive no implicit rendering roles, and their material inputs are preserved. If an older clear-glass asset exhibits the known colored center in a sun highlight, correct the unused clearcoat roughness in its editable source and re-export; Locus does not rewrite connected maps or apply a blanket patch to imported materials. The tested uncoated-glass correction is clearcoat roughness 0.2, with the intended base roughness and opacity retained. Intentional coatings and animated inputs need separate evaluation.

spatialAdaptation.wallEntities et roofEntities are validated references; they do not hide or cut the virtual architecture. Room Portal operates on the visitor’s tracked real walls, independently of this virtual geometry. The current product can open multiple walls, but not the real ceiling. Only the explicit desk mapping connects a virtual tabletop to desk alignment and desk passthrough.

Ambient animations (formatVersion 4)

A Room whose USDZ contains independently playable environment animation uses formatVersion: 4 and adds a non-empty ambientAnimations array:

"ambientAnimations": [
  {
    "id": "plant-breeze",
    "displayName": "Plant Breeze",
    "entityName": "Ambient_CloudFan_Breeze",
    "animationName": "default subtree animation",
    "isEnabledByDefault": true,
    "defaultSpeed": 1,
    "speedRange": [0.5, 1.5],
    "defaultIntervalRangeSeconds": [0, 0],
    "intervalRangeSeconds": [0, 60]
  }
]

entityName resolves one subtree in scene.usdz; animationName resolves one animation in that entity’s animation library. Each ID is stable and unique. Speed is a multiplier inside 0.25...2. Interval is the pause after a complete play: [0, 0] means replay immediately and therefore run continuously; any other range samples a fresh random delay after every completion. Both interval ranges are ordered seconds between 0 and 3,600, and the default endpoints must fit inside the adjustable range.

The Room motion tile in Quick Controls and Settings › Current Place › Motion operate on the same session values. Motion can save each animation’s switch, speed, and interval as that Room’s defaults on the current device. Format v1-v3 Rooms cannot declare this field and keep their existing behavior unchanged.

How desk alignment identifies a desk

Locus does not inspect USDZ geometry and guess which object looks like a desk. A Room author identifies the virtual tabletop explicitly by mapping a teleport ID to the exact exported entity name in spatialAdaptation.deskEntitiesByTeleportID. For the example above, seat.window must also be the id of a point in teleport-points.json, et scene.usdz must contain an entity named exactly Window_Desk_Top. A shared table may be mapped from several seat IDs. If a seat has no mapping, it remains usable as a teleport, but it is a first-class lounge seat: it never measures or aligns a real desk and never offers desk passthrough. See Lounge seats and hideable desks. There is currently no in-app surface picker that creates a desk mapping after import.

Make the mapped entity the tabletop itself or a tightly bounded tabletop slab, not the complete desk-and-chair hierarchy. Locus uses that entity’s recursive visual bounds in its own frame to derive the surface height, oriented width and depth, and yaw. Keep legs, chairs, lamps, computers, plants, and loose props outside the mapped entity’s descendants; otherwise they can raise the measured “top” or enlarge the footprint. Use a stable, unique semantic name such as Window_Desk_Top, keep the surface level and non-degenerate, and verify the meter-scale, +Y-up result in the exported USDZ. Blender may remain Z-up while authoring, but the exported entity must have one vertical axis and two horizontal axes without tilt or shear.

Calibrate the seat and table together. Put the chair at a clear near edge rather than diagonally off a corner, face it toward the tabletop and intended view, keep the nearest reach zone clear, and use a realistic tabletop height—about 0.72–0.76 m is a practical starting range. Do not rely on runtime correction to repair a badly placed asset: Locus refuses horizontal Room movement over 0.75 m or vertical movement over 0.35 m.

The visitor’s real desk is a separate measurement. On a physical Vision Pro, Locus requests world sensing and filters horizontal ARKit planes, rejecting floors and windows and preferring nearby, table-sized surfaces in front of the visitor. The current basic table candidate is 0.2–1.0 m below the tracked head, within 2.5 m, at least 0.09 m², at least 0.35 m on its short side, and no more than 5:1 in aspect ratio; an unclassified surface must satisfy stricter mesh, area, shape, and reach checks. A final match also rejects a physical near edge farther than 1.25 m. These are real-room filters, not a way to discover the virtual tabletop inside scene.usdz.

After a successful measurement, Locus turns the Room about the visitor to match desk-edge direction, moves it along that edge normal to match reach, and matches the tabletop height. Desk passthrough remains the visitor’s separate Show desk choice. If no trustworthy plane is found, Locus may move the authored edge within reach using a bounded fallback, but it does not claim alignment or open passthrough.

tools/validate_locus_asset.py checks the mapping shape, USDZ structure, and actual RealityKit entity bindings; it does not prove that an entity name resolves to the intended tabletop. Therefore import the exact validated ZIP and enter every mapped seat. Simulator can exercise package loading, but real-table detection, alignment, passthrough edges, scale, reach, and comfort require the exact delivered build on a physical Vision Pro.

Lounge seats and hideable desks

A sofa, daybed, bench, or other lounge seat may deliberately omit its entry in deskEntitiesByTeleportID. From Locus 1.1.5 this is a first-class authoring choice, not a gap to fill later: the seat enters directly at its authored floor and eye height, never triggers real-desk measurement or alignment, and never offers desk passthrough. Locus’s in-app seat list shows no desk icon for it (a seat that does have a desk mapping shows one). Do not point such a seat’s mapping at a nearby coffee table or side table just to give it a desk icon — an intentionally unmapped lounge seat is correct as-is.

The optional spatialAdaptation.deskGroupEntitiesByTeleportID is unrelated to lounge seats: it applies only to desk-backed seats that already have a deskEntitiesByTeleportID mapping, and lets an author offer a Masquer le bureau control for one. Its value names one authored entity per teleport ID — the single root whose whole subtree is that seat’s physical desk (top, legs, and anything resting on it):

"deskGroupEntitiesByTeleportID": {
  "seat.window": "Window_Desk_Group"
}

Rules Locus enforces:

A Room that fails any of these at load time fails to load; there is no partial fallback that silently drops the desk group and keeps the Room usable.

Hiding disables the whole named subtree — never a material, opacity, or per-entity visibility trick, and never the alignment surface’s own enabled state, which stays untouched because only an ancestor above it is disabled. Desk measuring, re-measuring, alignment, and the passthrough cutout all keep working while the desk is hidden, because they read the same surface entity named in deskEntitiesByTeleportID, unaffected by an ancestor’s visibility. A lighting luminaire group whose entities are all nested under a hidden desk group’s root also stops contributing its light while hidden; a group with only some entities under that root is left alone entirely.

Only a seat with a deskGroupEntitiesByTeleportID entry offers Hide Desk. Readers older than 1.1.5 simply do not decode this field: they still measure and align the desk exactly as before, and never offer a way to hide it. In the 1.1.5 app and later, once a visitor turns Hide Desk on (from the Hide desk tile in Quick Controls) it stays in effect for the rest of that immersive visit, across seats, Rooms, and Views, and Settings can default every new visit to starting with desks hidden.

Seat groups (formatVersion 6)

A Room that declares seatGroups uses formatVersion: 6, which requires Locus 1.2.0 or later; an older build refuses to import a grouped Room rather than silently dropping the groups. The field belongs in space.json, beside the existing Room metadata — teleport-points.json continues to own the concrete teleport coordinates and IDs:

"seatGroups": [
  {
    "id": "window-tables",
    "title": "Window Tables",
    "seatIDs": [
      "window-cafe-south",
      "window-banquette-south",
      "window-cafe-center",
      "window-banquette-center"
    ]
  },
  {
    "id": "coffee-bar",
    "title": "Coffee Bar",
    "seatIDs": [
      "coffee-bar-stool",
      "coffee-bar-stool-center",
      "coffee-bar-stool-south"
    ]
  }
]

Each group id is stable and unique, title is nonempty, and seatIDs is a nonempty ordered list of existing authored teleport IDs. A seat cannot appear in more than one group. Grouping only organizes the two-level navigation picker: a client that sees groups presents seating area → concrete seat, then keeps every ungrouped authored seat discoverable as a direct row. It never changes a seat’s identity, placement, desk alignment, Hide Desk contract, or eye height. A seat intentionally left out of every group remains a direct row, so a lone seat does not need a singleton group of its own; a Room that omits seatGroups keeps the legacy flat seat list. A visitor’s own personal/custom seats are separate persisted additions and do not inherit authored grouping.

Room video surfaces (formatVersion 7)

A Room whose scene.usdz contains a named mesh-bearing entity for looping video uses formatVersion: 7 and adds videoSurfaces — the first author is a fireplace’s fire on a quad in the firebox:

"videoSurfaces": [
  {
    "id": "fireplace-fire",
    "displayName": "Fireplace Fire",
    "entityName": "Fireplace_Fire_Video",
    "video": "fireplace-fire.mp4",
    "luminaireGroupID": "fireplace"
  }
]

A Room may declare at most 2 surfaces. id is lowercase letters, digits, and hyphens only, 1–64 bytes, and unique among the Room’s surfaces. displayName et entityName are nonempty and at most 200 UTF-8 bytes. video is a bare root .mp4 filename (no path separators); the set of video values across all surfaces must exactly match the .mp4 files present in the ZIP, so an unreferenced or missing video file fails import. entityName must resolve to a mesh-bearing subtree of scene.usdz, checked the same way as every other authored entity reference. The optional luminaireGroupID must name one of this Room’s lighting.luminaireGroups entries; while that group is lit (its master switch on and not force-disabled) the surface plays its video, and otherwise it shows its restored authored material — the tied group does not need to contain the surface’s own entity. videoSurfaces requires Locus 1.2.1 or later; an older build refuses to import a Room that declares it.

Room illuminated surfaces and view-forward yaw (formatVersion 8)

A lighting.luminaireGroups entry may add illuminatedEntities, and a Room may add a top-level viewForwardYawRadians; either one requires formatVersion: 8.

illuminatedEntities names surfaces an author’s own baked emissive texture washes with that group’s light — a wall lit by a hidden cove fixture, say — as distinct from entities, which names the fixture subtree itself:

"lighting": {
  "luminaireGroups": [
    {
      "id": "cove-light",
      "displayName": "Cove Light",
      "entities": ["Cove_Fixture_Glow"],
      "illuminatedEntities": ["Living_Room_Wall"],
      "controls": {"brightnessEVRange": [-4, 1], "supportsFullColor": false},
      "proxy": {
        "type": "point", "anchorEntity": "Cove_Fixture_Glow",
        "intensityLumens": 500, "attenuationRadiusMeters": 3,
        "castsShadow": false
      }
    }
  ]
}

When present, illuminatedEntities is non-empty, and none of its names may repeat a name already used by that same group’s entities, by another group’s entities ou illuminatedEntities, or by bakedIndirect.entities; none of it may be an ancestor or descendant of those same protected entities either. An illuminated surface’s material follows the group’s master switch, overall Room EV, and per-light enable/EV/color exactly like an entities member’s, but it is never a switch target itself (no hover or pinch), is never hidden by nearTeleportIDs (which only ever scopes the direct proxy), and is not considered by Hide Desk’s “every entity under the hidden desk root” rule.

viewForwardYawRadians is the model-space yaw — the same convention as a teleport point’s yawRadians — that faces the selected View’s straight-ahead direction, letting an author decouple the View’s orientation from wherever the first authored teleport point happens to face:

{
  "formatVersion": 8,
  "viewForwardYawRadians": 1.5707963267948966
}

When present it must be finite and within -π...π. It changes only the sky/IBL/sun orientation basis; seat placement, desk alignment, and every other teleport behavior are computed exactly as before, from the active teleport’s own yawRadians. Omitting it keeps the pre-v8 default: the first teleport point’s facing. Both illuminatedEntities et viewForwardYawRadians require Locus 1.2.1 or later.

Room particle effects (formatVersion 9)

A Room whose scene.usdz contains a transform-only anchor for a continuously running effect uses formatVersion: 9 and adds particleEffects — the first use is a fireplace’s fire:

"particleEffects": [
  {
    "id": "fireplace-fire",
    "displayName": "Fireplace Fire",
    "preset": "fire",
    "anchorEntity": "Fireplace_Fire_Emitter",
    "sizeMeters": [0.62, 0.04, 0.22]
  }
]

A Room may declare at most 4 effects. id follows the same lowercase-letters/digits/hyphens rule as videoSurfaces.id; displayName is nonempty and at most 200 UTF-8 bytes. preset is a closed enum — currently only "fire" — and the app owns the entire resulting look (color, particle count, timing); the author’s only contract is placement (anchorEntity) and size (sizeMeters: exactly 3 finite values, each within (0, 3] meters, in the anchor’s local Y-up space as width, height, depth, centered on the anchor origin — flames rise along the anchor’s local +Y). anchorEntity resolves like a lighting proxy’s anchorEntity: it does not need renderable geometry, since the expected authoring shape is a transform-only Xform exported from a Blender empty. An effect runs for the Room’s entire lifetime; it is never tied to a lighting.luminaireGroups switch and carries no audio. particleEffects requires Locus 1.2.1 or later.

Room baked light layers (formatVersion 10)

A Room whose lighting.bakedIndirect wants per-lamp control over the shared indirect atlas uses formatVersion: 10 and adds bakedIndirect.layers: one root PNG per declared luminaire group, holding only that group’s own contribution (direct plus bounce, material color included) to the shared atlas at its authored brightness/color.

"lighting": {
  "luminaireGroups": ["... v2 group objects ..."],
  "bakedIndirect": {
    "entities": ["Locus_BakedIndirect"],
    "layers": [
      {"luminaireGroupID": "desk-pendant", "image": "desk-pendant-indirect.png"}
    ]
  }
}

layers is non-empty and at most 12 entries. Each entry pairs a luminaireGroupID — naming one of this Room’s declared luminaire groups, at most once across the array — with a bare root .png image filename (no path separators). The set of image values across the array must exactly match the .png files present in the ZIP, so an unreferenced or missing image fails import. Every referenced image must be an 8- or 16-bit RGB or RGBA PNG at most 4096×4096, and every layer image in the same Room must share identical pixel dimensions. Locus composites these layers in linear light at load and whenever the visitor’s lighting changes, and falls back to plain format-v3 whole-atlas EV scaling if the shared atlas texture is missing or a layer’s dimensions do not match it. bakedIndirect.layers requires Locus 1.2.1 or later; format v3’s bakedIndirect.entities keeps working unchanged without it.

How Locus uses a Room

The authoring interface and the runtime rules are both documented here. Public Room ZIPs are converted into Locus’s internal Package v2 representation during import. That conversion does not make internal fields secret or require a creator to maintain two manifests. Author the five-file Room ZIP; Locus supplies the internal identity, file paths, and bookkeeping.

Author input Runtime behavior and creator responsibility
scene.usdz Locus loads the exported hierarchy, materials and transforms. Its actual visual bounds determine the coordinate frame for normalized seats. Author scale, clearances and appearance in the model.
teleport-points.json Each point is a selectable seat, not a moving character. anchorXZ locates it within the delivered model bounds; sourceFloorOffset is the floor height above the model’s minimum Y (including any foundation or floor-slab thickness), eyeHeight locates the eyes above it, and yawRadians sets its heading. Switching seats repositions the Room around the visitor.
seatedOrigin Defines the authored floor frame used to carry the safety volume to the active seat. Use an upright frame: a finite normalized quaternion with no pitch or roll. It does not replace the seat catalog or specify a walkable spawn collider.
safeHeadVolume Defines a box that follows the selected seat and its floor frame. Locus uses it with tracking and presence to decide when immersive content can be shown safely. Each half-extent has a runtime minimum of 1 m: an authored box smaller than 2 m on an axis cannot narrow that runtime range. This is not a wall collider or permission to put geometry close to the visitor’s head.
viewOpenings Exactly one logical opening connects a Room/View composition. It does not cut holes in the USDZ. Model the real architectural openings yourself. Virtual Space surrounds the Room with the View; this field does not determine which real walls Room Portal opens.
spatialAdaptation.wallEntities / roofEntities Exact validated references to virtual architecture. They do not hide those meshes or create real-world portals. Room Portal uses detected real walls and supports opening multiple walls; the current product cannot open the real ceiling.
deskEntitiesByTeleportID Names the tabletop used for that seat’s alignment and optional desk passthrough. Without a mapping the seat is a first-class lounge seat: it still loads, at its authored floor and eye height, but never measures, aligns, or offers passthrough for a desk. See Lounge seats and hideable desks.
deskGroupEntitiesByTeleportID Optional, 1.1.5+. For a subset of desk-backed seats, names the one entity whose whole subtree is that seat’s hideable desk, enabling a visitor Masquer le bureau control. A key must already have a deskEntitiesByTeleportID entry; the named entity must resolve uniquely and be an ancestor of that entry’s surface entity, or the Room fails to load. Readers before 1.1.5 ignore this field and offer no Hide Desk. See Lounge seats and hideable desks.
seatGroups Optional, 1.2.0+, requires formatVersion: 6. Presents several teleport IDs as one seating area in the two-level Seats picker; every listed ID must be an authored teleport, and a teleport cannot belong to more than one group. It never changes a seat’s identity, placement, desk mapping, Hide Desk contract, or eye height. Readers before 1.2.0 refuse to import a grouped Room. See Seat groups.
lighting Owns explicit emissive fixtures, optional bounded direct lights and optional shared indirect light. Controls do not discover lamps from names, and a material alone does not create a runtime point/spot light. A group’s optional illuminatedEntities (1.2.1+, formatVersion: 8) and bakedIndirect.layers (1.2.1+, formatVersion: 10) extend this without changing the base v2/v3 contract. See Room illuminated surfaces and view-forward yaw et Room baked light layers.
rendering Explicitly opts subtrees into softened View reflections or temporary window-obstruction fading. Omitted roles grant neither behavior. These permissions do not change the underlying material design.
ambientAnimations Binds embedded named clips to experimental switch/speed/interval controls. A valid entity name does not prove a clip exists or moves correctly; test playback in Locus.
videoSurfaces Optional, 1.2.1+, requires formatVersion: 7. Names a mesh-bearing entity that plays a looping video, optionally tied to a luminaire group’s switch. At most 2 per Room. See Room video surfaces.
particleEffects Optional, 1.2.1+, requires formatVersion: 9. Anchors a continuously running app-owned particle preset (currently only fire) to a named entity; the author controls placement and size only. At most 4 per Room. See Room particle effects.
viewForwardYawRadians Optional, 1.2.1+, requires formatVersion: 8. Names the model-space yaw that faces the selected View’s straight-ahead direction, in place of the first teleport point’s facing. Never changes seat placement or desk alignment. See Room illuminated surfaces and view-forward yaw.
thumbnail.jpg / previewCamera Supply the library image and initial 3D preview framing. Neither determines the immersive seat.

Collision and quality: internal fields, not additional author inputs

The importer currently creates this internal metadata for every public Room:

{
  "collision": {"mode": "embedded"},
  "quality": {
    "status": "unverified",
    "triangleCount": 0,
    "materialCount": 0,
    "entityCount": 0,
    "maxTextureDimension": 0
  }
}

These fields describe the internal package, not extra JSON to paste into the public space.json. Both the app importer and public validator reject them in that public file. Adding them only to the public validator would produce ZIPs that the app rejects.

The internal schema also has a separate collision mode with a USDZ path. The current Virtual Space loader does not use either mode to build a walking, physics, or visitor-body collision system. A declared collision mesh does not prevent someone moving through virtual furniture. Runtime head safety and real desk measurement are separate systems. No separate collision file is accepted in a public Room ZIP.

quality is bookkeeping, not a quality certificate. Import sets it to unverified with zero declared counts, then inspects the actual USDZ composition and mesh topology against the limits below. Supplying smaller numbers cannot bypass that inspection. The public validator performs actual model and texture budget checks too; it does not require a creator to estimate those values. Keep measured authoring statistics with the editable source when useful.

Model and texture budgets

Resource Maximum
Room ZIP 1 GiB
One extracted file, including scene.usdz 768 MiB
All extracted ZIP files 2 GiB
Entries inside the USDZ 4,096
Expanded USDZ members 1.5 GiB
Actual model triangles 5,000,000
Actual model materials 1,024
Actual model entities 100,000
One model texture dimension 16,384 pixels
One image’s decoded pixels 150,000,000
All embedded model texture pixels 500,000,000

ModelIO inspects the loaded composition rather than trusting JSON counts. Instance compositions are rejected by the current import budget policy; realize instances before export. These are rejection limits, not performance targets. A small Room should stay far below them. Appearance, interactive frame rate, tracking, reach and comfort still require checking the exact Room in Locus.

Flat compatibility View ZIP

Four root files are required and one is optional:

view.zip
|-- view.json
|-- provenance.json
|-- panorama.jpg
|-- thumbnail.jpg
|-- lighting.jpg       optional
|-- sound.json         optional, Locus 1.1.4
`-- waterfall.m4a      one or more, only with sound.json

panorama.jpg is a complete 2:1 equirectangular JPEG or PNG. Its decoded dimensions must match view.json. The 4K minimum for direct image imports (including Browser images) is not a View ZIP format restriction. thumbnail.jpg is required. lighting.jpg, when present, is a separate small 2:1 SDR image used for Room lighting and reflections.

{
  "formatVersion": 2,
  "displayName": "My View",
  "caption": "A complete panoramic View.",
  "panorama": {
    "projection": "equirectangular",
    "width": 12288,
    "height": 6144,
    "initialYawDegrees": 0
  },
  "environment": {
    "skyGainEV": 0.5,
    "exposureEV": 0,
    "horizonPitchDegrees": 0,
    "condition": "night",
    "colorGrade": {
      "contrast": 1.1,
      "saturation": 1.2
    },
    "directSun": {
      "enabled": false,
      "illuminanceLux": 5000
    }
  }
}

caption et environment are optional. initialYawDegrees is optional. contrast accepts 0.5...2; saturation accepts 0...2. An enabled sun also requires finite azimuthDegrees et elevationDegrees. These are starting values; a user may later change them with Edit View in Locus.

environment.condition is available in formatVersion: 2: dusk et night initially turn a composed Room’s lights on; day, overcast, and an omitted condition initially leave them off. It never maps to a Room brightness. The visitor can override the switch, and a lamp that is on uses its Room-authored intensity plus saved Room EV offsets.

Son

A Room or a View may carry its own audio. It is independent of everything else in the package: a still picture with sound is not an animated View, gets no new mark in Places, and a Room’s sound has nothing to do with its lighting or animations. Locus 1.1.4 is required — an older build rejects the ZIP rather than installing it without the audio.

mettre sound.json and the audio files at the ZIP root. Every audio file must be named by a source and every named file must be present; audio without sound.json, or a file no source names, is rejected rather than ignored, so a renamed clip is caught at import instead of going silently missing.

{
  "version": 1,
  "backgroundPriority": 20,
  "sources": [
    {
      "id": "forest-air",
      "title": "Forest air",
      "path": "forest-air.m4a",
      "role": "background",
      "gain": 0.6
    }
  ]
}
champ Required Meaning and validation
version yes 1 for plain loops, 2 for placement and randomized playback.
backgroundPriority yes Integer 0 through 100. See arbitration below.
sources yes One through eight sources.
sources[].id yes Up to 100 characters of letters, digits, dot, dash, or underscore; distinct within the file.
sources[].title yes Up to 100 characters, shown beside the source’s own control.
sources[].role yes background ou effect.
sources[].gain yes 0 through 1. The gains of all sources may total at most 1.
sources[].path version 1 One package-relative .m4a, .mp3, ou .wav file at the ZIP root.
sources[].clips version 2 One through eight distinct audio files, replacing path.
sources[].placement version 2 Where the source is, described below.
sources[].playback version 2 How it plays, described below.

Each audio file must be larger than zero and at most 128 MiB. When Locus prepares it for playback it also requires at most 48 kHz, at most two channels, and at most 600 seconds for a loop; a positioned source must be mono, and a randomized clip must be at most 30 seconds with at most 60 seconds of clips per source. A file that passes validation but breaks one of those playback rules loses that source, not the whole place.

Backgrounds arbitrate; effects do not. A View and a Room each declare a backgroundPriority, and only an enabled, prepared background with a nonzero volume claims it. The strictly higher priority silences the other owner’s backgrounds; equal priorities mix. Effects always keep their own master control. In Room Portal only the View’s audio plays. Every control is session-only: Locus does not rewrite the ZIP, and nothing here is saved.

Placement and randomized playback (version: 2)

Version 1 keeps its exact meaning forever. Version 2 replaces a source’s path with clips and adds placement et playback. role is unrelated to placement: a positioned waterfall is still a background.

placement is one of four shapes. frame must be the package’s own — view in a View ZIP, room in a Room ZIP — and rolloff is optional everywhere except ambient, finite 0 through 4, where 0 removes distance attenuation entirely and 1 is the default.

type forme
ambient {"type": "ambient"} and nothing else: no position, no distance attenuation, no change as the listener turns.
point One fixed point.
points Two through sixteen distinct candidate points, for randomized playback only.
region A direction-and-distance range to draw a fresh position from, frame: "view" only.

A View-frame point is {"azimuthDegrees": 25, "elevationDegrees": 20, "distanceMeters": 15}: azimuth -180 through 180, elevation -90 through 90, distance 0.1 through 100 metres. Azimuth 0 is the View’s forward direction and increases the same way initialYawDegrees does. A Room-frame point is {"positionMeters": [1, 2, -3]} in the Room model’s own coordinates, each component finite and within 100 metres.

A region adds azimuthCenterDegrees (-180 through 180), azimuthSpanDegrees (0 through 360, seam-crossing allowed), elevationRangeDegrees et distanceRangeMeters, each an ordered pair inside the same limits as a point.

Distance is an authored acoustic proxy. It is not depth measured from the panorama and not the radius of the sky. Choose what sounds right.

playback is either a loop or a randomized phrase:

{
  "id": "bird-calls",
  "title": "Bird calls",
  "clips": ["call-a.m4a", "call-b.m4a", "call-c.m4a"],
  "role": "background",
  "gain": 0.2,
  "placement": {
    "type": "points",
    "frame": "view",
    "points": [
      {"azimuthDegrees": -40, "elevationDegrees": 22, "distanceMeters": 12},
      {"azimuthDegrees": 15, "elevationDegrees": 28, "distanceMeters": 18}
    ],
    "rolloff": 0.2
  },
  "playback": {
    "type": "random",
    "intervalSeconds": [8, 25],
    "maxConcurrent": 1,
    "avoidImmediateRepeat": true
  }
}
playback.type Rules
loop Exactly one clip, and ambient ou point placement. Optional fadeInSeconds et fadeOutSeconds, each 0 through 5, default 0.
random intervalSeconds is an ordered pair within 0.25 through 600. maxConcurrent must be 1 in this version. avoidImmediateRepeat defaults to false and, when true, avoids repeating the previous clip and position while alternatives exist. No fades.

A randomized source waits a sampled interval before its first phrase and again after each phrase finishes. It chooses a clip and a position once per phrase and holds that position for the whole phrase — a call never moves while it sounds. Muting, leaving, switching places, or losing the foreground cancels what is pending; nothing accumulates while you are away.

provenance

Every Room and View uses the same required provenance.json shape. Third-party content declares license; Locus-owned content that grants no reuse license declares rights instead. Exactly one is required:

{
  "formatVersion": 1,
  "creatorOrAgency": "Example Studio",
  "sourcePageURL": "https://example.com/source",
  "originalAssetURL": "https://example.com/asset",
  "license": {
    "identifier": "CC0-1.0",
    "name": "CC0 1.0 Universal",
    "url": "https://creativecommons.org/publicdomain/zero/1.0/"
  },
  "requestedCredit": "Example Studio",
  "modificationNotes": "Prepared as a Locus Room.",
  "aiGenerated": false
}

sourcePageURL et originalAssetURL are optional HTTPS URLs. When aiGenerated Est true, aiProvider is required and names the provider or tools used. When aiGenerated Est false, omit aiProvider; supplying it is invalid. The license describes rights in the asset. An App Store EULA is an app distribution agreement and is not an asset provenance license.

For an all-rights-reserved original, replace license with:

"rights": {
  "statement": "All rights reserved. No separate reuse license is granted.",
  "url": "https://enterlocus.com/asset-rights/"
}

Pack and validate a flat compatibility ZIP

Keep only regular root files in the source directory, then run:

python3 tools/pack_locus_asset.py /absolute/path/to/asset /absolute/path/to/asset.zip
python3 tools/validate_locus_asset.py /absolute/path/to/asset.zip

These current public Python tools build and validate only the legacy/simple flat compatibility format; they do not build or validate complete Package v2. The packer is deterministic, refuses to overwrite an existing ZIP, and only publishes a flat ZIP accepted by the same validator. Room validation uses macOS sips, Xcode’s usdchecker, and a small Swift RealityKit checker compiled with xcrun swiftc. The downloadable Python validator includes its Swift checker; it requires no private repository or separately downloaded helper. The same binding rules are used by the app before import. A VALID result covers structure, metadata, image decoding, model structure, and machine-checkable entity bindings. Import the exact ZIP and enter every Room seat to check scale, appearance, and comfort.

In a flat compatibility ZIP, do not include .blend, GLB, source textures, parent folders, symlinks, catalog/, Experience files, an ID, or locusplace.json. Those last three belong only to the canonical complete Package v2 layout described above.

Inspect the delivered model before packaging

The flat compatibility ZIP validator and the app’s public Room import preflight both load models with authored name references through RealityKit. References in spatial adaptation, rendering, lighting (including proxy anchors and baked indirect), and ambient animations must resolve to exactly one entity. Rendering groups also reject overlapping ancestor/descendant bindings and subtrees without renderable geometry, matching runtime rules. A Blender object and its mesh must not share a referenced name: use a distinct mesh name such as Table_Cup_Geometry. Unreferenced duplicate names are allowed.

Import rejects these failures before committing a library entry or consuming an import allowance. Transport, schema and resource-budget validation precede the native load. This is binding validation, not a substitute for light-material, animation-playback or physical-device visual acceptance. The optional audit_locus_room.py tool also opens the delivered USD scene and checks actual entity identities, overlapping rendering bindings, spotlight directions, and the supported shader network. Run it with a Python environment containing Pixar USD (pxr), such as Blender’s bundled Python:

python3 tools/audit_locus_room.py /path/to/room/space.json /path/to/room/scene.usdz

The audit reports the exact entity or shader path when a fix is needed. Bake unsupported procedural nodes to Preview Surface texture inputs and bake displacement into geometry. Material animation is outside this supported author contract and requires separate target-renderer evaluation. Run the public packer and validator afterward, then import the resulting ZIP and check it in Locus; offline checks cannot establish glass compositing, visual quality, tracking, or physical comfort.

Build an independent material example

tools/build_material_study.py creates an editable Blender Room with matte and textured glossy floors, three glass finishes, a transparent water surface, roughness and coat maps, a wall-facing spotlight, and explicitly declared furniture. It uses arbitrary object names and receives a fresh identity on normal import. It requires a Locus build supporting Room v5.

blender --background --factory-startup --python-exit-code 1 --python tools/build_material_study.py -- --output /path/to/material-study
python3 tools/audit_locus_room.py /path/to/material-study/room/space.json /path/to/material-study/room/scene.usdz
python3 tools/pack_locus_asset.py /path/to/material-study/room /path/to/material-study.zip

Use a Python environment with Pixar USD for the audit. Keep MaterialStudy.blend and the generated textures with the editable source; this workflow imports the result as a flat compatibility ZIP.