@threenative/core 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,682 @@
1
+ # SPDX-License-Identifier: GPL-2.0-or-later
2
+ #
3
+ # Copyright (C) 2026 ThreeNative contributors
4
+ #
5
+ # This program is free software; you can redistribute it and/or modify it under the terms of the
6
+ # GNU General Public License as published by the Free Software Foundation; either version 2 of the
7
+ # License, or (at your option) any later version. See ../LICENSE.GPL.
8
+ #
9
+ # Export a Blender-authored world as a ThreeNative world package v1: world.json, a raw uint16
10
+ # heightmap, one GLB per referenced asset (plus a decimated LOD1), cell-split chunk GLBs and a
11
+ # placements.bin of instance transforms. The package format is binding and defined in
12
+ # packages/core/src/world-package.ts; this recipe only writes it.
13
+ #
14
+ # It never picks a look: materials, colours, lights and curves come from the .blend untouched.
15
+ #
16
+ # blender -b world.blend --python export_world.py -- --out /tmp/world --cell 64
17
+ # blender --background --factory-startup --python export_world.py -- \
18
+ # '{"source":"/abs/world.blend","out":"/abs/world","cell":64,"spacing":2}'
19
+ #
20
+ # Conventions, all custom properties: `tn_world_terrain` marks the heightmap mesh, `tn_asset_id`
21
+ # names a scatter source (the object name is the warned fallback), `tn_max_distance` is an asset's
22
+ # cull distance, `tn_lod_distance` overrides the default 60 m LOD1 range, and a collection marked
23
+ # `tn_world_chunk` exports as per-cell chunk GLBs. Hidden, excluded and `_`-prefixed collections are
24
+ # skipped, and so is anything nested under one. Adapt it: the cell size, the LOD ratio and the asset
25
+ # conventions are the parts a game usually wants to change.
26
+
27
+ import array
28
+ import json
29
+ import math
30
+ import os
31
+ import shutil
32
+ import struct
33
+ import sys
34
+
35
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
36
+
37
+ import bpy # noqa: E402
38
+ from mathutils import Matrix # noqa: E402
39
+ from mathutils.bvhtree import BVHTree # noqa: E402
40
+
41
+ from _common import collapse_decimate, emit, fail, load, meshes # noqa: E402
42
+
43
+ RESULT_RECORD_FLOATS = 8
44
+ HEIGHTMAP_MAX = 65535
45
+ LOD_RATIO_DEFAULT = 0.25
46
+ LOD_DISTANCE_DEFAULT = 60.0
47
+ CAPTURE_ENGINE = "TN_WORLD_EXPORT_CAPTURE"
48
+
49
+
50
+ def parse_request():
51
+ """Accept the JSON request the recipe runner sends, or plain `--key value` flags.
52
+
53
+ `blender -b world.blend --python export_world.py -- --out <dir> --cell <m>` opens the .blend
54
+ first; the same script through `blender_run_python` gets one JSON argument instead.
55
+ """
56
+ tail = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
57
+ if len(tail) == 1:
58
+ try:
59
+ value = json.loads(tail[0])
60
+ except ValueError:
61
+ value = None
62
+ if isinstance(value, dict):
63
+ return value
64
+ payload = {}
65
+ index = 0
66
+ while index < len(tail):
67
+ token = tail[index]
68
+ if token.startswith("--"):
69
+ key = token[2:]
70
+ if "=" in key:
71
+ key, value = key.split("=", 1)
72
+ else:
73
+ index += 1
74
+ if index >= len(tail):
75
+ fail("'--%s' needs a value" % key)
76
+ value = tail[index]
77
+ payload[key] = value
78
+ index += 1
79
+ return payload
80
+
81
+
82
+ def number(payload, key, default=None):
83
+ raw = payload.get(key, default)
84
+ if raw is None:
85
+ fail("export_world requires a '%s' argument" % key)
86
+ try:
87
+ value = float(raw)
88
+ except (TypeError, ValueError):
89
+ fail("'%s' must be a number, got %r" % (key, raw))
90
+ if not math.isfinite(value) or value <= 0:
91
+ fail("'%s' must be greater than zero, got %r" % (key, raw))
92
+ return value
93
+
94
+
95
+ def package_path(out, relative):
96
+ """A package-relative path, refused when it resolves outside `out`.
97
+
98
+ Asset ids and collection names are authored in a .blend and become file names here, so
99
+ `../../escape` is a name like any other until it is joined. Resolve symlinks as well as `..`,
100
+ or a planted link under `out` writes through it and absolute containment sees nothing wrong.
101
+ Refuse it and name the culprit.
102
+ """
103
+ root = os.path.realpath(out)
104
+ resolved = os.path.realpath(os.path.join(out, relative))
105
+ if os.path.commonpath((root, resolved)) != root:
106
+ fail("'%s' would write outside the output directory" % relative)
107
+ return resolved
108
+
109
+
110
+ def object_bounds_gltf(obj):
111
+ vertices = obj.data.vertices
112
+ if len(vertices) == 0:
113
+ fail("asset '%s' has no vertices to export" % obj.name)
114
+ xs = [vertex.co.x for vertex in vertices]
115
+ ys = [vertex.co.z for vertex in vertices]
116
+ zs = [-vertex.co.y for vertex in vertices]
117
+ return {"max": [max(xs), max(ys), max(zs)], "min": [min(xs), min(ys), min(zs)]}
118
+
119
+
120
+ def extract(depsgraph, data):
121
+ """Read everything that needs the render-mode depsgraph into plain Python values.
122
+
123
+ The depsgraph handed to `render()` is only valid for the duration of that call, so nothing
124
+ that outlives it (objects, meshes) is kept — only tuples and numbers.
125
+ """
126
+ terrain = next(
127
+ (
128
+ obj
129
+ for obj in bpy.data.objects
130
+ if obj.type == "MESH" and obj.get("tn_world_terrain") is not None
131
+ ),
132
+ None,
133
+ )
134
+ if terrain is None:
135
+ fail("no mesh carries the 'tn_world_terrain' custom property")
136
+
137
+ evaluated = terrain.evaluated_get(depsgraph)
138
+ mesh = evaluated.to_mesh()
139
+ world = evaluated.matrix_world
140
+ blender_vertices = [world @ vertex.co for vertex in mesh.vertices]
141
+ polygons = [tuple(polygon.vertices) for polygon in mesh.polygons]
142
+ bvh = BVHTree.FromPolygons(
143
+ [tuple(vertex) for vertex in blender_vertices], polygons, all_triangles=False
144
+ )
145
+ evaluated.to_mesh_clear()
146
+
147
+ min_x = min(vertex.x for vertex in blender_vertices)
148
+ max_x = max(vertex.x for vertex in blender_vertices)
149
+ max_z = max(-vertex.y for vertex in blender_vertices)
150
+ min_z = min(-vertex.y for vertex in blender_vertices)
151
+ max_blender_z = max(vertex.z for vertex in blender_vertices)
152
+ min_blender_z = min(vertex.z for vertex in blender_vertices)
153
+
154
+ spacing = data["spacing"]
155
+ span_x = max_x - min_x
156
+ span_z = max_z - min_z
157
+ columns = max(2, int(math.ceil(span_x / spacing)) + 1)
158
+ rows = max(2, int(math.ceil(span_z / spacing)) + 1)
159
+ size_x = (columns - 1) * spacing
160
+ size_z = (rows - 1) * spacing
161
+
162
+ origin_z = max_blender_z + max(1.0, (max_blender_z - min_blender_z) * 0.01)
163
+ reach = origin_z - min_blender_z + spacing * 2.0 + 1.0
164
+ # A ray exactly on the boundary can miss a mesh that ends there; nudge the outermost samples
165
+ # inward by a thousandth of a cell so edge vertices read the edge height instead of the floor.
166
+ epsilon = spacing * 1e-3
167
+ heights = []
168
+ misses = 0
169
+ for row in range(rows):
170
+ gltf_z = min(max(min_z + row * spacing, min_z + epsilon), max_z - epsilon)
171
+ blender_y = -gltf_z
172
+ for column in range(columns):
173
+ gltf_x = min(max(min_x + column * spacing, min_x + epsilon), max_x - epsilon)
174
+ location = bvh.ray_cast((gltf_x, blender_y, origin_z), (0.0, 0.0, -1.0), reach)[0]
175
+ if location is None:
176
+ misses += 1
177
+ heights.append(min_blender_z)
178
+ else:
179
+ heights.append(location.z)
180
+ if misses:
181
+ sys.stderr.write(
182
+ "TN_BLENDER_WARNING: %d terrain grid samples missed the mesh; clamped to its floor\n"
183
+ % misses
184
+ )
185
+
186
+ height_min = min(heights)
187
+ height_max = max(heights)
188
+ if height_max - height_min < 1e-6:
189
+ height_max = height_min + 1.0
190
+
191
+ cell_size = data["cell"]
192
+ max_cell_x = max(1, int(math.ceil(size_x / cell_size)))
193
+ max_cell_z = max(1, int(math.ceil(size_z / cell_size)))
194
+
195
+ asset_objects = {}
196
+ by_asset = {}
197
+ cell_records = {}
198
+ warned_missing = set()
199
+ warned_scale = set()
200
+ for instance in depsgraph.object_instances:
201
+ if not instance.is_instance:
202
+ continue
203
+ source = instance.instance_object
204
+ if source is None:
205
+ continue
206
+ original = getattr(source, "original", None) or source
207
+ asset_id = original.get("tn_asset_id")
208
+ if asset_id is None:
209
+ asset_id = source.name
210
+ if asset_id not in warned_missing:
211
+ warned_missing.add(asset_id)
212
+ sys.stderr.write(
213
+ "TN_BLENDER_WARNING: instanced object '%s' has no tn_asset_id; using its name\n"
214
+ % asset_id
215
+ )
216
+ if not isinstance(asset_id, str) or not asset_id:
217
+ fail("an instance resolved to an empty asset id")
218
+ asset_objects.setdefault(asset_id, source.name)
219
+
220
+ matrix = instance.matrix_world
221
+ translation = matrix.translation
222
+ gltf_x, gltf_y, gltf_z = translation.x, translation.z, -translation.y
223
+ rotation = matrix.to_quaternion()
224
+ quaternion = (rotation.x, rotation.z, -rotation.y, rotation.w)
225
+ axis_scale = matrix.to_scale()
226
+ uniform = (axis_scale.x + axis_scale.y + axis_scale.z) / 3.0
227
+ if uniform > 0:
228
+ deviation = max(
229
+ abs(axis_scale.x - uniform),
230
+ abs(axis_scale.y - uniform),
231
+ abs(axis_scale.z - uniform),
232
+ )
233
+ if deviation / uniform > 0.01 and asset_id not in warned_scale:
234
+ warned_scale.add(asset_id)
235
+ sys.stderr.write(
236
+ "TN_BLENDER_WARNING: asset '%s' has a non-uniform instance scale; using the mean\n"
237
+ % asset_id
238
+ )
239
+ cell_x = min(max_cell_x - 1, max(0, int(math.floor((gltf_x - min_x) / cell_size))))
240
+ cell_z = min(max_cell_z - 1, max(0, int(math.floor((gltf_z - min_z) / cell_size))))
241
+ record = (
242
+ gltf_x,
243
+ gltf_y,
244
+ gltf_z,
245
+ quaternion[0],
246
+ quaternion[1],
247
+ quaternion[2],
248
+ quaternion[3],
249
+ uniform,
250
+ )
251
+ cell_records.setdefault((cell_x, cell_z), {}).setdefault(asset_id, []).append(record)
252
+ by_asset[asset_id] = by_asset.get(asset_id, 0) + 1
253
+
254
+ data.update(
255
+ {
256
+ "asset_objects": asset_objects,
257
+ "by_asset": by_asset,
258
+ "cell_records": cell_records,
259
+ "columns": columns,
260
+ "extent": {"minX": min_x, "minZ": min_z, "sizeX": size_x, "sizeZ": size_z},
261
+ "height_max": height_max,
262
+ "height_min": height_min,
263
+ "heights": heights,
264
+ "max_cell_x": max_cell_x,
265
+ "max_cell_z": max_cell_z,
266
+ "rows": rows,
267
+ }
268
+ )
269
+
270
+
271
+ def capture_render_depsgraph(data):
272
+ """Run one render through a throwaway engine, whose `render(self, depsgraph)` receives the
273
+ depsgraph built at render settings. `evaluated_depsgraph_get()` is viewport mode, and viewport
274
+ "share" tricks would otherwise leak into an export that must use render density."""
275
+
276
+ class RenderCapture(bpy.types.RenderEngine):
277
+ bl_idname = CAPTURE_ENGINE
278
+ bl_label = "TN World Export Capture"
279
+
280
+ def render(self, depsgraph):
281
+ extract(depsgraph, data)
282
+
283
+ scene = bpy.context.scene
284
+ temporary_camera = None
285
+ if scene.camera is None:
286
+ camera_data = bpy.data.cameras.new("TNWorldExportCamera")
287
+ temporary_camera = bpy.data.objects.new("TNWorldExportCamera", camera_data)
288
+ scene.collection.objects.link(temporary_camera)
289
+ scene.camera = temporary_camera
290
+ previous_engine = scene.render.engine
291
+ bpy.utils.register_class(RenderCapture)
292
+ try:
293
+ scene.render.engine = CAPTURE_ENGINE
294
+ try:
295
+ bpy.ops.render.render(write_still=False)
296
+ finally:
297
+ scene.render.engine = previous_engine
298
+ finally:
299
+ bpy.utils.unregister_class(RenderCapture)
300
+ if temporary_camera is not None:
301
+ bpy.data.objects.remove(temporary_camera, do_unlink=True)
302
+ if "extent" not in data:
303
+ fail("could not obtain a render-mode depsgraph")
304
+
305
+
306
+ def glb_mesh_count(path):
307
+ """Meshes in a written GLB, read from its JSON chunk. Zero means no geometry was exported."""
308
+ with open(path, "rb") as handle:
309
+ data = handle.read()
310
+ offset = 12
311
+ while offset + 8 <= len(data):
312
+ length, chunk_type = struct.unpack_from("<II", data, offset)
313
+ offset += 8
314
+ if chunk_type == 0x4E4F534A:
315
+ chunk = data[offset : offset + length].rstrip(b"\x00 ")
316
+ return len(json.loads(chunk.decode("utf-8")).get("meshes", []))
317
+ offset += length
318
+ return 0
319
+
320
+
321
+ def write_glb(path, objects):
322
+ directory = os.path.dirname(path)
323
+ if directory and not os.path.isdir(directory):
324
+ os.makedirs(directory, exist_ok=True)
325
+ for candidate in bpy.context.view_layer.objects:
326
+ candidate.select_set(False)
327
+ # A scatter source can sit in an excluded collection, or in a visible collection nested under
328
+ # a hidden one. The glTF exporter skips an object that is in neither a visible collection nor
329
+ # an excluded-but-selected one, and Blender refuses to select an object outside the view
330
+ # layer, so relink every object into a temporary visible collection for the export only and
331
+ # put it back where it came from afterwards.
332
+ staging = bpy.data.collections.new("_tn_export_staging")
333
+ bpy.context.scene.collection.children.link(staging)
334
+ previous = []
335
+ for item in objects:
336
+ previous.append((item, list(item.users_collection)))
337
+ for collection in list(item.users_collection):
338
+ collection.objects.unlink(item)
339
+ staging.objects.link(item)
340
+ bpy.context.view_layer.update()
341
+ try:
342
+ for item in objects:
343
+ item.hide_set(False)
344
+ item.hide_viewport = False
345
+ item.hide_render = False
346
+ item.select_set(True)
347
+ bpy.context.view_layer.objects.active = objects[0]
348
+ bpy.ops.export_scene.gltf(
349
+ filepath=path,
350
+ export_apply=True,
351
+ export_format="GLB",
352
+ export_yup=True,
353
+ use_selection=True,
354
+ )
355
+ finally:
356
+ for item, collections in previous:
357
+ for collection in collections:
358
+ collection.objects.link(item)
359
+ bpy.data.collections.remove(staging)
360
+ if not os.path.isfile(path):
361
+ fail("export wrote no file at '%s'" % path)
362
+ if glb_mesh_count(path) < 1:
363
+ fail("export of '%s' wrote no geometry; its source has no exportable mesh" % path)
364
+
365
+
366
+ def skipped_collections(layer, under_hidden=False):
367
+ """Collection names the recipe must skip, ancestors included.
368
+
369
+ A collection nested under a hidden or excluded one is hidden too, so the chain is walked from
370
+ the view layer down: the rule is about what a player can see, not about one datablock's flags.
371
+ """
372
+ skipped = set()
373
+ for child in layer.children:
374
+ collection = child.collection
375
+ hidden = (
376
+ under_hidden
377
+ or child.exclude
378
+ or child.hide_viewport
379
+ or collection.name.startswith("_")
380
+ or collection.hide_viewport
381
+ or collection.hide_render
382
+ )
383
+ if hidden:
384
+ skipped.add(collection.name)
385
+ skipped |= skipped_collections(child, hidden)
386
+ return skipped
387
+
388
+
389
+ def terrain_layer_texture(table_dir, textures, layer_id, kind):
390
+ """The source file of one texture set's map, searched in the table's `textures.search` dirs."""
391
+ name = textures[kind].replace("{id}", layer_id)
392
+ for relative in textures.get("search", ["."]):
393
+ base = os.path.normpath(os.path.join(table_dir, relative))
394
+ for root, _subdirs, files in os.walk(base):
395
+ if name in files:
396
+ return os.path.join(root, name)
397
+ fail("terrain layers: texture '%s' not found under %s" % (name, textures.get("search", ["."])))
398
+
399
+
400
+ def terrain_layer_mask(path, size):
401
+ """A mask image as raw bytes, read through Blender (Non-Color, so values are not converted).
402
+
403
+ Blender stores pixels bottom-up, which is the package's splat row order.
404
+ """
405
+ import numpy as np
406
+
407
+ image = bpy.data.images.load(path, check_existing=False)
408
+ try:
409
+ image.colorspace_settings.name = "Non-Color"
410
+ if tuple(image.size) != (size, size):
411
+ image.scale(size, size)
412
+ pixels = np.empty(size * size * 4, np.float32)
413
+ image.pixels.foreach_get(pixels)
414
+ finally:
415
+ bpy.data.images.remove(image)
416
+ return np.clip(pixels.reshape(size, size, 4) * 255.0 + 0.5, 0, 255).astype(np.uint8)
417
+
418
+
419
+ def export_terrain_layers(out, table_path, root=None):
420
+ """Ship a game's terrain material table into the package, for `loadTerrainSplat`.
421
+
422
+ The table (the game's; its DCC shader can read the same file) names its masks, texture sets and
423
+ blend values. This writes `terrain/layers.json` (the table plus the splat layout),
424
+ `terrain/splat.rgba8` (masks packed into RGBA8 planes, raw so no cook converts them) and
425
+ `terrain/tex/<id>_diff.jpg` / `<id>_nrm.jpg`, and returns the `terrain.layers` paths.
426
+ Relative paths in the table resolve against `root` when given, else the table's folder.
427
+ """
428
+ import numpy as np
429
+
430
+ with open(table_path) as handle:
431
+ table = json.load(handle)
432
+ table_dir = os.path.abspath(root) if root else os.path.dirname(os.path.abspath(table_path))
433
+ size = int(table.get("splatSize", 1024))
434
+ masks = table.get("masks", {})
435
+ if not masks:
436
+ fail("terrain layers: the table names no masks")
437
+
438
+ planes, placed = [], {}
439
+ for name, spec in masks.items():
440
+ if spec.get("channels", "rgb") == "rgb":
441
+ planes.append(np.full((size, size, 4), 255, np.uint8))
442
+ rgba = terrain_layer_mask(os.path.join(table_dir, spec["image"]), size)
443
+ planes[-1][:, :, :3] = rgba[:, :, :3]
444
+ placed[name] = [len(planes) - 1, "rgb"]
445
+ free_alpha = list(range(len(planes)))
446
+ for name, spec in masks.items():
447
+ if spec.get("channels", "rgb") == "rgb":
448
+ continue
449
+ value = terrain_layer_mask(os.path.join(table_dir, spec["image"]), size)[:, :, 0]
450
+ if free_alpha:
451
+ plane = free_alpha.pop(0)
452
+ planes[plane][:, :, 3] = value
453
+ placed[name] = [plane, "a"]
454
+ else:
455
+ planes.append(np.full((size, size, 4), 255, np.uint8))
456
+ planes[-1][:, :, 0] = value
457
+ placed[name] = [len(planes) - 1, "r"]
458
+
459
+ terrain = os.path.join(out, "terrain")
460
+ tex = os.path.join(terrain, "tex")
461
+ shutil.rmtree(tex, ignore_errors=True)
462
+ os.makedirs(tex, exist_ok=True)
463
+ with open(os.path.join(terrain, "splat.rgba8"), "wb") as handle:
464
+ for plane in planes:
465
+ handle.write(np.ascontiguousarray(plane).tobytes())
466
+
467
+ textures = table.get("textures", {})
468
+ for layer in [table["base"], *table["layers"]]:
469
+ shutil.copyfile(
470
+ terrain_layer_texture(table_dir, textures, layer["id"], "diff"),
471
+ os.path.join(tex, "%s_diff.jpg" % layer["id"]),
472
+ )
473
+ if layer.get("normal"):
474
+ # `_nrm` is the asset cook's normal-map convention: linear, lossless UASTC.
475
+ shutil.copyfile(
476
+ terrain_layer_texture(table_dir, textures, layer["id"], "nrm"),
477
+ os.path.join(tex, "%s_nrm.jpg" % layer["id"]),
478
+ )
479
+
480
+ shipped = {key: table[key] for key in ("base", "breakup", "macro", "layers")}
481
+ shipped["splat"] = {"masks": placed, "planes": len(planes), "size": size}
482
+ shipped["textures"] = "terrain/tex"
483
+ with open(os.path.join(terrain, "layers.json"), "w") as handle:
484
+ json.dump(shipped, handle, indent=2, sort_keys=True)
485
+ handle.write("\n")
486
+ return {"splat": "terrain/splat.rgba8", "table": "terrain/layers.json"}
487
+
488
+
489
+ def main():
490
+ payload = parse_request()
491
+ out = payload.get("out")
492
+ if not isinstance(out, str) or not out:
493
+ fail("export_world requires an 'out' directory")
494
+ out = os.path.abspath(out)
495
+ terrain_table = payload.get("terrain-layers")
496
+ terrain_root = payload.get("terrain-root") or None
497
+ if payload.get("terrain-only"):
498
+ # Re-ship only the terrain layers into an existing package: no scene, no re-export.
499
+ if not isinstance(terrain_table, str) or not terrain_table:
500
+ fail("export_world terrain-only needs 'terrain-layers'")
501
+ manifest_path = os.path.join(out, "world.json")
502
+ with open(manifest_path) as handle:
503
+ manifest = json.load(handle)
504
+ manifest["terrain"]["layers"] = export_terrain_layers(out, terrain_table, terrain_root)
505
+ with open(manifest_path, "w") as handle:
506
+ json.dump(manifest, handle, indent=2, sort_keys=True)
507
+ handle.write("\n")
508
+ emit({"out": out, "recipe": "export_world", "terrainLayers": manifest["terrain"]["layers"]})
509
+ return
510
+ source = payload.get("source")
511
+ if isinstance(source, str) and source:
512
+ load(source)
513
+ elif not bpy.data.filepath:
514
+ fail("export_world needs a 'source' .blend or an already-open file")
515
+
516
+ for directory in ("terrain", "assets", "chunks"):
517
+ os.makedirs(os.path.join(out, directory), exist_ok=True)
518
+
519
+ data = {"cell": number(payload, "cell"), "spacing": number(payload, "spacing", 2.0)}
520
+ capture_render_depsgraph(data)
521
+
522
+ spacing = data["spacing"]
523
+ columns, rows = data["columns"], data["rows"]
524
+ height_min, height_max = data["height_min"], data["height_max"]
525
+
526
+ heightmap = array.array("H")
527
+ for value in data["heights"]:
528
+ heightmap.append(int(round((value - height_min) / (height_max - height_min) * HEIGHTMAP_MAX)))
529
+ if sys.byteorder != "little":
530
+ heightmap.byteswap()
531
+ with open(os.path.join(out, "terrain", "heightmap.u16"), "wb") as handle:
532
+ handle.write(heightmap.tobytes())
533
+
534
+ asset_objects = data["asset_objects"]
535
+ assets = {}
536
+ for asset_id in sorted(asset_objects):
537
+ obj = bpy.data.objects.get(asset_objects[asset_id])
538
+ if obj is None or obj.type != "MESH":
539
+ fail("asset '%s' resolves no mesh object" % asset_id)
540
+ asset_bounds = object_bounds_gltf(obj)
541
+
542
+ relative = "assets/%s.glb" % asset_id
543
+ saved = obj.matrix_world.copy()
544
+ obj.matrix_world = Matrix.Identity(4)
545
+ bpy.context.view_layer.update()
546
+ write_glb(package_path(out, relative), [obj])
547
+ obj.matrix_world = saved
548
+ bpy.context.view_layer.update()
549
+
550
+ lod_object = obj.copy()
551
+ lod_object.data = obj.data.copy()
552
+ lod_object.name = "%s_lod1" % asset_id
553
+ lod_object.data.name = "%s_lod1" % asset_id
554
+ lod_object.matrix_world = Matrix.Identity(4)
555
+ bpy.context.scene.collection.objects.link(lod_object)
556
+ collapse_decimate(lod_object, obj.get("tn_lod_ratio", LOD_RATIO_DEFAULT))
557
+ lod_relative = "assets/%s_lod1.glb" % asset_id
558
+ write_glb(package_path(out, lod_relative), [lod_object])
559
+ lod_mesh = lod_object.data
560
+ bpy.data.objects.remove(lod_object, do_unlink=True)
561
+ bpy.data.meshes.remove(lod_mesh)
562
+
563
+ asset = {
564
+ "bounds": asset_bounds,
565
+ "glb": relative,
566
+ "lods": [
567
+ {
568
+ "distance": float(obj.get("tn_lod_distance", LOD_DISTANCE_DEFAULT)),
569
+ "glb": lod_relative,
570
+ }
571
+ ],
572
+ }
573
+ max_distance = obj.get("tn_max_distance")
574
+ if max_distance is not None:
575
+ asset["maxDistance"] = float(max_distance)
576
+ assets[asset_id] = asset
577
+
578
+ extent = data["extent"]
579
+ cell_size = data["cell"]
580
+ chunk_cells = {}
581
+ skipped = skipped_collections(bpy.context.view_layer.layer_collection)
582
+ for collection in bpy.data.collections:
583
+ if collection.get("tn_world_chunk") is None or (
584
+ collection.name in skipped
585
+ or collection.name.startswith("_")
586
+ or collection.hide_viewport
587
+ or collection.hide_render
588
+ ):
589
+ continue
590
+ grouped = {}
591
+ for obj in collection.objects:
592
+ if obj.type != "MESH":
593
+ continue
594
+ translation = obj.matrix_world.translation
595
+ gltf_x, gltf_z = translation.x, -translation.y
596
+ cell_x = min(
597
+ data["max_cell_x"] - 1,
598
+ max(0, int(math.floor((gltf_x - extent["minX"]) / cell_size))),
599
+ )
600
+ cell_z = min(
601
+ data["max_cell_z"] - 1,
602
+ max(0, int(math.floor((gltf_z - extent["minZ"]) / cell_size))),
603
+ )
604
+ grouped.setdefault((cell_x, cell_z), []).append(obj)
605
+ for (cell_x, cell_z), objects in grouped.items():
606
+ # ponytail: a chunk is assigned whole to the cell holding its origin; no mesh is clipped
607
+ # across a boundary. Add per-cell clipping when a game's chunk geometry straddles cells
608
+ # at a scale where the overlap costs a visible draw.
609
+ relative = "chunks/%s_%d_%d.glb" % (collection.name, cell_x, cell_z)
610
+ write_glb(package_path(out, relative), objects)
611
+ chunk_cells.setdefault((cell_x, cell_z), []).append(relative)
612
+
613
+ placements = array.array("f")
614
+ placement_records = []
615
+ cells = []
616
+ for cell in sorted(set(data["cell_records"]) | set(chunk_cells)):
617
+ runs = []
618
+ for asset_id in sorted(data["cell_records"].get(cell, {})):
619
+ records = data["cell_records"][cell][asset_id]
620
+ runs.append(
621
+ {"asset": asset_id, "count": len(records), "offset": len(placement_records)}
622
+ )
623
+ placement_records.extend(records)
624
+ entry = {"runs": runs, "x": cell[0], "z": cell[1]}
625
+ if cell in chunk_cells:
626
+ entry["chunks"] = sorted(chunk_cells[cell])
627
+ cells.append(entry)
628
+ for record in placement_records:
629
+ placements.extend(record)
630
+ if sys.byteorder != "little":
631
+ placements.byteswap()
632
+ with open(os.path.join(out, "placements.bin"), "wb") as handle:
633
+ handle.write(placements.tobytes())
634
+
635
+ manifest = {
636
+ "assets": assets,
637
+ "cellSize": cell_size,
638
+ "cells": cells,
639
+ "extent": extent,
640
+ "placements": "placements.bin",
641
+ "terrain": {
642
+ "columns": columns,
643
+ "heightMax": height_max,
644
+ "heightMin": height_min,
645
+ "heightmap": "terrain/heightmap.u16",
646
+ **(
647
+ {"layers": export_terrain_layers(out, terrain_table, terrain_root)}
648
+ if isinstance(terrain_table, str) and terrain_table
649
+ else {}
650
+ ),
651
+ "rows": rows,
652
+ "spacing": spacing,
653
+ },
654
+ "version": 1,
655
+ }
656
+ with open(os.path.join(out, "world.json"), "w") as handle:
657
+ json.dump(manifest, handle, indent=2, sort_keys=True)
658
+ handle.write("\n")
659
+
660
+ viewport_instances = 0
661
+ for instance in bpy.context.evaluated_depsgraph_get().object_instances:
662
+ if instance.is_instance and instance.instance_object is not None:
663
+ viewport_instances += 1
664
+
665
+ emit(
666
+ {
667
+ "assetsWritten": len(assets),
668
+ "cells": len(cells),
669
+ "instances": sum(data["by_asset"].values()),
670
+ "instancesByAsset": data["by_asset"],
671
+ "meshes": len(meshes()),
672
+ "out": out,
673
+ "placementsBytes": len(placements) * 4,
674
+ "recipe": "export_world",
675
+ "source": bpy.data.filepath or (source if isinstance(source, str) else ""),
676
+ "viewportInstances": viewport_instances,
677
+ }
678
+ )
679
+
680
+
681
+ if __name__ == "__main__":
682
+ main()