03 — The Blender Add-on (editor half)
Editor walkthrough: Blender 01–04 · Package map below is for contributors hacking Python.
A Blender 4.2+/5.x extension (blender_addon/, installed from babylon_level_kit_extension.zip). It owns authoring, GUID assignment, and the export that produces the two artifacts.
Prefabs (linked .blend files)
Each separate .blend library file can act as a prefab: link it into the level (File → Link…), place instances, then Make Library Override on the collection or objects you need to customize. Component fields on overrides are editable because the add-on marks Object.bjs_components and nested PropertyGroups as library-overridable (core/props.py). Export flattens linked instances into the level artifacts like any other object; duplicate GUIDs across instances are re-issued on export. Full workflow: Prefabs.
Where the UI lives
Split by object vs scene:
- 3D viewport N-panel, "Babylon Object" tab (
ui/view3d_panels.py) — the selected object: component stack, light/camera/animation child panels. When an object has two or more enabled Collider components, the panel shows a compound-body notice; each collider header is numbered (Collider 1/3, …) and its body repeats the hint. The pin button in the Components header locks the inspector to one object (WindowManager.bjs_pinned_object) so you can change the viewport selection while editing — every component verb resolves its target throughcore/inspector.py:inspector_object(), so they all act on the pinned object. - Properties › Material › Babylon (
ui/material_panels.py) — the material datablock shown in Material Properties (e.g. pick Lights in the slot list): Node Material Editor JSON, Scan NME (texture slots + inspector-visibleInputBlockparameters + inspector-visibleGradientBlockcolor stops), Extract Textures… (decode embeddeddata:/base64Stringpayloads to PNG/JPG beside the JSON), optional texture override rows (materials/on export). Applies to every mesh slot using that Blender material. - 3D viewport N-panel, "Babylon Scene" tab (
ui/scene_panels.py+ui/post_panels.py+ui/input_panel.py) — scene-wide: rendering (clear/ambient, freeze shadows), Environment (Default Environment, Intensity, Rotation Y, Show Skybox, Skybox Ignores Fog), fog, Atmosphere, post-processing, Input Actions, Collision Layers, and Export (Live Link / Debug Build / Validate viaui/common.py:draw_export_controls()).
Exposed lists (@exposed({ type: "list" }))
Each list field in a SCRIPT component renders as its own collapsible box (BJSExposedVar.show_expanded) — independent of the component's header collapse — with a triangle toggle and an N item(s) summary when folded. Grow a list three ways: the + button (bjs.list_add), a typed count field (the var's list_count get/set resizes the collection), and — for entity lists — an Add Selected button (bjs.list_add_selected) that drops every selected viewport object into the list at once, skipping the list's own object and any duplicates. Pin the inspector first so picking those objects doesn't switch the panel away.
Package map
| Package | Single purpose |
|---|---|
__init__.py | extension registration order + dev-reload of submodules |
core/ | pure helpers, nothing registered: ids.py (GUIDs + VISIBLE_KEY + CAST_SHADOWS_KEY), script_parse.py (@exposed / @inputMap regex parsing), props.py (library-override property wrappers), prop_copy.py (generic RNA copy/snapshot + remove_collection_item) |
components/ | per-object data model: component.py (BJSComponent, trigger/click events), exposed_vars.py, object_settings.py, clipboard.py, constants.py (all enums) |
materials/ | per-Material NME overrides: properties.py (BJSNmeTexture, BJSNmeInput, BJSNmeGradient), nme_scan.py + nme_inputs.py + nme_gradients.py (enumerate texture blocks, inspector inputs, and gradient blocks from JSON), nme_textures.py (detect/decode/extract embedded textures) |
scene/ | scene-wide render settings on Scene.bjs_scene (settings.py, environment.py for World Output texture discovery, atmosphere.py for physical sky, post_processing.py for the nested post block) |
input_actions/ | the Input Actions asset end-to-end: properties.py, defaults.py, serialize.py, operators.py |
export/ | everything that writes files: level.py (orchestrator), components.py (registry dispatch + reference walker) + component_serializers.py (the SERIALIZERS registry — one function per component type, axis conversion here), materials.py (NME JSON + texture patch), datablocks.py, animation.py, scene.py, atmosphere.py, post_processing.py, assets.py (copy_asset), validate.py, live_link.py |
operators/ | component verbs (components.py), script pick + Sync (scripts.py), Validate + Export (export_ops.py), NME scan/extract/Launcher (material_ops.py) |
ui/ | all panels and menus: view3d_panels.py, material_panels.py, scene_panels.py, post_panels.py, input_panel.py, component_draw.py (header + dispatch) + component_bodies.py (the BODY_DRAWERS registry — one inspector body per component type), common.py, menus.py |
viewport/ | GPU overlays: collider_preview.py (collider wireframe), cog_preview.py (dynamic rigid-body CoM cross), probe_preview.py (reflection-probe influence volume) |
Rule of thumb: core/, components/, scene/, input_actions/ own *data*; export/ owns *output*; operators/ owns *behavior*; ui/ owns *presentation*.
Contributor rules — persisted enums and @exposed types
Blender stores EnumProperty values as integer indices inside .blend files, not as the string id. Reordering or inserting items in the middle of an enum tuple silently remaps every saved value.
@exposed variable kinds (vtype) are stored as string ids ('ENTITY', 'COLOR', …) on BJSExposedVar, like elem_type already was. Sync from the script source sets the type — artists never pick it from a dropdown. VAR_TYPES is UI metadata only and can be reordered freely.
NME input kinds (value_type) on BJSNmeInput follow the same rule: string ids set by Scan NME from the JSON block type. NME_INPUT_TYPES is UI metadata only.
Collision layer picks (collision_layer) store the layer name as a string; the dropdown (collision_layer_select) is a get/set proxy over the scene layer list, so removing or reordering scene layers never retargets saved components. Renaming a layer means components pointing at the old name must be re-picked — Validate flags layer names that are no longer in the scene list.
Component kinds (comp_type) are still an EnumProperty. Never insert new entries in the middle of COMPONENT_TYPES — always append at the end. The Add Component menu uses ADD_COMPONENT_MENU instead, so new types can appear under the right section without shifting saved indices.
After changing @exposed fields in code, hit per-component Sync Variables before re-exporting. After changing an NME JSON, hit Scan NME on affected materials.
Registries that must stay in sync across Python and TypeScript (COMPONENT_TYPES, SERIALIZERS, BODY_DRAWERS, …) are checked by node scripts/check-component-types.mjs — run it when you add a component kind. Enum tuples are not auto-checked; treat reordering as a breaking change.
Contributor rules — editing collection properties
Blender forbids bpy_prop_collection.remove() on the linked base rows of library-override (prefab) data: you can insert and remove local rows, but the linked rows cannot be touched. Never call .remove() directly on addon collections; use core/prop_copy.py:remove_collection_item(collection, index), which is best-effort — it removes the row when allowed and returns False without mutating anything when Blender refuses. Do not "fix" a refused remove with clear() + rebuild: clear() only drops the local rows, so re-adding duplicates every linked row (this caused wheel/body fields and Sync results to double up on prefab cars).
For the same reason, code that reconciles a collection against a fresh source — e.g. sync_exposed_vars — must update rows in place (match by name, edit fields, add missing) rather than clear-and-rebuild, and should de-duplicate defensively (keep the first row per name) to repair files a previous bug already doubled. The module also owns the generic RNA deep-copy used by the component clipboard (copy_props) and the virtual-property skip list (VIRTUAL_PROPS: get/set proxies like e_val, list_count, collision_layer_select that must never be copied directly).
The export pipeline (what "Export Level" does)
Validate (
export/validate.py:validate_scene) — warnings surface in the report.GUID pass (
export/level.py) —ensure_object_id(core/ids.py) for every object that needs one, including referenced objects (entity fields, camera targets, trigger/click targets, constraint targets) so references always resolve. Duplicated GUIDs (copy-pasted objects) are re-issued.Stamp glTF extras —
_stamp_gltf_extraswrites transientbjs_visible: 0for viewport-hidden objects (visible_get(), including collection hierarchy) andbjs_cast_shadows: 0when ray-visibility Shadow is off (visible_shadow); cleared after the glb is written.Write the glb via Blender's glTF exporter (+Y-up,
use_renderable=Trueskips render-disabled objects, GUIDs + visibility/shadow-casting flags in node extras).Build the manifest — per renderable entity:
serialize_components(viewport-hidden entities also get"visible": falsein the manifest) (export/components.pydispatching through theSERIALIZERSregistry inexport/component_serializers.py; one dict per component), plus auto-derivedlight/camera/animationblocks; plus the scene block (export/scene.py); plus the top-level"debug"flag (Debug Build checkbox, owned byexport/live_link.py). Schema"version": 4— checked at load byValidateManifestand kept in sync with the runtime byscripts/check-component-types.mjs.Copy side files —
begin_asset_export()thencopy_asset/save_image_asset(export/assets.py): World environment texture whenfind_world_env_node(scene/environment.py) finds one on the active World Output chain (sanitized filename underenv/);rotationYfrom a Mapping node on the texture Vector input (-Mapping.Zfor Z-up → Y-up); Default Environment setsuseDefaultin the manifest instead of a file (withintensity/rotationYfromenvironment_intensity/environment_rotation_yonScene.bjs_scene); Show Skybox / Skybox Ignores Fog →createSkybox/skyboxIgnoreFog(forced off when Atmosphere is on — the addon renders the sky); color-grading LUTs →post/, audio →audio/, GUI layouts →gui/, particle systems →particles/(JSON viaexport_particle_system; optional Scan Textures + per-slot image picks patchParticleTextureSourceBlockURLs in the exported JSON), node materials →materials/(NME JSON viaexport_node_material; each distinct NME source file is copied once per export, then patched — shared JSON across several Blender materials is safe; Scan NME on the Properties › Material › Babylon panel lists texture rows, inspector-visibleInputBlockparameters, and inspector-visibleGradientBlockcolor stops; embedded slots withdata:/base64Stringstay in the JSON unless you pick an external override, which strips the embed and writes manifesttextures[]; authored inputs and gradients write manifestinputs[]/gradients[]), 3D button images →gui/. Each export pass resets path reservations so re-exports overwrite the same sanitized name (Live Link safe)._2,_3, … suffixes apply only when two different sources collide on the same sanitized name in a single export.Remember the path for Live Link.
Object visibility (eye vs camera vs shadow)
| Blender toggle | Property | Export behavior |
|---|---|---|
| Eye (viewport) | visible_get() false | Included in glb; "visible": false in manifest + bjs_visible in node extras → invisible at load |
| Collider › Make Invisible | collider_make_invisible | Included in glb; COLLIDER row makeInvisible: true → HideEntityNode at load (physics unchanged) |
| Ray Visibility › Shadow | visible_shadow false | Included in glb; bjs_cast_shadows: 0 in node extras → receive-only at load (no shadow casting) |
| Camera (render) | hide_render | Excluded from glb and manifest (_is_renderable) |
Axis conversions (Blender Z-up → Babylon Y-up)
Done at export in export/component_serializers.py, so the runtime receives Babylon-space values unchanged: vectors (x, y, z) → (x, z, −y); sizes swap y/z; quaternions keep w with vector part converted; constraint axis enum X/Y/Z → unit vectors via the same map. CUSTOM constraints also export six per-axis rows (axes[]: mode, min/max, stiffness/damping). viewport/collider_preview.py, viewport/cog_preview.py, and viewport/probe_preview.py draw raw Blender values, so the viewport previews match the exported body, CoM, and probe influence volume.