@threenative/core 0.3.3 → 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.
- package/capabilities.json +555 -21
- package/dist/{assets-CYKk2WTu.d.ts → assets-CqvE429w.d.ts} +45 -3
- package/dist/{canvas-layer-C1SnMoJ-.d.ts → canvas-layer-DDmC_VVF.d.ts} +1 -1
- package/dist/{game-D_6r-k4Y.d.ts → game-CljaDv4D.d.ts} +144 -10
- package/dist/{gpu-readback-CMklJs6r.d.ts → gpu-readback-CqJEfWNQ.d.ts} +16 -6
- package/dist/hot.d.ts +4 -4
- package/dist/index.d.ts +467 -35
- package/dist/index.js +3208 -257
- package/dist/playtest.d.ts +11 -5
- package/dist/playtest.js +55 -5
- package/dist/react.d.ts +2 -2
- package/dist/{renderer-Cy4qeBOA.d.ts → renderer-CfsS2hxi.d.ts} +190 -2
- package/dist/ui-layer.d.ts +5 -3
- package/dist/world.d.ts +900 -14
- package/dist/world.js +10876 -126
- package/gpl/fixtures/make_world_fixture.py +184 -0
- package/gpl/recipes/_common.py +11 -0
- package/gpl/recipes/decimate.py +11 -5
- package/gpl/recipes/export_world.py +682 -0
- package/mcp/blender-server.mjs +55 -1
- package/mcp/engine-server.mjs +54 -20
- package/mcp/servers.mjs +3 -3
- package/package.json +6 -6
- package/patches/three@0.185.1.patch +2101 -138
- package/scripts/apply-three-patch.mjs +61 -37
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
# Build the tiny, deterministic, CC0-primitives-only world that the export_world spec runs. It
|
|
10
|
+
# exercises every convention the recipe understands: a marked terrain, two tn_asset_id scatter
|
|
11
|
+
# sources and a ground-cover asset with tn_max_distance, a render-density scatter switched by an
|
|
12
|
+
# Is Viewport node, and a tn_world_chunk collection straddling a cell boundary.
|
|
13
|
+
#
|
|
14
|
+
# blender -b --factory-startup --python make_world_fixture.py -- --out /tmp/world.blend
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
import bpy
|
|
21
|
+
from mathutils import Matrix
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fail(message):
|
|
25
|
+
sys.stderr.write("TN_BLENDER_ERROR: %s\n" % message)
|
|
26
|
+
sys.stderr.flush()
|
|
27
|
+
os._exit(2)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_args():
|
|
31
|
+
argv = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
|
32
|
+
payload = {}
|
|
33
|
+
index = 0
|
|
34
|
+
while index < len(argv):
|
|
35
|
+
token = argv[index]
|
|
36
|
+
if token.startswith("--"):
|
|
37
|
+
key = token[2:]
|
|
38
|
+
if "=" in key:
|
|
39
|
+
key, value = key.split("=", 1)
|
|
40
|
+
else:
|
|
41
|
+
index += 1
|
|
42
|
+
if index >= len(argv):
|
|
43
|
+
fail("'--%s' needs a value" % key)
|
|
44
|
+
value = argv[index]
|
|
45
|
+
payload[key] = value
|
|
46
|
+
index += 1
|
|
47
|
+
return payload
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def displace(terrain):
|
|
51
|
+
for vertex in terrain.data.vertices:
|
|
52
|
+
x, y = vertex.co.x, vertex.co.y
|
|
53
|
+
vertex.co.z = (
|
|
54
|
+
3.0 * math.sin(x * 0.11) * math.cos(y * 0.13)
|
|
55
|
+
+ 1.5 * math.sin(x * 0.031 + y * 0.047)
|
|
56
|
+
+ 0.5 * math.cos((x + y) * 0.21)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def scatter_group(asset, render_density, viewport_density, seed):
|
|
61
|
+
group = bpy.data.node_groups.new("TNScatter_%s" % asset.name, "GeometryNodeTree")
|
|
62
|
+
group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
|
|
63
|
+
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
|
64
|
+
source = group.nodes.new("NodeGroupInput")
|
|
65
|
+
output = group.nodes.new("NodeGroupOutput")
|
|
66
|
+
distribute = group.nodes.new("GeometryNodeDistributePointsOnFaces")
|
|
67
|
+
distribute.distribute_method = "RANDOM"
|
|
68
|
+
distribute.inputs["Seed"].default_value = seed
|
|
69
|
+
instance = group.nodes.new("GeometryNodeInstanceOnPoints")
|
|
70
|
+
info = group.nodes.new("GeometryNodeObjectInfo")
|
|
71
|
+
info.inputs["Object"].default_value = asset
|
|
72
|
+
# Object-level instancing: the depsgraph reports the asset object itself for every point, which
|
|
73
|
+
# is what lets the exporter read tn_asset_id off `instance_object`. Realised geometry instances
|
|
74
|
+
# report the emitter instead, and the asset id is lost.
|
|
75
|
+
info.inputs["As Instance"].default_value = True
|
|
76
|
+
is_viewport = group.nodes.new("GeometryNodeIsViewport")
|
|
77
|
+
switch = group.nodes.new("GeometryNodeSwitch")
|
|
78
|
+
switch.input_type = "FLOAT"
|
|
79
|
+
switch.inputs["False"].default_value = render_density
|
|
80
|
+
switch.inputs["True"].default_value = viewport_density
|
|
81
|
+
group.links.new(source.outputs["Geometry"], distribute.inputs["Mesh"])
|
|
82
|
+
group.links.new(is_viewport.outputs["Is Viewport"], switch.inputs["Switch"])
|
|
83
|
+
group.links.new(switch.outputs["Output"], distribute.inputs["Density"])
|
|
84
|
+
group.links.new(distribute.outputs["Points"], instance.inputs["Points"])
|
|
85
|
+
group.links.new(info.outputs["Geometry"], instance.inputs["Instance"])
|
|
86
|
+
group.links.new(instance.outputs["Instances"], output.inputs["Geometry"])
|
|
87
|
+
return group
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def make_emitter(asset, render_density, viewport_density, seed):
|
|
91
|
+
bpy.ops.mesh.primitive_grid_add(x_subdivisions=8, y_subdivisions=8, size=200, location=(0, 0, 0))
|
|
92
|
+
emitter = bpy.context.object
|
|
93
|
+
emitter.name = "scatter_%s" % asset.name
|
|
94
|
+
modifier = emitter.modifiers.new("TNScatter", "NODES")
|
|
95
|
+
modifier.node_group = scatter_group(asset, render_density, viewport_density, seed)
|
|
96
|
+
return emitter
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def move_to_collection(instance, collection):
|
|
100
|
+
for existing in list(instance.users_collection):
|
|
101
|
+
existing.objects.unlink(instance)
|
|
102
|
+
collection.objects.link(instance)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main():
|
|
106
|
+
payload = parse_args()
|
|
107
|
+
out = payload.get("out")
|
|
108
|
+
if not isinstance(out, str) or not out:
|
|
109
|
+
fail("make_world_fixture requires an '--out' .blend path")
|
|
110
|
+
out = os.path.abspath(out)
|
|
111
|
+
directory = os.path.dirname(out)
|
|
112
|
+
if directory and not os.path.isdir(directory):
|
|
113
|
+
os.makedirs(directory, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
116
|
+
|
|
117
|
+
bpy.ops.mesh.primitive_grid_add(x_subdivisions=64, y_subdivisions=64, size=256)
|
|
118
|
+
terrain = bpy.context.object
|
|
119
|
+
terrain.name = "terrain"
|
|
120
|
+
displace(terrain)
|
|
121
|
+
terrain["tn_world_terrain"] = 1
|
|
122
|
+
|
|
123
|
+
bpy.ops.mesh.primitive_cone_add(vertices=8, radius1=1.0, radius2=0.0, depth=4.0)
|
|
124
|
+
pine = bpy.context.object
|
|
125
|
+
pine.name = "pine"
|
|
126
|
+
pine["tn_asset_id"] = "pine"
|
|
127
|
+
make_emitter(pine, 0.02, 0.002, 7)
|
|
128
|
+
|
|
129
|
+
bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=1.0)
|
|
130
|
+
rock = bpy.context.object
|
|
131
|
+
rock.name = "rock"
|
|
132
|
+
rock["tn_asset_id"] = "rock"
|
|
133
|
+
make_emitter(rock, 0.02, 0.002, 11)
|
|
134
|
+
|
|
135
|
+
bpy.ops.mesh.primitive_circle_add(vertices=8, radius=0.5, fill_type="NGON")
|
|
136
|
+
cover = bpy.context.object
|
|
137
|
+
cover.name = "ground_cover"
|
|
138
|
+
cover["tn_asset_id"] = "ground_cover"
|
|
139
|
+
cover["tn_max_distance"] = 30.0
|
|
140
|
+
make_emitter(cover, 0.05, 0.005, 13)
|
|
141
|
+
|
|
142
|
+
# Real worlds keep scatter sources in an excluded collection so only their instances show.
|
|
143
|
+
sources = bpy.data.collections.new("_src")
|
|
144
|
+
bpy.context.scene.collection.children.link(sources)
|
|
145
|
+
for source in (pine, cover):
|
|
146
|
+
move_to_collection(source, sources)
|
|
147
|
+
bpy.context.view_layer.layer_collection.children["_src"].exclude = True
|
|
148
|
+
|
|
149
|
+
# Other real worlds nest a source in a visible collection under a hidden one, which is a
|
|
150
|
+
# different shape from an excluded collection: the object is in the view layer, so the glTF
|
|
151
|
+
# exporter skips it for not being in any visible collection unless the recipe stages it.
|
|
152
|
+
hidden = bpy.data.collections.new("hidden_sources")
|
|
153
|
+
hidden.hide_viewport = True
|
|
154
|
+
hidden.hide_render = True
|
|
155
|
+
bpy.context.scene.collection.children.link(hidden)
|
|
156
|
+
nested = bpy.data.collections.new("_src nested")
|
|
157
|
+
hidden.children.link(nested)
|
|
158
|
+
move_to_collection(rock, nested)
|
|
159
|
+
|
|
160
|
+
yard = bpy.data.collections.new("yard")
|
|
161
|
+
yard["tn_world_chunk"] = 1
|
|
162
|
+
bpy.context.scene.collection.children.link(yard)
|
|
163
|
+
|
|
164
|
+
bpy.ops.mesh.primitive_cube_add(size=2, location=(-100, 0, 1))
|
|
165
|
+
first = bpy.context.object
|
|
166
|
+
first.name = "yard_crate_a"
|
|
167
|
+
move_to_collection(first, yard)
|
|
168
|
+
|
|
169
|
+
bpy.ops.mesh.primitive_cube_add(size=2, location=(-30, 20, 1))
|
|
170
|
+
second = bpy.context.object
|
|
171
|
+
second.name = "yard_crate_b"
|
|
172
|
+
move_to_collection(second, yard)
|
|
173
|
+
|
|
174
|
+
# A unit-scale sanity anchor: this object crosses no convention and must not disturb a count.
|
|
175
|
+
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 3))
|
|
176
|
+
anchor = bpy.context.object
|
|
177
|
+
anchor.name = "anchor"
|
|
178
|
+
anchor.matrix_world = Matrix.Identity(4)
|
|
179
|
+
|
|
180
|
+
bpy.ops.wm.save_as_mainfile(filepath=out)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|
package/gpl/recipes/_common.py
CHANGED
|
@@ -81,6 +81,17 @@ def uv_layers():
|
|
|
81
81
|
return sum(len(item.data.uv_layers) for item in meshes())
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def collapse_decimate(item, ratio):
|
|
85
|
+
"""Add the collapse-decimate modifier `decimate` uses, so other recipes reuse that recipe's
|
|
86
|
+
operation instead of copying it. It measures nothing and writes nothing; the caller decides
|
|
87
|
+
what to export."""
|
|
88
|
+
modifier = item.modifiers.new(name="TNDecimate", type="DECIMATE")
|
|
89
|
+
modifier.decimate_type = "COLLAPSE"
|
|
90
|
+
modifier.ratio = float(ratio)
|
|
91
|
+
modifier.use_collapse_triangulate = True
|
|
92
|
+
return modifier
|
|
93
|
+
|
|
94
|
+
|
|
84
95
|
def export(out):
|
|
85
96
|
directory = os.path.dirname(out)
|
|
86
97
|
if directory and not os.path.isdir(directory):
|
package/gpl/recipes/decimate.py
CHANGED
|
@@ -23,7 +23,16 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
23
23
|
|
|
24
24
|
import bpy # noqa: E402
|
|
25
25
|
|
|
26
|
-
from _common import
|
|
26
|
+
from _common import (
|
|
27
|
+
collapse_decimate,
|
|
28
|
+
emit,
|
|
29
|
+
export,
|
|
30
|
+
fail,
|
|
31
|
+
load,
|
|
32
|
+
meshes,
|
|
33
|
+
request,
|
|
34
|
+
triangles,
|
|
35
|
+
) # noqa: E402
|
|
27
36
|
|
|
28
37
|
payload = request()
|
|
29
38
|
source = payload.get("source")
|
|
@@ -43,10 +52,7 @@ if before == 0:
|
|
|
43
52
|
fail("'%s' has no triangles to decimate" % source)
|
|
44
53
|
|
|
45
54
|
for item in meshes():
|
|
46
|
-
|
|
47
|
-
modifier.decimate_type = "COLLAPSE"
|
|
48
|
-
modifier.ratio = float(ratio)
|
|
49
|
-
modifier.use_collapse_triangulate = True
|
|
55
|
+
collapse_decimate(item, ratio)
|
|
50
56
|
|
|
51
57
|
after = triangles()
|
|
52
58
|
export(out)
|