MC3 (MeshCraft 3D) is an XML-based scene format (.mc3.xml). It describes a 3D scene as editable constructive objects rather than raw triangle meshes.
<?xml version="1.0" encoding="UTF-8"?>
<mc3 version="0.3" model="MyScene" unit="meter" coordinate_system="right_handed_y_up">
...
</mc3>| Attribute | Values | Default |
|---|---|---|
version |
string | "0.3" |
model |
string | "unnamed" |
unit |
"meter", "centimeter", "inch" |
"meter" |
coordinate_system |
"right_handed_y_up", "right_handed_z_up" |
"right_handed_y_up" |
rotation_units |
"degrees", "radians" |
"degrees" |
euler_order |
"XYZ", "XZY", "YXZ", "YZX", "ZXY", "ZYX" |
"XYZ" |
default_camera |
string (references a <camera>'s name) |
— (see Cameras) |
rotation_units/euler_order are preserved by the format readers/writers and
honored consistently by MCB, mc3togltf, and the live editor: rendering,
CSG, picking, gizmos, object/world transforms, cameras, Walk Mode and animated
transform overrides all use the authored convention. Scene Properties exposes
both declarations. Its explicit Normalize rotation to degrees/XYZ action
bakes static object, state, definition and camera rotations without changing
their visual result. It is intentionally unavailable when Euler rotation
channels are animated, because converting three independently interpolated
channels losslessly would otherwise change the animation.
coordinate_system is operational (SYS-W14-14). The editor's native
rendering space is right-handed Y-up. For a
right_handed_z_up document, every authored point and direction is rotated
by -90° around X — (x, y, z) → (x, z, -y) — before rendering; this is
also used by viewport picking, gizmos, walk collision, CSG overlays,
world-position display, document cameras and lights. mc3togltf emits the
same conversion as one named root node above all exported scene roots,
cameras and punctual lights, preserving local transforms and animation
channels.
Changing the dropdown changes the declaration only, because it is a semantic choice about already-authored values. For an existing Z-up scene, Scene Properties offers Normalize to Y-up: it converts scene-level cameras/lights and wraps all root objects in an explicit -90° X group, so the visible scene is retained without silently rewriting a file on load.
<mc3 ...>
<library namespace="..." version="..."/>
<imports>...</imports>
<include file="..."/>
<meta>...</meta>
<environment>...</environment>
<lights>...</lights>
<cameras>...</cameras>
<textures>...</textures>
<materials>...</materials>
<scripts>...</scripts>
<sounds>...</sounds>
<music>...</music>
<triggers>...</triggers>
<states>...</states>
<definitions>...</definitions>
<objects>...</objects>
<actions>...</actions>
</mc3>All sections are optional, but the XSD schema (mc3/mc3.xsd) requires
whichever sections are present to appear in this exact order —
<objects> before <materials>, for example, fails schema validation
even though Mc3XmlParser itself is lenient about order. This matters
in practice: the AI Assistant's "Apply to Scene" pipeline validates
AI-generated XML against this schema (see MESHCRAFT_HAS_LIBXML2 in
AiResponseAlgorithms.hpp) and rejects out-of-order responses.
R110/R101: reusable-library identity and cross-library references, mostly
relevant to .mc3lib.xml/.mc3lib.json reusable-definition-library files
(Mc3Document::saveToLibraryFile()/loadFromLibraryFile()) rather than
ordinary scene documents.
<mc3 version="0.3" model="CityAssets">
<library namespace="city-core" version="3.2.1" hash="sha256:..."/>
<imports>
<import namespace="furniture" source="mc3lib://furniture-pack@1.0.0" hash="sha256:..."/>
</imports>
...
</mc3><library> (at most one, root-level) declares that this document itself
is a reusable library, referenced elsewhere as mc3lib://<namespace>@<version>.
Absent on an ordinary scene/model document.
| Attribute | Type | Required | Description |
|---|---|---|---|
namespace |
string | yes | e.g. "city-core" |
version |
string | yes | semver major.minor.patch |
hash |
string | no | "sha256:<64 lowercase hex chars>" of the library's own content (Mc3Document::computeLibraryContentHash()) |
<imports> (at most one, root-level) lists libraries this document pulls
in under a local alias, so <instance>s can reference their definitions as
"<namespace>:<definitionId>".
<import> attribute |
Type | Required | Description |
|---|---|---|---|
namespace |
string | yes | local alias, e.g. "furniture" |
source |
string | yes | "mc3lib://<library-name>@<version>" — the library's own declared name/version, which may differ from the local alias |
hash |
string | no | if present, verified against the resolved library's own content hash on import |
Editor status (SYS-W14-21, 2026-07-20): <imports> are actually
resolved by the editor, not just round-tripped — Mc3ImportResolver
(R101, mc3/src/Mc3ImportResolver.cpp) is invoked automatically right
after every load (search directory = the loaded document's own
directory) and merges each imported library's definitions into
document_.definitions under its local alias, so
<instance definition="namespace:id"> referencing an imported
definition renders. An explicit "Resolve Imports" button in the editor's
Imports tab re-runs resolution after editing the rows, without a full
reload. A resolution failure (missing library file, content-hash
mismatch, an import cycle, or exceeding the resolver's chain-depth cap)
does not fail the whole document load — it's reported to the user and
the affected imports simply stay unresolved.
Lets one .mc3.xml file pull shared definitions, materials, and
textures from another file, so an asset library can be maintained once
and reused across multiple scenes.
<!-- scene.mc3.xml -->
<mc3 version="0.3" model="MyScene">
<include file="furniture_library.mc3.xml"/>
<include file="materials_pbr.mc3.xml"/>
<objects>
<instance name="Chair1" definition="chair" position="0 0 0"/>
</objects>
</mc3>| Attribute | Type | Required |
|---|---|---|
file |
string (path) | yes |
What gets merged — only <definitions>, <materials>, and
<textures> from the included file. Everything else in an included file
(<objects>, <environment>, <lights>, <cameras>, <actions>,
<scripts>, <sounds>, <music>, <triggers>, <states>, <meta>) is
silently ignored — an included file is treated purely as an asset
library, never as a sub-scene.
Merge order and local-override policy: includes are merged before
the including file's own <definitions>/<materials>/<textures> are
parsed, and later parsing simply overwrites same-id map entries — so
a local entry with the same id as an included one always wins,
with no error or warning. This lets a scene "override" one asset from a
shared library without forking the whole library file.
Path resolution: file is resolved relative to the file that
contains the <include> element, not relative to the top-level scene
file. So if a.mc3.xml includes libs/b.mc3.xml, and b.mc3.xml
itself includes c.mc3.xml, c.mc3.xml's path is resolved relative to
libs/, not to a.mc3.xml's directory.
Nested includes and merge order: an included file's own <include>s
are processed before that file's own definitions/materials/textures
are merged (depth-first, pre-order) — so in the a → b → c chain above,
c's assets are merged first, then b's own assets, then (after a
processes any of its other top-level includes) a's own local content
last. Only the top-level file's <include> elements are recorded in the
saved output (doc.includes) — nested includes are followed and merged
but not themselves re-emitted as separate <include> elements.
Diamond includes (e.g. A→B, A→C, B→D, C→D): each included
file is merged exactly once, tracked by a "processed" set keyed on
the file's canonical (resolved, absolute) path — the second and later
times D is reached, it's silently skipped rather than merged twice or
erroring.
Cycle detection: a "currently being processed" set tracks the
include chain's call stack (depth-first). If a file's canonical path is
already in that set when it's reached again (e.g. A includes B, B
includes A), parsing throws
std::runtime_error("Cyclic <include> detected: ...") rather than
recursing forever.
Roundtrip / skip-set on save: Mc3Document tracks which definition/
material/texture ids came from an include
(includedDefs/includedMaterials/includedTextures), and the writer
does not re-emit those entries into the saved file — they continue
to live only in the referenced library file, keeping the include
structure intact across save/load. If the main file locally overrides
one of those ids, the override is detected during parsing (the local
entry is removed from the "included" set the moment it's parsed) and
correctly gets written out as local content on save, not skipped. SVG
textures (<texture type="svg">) share textures' own id-tracking and
are covered by this same skip-set (fixed in STAB-0091 — the writer's
SVG-texture loop was previously missing this check and silently
re-inlined included SVG textures on every save).
<embeds> are include-aware (STAB-0092): embeds declared by an included
file are merged alongside its definitions. Their external src paths are
rebased from the included file's directory to the main document's directory,
and includedEmbeds ensures an unchanged included embed is not inlined when
the main document is saved. A local edit makes it local content, matching the
existing definitions/materials/textures ownership rule. Duplicate ids use the
same documented last-write-wins rule as other included resources and emit a
named warning.
Free-form document-level key/value metadata — author, license, description, version tags, etc. Purely descriptive; not interpreted by the parser, exporter, or editor beyond round-tripping it.
<meta>
<metaentry key="author" value="Jane Doe"/>
<metaentry key="license" value="MIT"/>
<metaentry key="description" value="A small demo scene"/>
</meta>| Attribute | Type | Required |
|---|---|---|
key |
string | yes |
value |
string | yes |
Note: this is distinct from the older <metadata> section (<property name="..." value="..."/> children), which exists for opaque pass-through data captured during import from other formats. <meta>/<metaentry> is the newer, general-purpose key/value store — prefer it for new content.
<environment>
<background color="0.1 0.1 0.15"/>
<background_texture>textures/sky.png</background_texture>
<skybox_texture>textures/skybox.png</skybox_texture>
<fog mode="linear" color="0.5 0.5 0.5" start="10" end="100" density="0.01"/>
</environment><background_texture>/<skybox_texture> are element text content
(not attributes on <background>), each holding a path resolved the same
way <texture uri="..."> is (relative to the top-level scene file's
directory).
Live-viewport fog implementation (AUD-078, 2026-07-20): <fog> is
applied by SceneRenderer.cpp entirely via a per-object CPU-side color
blend (mixes each object's draw color toward fog.color based on camera
distance, correctly honoring both mode="linear" (start/end) and
mode="exponential" (density)) — a complete, correct implementation on
its own. An earlier version of this code also enabled CNA BasicEffect's
own built-in GPU fog on top, unconditionally whenever <fog> existed,
always using linear start/end regardless of the declared mode —
wrong for Exponential mode (which has no start/end concept at all) and
redundant even for Linear mode. That extra GPU-fog call has been removed.
Empirically confirmed (rendering the exact same scene with and without it)
that it had produced no measurable pixel difference in this renderer's
actual configuration — so this was a dead/incorrect code cleanup, not a
fix for a previously-visible rendering defect.
<lights>
<directional name="Sun" color="1 1 1" brightness="2.0" direction="-1 -2 -1" cast_shadows="false"/>
<point name="Lamp" color="1 0.9 0.8" brightness="3.0" position="0 3 0" range="10"/>
<spot name="Spot" color="1 1 1" brightness="5.0" position="0 5 0" direction="0 -1 0"
angle="30" falloff="0.5" range="20"/>
<ambient color="0.2 0.2 0.2" brightness="1.0"/>
</lights>brightness is an arbitrary, unitless authored scalar (default 1.0) —
not a physical lux/candela value itself. As of AUD-077 (2026-07-20), the
live editor viewport DOES use it for actual illumination for
directional/ambient lights specifically — see "Live-viewport shading"
below — though it's still just a multiplier on the light's own color,
not a physically calibrated unit. glTF's
KHR_lights_punctual spec requires intensity to be lux for directional
lights and candela for point/spot — physically different units, since a
candela is already "per steradian" and a lux isn't. As of SYS-W14-26
(2026-07-20), mc3togltf applies a deliberate, documented per-light-type
conversion instead of writing brightness unconverted into every type
(which made the same authored number implicitly ~13× dimmer as a
point/spot candela value than as a directional lux value, in any
glTF-conformant PBR viewer): directional.intensity = brightness
(unconverted — matches Blender's own glTF exporter's Sun-lamp convention,
treating W/m² as directly usable as lux), point.intensity = spot.intensity = brightness / (4π) × 683 (683 lm/W is the CIE
photometric luminous-efficacy constant at the 555nm peak-sensitivity
wavelength; ÷4π converts from total emitted "power" to per-steradian
candela — the same formula Blender's exporter uses for Point/Spot lamps).
This is a deliberate scale factor chosen to match an established,
widely-recognized exporter convention, not a claim that brightness is
now a fully physically-calibrated real-world quantity — there is still no
editor-side lux/candela input mode.
ambient has no glTF equivalent at all — neither glTF 2.0 core nor
KHR_lights_punctual support ambient lighting, a real spec gap, not an
oversight — so it is never exported as an actual light. As of
SYS-W14-27 (2026-07-20), it is instead approximated by baking its
contribution into every material's own emissive channel: every
<ambient> light's color × brightness in the document is summed
(multiple ambients combine the same way multiple real fill lights
would), then that flat RGB contribution is added to each material's
emissiveFactor, tinted by that material's own base_color (so the
approximation still reflects each material's own albedo rather than
washing every material out to the same flat color) and clamped to
[0, 1]. This is a lossy but useful approximation — not physically
accurate global illumination — that keeps a glTF-conformant viewer's
render from looking fully unlit wherever an ambient fill was authored,
instead of just silently going dark. A warning is still printed naming
each ambient light and explaining that it was baked rather than
exported as a light.
Live-viewport shading (AUD-077, SYS-W14-33, 2026-07-26): the
editor maps up to three directional lights to BasicEffect's real
DirectionalLight0/1/2 slots and the first ambient light to
AmbientLightColor. On CNA backends with a working source-GLSL
ShaderEffect contract (currently EasyGL), it additionally previews the
first eight document-order point/spot lights on normal-and-UV mesh
paths. Point lights use color × brightness, inverse-square attenuation,
and a hard range cutoff (range="0" is unlimited). Spot lights add an
outer cone of angle degrees and a smooth inner cone at
angle × (1 - falloff); falloff="0" is a hard cone edge. This preview
does not model shadows or photometric unit conversion: brightness is the
same authored non-negative multiplier used by the editor, while export keeps
its separately documented glTF conversion.
Backends without that source-shader capability, or where the shader does not
compile, explicitly log that point/spot preview has fallen back; they retain
BasicEffect directional/ambient shading and the normal light gizmos. Geometry
that does not use the normal/UV mesh path also retains BasicEffect. A
point/spot-only scene with a usable shader has no inherited default rig;
without that shader (or with no authored lights), the viewport restores its
original default rig instead of inheriting a previously opened document's
lights. mc3togltf remains the ground truth for exported lighting.
<cameras default="MainCam">
<camera name="MainCam" type="perspective" position="3 3 5" target="0 0 0"
fov="60" near="0.1" far="1000"/>
<camera name="OrthoTop" type="orthographic" position="0 10 0" target="0 0 0"
size="10"/>
<camera name="RotCam" type="perspective" position="0 2 5" rotation="-10 0 0"
fov="60" near="0.1" far="1000"/>
</cameras>The default camera can also be set via the root <mc3 default_camera="MainCam">
attribute instead of <cameras default="...">. If both are present,
<cameras default="..."> wins. If neither is present, the first <camera>
in document order is used. The writer always outputs the <cameras default>
form on save, regardless of which spelling was used on load.
rotation (optional [x, y, z] in the document's rotation_units and
euler_order) is an alternative to target for
aiming the camera — set one or the other, not both meaningfully at once
(if rotation is present, it takes priority over target). Live-editor
status (AUD-079, 2026-07-20): both the camera gizmo and Look-Through-Camera
mode now actually honor rotation when present — previously both always
derived the view direction from target (silently pointing at its unused
{0,0,0} default whenever a camera was authored with rotation alone),
while mc3togltf's export already handled it correctly.
<textures>
<texture id="wall_tex" uri="textures/wall.png" wrap_u="repeat" wrap_v="repeat"
filter="linear" color_space="srgb" mip_maps="true"/>
</textures>| Attribute | Type | Default | Description |
|---|---|---|---|
mip_maps |
bool | true |
Whether mipmaps should be generated for this texture |
mip_maps status (SYS-W14-22, 2026-07-20): honored by mc3togltf —
false makes the exporter emit a plain (non-mipmap) LINEAR/NEAREST
glTF sampler minFilter instead of unconditionally requesting a
mipmapped one. Not honored by the live editor viewport — CNA's
Texture2D asset-loading constructor has no mipmap parameter (only its
raw-pixel constructor does, and CNA's own OpenGL backend explicitly does
not generate mipmaps by default for the Linear filter that path uses),
so no mip chain is ever generated for viewport textures regardless of
this flag. Closing that gap needs a CNA-side API addition, out of scope
per CLAUDE.md's CNA boundary — documented here as a known, deliberate
gap rather than left unexamined.
Path resolution: uri is resolved relative to the top-level
scene file's directory — every consumer (GltfExporter.cpp,
SceneRenderer.cpp) resolves it as doc.sourcePath / uri, where
sourcePath is set once, from the file originally passed to
Mc3Document::loadFromFile(). This is not the same rule as
<include>'s own path resolution above: a <texture> declared inside
an <include>d file still resolves its uri relative to the
top-level scene file's directory, not relative to the included
file's own directory. In practice this means texture files referenced
by a shared/included material library should be placed relative to
wherever scenes that include that library actually live, not relative
to the library file itself.
color_space is a pass-through hint, not enforced — but mismatches are
now warned about (SYS-W14-23, 2026-07-20). mc3togltf parses and
stores it, and still never re-encodes pixels at export time (the
texture's raw file bytes are copied/referenced as-is — correctness of
the actual encoding is entirely the responsibility of the image file on
disk). glTF 2.0 requires baseColorTexture/emissiveTexture to be
sRGB-encoded and normalTexture/metallicRoughnessTexture/
occlusionTexture to be linear (non-color) data — a fixed, spec-mandated
convention per slot that glTF has no per-texture way to override, so the
exported file always follows it regardless of what's declared. What
changed: if a texture's declared color_space conflicts with its
slot's mandated encoding (e.g. color_space="srgb" on a texture used as
normal_texture), the exporter now emits an explicit warning naming the
material, the texture id, the slot, and both the declared and required
color space — surfacing the likely-mistaken authoring intent instead of
silently doing nothing with it.
<materials>
<material id="red_metal" roughness="0.2" metallic="0.8"
alpha_mode="opaque" double_sided="false"
normal_scale="1.0" occlusion_strength="1.0" alpha_cutoff="0.5">
<base_color>0.8 0.1 0.1 1.0</base_color> <!-- RGBA -->
<emissive_color>0 0 0</emissive_color> <!-- RGB, HDR values allowed (e.g. 2.4 0.9 0.05) -->
<base_color_texture>wall_tex</base_color_texture>
<normal_texture>wall_normal</normal_texture>
<metallic_roughness_texture>wall_mr</metallic_roughness_texture>
<occlusion_texture>wall_ao</occlusion_texture>
<emissive_texture>wall_em</emissive_texture>
</material>
</materials>Inline Lua source, referenced by id from <triggers> (<run-script ref="..."/>) or by future runtime hooks. The script body is the element's text content (CDATA-safe via mixed="true" in the schema).
<scripts>
<script id="onStart" type="lua">print("scene started")</script>
<script id="onCollide" type="lua">player:takeDamage(10)</script>
</scripts>| Attribute | Type | Required | Notes |
|---|---|---|---|
id |
ID | yes | Referenced by <run-script ref="..."> |
type |
string | yes | Only "lua" is currently defined |
Status: data model, parser, writer, MCB round-trip, and XSD validation are complete (STAB-0032). A sandboxed Lua 5.4 interpreter (LuaScriptRunner, Lua + sol2) runs explicitly from the Scripts tab or a trigger's <run-script> step (Triggers (N5)). Each non-empty run gets an isolated deep document copy, a 16 MiB Lua allocation cap, and a 50-million-instruction ceiling; it re-resolves the selected target by unique object ID, validates the resulting complete document, then commits atomically. A failure therefore does not leave a partial document edit, undo snapshot, selection change, or dirty state. Two globals are bound while a script runs: def (compose-time socket placement — place/place_at/has_socket, mirroring ../mesh-world's own established R103/R104 API) and scene (broader: scene:find(idOrName) returns a handle to read/write any object's position/rotation/scale/visible/material). No automatic execution exists yet (e.g. running a definition's script the moment it's placed/composed) — only the two explicit entry points above. Exporters (mc3togltf/mc3tomcb) still never execute scripts.
One-shot/loopable sound effects and background music tracks, referenced by id from <triggers> (<play-sound ref="...">, <play-music ref="...">).
<sounds>
<sound id="explosion" src="sounds/explosion.ogg" loop="false"/>
<sound id="footstep" src="sounds/footstep.ogg" loop="true"/>
</sounds>
<music>
<track id="theme" src="music/theme.ogg" loop="true"/>
<track id="battle" src="music/battle.ogg" loop="true"/>
</music>| Element | Attribute | Type | Required | Default |
|---|---|---|---|---|
<sound> |
id |
ID | yes | — |
<sound> |
src |
URI | yes | — |
<sound> |
loop |
bool | no | false |
<track> (inside <music>) |
id |
ID | yes | — |
<track> (inside <music>) |
src |
URI | yes | — |
<track> (inside <music>) |
loop |
bool | no | true |
Status: data model, parser, writer, MCB round-trip, and XSD validation are complete (STAB-0032). Real audio playback IS implemented in the editor (Editor::AudioPreview, STAB-0706) — the Audio tab's own ▶/■ buttons play a <sound>/<track> directly, and a trigger's <play-sound>/<play-music> step (Triggers (N5)) reuses the same mechanism when fired. Exporters still never touch audio (not a glTF/OBJ concept).
Named sequences of steps — references into <actions>, <sounds>, <scripts>, and <music> — intended to be fired by future gameplay/event logic.
<triggers>
<trigger id="door_open">
<play-action ref="anim_open"/>
<play-sound ref="creak"/>
<run-script ref="onOpen"/>
</trigger>
<trigger id="pickup">
<play-action ref="pickup_anim"/>
<play-music ref="fanfare"/>
</trigger>
</triggers>| Step element | References |
|---|---|
<play-action ref="..."/> |
an <action name="..."> in <actions> |
<play-sound ref="..."/> |
a <sound id="..."> in <sounds> |
<play-music ref="..."/> |
a <track id="..."> in <music> |
<run-script ref="..."/> |
a <script id="..."> in <scripts> |
A <trigger> can contain any number of steps in any order/combination. ref values are plain strings in the schema (not IDREF) — cross-references are not validated at parse time.
Status: data model, parser, writer, MCB round-trip, and XSD validation are complete (STAB-0043). As of SYS-W14-19 (2026-07-20), the editor's Triggers tab has an explicit "Fire" action (a per-row button, and a "Fire Trigger" button in the detail view) that actually executes a trigger's steps in order: <play-action> drives the same Timeline playback state the Play button uses, <play-sound>/<play-music> call Editor::AudioPreview::play(), <run-script> runs via LuaScriptRunner (Scripts (N3)). SYS-W14-40 also invokes trigger steps from explicit Event Preview/Play, but stages their playback until the whole event batch validates and commits. Only one "current action"/one shared audio-preview slot exists, so multiple <play-action> (or multiple <play-sound>/<play-music>) steps in one trigger replace rather than layer.
Named snapshots of per-object property overrides (visibility, transform, material) — e.g. "day" vs. "night" variants of the same scene.
<states>
<state name="day">
<object-override id="lamp" visible="false"/>
<object-override id="sun" visible="true" material="day_mat"/>
</state>
<state name="night">
<object-override id="lamp" visible="true" position="0 3 0" material="night_mat"/>
<object-override id="sun" visible="false"/>
</state>
</states>Attribute (on <object-override>) |
Type | Required |
|---|---|---|
id |
string | yes — the target object's id attribute (see Objects) |
visible |
bool | no |
position |
vec3 | no |
rotation |
vec3 | no |
material |
string | no |
Only the attributes present on <object-override> are overridden; everything else keeps the target object's base value.
Status: data model, parser, writer, MCB round-trip, and XSD validation are complete (STAB-0044). As of SYS-W14-20 (2026-07-20), the editor's States tab has an "Apply State" button that writes a state's overrides onto the matching live objects (by id) right now, so a state can be previewed interactively. Only the SET fields on an override are applied — an unset field leaves the target object's existing value untouched, matching this section's own "only the attributes present... are overridden" contract exactly. SYS-W14-40 lets an explicit Event Preview/Play binding target a named state through the same isolated, validated batch as trigger scripts; a failed later script rolls the state change back instead of publishing a partial event result.
<event-bindings> stores explicit document-level links from an ordinary
object or Area to a named trigger or scene state. It avoids implicit
name-based gameplay rules and is preserved by XML, semantic .mc3.json, and
MCB.
<event-bindings>
<binding id="door_enter" source="door_area" event="enter"
target_type="trigger" target="open_door" cooldown="0.25"/>
<binding id="night_tick" source="clock" event="timer"
target_type="state" target="night" enabled="false"
once="true" interval="2"/>
</event-bindings>| Attribute | Type / values | Required | Default | Meaning |
|---|---|---|---|---|
id |
XML ID | yes | — | persistent binding identity; must be unique in the XML document |
source |
string | yes | — | object or Area id that emits the event |
event |
enter, exit, click, timer |
yes | — | event kind |
target_type |
trigger, state |
yes | — | target namespace |
target |
string | yes | — | trigger id or state name |
enabled |
bool | no | true |
disabled bindings are ignored |
cooldown |
float seconds | no | 0 |
minimum time after a successful dispatch before the binding can dispatch again |
once |
bool | no | false |
successful dispatch is allowed only once per runtime/simulation session |
interval |
float seconds | no | 1 |
timer period; only used when event="timer" |
The editor's Events tab has an explicit Preview / Play events mode.
Normal editing does not dispatch bindings. While Preview/Play is enabled,
timers advance once per binding per frame, Walk Mode produces Area enter /
exit events from the walk camera, and ordinary viewport picking produces
click events. The dispatcher has a recursion guard and a 32-dispatch budget
per call; enabled, cooldown, and one-shot controls are retained only after a
successful execution. Trigger steps, state overrides, and Lua scripts run on
one isolated copy of the document, validate as a batch, and commit once with
one undo/history boundary. A failed script rolls back all earlier state/script
changes in that batch and suppresses pending action/audio effects. This is an
editor preview, not a general game runtime.
Event bindings are MC3/MCB runtime semantics only. mc3togltf emits one
explicit warning and omits all bindings because glTF has no portable
equivalent for MC3 triggers or state application.
All objects share common transform attributes:
| Attribute | Type | Default | Description |
|---|---|---|---|
name |
string | "" |
Display name |
id |
string | "" |
Unique identifier for references |
material |
string | "" |
Material id |
visible |
bool | true |
Visibility |
position |
vec3 | 0 0 0 |
Translation |
rotation |
vec3 | 0 0 0 |
Euler angles in the document's rotation_units and euler_order (degrees/XYZ by default) |
scale |
vec3 or float | 1 1 1 |
Non-uniform or uniform scale |
pivot |
vec3 | 0 0 0 |
Rotation/scale pivot offset from position |
collision |
string | "none" |
"none", "box", "sphere", "capsule", "mesh", "convex"; Walk Mode accepts only the compatible subset described in the capability matrix |
tags |
space-separated | "" |
Arbitrary tags |
layer |
string | "" |
Named layer |
role |
string | "" |
"cutter" marks this object as a CSG cutter (see CSG operations) |
script |
string | "" |
R103: references a <script id="..."> (see Scripts) to run when this object triggers |
<box name="Crate" size="1 1 1" material="wood"/>
<cube name="UnitCube"/>| Attribute | Type | Default |
|---|---|---|
size |
vec3 | 1 1 1 |
<sphere name="Ball" radius="0.5" segments="32"/><cylinder name="Pillar" radius="0.3" height="2.0" segments="32" axis="y"/><cone name="Hat" radius="0.5" height="1.0" segments="32"/><plane name="Floor" size="10 10" axis="y"/>Note: size is vec2 — width (X) × depth (Z). The canonical form is "W D".
| Attribute | Type | Default |
|---|---|---|
size |
vec2 | 1 1 |
axis |
"x", "y", "z" |
"y" |
<torus name="Ring" major_radius="0.35" minor_radius="0.15" segments="32"/>| Attribute | Type | Default |
|---|---|---|
major_radius |
float | 0.35 |
minor_radius |
float | 0.15 |
segments |
positive integer | 32 |
<capsule name="Pill" radius="0.5" height="1.0" segments="32" axis="y"/>| Attribute | Type | Default |
|---|---|---|
radius |
float | 0.5 |
height |
float | 1.0 |
segments |
positive integer | 32 |
axis |
"x", "y", "z" |
"y" |
<disk name="Coin" radius="0.5" inner_radius="0" segments="32" axis="y"/>| Attribute | Type | Default |
|---|---|---|
radius |
float | 0.5 |
inner_radius |
float | 0 (solid disk; > 0 makes an annulus/ring) |
segments |
positive integer | 32 |
axis |
"x", "y", "z" |
"y" |
Note: the legacy attribute name minor_radius is still accepted as an
alias for inner_radius for backward compatibility, but inner_radius is
the canonical name and the only one in the schema.
<grid name="Terrain" size="10 1 10" subdivisions_x="4" subdivisions_z="4"/>| Attribute | Type | Default |
|---|---|---|
size |
vec3 | 1 1 1 |
subdivisions_x |
positive integer | 4 |
subdivisions_z |
positive integer | 4 |
<icosphere name="Rock" radius="0.5" segments="2"/>| Attribute | Type | Default |
|---|---|---|
radius |
float | 0.5 |
segments |
positive integer | 2 (subdivision level; note this primitive's default differs from the 32 shared by the other primitives above) |
All five are supported by the MeshCraft editor and exported by mc3togltf.
Optional child element on geometry-producing primitives (box/sphere/cylinder/ cone/plane/cube/torus/capsule/disk/grid/icosphere/mesh/extrude) and CSG roots, overriding that object's default UV generation.
<box name="Wall" size="4 3 0.2">
<uv_mapping projection="box" scale_u="2.0" scale_v="1.0" offset_u="0" offset_v="0" rotation="0"/>
</box>| Attribute | Type | Default | Description |
|---|---|---|---|
projection |
"planar", "box", "sphere" |
unset | Projection mode |
scale_u / scale_v |
float | 1.0 |
Per-axis UV scale (tiling) |
offset_u / offset_v |
float | 0.0 |
Per-axis UV offset |
rotation |
float (degrees) | 0.0 |
UV rotation |
projection (SYS-W14-24, SYS-W14-06): "box" and "sphere" are
implemented by mc3togltf. Ordinary primitive viewport projection remains
export-only, but CSG roots use the same generated projection in both the
exporter and live CSG preview.
"box"/triplanar picks, per vertex, the dominant axis of that vertex's
normal (or its direction from the mesh's local bounding-box center if
normals are absent) and projects onto the other two axes using raw,
non-normalized local-space coordinates — so a texture's apparent scale
stays tied to object size, and scale_u/scale_v (applied afterward)
are the tiling control, exactly as they already are against the default
planar unwrap. "sphere" is an equirectangular mapping around the
mesh's local bounding-box center, normalized to [0, 1] on both axes.
scale_u/scale_v/offset_u/offset_v/rotation are still applied
on top of either regenerated projection, same as for "planar".
For a CSG root, uv_mapping is applied after the Manifold boolean to the
generated result: "planar" projects local X/Z, "box" uses the dominant
normal axis, and "sphere" is the documented equirectangular projection.
Without it, a CSG result defaults to box projection. This is generated mapping,
not a retained authored unwrap from the operands; see CSG operations
for that remaining limitation.
<mesh name="Tree" src="meshes/tree.obj" material="bark"/>Note: The attribute is src (not source).
Material-aware OBJ import: MeshCraft's File → Import OBJ command creates
a <group> and one <mesh> child for every populated usemtl assignment.
Each child keeps the source src, receives one generated MC3 material, and
carries the opaque source-import selector below. The viewport and
mc3togltf read that selector, so each child loads only its own faces rather
than re-flattening the OBJ during a later preview/export.
<group name="tree">
<mesh name="tree_bark" src="meshes/tree.obj" material="tree_bark">
<metadata>
<property name="meshcraft.obj.material_index" value="0"/>
</metadata>
</mesh>
</group>value is the non-negative OBJ material-table index; -1 represents faces
with no resolvable usemtl material. It is an importer-owned opaque metadata
key and should be kept unchanged when manually reorganising imported mesh
children. A normal <mesh> without this key retains the backwards-compatible
behaviour of loading every face from its OBJ source.
The importer maps MTL Kd, d/Tr, Ke, Pr, and Pm into MC3 base
color/alpha, emissive color, roughness, and metallic values. It explicitly
warns for fields it cannot faithfully preserve (for example classic
specular/illumination terms and texture-map paths) instead of claiming a
lossless conversion. Missing MTL files leave a safely parsed unassigned mesh
group; malformed vertex, normal, and UV indices reject the import.
The source path itself still follows the regular resource policy. A selected
OBJ inside the current scene directory is saved as a relative src; an
external selected OBJ remains absolute, so safe glTF export rejects it unless
external resources are explicitly enabled. Import does not turn an external
path into a .. traversal or otherwise bypass resource containment.
<extrude name="Arch" segments="32" twist="0" smooth="true" caps="true">
<cross_section type="circle" radius="0.1" segments="16"/>
<path type="arc" radius="2.0" angle="180"/>
</extrude>Cross-section types: rect (w/h), circle (radius, segments), polygon (radius, sides), star (radius, inner_radius, sides), custom (<point x y/> children).
Path types: line (length, axis), arc (radius, angle), helix (radius, height, turns), polyline (<point x y z/>), bezier (Catmull-Rom through <point x y z cx cy cz/>).
<group name="Building">
<box name="Wall" .../>
<sphere name="Dome" .../>
</group><definitions>
<definition id="tree_def">
<sphere name="Canopy" radius="0.5"/>
</definition>
</definitions>
<objects>
<instance name="Tree1" definition="tree_def" position="3 0 0"/>
<instance name="Tree2" definition="tree_def" position="5 0 0" material_override="autumn_mat"/>
</objects>| Attribute | Type | Required | Description |
|---|---|---|---|
definition |
string | yes | References a <definition id="..."> |
material_override |
string | no | Material id applied to this instance in place of the definition's own material |
variants |
space-separated definition ids | no | Alternate definitions this instance may be randomly assigned among (variant picking) |
<difference name="DoorFrame">
<box name="Frame" size="1 2 0.2"/>
<box name="Opening" size="0.6 1.8 0.3" role="cutter"/>
</difference>
<union name="Combined">...</union>
<intersection name="Cut">...</intersection>Export note: mc3togltf evaluates CSG booleans using the Manifold library and exports the result as a single merged mesh. The CSG root's transform (position/rotation/scale) is preserved as a glTF node TRS; children are fully baked into the boolean result and do not appear as separate glTF nodes.
Supported child primitives for CSG: Box, Cube, Sphere, Cylinder, Cone (analytic), Torus, Capsule, IcoSphere (triangulated), nested CSG/Group/Instance (if their children are also supported).
Unsupported child types cause the export to fail with an error in default mode: Plane, Disk, Grid (not watertight), Mesh (OBJ), Extrude (complex topology).
Pass --allow-approximate-csg (CLI) or enable the "Allow approximate CSG export" checkbox (editor) to bypass Manifold evaluation and export children as separate meshes instead. This mode is geometrically incorrect and intended only as a debug fallback.
Strict mode is the default (allowApproximateCSG = false in GltfExporter; the mc3togltf_csg_strict test asserts this): a CSG node containing an unsupported child type fails the whole export with an error rather than silently producing wrong geometry. There is no separate "strict" flag to set — it's simply what happens unless --allow-approximate-csg is explicitly passed.
CSG output shading/material behavior (SYS-W14-06, SYS-W14-34):
- Manifold calculates vertex normals after the boolean with its 60-degree sharp-edge threshold. Curved result surfaces shade smoothly; hard edges such as box corners stay sharp. This is generated result geometry, so it does not retain arbitrary authored normal vectors from inputs.
- The output always has usable generated UVs: default box projection or the
CSG root's explicit
uv_mapping. It intentionally does not preserve the operands' original UV seams/unwrapping through new cut surfaces. Retaining that would require feeding UV-carryingMeshGLdata for every analytic primitive and defining seam policy for boolean-created vertices. - An explicit material on the CSG root remains a full-result override. Without
one,
mc3togltfand the live preview restore the Manifold source relation as material-index ranges per effective child material, including cut faces associated with that input. The preview shares one generated vertex buffer between those ranges and resolves each range's base color/texture normally; an explicit root material deliberately collapses it back to one range. A material edit is part of the CSG cache key, so it takes effect immediately without waiting for a document reload.
R111: optional child element on any object (mesh_world_revival.md §6),
in practice authored on <definition> contents describing catalog/
authoring metadata about a reusable asset — deliberately covers only
AUTHORABLE fields (things a human/generator would author), not derived
data like triangle counts or a validation-status snapshot (see
Mc3AssetMetadata.hpp's own doc comment for the full rationale of what
was deliberately left out).
<definition id="chair_def">
<box name="Chair" size="0.5 0.9 0.5">
<assetMetadata category="furniture" subcategory="chair" facing="-Z"
collision_proxy="box" shadow_policy="cast_receive"
license="CC-BY-4.0" provenance="hand-authored"
source="lua.object.chair.simple" version="1.2.3"
instancing_eligible="true" max_visibility_distance="250"
selection_weight="1.0" nominal_size="0.5 0.9 0.5"
bounds_min="-0.25 0 -0.25" bounds_max="0.25 0.9 0.25"
clearance_volume="0.8 1.2 0.8">
<semanticTags><tag value="seating"/><tag value="wood"/></semanticTags>
<styleTags><tag value="rustic"/></styleTags>
<regionTags/>
<periodTags/>
<materialSlots><tag value="seat"/><tag value="legs"/></materialSlots>
<sockets><socket name="seat_top" position="0 0.45 0"/></sockets>
<lods><lod tier="near" definition="chair_def"/></lods>
</assetMetadata>
</box>
</definition>| Attribute | Type | Default | Description |
|---|---|---|---|
category / subcategory |
string | "" |
Catalog classification |
facing |
string | "" |
Front-facing axis convention, e.g. "-Z", "+X" |
collision_proxy |
string | "" |
Free-form collision shape descriptor, e.g. "box", "convex_hull", "none" |
shadow_policy |
string | "" |
Free-form shadow behavior descriptor, e.g. "cast_receive", "cast_only", "none" |
license |
string | "" |
SPDX id or free text |
provenance |
string | "" |
Author/source description |
source |
string | "" |
Generator id (e.g. "lua.object.window.simple") or an AI request/recipe hash |
version |
string | "" |
Per-definition semantic version (distinct from <library version="...">, which versions the whole library file) |
instancing_eligible |
bool | true |
Whether this definition is safe to instance many times |
max_visibility_distance |
float | 0 |
LOD/culling hint; 0 = unspecified/no limit |
selection_weight |
float | 1.0 |
Relative weight for random-variant picking; higher = more common |
nominal_size |
vec3 | 0 0 0 |
Approximate authored size |
bounds_min / bounds_max |
vec3 | 0 0 0 |
Bounding box in the definition's own local space |
clearance_volume |
vec3 | 0 0 0 |
Required clearance as a width/height/depth size (not a positioned box) |
Child elements (all optional):
| Element | Contains | Description |
|---|---|---|
<semanticTags> / <styleTags> / <regionTags> / <periodTags> / <materialSlots> |
zero or more <tag value="..."/> |
Free-form tag lists |
<sockets> |
zero or more <socket name="..." position="x y z"/> |
Named anchor points/attachment sockets in local space |
<lods> |
zero or more <lod tier="..." definition="..."/> |
LOD tier name → definition id (e.g. tier="near" → a higher-detail definition); definition may reference an id in an imported library, not just this document |
Runtime semantics (SYS-W14-29): for an <instance>, the consumer first
chooses its deterministic variant (FNV-1a of the serialized instance id; not
an implementation-defined std::hash or per-frame random value), then reads
that base definition's metadata. The live viewport treats near, mid, and
far as authored definition tiers, using configurable 25 m / 75 m defaults
and 2 m hysteresis. A missing mid or far tier falls back to the next finer
authored tier; a missing target definition falls back safely to the base and
is reported in the selected Instance's Asset Definition LOD debug panel.
max_visibility_distance culls only the live viewport when positive. This is
separate from the renderer's older primitive-tessellation LOD, which never
changes an authored definition. glTF/GLB export has no camera-distance context
and therefore exports the explicit near/default tier (including CSG
instances), not viewport culling or a provisional LOD extension.
<actions>
<action name="Spin" duration="2.0" loop="true" autoplay="false" time_scale="1.0">
<clip name="Intro" start="0.0" end="0.5" playback_rate="1.0"/>
<clip name="ReverseLoop" start="0.5" end="2.0"
playback_rate="0.75" loop="true" reverse="true" transition="0.2"/>
<channel target="Wheel" property="rotation.y">
<keyframe time="0" value="0" interp="linear"/>
<keyframe time="2" value="360" interp="linear"/>
</channel>
</action>
</actions><action> attribute |
Type | Default | Description |
|---|---|---|---|
name |
string | required | Stable action name |
duration |
seconds | 1.0 |
Time domain shared by all channels and clips |
loop |
bool | false |
Whole-action preview loop mode (used when no named clip is selected) |
autoplay |
bool | false |
Whether this action starts playing automatically when the document loads |
time_scale |
positive float | 1.0 |
Whole-action playback-speed multiplier |
<clip> is an optional named, non-destructive playback range placed before
the action's <channel> children. It references the existing channel data;
creating, editing, or exporting a clip never trims or duplicates the action's
keyframes. XML, semantic JSON (clips with camel-case playbackRate), and
MCB all retain the same data.
<clip> attribute |
Type | Default | Description |
|---|---|---|---|
name |
string | required | Unique clip name within the action |
start, end |
seconds | required | Inclusive range in the parent action's time domain |
playback_rate |
positive float | 1.0 |
Multiplies the action's time_scale for this clip |
loop |
bool | false |
Loop mode for this clip, independent of the whole action |
reverse |
bool | false |
Starts at end and plays toward start |
transition |
seconds | 0.0 |
Editor-preview cross-fade time when switching into this clip |
The reader clamps ranges to the action duration, expands a degenerate range to
a minimal non-zero interval, requires a non-empty name and positive rate, and
clamps transition time to 0..60 seconds. The Timeline's Clip selector
offers the whole action and each named clip; + Clip and Edit author
these fields. Selecting a clip with a positive transition cross-fades numeric
overrides from the preceding selection, while visibility changes at the
midpoint. This is preview behavior, not a separate blend-tree format.
Animated properties (property attribute — dot notation, not underscores; see
Mc3Animation.cpp's animatedPropertyName()/animatedPropertyFromName(), the
single source of truth both the parser and the editor's Timeline/Anim panel use):
- Transform:
position.x/y/z,rotation.x/y/z,scale.x/y/z - Visibility:
visible - Deform:
deform.x/y/z - Material (targets the object's assigned material — see
Mc3Object.material):material.baseColor.r/g/b/a,material.roughness,material.metallic,material.emissive.r/g/b
Interpolation: linear, step, cubic (cubic bezier with <handle_left dt dv/> and <handle_right dt dv/>)
Units: duration, clip ranges, and keyframe time are already in
seconds — there is no frame-rate concept anywhere in the format. For a
whole action, mc3togltf bakes time_scale into the output sampler times.
For a named clip it inserts boundary samples, starts the glTF animation at
zero, bakes the effective action × clip speed, and bakes reverse ordering.
This preserves the supported transform playback timing without changing MC3
keyframes.
glTF animation policy: glTF core can animate only TRS here. The exporter
therefore always omits MC3 visible, deform.*, and material.* channels
and emits a per-channel diagnostic (with the MC3 object ID when known).
Named clips become separate animations named Action::Clip; preview-only
transitions have no portable glTF equivalent. The export dialog and CLI offer
two honest choices: Core TRS + MC3 playback metadata (default) retains
MC3 playback hints such as loop, autoplay, clip name/range/rate, and reverse
in legal animation.extras; Portable core TRS only (mc3togltf --portable-animation-core) writes only the baked core animation. Neither
choice claims or requires a glTF extension, and ordinary glTF viewers may
ignore extras; neither is universal MC3 animation equivalence.
cubic (bezier) export: glTF's own CUBICSPLINE sampler mode requires
real in/out tangent data in a strict triple-per-keyframe layout, which mc3's
tangent-handle data isn't converted into — instead, mc3togltf bakes a
bezier-interpolated channel down to dense LINEAR samples at a fixed 30
samples/sec, using the exact same curve evaluation the live editor uses (so
the export visually matches the editor preview). The rate is fixed, not
adaptive to curve complexity — a very long bezier action produces a
correspondingly large sampler purely from its duration, and a very fast/sharp
curve in a short time window could in principle be under-sampled.
| Feature | Exported |
|---|---|
| Box, Cube | ✅ |
| Sphere | ✅ |
| Cylinder, Cone | ✅ |
| Plane | ✅ |
| Extrude (all path types) | ✅ |
OBJ mesh (src) |
✅ |
| Materials (PBR, textures) | ✅ |
| Lights | ✅ |
| Cameras | ✅ |
Transform animations (position/rotation/scale) and named clips |
✅ — named clips are non-destructively baked to separate zero-based Action::Clip glTF animations; rate and reverse are baked into sampler timing/value order |
MC3 playback hints (loop, autoplay, clip range/rate/reverse) |
✅ optional metadata only — Core TRS + MC3 playback metadata writes legal animation.extras; Portable core TRS only intentionally omits it. Neither is a glTF extension or a promise that viewers will apply it. |
Animations (visible, deform.x/y/z, all material.* channels) |
❌ (no glTF core-spec equivalent — glTF animation channels can only target translation/rotation/scale/weights). The channel is skipped with an explicit per-channel report, carrying the MC3 object ID when it can be resolved; no fallback is attempted (e.g. visible is not approximated via a scale-to-zero animation). |
| Torus, Capsule, Disk, Grid, IcoSphere | ✅ |
| CSG (union/difference/intersection) | ✅ (evaluated by Manifold; unsupported child types fail the export; --allow-approximate-csg exports children separately as debug fallback) |
| Instance (via definitions) | ✅ |
Per-object UV mapping (<uv_mapping scale_u/scale_v/offset_u/offset_v/rotation>) |
✅ (AUD-024) — applied to generated TEXCOORD_0 (scale, then rotate about the UV origin, then offset). projection="box"/"sphere" (SYS-W14-24) regenerate TEXCOORD_0 in mc3togltf before scale/offset/rotation. The live editor also previews default/box/sphere mapping with scale, rotation, and offset for ordinary normal/UV geometry (SYS-W14-32); its independently generated CSG mapping cache remains a separate path. |
Per-object metadata (<metadata><property name="..." value="..."/></metadata>) |
✅ (AUD-029) — serialized into node.extras.metadata, alongside the pre-existing tags/collision extras |
--stats "Warnings" count |
✅ truthful (AUD-026) — every "Warning:" print site in GltfExporter.cpp increments the shared counter (verified by grep, not spot-checked); previously several paths (unknown material, SVG-slot warnings, ambient-light drop, duplicate node name, image-format detection, missing embed texture, action warnings) printed a warning without counting it |
TANGENT accessor (for normal_texture-mapped meshes) |
✅ (STAB-0664) — computed per-vertex (standard per-triangle-then-averaged-then-Gram-Schmidt-orthogonalized algorithm, not a full MikkTSpace port), only when a mesh has both NORMAL/TEXCOORD_0 and its material sets normal_texture; meshes without a normal map get no TANGENT (not needed) |
SVG textures (N1, <textures><texture> with an SVG source) |
✅ — external and inline SVG are rasterized into bounded PNG pixels for both glTF export and the live viewport. See the Textures section for the 2048px safety cap and cache/sampler details. |
Embedded GLB (N2, <mesh src="embed:id"/>) |
✅ (SYS-W14-05) — external self-contained .glb files and inline base64 GLB are decoded, their default-scene node transforms are flattened, and triangle geometry reaches both mc3togltf and the live viewport. The MC3 object's material remains authoritative: source GLB materials/textures, skins, morph targets, animations, non-triangle primitives, loose .gltf companion-file assets, singular transforms, and geometry beyond 64 MiB/300,000 triangles are deliberately rejected with a named warning rather than partially or unsafely imported. |
| Scripts, Sounds, Music, Triggers, Scene States, Event Bindings, Meta (N3-N7) | ❌ (no glTF equivalent — these are MC3/MCB-only data, round-tripped but not translated to any glTF concept; exporting event bindings emits one explicit omission warning; see Scripts (N3) etc. above) |
Measured directly (not assumed): exporting scenes of 200 through 50,000
top-level primitive objects (box/sphere/cylinder/cone, round-robin, no
<instance>/definition sharing — each object independently triggers its own
buildMesh() call, the actual worst case since instanced geometry is
deduplicated via buildDefCacheKey()), export time and peak resident memory
both scale linearly with object count, with no sign of quadratic-or-worse
growth:
| Objects | Time | Peak RSS | time/object | RSS/object |
|---|---|---|---|---|
| 1,000 | 0.02s | 8.2 MB | 0.020 ms | 8.4 KB |
| 5,000 | 0.09s | 16.0 MB | 0.019 ms | 3.3 KB |
| 10,000 | 0.18s | 26.1 MB | 0.018 ms | 2.7 KB |
| 25,000 | 0.47s | 55.2 MB | 0.019 ms | 2.3 KB |
| 50,000 | 0.93s | 104.1 MB | 0.019 ms | 2.1 KB |
Per-object time and memory cost stay flat (even improving slightly as fixed process/parsing overhead amortizes over more objects) all the way to 50,000 objects — real-world scenes, which are very unlikely to approach that count, export in well under a second. This was previously unaudited (flagged, not confirmed, by the STAB-0662-0701 export-quality audit); no code change was needed as a result of this measurement.
MCB (.mcb) is a compact binary encoding of the exact same Mc3Document model that .mc3.xml describes — same fields, same tree structure, just serialized as tagged binary values instead of XML text. It exists for faster load times at runtime; it is not a separate format with different capabilities, and every .mc3.xml scene round-trips through MCB losslessly (see mc3_roundtrip/mcb_roundtrip/mc3tomcb_roundtrip tests).
File extension: .mcb
Producing an MCB file — via the mc3tomcb CLI (direction is chosen by file extension):
./cmake-build-debug/mc3tomcb/mc3tomcb scene.mc3.xml scene.mcb # XML -> MCB
./cmake-build-debug/mc3tomcb/mc3tomcb scene.mcb scene.mc3.xml # MCB -> XMLOr from C++, via the Mcb library (mcb/include/MeshCraft/Mcb/):
#include <MeshCraft/Mcb/McbWriter.hpp>
#include <MeshCraft/Mcb/McbReader.hpp>
MeshCraft::Mcb::saveToFile(doc, "scene.mcb");
Mc3::Mc3Document doc2 = MeshCraft::Mcb::loadFromFile("scene.mcb");Header layout (mcb/include/MeshCraft/Mcb/McbFormat.hpp):
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 4 bytes | Magic | "MCB\0" |
| 4 | 1 byte | Version | Currently 1 (MCB_VERSION); readers reject any other value |
| 5 | 1 byte | Flags | Bit 0 = compressed payload (SYS-W14-25, 2026-07-20 — see below) |
| 6-7 | 2 bytes | Reserved | Always 0 |
| 8+ | — | Payload | See below — layout depends on the Flags byte |
Uncompressed (flags bit 0 unset, the default):
| Offset | Size | Field | Notes |
|---|---|---|---|
| 8 | 1 byte | Root tag | Always TAG_OBJ (0x07) |
| 9+ | — | Document | The document as a tagged key/value tree (see below) |
Compressed (flags bit 0 set):
| Offset | Size | Field | Notes |
|---|---|---|---|
| 8 | 4 bytes | Uncompressed size | uint32 LE, byte length of the decompressed document payload (root tag + document) |
| 12 | 4 bytes | Compressed size | uint32 LE, byte length of the zlib-deflated bytes that follow |
| 16+ | — | Compressed bytes | zlib (deflate) compression, at Z_BEST_COMPRESSION, of [TAG_OBJ byte][document payload] |
MeshCraft::Mcb::saveToBinary(doc, out, /*compress=*/true) opts into
the compressed layout (default false — writes the uncompressed
layout, byte-for-byte the same as before this option existed).
loadFromBinary/loadFromFile transparently detect and decompress
either layout via the flags byte — callers never need to know which
one a given .mcb file uses. Requires this build to have been compiled
with zlib available (mcb/CMakeLists.txt's find_package(ZLIB),
optional — see THIRD_PARTY.md); compress=true throws a clear error
if zlib is unavailable, and reading a compressed file throws a distinct
"requires zlib" error rather than misparsing it. The claimed
uncompressed/compressed sizes are each validated against a 512MB sanity
ceiling before being used to size any buffer — the same zip-bomb
defense already applied to every other length-prefixed field in this
reader (kMcbMaxStringLen/kMcbMaxCollectionCount).
Payload encoding: every value is a 1-byte type tag followed by its data — TAG_BOOL/TAG_I32/TAG_F32 (fixed-size), TAG_STR (uint32 length + UTF-8 bytes, no null terminator), TAG_VEC3/TAG_VEC4 (3 or 4 float32), TAG_OBJ (key/value pairs terminated by a zero-length key), TAG_ARR (uint32 count + that many tagged values), TAG_MAP (uint32 count + that many STR key + tagged value pairs). Every Mc3Document field (objects, materials, textures, definitions, scripts, sounds, music, triggers, states, event bindings, meta, etc. — including all N1-N7 extensions) is written under a string key matching its XML element/attribute name, so the two formats stay structurally parallel.
Relationship to .mc3.xml: MCB is a runtime-loading optimization, not an authoring format — there is no MCB-specific editor UI; you edit .mc3.xml and convert to .mcb as a build/export step (or open a .mcb directly, which the app transparently round-trips through the same Mc3Document model). Compression (flags bit 0) is opt-in via saveToBinary/saveToFile's compress parameter — there is no editor UI toggle for it (out of scope for SYS-W14-25); a compressed .mcb produced by another caller still loads transparently through the normal loadFromFile() path either way.
Two related but distinct kinds of input limits are enforced at load time in
mc3/src/Mc3XmlParser.cpp. Both are additive safety nets over the base
type/finiteness checks every numeric attribute already gets (finiteOr
rejects NaN/Inf; malformed non-numeric text like "abc" is defaulted, not
thrown) — see finite_input_test/mc3_numeric_range_test for the former and
mc3_input_budget_test/mc3_document_budget_test for the latter.
Per-field numeric ranges (SYS-W1-02) — a value that's a perfectly finite
float can still be outside its field's documented valid domain (a FOV of
600 degrees, a roughness of -3). Out-of-range values are clamped, not
rejected — the whole document isn't refused over what's almost always an
authoring typo, not an attack (the same judgment call this doc's
"tessellation count" note below makes). Named constants live next to each
check in Mc3XmlParser.cpp.
| Domain | Field(s) | Valid range | Notes |
|---|---|---|---|
| Camera | fov |
[1, 179] degrees |
0 or ≥180 isn't representable by a symmetric perspective frustum |
| Camera | near |
> 0 |
perspective projection divides by near |
| Camera | far |
> near (by ≥ 0.001) |
near≥far collapses the z-buffer's usable depth range |
| Camera | aspect |
> 0 |
zero/negative mirrors or collapses the view volume |
| Material | roughness, metallic |
[0, 1] |
glTF PBR convention; every consumer (live shading, glTF export) assumes it |
| Material | occlusion_strength, alpha_cutoff |
[0, 1] |
same glTF PBR convention |
| Material | base_color's alpha channel |
[0, 1] |
opacity; RGB channels and emissive_color are deliberately left unclamped (emissive explicitly allows HDR values above 1.0) |
| Geometry | primitive radius/height/size/major_radius/minor_radius |
>= 0 |
negative has no physical meaning; only negative is clamped (to 0) — zero itself is already meaningful for some fields, e.g. <disk inner_radius="0"/> |
| Geometry | extrude <cross_section> width/height/radius/inner_radius |
>= 0 |
same reasoning |
| Geometry | extrude <path> length/radius (arc or helix)/height (helix) |
>= 0 |
same reasoning; angle/turns are left unclamped (legitimately signed, for direction) |
| Environment | <fog density="..."> |
>= 0 |
negative inverts the exponential falloff's intended direction |
| Environment | <fog start="..." end="..."> |
start < end |
not clamped — SceneRenderer.cpp already guards end > start before dividing, so this is diagnostic-only (a warning), not a value repair |
| Animation | <action time_scale="..."> |
> 0 |
0 permanently stalls playback; negative isn't a supported "reverse" feature |
| Transform | scale (base transform, <deform scale="...">, and named <state scale="...">) |
magnitude >= 1e-4 per axis |
near-zero degenerates the transform matrix (non-invertible); sign is preserved — negative scale is a legitimate glTF-supported mirroring feature, only near-zero magnitude is clamped |
Not applicable — no field exists to range-check: Audio (<sound>/
<music><track>) has no volume/pitch field at all in the current format
(id/src/loop only — see the Sounds and Music section above; audio
playback itself isn't implemented yet). Post-processing/bloom is a
runtime/editor UI toggle, not scene-file data at all (see the
testEnvironmentAllFieldsRoundtrip note in mc3/test/roundtrip_test.cpp) —
there is no Mc3Environment field for it to range-check. Both would need a
field added to the format first, which is out of this task's scope.
Document-complexity budgets (SYS-W1-03) — independent of any single
field's own range, a document-wide running total across many
individually-legal values can still be pathological (100,000 spheres at
segments="4096" are each legal on their own but request ~8.6e11 vertices
combined). These are hard rejections (the document fails to load with a
clear error naming the budget), not clamps — unlike a per-field range, there
is no sane "clamp" for "too many objects", only "refuse before the
corresponding allocation is attempted downstream." A DocumentBudget
(thread-local, reset once per top-level parse()/parseString() call,
shared across all <include>s merged into it) tracks each dimension:
| Dimension | Ceiling | Notes |
|---|---|---|
| Total objects | 100,000 | AUD-059; every parsed <box>/<group>/... counts, including inside <definitions> |
| Total tessellation weight | 500,000 | AUD-059; sum of every segments/sides/subdivisions_* value across the whole document |
Total <include> fan-out |
1,000 | SYS-W1-04; distinct non-cyclic, non-diamond-duplicate included files |
| Total materials | 20,000 | |
| Total textures | 20,000 | <texture> and <texture type="svg"> combined |
| Total embeds (count) | 1,000 | independent of the existing 64MB per-embed base64 size ceiling |
| Total embed bytes (sum) | 256MB | sum of every embed's base64 content length combined — bounds N embeds each individually under the per-embed cap from summing to unbounded memory |
| Total actions | 10,000 | |
| Total channels | 200,000 | across all actions |
| Total keyframes | 2,000,000 | across all channels |
| Total definitions | 20,000 | doc.definitions map-entry count, independent of each definition's own object-tree cost (already counted under "total objects") |
| Children per node | 20,000 | not a document-wide running total — a LOCAL per-node breadth cap (one <group>/<union>/<difference>/<intersection>/<area>'s direct children), distinct from total object count (a wide-but-shallow tree can stay under the total-object budget while still choking non-virtualized UI tree widgets) |
| Max document bytes | 512MB | the raw input file/string size itself, checked via std::filesystem::file_size() before tinyxml2 buffers or parses anything |
| Recursion (nesting) depth | 500 (tinyxml2's own TINYXML2_MAX_ELEMENT_DEPTH) |
not a novel MC3-level guard — confirmed empirically that tinyxml2's own built-in element-depth cap already rejects (XML_ELEMENT_DEPTH_EXCEEDED) any XML nested deeper than this before Mc3XmlParser's own parseObject/parseChildren recursion is ever reached, so the C++ call stack can never recurse past ~500 levels regardless of input |
Explicitly not implemented: a "total generated output bytes" estimate (i.e. guessing at the eventual exported GLB or in-memory geometry size across the whole document) was considered and deliberately skipped — it would require estimating downstream allocation with no precise formula (tessellation weight already approximates geometry complexity; texture/ embed byte totals already bound the largest binary blobs), so a fuzzy byte-estimate budget wouldn't usefully bound anything beyond what the dimensions above already do. The input byte ceiling above ("Max document bytes") is the precise, actionable version of a "max bytes" budget.
The XSD schema is at mc3/mc3.xsd. Validate with:
python3 test/validate_xsd.py mc3/mc3.xsd scene.mc3.xmlUnrecognized XML attributes and elements are silently dropped on
round-trip (load, then save). Mc3XmlParser.cpp reads every field via
named, explicit lookups with no "collect anything I didn't recognize"
fallback, and Mc3XmlWriter.cpp rebuilds the XML element from scratch on
save, emitting only the fields it knows about. There is no attribute-bag,
raw-node-preservation, or version-gate mechanism anywhere in either layer.
This is a deliberate, accepted limitation, not a bug: a document
authored with a newer MeshCraft version (or hand-edited with an
experimental attribute) that's opened and saved by an older version, or
vice versa, will lose whatever the loading version doesn't recognize —
silently, with no warning. (doc.metadata/<meta> are a separate,
narrow, opt-in passthrough store for exactly those two elements — not a
general mechanism for arbitrary unrecognized data.) Revisit only if a
concrete forward/backward-compatibility need arises; see plan.md's
SYS-W5-03 entry for the tradeoffs considered (generic attribute bag /
raw-node preservation / hard version gate) and why none was adopted.