Referencia técnica
Public Locus Package v2 and compatibility ZIP formats
En esta página
- Complete Package v2 — canonical import format
- One pure-data capability contract
- Complete View source and midground data (Locus 1.2.1)
- Locus 1.3.0 View effects and orbital profiles
- Locus 1.4 View additions
- Legacy/simple flat ZIP compatibility
- Flat compatibility Room ZIP
- Room lighting (formatVersion 2)
- Room indirect lighting (formatVersion 3)
- Authored rendering and spotlight direction (formatVersion 5)
- Ambient animations (formatVersion 4)
- How desk alignment identifies a desk
- Lounge seats and hideable desks
- Seat groups (formatVersion 6)
- Room video surfaces (formatVersion 7)
- Room illuminated surfaces and view-forward yaw (formatVersion 8)
- Room particle effects (formatVersion 9)
- Room baked light layers (formatVersion 10)
- How Locus uses a Room
- Collision and quality: internal fields, not additional author inputs
- Model and texture budgets
- Flat compatibility View ZIP
- Sonido
- Placement and randomized playback (version: 2)
- Origen
- Pack and validate a flat compatibility ZIP
- Inspect the delivered model before packaging
- 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 Importar una Vista Y Importar una Habitación. Use an
ordinary .zip filename for normal public delivery. Locus accepts the same ZIP
bytes with a .zip o .locusplace Nombre del archivo.
The ZIP root directly contains locusplace.json Y 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/, y opcional 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 Y 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 Para 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:
pathis a package-relative USDZ. It is placed at the Room floor under the Room’s first seat and may usetransformto correct authoring coordinates. The USDZ must pass the same archive-member allowlist, structural inspection, model, and texture budgets as other imported runtime models.terrainEntitiesis an optional, unique, non-empty list of model-unit names and is valid only withpath. Locus lowers those ground units under the Room footprint and hides intersecting non-ground units so scenery does not clip through the Room.windSwayis optional and valid only withpath. It authors a rigid tilt, about each matched prop’s own origin, for midground props (never a unit named interrainEntities) so a plant appears to bend from its grounded base.entitiesis a non-empty, unique list of name prefixes: every midground unit whose own name starts with one of them sways.amplitudeDegrees(peak tilt from rest) is finite within0...15.periodSeconds(seconds per full sway cycle) is finite within0.5...30. The optionaldirectionDegrees(azimuth the wind comes from, same convention aswater.waveDirectionDegrees) is finite within-360...360and defaults to0; plants tilt downwind. Locus 1.4 adds an optional traveling-windwavelengthMeters; see Smaller additions.water.levelMetersis required and must be finite from-20through0.water.maskis a package-relative grayscale 2:1 PNG in the panorama’s equirectangular coordinates.water.normalMapis a package-relative RGB or RGBA PNG. The optional numeric surface controls are range-checked before import.water.wavesmay name a PNG normal atlas plusframes(2...512),columns(1...64),loopSeconds(1...120), andtileMeters(0.5...50). The atlas dimensions must fit the declared square frames.water.shoremay name a top-downbedAlbedoJPEG/PNG, 8-bit grayscalebedHeightPNG, RGB/RGBAdetailPNG, optional 8-bit RGBfieldPNG, and optional 8-bit grayscalesunVisibilityPNG, together with their bounded extent, height range, clarity, swash, foam, caustics, and beach-slope parameters.sunVisibilitymarks where the shore’s own terrain, rocks, or trees shadow the bed —255is fully sunlit, lower values are shadowed — so caustics and the direct-sun share of the bed’s lighting fade out where it is low; omitting it means fully sunlit everywhere. It sharesbedHeight’s extent and orientation by authoring convention, but Locus checks only that it is an 8-bit grayscale PNG, not that its pixel dimensions matchbedHeight.water.surfadds real-time shallow-water surf breaking on top ofshore, and is valid only whenwater.shoreis also present.presetis one ofcalm-lake,lake-swell, oocean-surf. Optionalstrength(amplitude multiplier on incident waves) is0...2; optionaltideMeterses-0.5...0.5.originMetersis a required[x, z]landward center point of the solver domain, each component finite within±200meters, in the same View-local frame asshore.extentMeters.shoreNormalDegreesis the required offshore direction, finite within-360...360, using the same formula aswaveDirectionDegrees.crossShoreMetersYalongShoreMetersare both required and finite within2...100meters. OptionalcellMetersdefaults to0.25and must be finite within0.05...1.0; the resulting solver grid (crossShoreMetersYalongShoreMeters, each divided bycellMetersand rounded up) must be at least 4 and at most 256 cells on each axis.
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 Para 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 o 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 es "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 es "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" o
"ocean" (the default); legacy "lake" Y "stream" remain readable
aliases, but are not new authoring choices. waveHeightMeters es 0...20,
waveLengthMeters is bounded by the region’s sampling floor through 1000 m,
waveSpeed es -100...100, y directionDegrees es -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 es -50...50, reliefMeters es 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
es -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 es 0...24, dayDurationSeconds es 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 Y 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 es [0,1), v es
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, y 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 Y
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, o 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 o 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, o
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, y
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 o
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 o
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 Y 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 Y 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.
El 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 es
0.1...4, crossfade es 0.05...0.45, y 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 Y 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 Y 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 Y 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 es 0...4 Y
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 Y latitudeSouthDegrees as tiles, and every
(row, column) exactly once with its sha256 Y 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 Y
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 Y
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 o 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
midground.windSway.wavelengthMeters(1...200; declareminimumAppVersion1.3.4, Library capabilitylocus.view.midground.wind-sway.wave.v1) turns per-plant sway into one gust wave rolling downwind atwavelengthMeters / periodSecondsmetres per second, and plants lean downwind on average. Author each plant clump as its own unit with its origin at its root. Import does not check this floor; earlier versions of Locus ignore the field and sway each plant on its own.sky.moon.drawnDiameterDegrees(0.5172...24) fixes the Moon’s drawn angular diameter instead of the natural size timessizeScale. It changes only the drawn disc and the stars it hides, not moonlight or sky glow. Earlier versions of Locus ignore it.celestial.sunDisc.angularSizeDegreesnow accepts values down to0.001.oceanGlint, an experimental block in two Library revisions that never shipped publicly, is not part of the public format; Locus rejects it.
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, y 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 o 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 Y
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, o 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.
softenedReflectionEntitiesopts those subtrees into an environment source that retains the View’s vertical brightness distribution while averaging its horizontal detail. This can avoid recognizable distant landmarks in glazing; it has no horizontal reflection detail, is not a Room reflection, and never changes material roughness, coatings, or opacity.uiFadeEntitiespermits temporary fading when that subtree blocks the visitor’s windows. Declare furniture deliberately and keep structure out unless fading that structure is intended. A fade multiplies existing entity opacity and restores it afterward. Keep animated opacity on a child of a static fade root so two controls do not write the same component.
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 Y 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, y
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 Ocultar escritorio
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 key here must also have a
deskEntitiesByTeleportIDentry for the same teleport ID; - the value must be non-blank, trimmed text;
- the named entity must resolve to exactly one entity in
scene.usdz; y - the seat’s
deskEntitiesByTeleportIDsurface entity must be a descendant of the named group entity — group and surface must name two different entities in the same subtree, not two unrelated names.
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 Y 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 o 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 Y
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 Ocultar escritorio 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 Y 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 Y environment are optional. initialYawDegrees is optional.
contrast accepts 0.5...2; saturation accepts 0...2. An enabled sun also
requires finite azimuthDegrees Y elevationDegrees. These are starting
values; a user may later change them with Edit View in Locus.
environment.condition is available in formatVersion: 2: dusk Y 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.
Sonido
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.
Poner 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
}
]
}
| Campo | 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 o 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, o .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 Y 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 |
Forma |
|---|---|
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 Y 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 o point placement. Optional fadeInSeconds Y 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.
Origen
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 Y originalAssetURL are optional HTTPS URLs. When
aiGenerated es true, aiProvider is required and names the provider or
tools used. When aiGenerated es 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.