Target: Blender 4.5 LTS (all claims below verified empirically against Blender 4.5.3 LTS, headless, factory startup, on macOS — probe scripts in /tmp/blender_probe*.py).
Goal: represent an external parametric scene graph (Pascal nodes: site → building → level → elements, each with an ID, type, and parameter dict) inside a .blend file with zero information loss, while keeping the file a pleasant, idiomatic Blender project for humans.
Core strategy (summary):
- Source of truth: store the complete original JSON verbatim in a Text datablock (
bpy.data.texts["pascal_source.json"]) — byte-exact round-trip verified. - Per-node data: mirror each node's ID/type/params onto the corresponding datablock as custom properties (ID properties), following the glTF importer's
extrasprecedent (pascal_id,pascal_type,pascal_params, …). - Never rely on datablock names for identity. Blender renames on collision (
.001) and truncates at 63 bytes. Names are display labels;pascal_idcustom props are identity. - Hierarchy: Collections for the site/building/level containers; objects (meshes + empties) inside them. Convert Y-up→Z-up into the data at import time (the glTF-importer way), keep the scene in meters.
Verified in 4.5.3 — assignment datablock['key'] = value works on:
| Type | ID props? | Custom Properties UI panel? |
|---|---|---|
Scene |
✅ | ✅ (Scene properties tab) |
World |
✅ | ✅ |
Object |
✅ | ✅ (Object tab) |
Mesh (and other object data) |
✅ | ✅ (Object Data tab) |
Collection |
✅ | ✅ (COLLECTION_PT_collection_custom_props) |
Material |
✅ | ✅ |
Light, Camera, Armature, Image, Action, Text |
✅ | ✅ (where a properties tab exists) |
NodeTree, individual Node |
✅ | shown in N-panel Item for nodes |
ViewLayer, Bone, PoseBone |
✅ | ✅ |
Modifier |
❌ TypeError: id properties not supported for this type |
— |
Practical upshot: every container we care about (Scene, Collection, Object, Mesh, Material, World) takes custom properties. Only non-ID structs like modifiers don't.
Note: 4.5 does not have the
system_propertiessplit that landed in 5.x (hasattr(id, 'system_properties') == Falsein 4.5.3). All ID properties on a datablock live in one group and all show in the UI panel.
| Python value | Stored as | Round-trips save/load? | Notes |
|---|---|---|---|
int |
C int (32-bit) |
✅ | 2**31 raises OverflowError — max 2147483647. Store 64-bit IDs/timestamps as strings. |
float |
C double |
✅ bit-exact (3.141592653589793 survives exactly) |
|
bool |
bool | ✅ stays bool after reload (not int) |
|
str |
UTF-8 string | ✅ (unicode incl. CJK, emoji, \n, \t, quotes, backslashes verified) |
|
None |
NoneType idprop |
✅ survives save/reload, top-level and nested in dicts | JSON null is representable — good. |
bytes |
bytes | ✅ | |
dict (nested, any depth) |
IDPropertyGroup |
✅ (5-level nesting verified) | insertion order preserved, incl. across save/reload |
[int,…], [float,…], [bool,…] |
IDPropertyArray (typecodes i/d/b) |
✅ | mixed [1, 2.5] coerces all to float (d) |
["a","b"], [{…},…], [[…],…] |
Python-style idprop list | ✅ | |
[], {} |
empty array / empty group | ✅ | |
tuple |
converted to IDPropertyArray |
✅ (comes back as array, not tuple) | |
mixed list [1, "a", {…}] |
❌ TypeError: only floats, ints, booleans and dicts are allowed in ID property arrays |
— | heterogeneous JSON arrays cannot be mirrored 1:1 — see §1.4 |
set |
❌ TypeError |
— |
- Property key names: max 63 characters (
KeyError: 'the length of IDProperty names is limited to 63 characters'at 64). Applies to nested dict keys too. - Failed assignment is atomic-ish but destructive: assigning a dict containing one over-long key raises and the entire property ends up absent — validate keys before mirroring.
- Unicode keys allowed (
obj["clé_日本"]works) — but the 63 limit is characters-as-bytes at the name-buffer level; keep keys ASCII and short. - Value size: effectively unbounded. A 10 MB unicode string in a Scene custom property assigned in 0.004 s, survived uncompressed and compressed save/reload with identical SHA-256.
- Int range: signed 32-bit only (see table).
ID properties are almost a JSON superset, with exactly these mismatches:
- Heterogeneous arrays (
[1, "a"]) — unsupported. 2. Mixed int/float arrays promote ints to floats. 3. Ints ≥ 2³¹ overflow. 4. Key length > 63 raises. 5. Numeric key order inside a dict is preserved, but a JSON document's formatting (whitespace, key duplication, number formatting like1.0vs1) is not.
Design consequence: custom properties are the convenience mirror for humans and tooling; the verbatim JSON Text datablock (§2) is the lossless source of truth. If a node's params contain a heterogeneous array or big int, store that subtree as a JSON string prop (e.g. pascal_params_json) and skip the pretty mirror for it.
obj["pascal_id"] = "node-7f3a" # create/assign (dict-style)
obj.get("pascal_id") # read with default
"pascal_id" in obj / obj.keys() # membership / iterate
del obj["pascal_id"] # remove
grp = obj.id_properties_ensure() # -> IDPropertyGroup (the root group; creates if missing)
obj.id_properties_clear() # wipe all idprops
ui = obj.id_properties_ui("height_m") # UI metadata manager (int/float/string props only)
ui.update(min=0, max=10, soft_max=5, default=2.5,
description="Wall height", subtype='DISTANCE')
ui.as_dict() # inspect
obj.property_overridable_library_set('["height_m"]', True) # allow library overridesVerified behaviors:
id_properties_ui()works for scalar/string props; raisesTypeErroron dict (IDPropertyGroup) props ("does not support UI data"). UI metadata (min/max/subtype/description/default) survives save/reload, as does the library-overridable flag.IDPropertyGroup.to_dict()/IDPropertyArray.to_list()convert back to plain Python;to_dict()preservesNonevalues and key order. Ajson → idprop → to_dict() → jsonround-trip is exact for JSON documents that avoid the §1.4 caveats (verified).- Everything above persists in the .blend (uncompressed and compressed verified) provided the owning datablock is saved — see §1.6.
ID properties themselves always survive; the datablock they sit on might not. Blender garbage-collects datablocks with zero users on save:
- An unlinked
Collection(not a child of any scene's collection tree) was purged on save (verified: unlinked collections vanished after reload). - Materials/Worlds/etc. with no users are purged unless
use_fake_user = True(verified survival with fake user). - Text datablocks default to
use_fake_user = Truein 4.5 (verified) — they survive without any extra step.
Rule: every datablock carrying Pascal data must be reachable from the scene (linked) or fake-user'd.
All ID properties on Scene/Object/Mesh/Collection/Material/World appear in the corresponding Properties editor → Custom Properties panel (edit/remove buttons included). Dict-valued props display via their to_dict() repr (see rna_prop_ui.py); long arrays and nested groups are shown read-only-ish as text. Scalars with id_properties_ui metadata render as proper sliders with units (e.g. subtype='DISTANCE' shows meters).
Two candidate homes; use a Text datablock as primary, optionally duplicating into a Scene prop.
txt = bpy.data.texts.get("pascal_source.json") or bpy.data.texts.new("pascal_source.json")
txt.clear()
txt.from_string(json_str) # fast bulk path
round_trip = txt.as_string() # exactVerified round-trip safety:
- Byte-exact: unicode (CJK, accents, ✓), embedded
null, quotes/escapes, trailing-newline vs no-trailing-newline, and even\r\nline endings all round-trip exactly viafrom_string()/as_string()and across save/reload (SHA-256 compared, 10 MB payload). - Text datablocks are line-based internally but this does not corrupt content (a 1.6 MB pretty-printed JSON and a 10 MB multi-line blob round-tripped exactly).
use_fake_userdefaults to True → survives save with zero users.- Name obeys the 63-byte datablock limit (
"N"*100→ truncated to 63) — fine forpascal_source.json.
Performance gotcha (verified, big): Text write speed is line-length-bound, not size-bound:
| Payload | from_string() time |
|---|---|
| 100 kB single line | 0.14 s |
| 1 MB single line | 13.4 s (quadratic in line length) |
| 10 MB multi-line (120-char lines) | 0.009 s |
1.6 MB pretty-printed JSON (indent=2) |
0.004 s |
→ Always store pretty-printed (or otherwise line-broken) JSON, never minified single-line JSON, in a Text datablock. If you must store minified, put it in a Scene string prop instead (10 MB assign = 0.004 s).
Pros: visible/editable in the Text Editor (great debuggability), diff-able mental model, exact round-trip, no size issue, addon scripts can json.loads(txt.as_string()).
Cons: a user can edit or delete it (mitigate: also store a content hash + schema version as Scene props and validate on export); minified-JSON perf trap above.
scene['pascal_source_json'] = json_str — verified exact for 10 MB unicode strings, fast regardless of line structure, survives compressed save.
Pros: harder to fat-finger-edit than a text block; travels with the Scene on append/link.
Cons: renders as one giant string field in the Custom Properties panel (UI pain); invisible to casual inspection.
scene['pascal_schema_version'] = "1.0"
scene['pascal_source_hash'] = hashlib.sha256(json_bytes).hexdigest()
scene['pascal_source_text'] = "pascal_source.json" # name of the Text datablock
scene['pascal_import_time'] = "2026-08-01T12:00:00Z" # string, not int (32-bit int limit!)Verified semantics in 4.5.3:
- Nesting: arbitrary depth (
Site → Building → Level-0verified). The structure is a DAG, not a tree — the same collection can be linked under two parents (verified), and an object can be linked into multiple collections simultaneously (verified). Cycles are blocked at the API level (RuntimeError: Collection 'Site' already in collection 'Level-0'). For a clean hierarchy mirror, enforce single-parent by convention. - Naming: datablock names are limited to 63 bytes, not characters (verified: 100×
A→ 63 chars; 60×é(2-byte) → 31 chars/62 bytes; 60×日(3-byte) → 21 chars/63 bytes). Truncation is UTF-8-aware (never splits a codepoint). - Collisions: names are unique per datablock type (a Mesh and an Object may both be
PascalWall; two Collections cannot both beLevel— second becomesLevel.001). - Instancing: an Empty with
instance_type='COLLECTION'+instance_collectioninstantiates a whole collection (verified) — the natural mapping for repeated Pascal subtrees (typical apartments, repeated fixtures).Collection.instance_offsetsets the instancing origin. - Exclusion/visibility: the per-view-layer checkbox is
LayerCollection.exclude(found by walkingview_layer.layer_collection.children), not on the Collection ID; it persists in the file (verified). ID-levelCollection.hide_viewport/hide_renderalso exist and apply globally.color_tag('COLOR_01'…'COLOR_08') is handy for visually distinguishing Pascal-managed collections. - Collections take custom properties and they survive reload (verified) — so containers carry
pascal_idjust like objects. - Unlinked collections are purged on save (§1.6) — always link Pascal collections into
scene.collectiontree.
Verified renaming behavior:
- Creating/renaming to an existing name of the same ID type silently appends
.001,.002, … (rename-onto-existing gavePascalWall.003). Suffix counts beyond.999keep growing (Dup.1004verified) — the counter is not capped at 3 digits. - Freed suffixes are reused: after deleting
PascalWall.001, the nextPascalWallbecamePascalWall.001again → names are not stable identifiers over edit sessions. - At the 63-byte cap, collision handling truncates the base name to make room for the suffix: four objects named
B*63came back as lengths 63, 62, 61, 60 (each ending...BBB.001etc. within 63 bytes) — so long IDs get silently mangled twice (truncation + suffix). - Any UTF-8 is legal in names, including
/ . :(verifieda/b.c:d).
Implication: a UUID (36 chars, verified fits) could be a name, but a second import of the same node, a user duplicate (Shift-D), or an append would silently produce <uuid>.001 and break the mapping. Therefore:
- Identity:
datablock['pascal_id'] = "<node-id>"on Object and its Mesh/Collection. Custom props are duplicated verbatim on user-duplication, so exporters must treatpascal_id+ one-of-many resolution (or apascal_instancemarker) explicitly. - Names: human-readable, best-effort, e.g.
"{type}:{short-id} {label}"truncated to fit 63 bytes; never parsed on export. - Build the export map by scanning
pascal_idprops, notbpy.data.objects[name].
us = scene.unit_settings
us.system = 'METRIC' # enum: NONE / METRIC / IMPERIAL
us.scale_length = 1.0 # 1 Blender unit = 1 m
us.length_unit = 'METERS'Verified: factory default in 4.5.3 is already METRIC / 1.0 / METERS; the settings persist per-Scene in the file. Set them explicitly anyway (defaults can differ per user startup file). Note length_unit's enum is populated dynamically from system (RNA introspection only shows DEFAULT; assignment of 'METERS' works and persists — verified). The glTF importer consumes scale_length as its unit factor (u = 1.0 / scale_length), confirming scale_length=1.0 ⇒ meters convention.
Pascal is Y-up (glTF-style); Blender is Z-up, right-handed. The official glTF importer (io_scene_gltf2, bundled with 4.5.3) converts, verified from its source (blender/imp/blender_gltf.py::set_convert_functions):
location: (x, y, z)_gltf → (x, −z, y)_blender # X, -Z, Y
quaternion (x,y,z,w)_gltf → (w, x, −z, y)_blender
scale: (x, y, z) → (x, z, y)
i.e. a +90° rotation about X applied into the math, plus a camera/light correction quaternion (Quaternion((√2/2, √2/2, 0, 0))) because those objects aim down −Z/+Y differently. The exporter's mirror option is export_yup ("+Y Up", default True).
Where to apply the conversion — bake it into the data (the importer way):
- The glTF importer converts every node TRS and every mesh vertex/normal at import time (
gltf.locs_batch_gltf_to_blender(vert_locs)inimp/mesh.py), producing a file with no compensating root rotation. - Do the same for Pascal. Do not park the scene under a root empty with
rotation_euler.x = radians(90): that leaves every world matrix "lying" about up, breaks physics/snapping/walk-nav assumptions, survives poorly through Apply-Transform, and makes exported values need double bookkeeping. - Concretely: transform node translations/rotations with the mapping above; transform authored mesh coordinates with the same swap; on export, apply the exact inverse
(x, y, z)_blender → (x, z, −y)_gltf. This is lossless (a pure signed axis permutation — no floating-point rounding beyond sign flips). - Keep original transform values available for paranoid round-tripping by storing the node's raw source TRS in
pascal_params(or rely on the verbatim JSON text block).
| Criterion | Empty (parent object) | Collection |
|---|---|---|
| Carries a transform | ✅ (children inherit) | ❌ (only instance_offset, used when instanced) |
| Outliner UX | one flat object tree; children indent under parent | first-class hierarchy UI, checkboxes, isolation, color tags |
| Per-container hide/exclude/render toggles | via object hide (affects children only through parenting quirks) | ✅ per view-layer exclude, hide_viewport, holdout, indirect_only |
| Instancing a subtree | ❌ (must duplicate) | ✅ collection-instance empties (verified) |
| Membership | strictly one parent | object may be in several collections; collection may have several parents (DAG) |
| Custom properties | ✅ | ✅ (verified, survives reload) |
| Transform inheritance pitfalls | scale/rotation on the container silently affects all children | none — collections don't transform |
| Selection/export grouping | manual | natural (collection.all_objects) |
Recommendation (matches how architects use Blender and how the glTF importer builds scenes):
- Site / Building / Level → Collections (
Site,Site/Building-A… as nested collections). These are organizational nodes; giving them transforms via empties invites accidental scaling of a whole building. Storepascal_id/pascal_typeprops on the collection. - If a Pascal container node carries a non-identity transform (e.g. a building rotated on the site), add one Empty per transformed container as the parent of that container's objects (empty display type
PLAIN_AXES, default size 1.0 — verified), inside its collection: collection = grouping, empty = transform. The glTF importer itself represents intermediate scene-graph nodes as empties, so this is precedented. - Repeated subtrees → collection instances (empty with
instance_type='COLLECTION'). - Levels one wants to solo/hide get that for free via layer-collection
exclude(persists; verified).
Bundled importer io_scene_gltf2 (4.5.3) — blender/com/extras.py:
- Import:
set_extras(blender_element, extras)copies every key of a node/mesh/material/camera/light/sceneextrasdict directly into custom properties (blender_element[key] = value); values that fail assignment (e.g. heterogeneous arrays, §1.4) are stringified as fallback (str(value)) rather than dropped. Applied to Objects (node extras), Meshes (mesh extras, excludingtargetNames), Materials, Lights, Cameras, Scene, and even edit/pose bones. Scene-extras import is gated by animport_scene_extrasoption (default on); node/mesh extras are imported unconditionally. - Export:
generate_extras()walkselement.keys(), skips aBLACK_LIST(internal keys likecycles,glTF2ExportSettings), convertsIDPropertyArray → to_list(),IDPropertyGroup → to_dict()(with a JSON-convertibility check), and emits them asextras. Gated by theexport_extrasoption ("Custom Properties", default off).
Pascal should mirror this design, with the same defensive moves:
def apply_pascal_node(db, node): # db: Object/Mesh/Collection/Material
db['pascal_id'] = node['id'] # identity — never in the name
db['pascal_type'] = node['type']
try:
db['pascal_params'] = node['params'] # pretty mirror (dict idprop)
except (TypeError, OverflowError, KeyError): # §1.4 mismatches
db['pascal_params_json'] = json.dumps(node['params'], ensure_ascii=False)plus a reserved-prefix blacklist (pascal_*) so user-added custom props are ignored by the exporter, and the verbatim source (§2) as the ultimate fallback.
scene.unit_settings ← METRIC / 1.0 / METERS; convert axes into data (§5.2).- Write pretty-printed source JSON to Text datablock
pascal_source.json(from_string; never single-line — 13 s vs 4 ms at 1 MB); store sha256 + schema version as Scene props. - Create Collections for containers, Objects/Meshes for elements; link everything under
scene.collection(zero-user purge!). - Stamp
pascal_id/pascal_type/pascal_paramson every created datablock; validate keys ≤ 63 chars, ints < 2³¹, no heterogeneous arrays — fall back to*_jsonstring props. - Add
id_properties_uimetadata (units, min/max, descriptions) on scalar params you want editable — it persists and gives real sliders. - Never read identity from names; export by scanning
pascal_id.