@bitmagic/cli 0.1.71-dev.1 → 0.1.71-dev.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.
Files changed (40) hide show
  1. package/README.md +15 -6
  2. package/assets/blender-level/README.md +3 -1
  3. package/assets/blender-level/bmedit/__init__.py +26 -0
  4. package/assets/blender-level/bmedit/blender.py +469 -0
  5. package/assets/blender-level/bmedit/colour.py +61 -0
  6. package/assets/blender-level/review.py +42 -6
  7. package/assets/blender-level/review_asset.py +138 -0
  8. package/assets/blender-level/tests/test_bmedit_colour.py +36 -0
  9. package/assets/skills/publishing-to-poki/SKILL.md +14 -5
  10. package/dist/commands/forge.js +10 -2
  11. package/dist/commands/forge.js.map +1 -1
  12. package/dist/commands/publish.js +4 -0
  13. package/dist/commands/publish.js.map +1 -1
  14. package/dist/forge/edit-target.d.ts +3 -0
  15. package/dist/forge/edit-target.js +14 -2
  16. package/dist/forge/edit-target.js.map +1 -1
  17. package/dist/forge/run-pipeline.js +22 -11
  18. package/dist/forge/run-pipeline.js.map +1 -1
  19. package/dist/poki/assets.js +6 -2
  20. package/dist/poki/assets.js.map +1 -1
  21. package/dist/poki/check.js +29 -0
  22. package/dist/poki/check.js.map +1 -1
  23. package/dist/poki/handoff.js +4 -1
  24. package/dist/poki/handoff.js.map +1 -1
  25. package/dist/poki/publish.js +9 -2
  26. package/dist/poki/publish.js.map +1 -1
  27. package/dist/poki/service-urls.d.ts +4 -0
  28. package/dist/poki/service-urls.js +38 -0
  29. package/dist/poki/service-urls.js.map +1 -0
  30. package/dist/poki/source-diagnostics.d.ts +3 -0
  31. package/dist/poki/source-diagnostics.js +32 -3
  32. package/dist/poki/source-diagnostics.js.map +1 -1
  33. package/dist/publish/art-style.d.ts +10 -0
  34. package/dist/publish/art-style.js +17 -0
  35. package/dist/publish/art-style.js.map +1 -0
  36. package/dist/publish/client.d.ts +6 -0
  37. package/dist/publish/client.js.map +1 -1
  38. package/dist/scaffold/blender-level-skill-files.js +1 -0
  39. package/dist/scaffold/blender-level-skill-files.js.map +1 -1
  40. package/package.json +4 -4
package/README.md CHANGED
@@ -1598,10 +1598,17 @@ requests except the intercepted SDK. Local validation uses an SDK test stub and
1598
1598
  pause/ad/resume cycle; it does not certify Poki acceptance. Test the payload folder with the real
1599
1599
  SDK in [Poki Inspector](https://inspector.poki.dev) before submitting. Game-specific paths that
1600
1600
  the startup check does not exercise also need testing. Runtime services such as multiplayer and
1601
- AI cannot be bundled; observed dependencies fail export with diagnostics. The source scan also
1602
- reports `serviceRisks` for known game-data, leaderboard identity, and AI calls, even when they
1603
- sit behind custom chapter menus. These are review hints, not reachability analysis or a complete
1604
- network inventory. Keep the game's features until its creator chooses an integration approach.
1601
+ AI cannot be bundled. Online-only APIs (game data, leaderboard identity, runtime AI/voice,
1602
+ ghost racing and live connections) and known Bitmagic backend URLs in game sources now fail
1603
+ preflight before building, even if they sit behind custom chapter menus. Neither `--force`
1604
+ nor a playtest bypasses this requirement. The scan is conservative: runtime conditions do not
1605
+ exempt online code from the Poki source variant. Keep the game's features until its creator
1606
+ chooses an offline implementation; ordinary Bitmagic publishing is unaffected.
1607
+
1608
+ Game-owned resource APIs such as `fetch`, XHR, workers and beacons require `--poki-playtest`.
1609
+ Exercise their flows, including score submission and later levels. Known backend responses are
1610
+ never vendored as assets, even if an endpoint ends in `.json`. Browser-blocked requests and
1611
+ caught network errors still fail validation. The normal game/SDK layout is preserved.
1605
1612
 
1606
1613
  Use `--poki-playtest playtest/poki.mjs` to extend every desktop/mobile discovery and extracted-ZIP
1607
1614
  check with actual gameplay actions. Create a self-contained ESM module using the game's controls,
@@ -1621,8 +1628,10 @@ export default async ({ frame, platform }) => {
1621
1628
  The callback receives the game iframe and `desktop`/`mobile` platform, has a 30-second timeout,
1622
1629
  and must finish in active gameplay for the pause/ad/resume check. A scenario failure fails export;
1623
1630
  external requests it triggers are subject to the same packaging policy. Its path and SHA-256
1624
- are recorded in the manifest. Without a scenario, the output explicitly reports the limited
1625
- startup coverage. Neither path certifies every gameplay branch or physical-phone behavior.
1631
+ are recorded in the manifest. Without game-owned resource calls, a scenario remains optional
1632
+ and the output explicitly reports limited startup coverage. The source scan and runtime checks
1633
+ are not a complete reachability analysis: neither path certifies every gameplay branch or
1634
+ physical-phone behavior.
1626
1635
 
1627
1636
  The result reports `status: "exported"`, `uploaded: false`, and `handoffPath`. For an authorized
1628
1637
  upload, follow the [official Poki CLI](https://github.com/poki/poki-cli), uploading only `payloadDir`
@@ -13,9 +13,11 @@ skill's step 1 tells an agent how to copy them into a project's `tools/blender-l
13
13
  |---|---|---|
14
14
  | `find_blender.py` | python3 | prints the Blender executable, or how to install one |
15
15
  | `bmlevel/` | Blender, or python3 for a dry run | the authoring vocabulary (`__init__.py`), the bpy-free maths and JSON writers (`core.py`), the Blender backend (`scene_bpy.py`) and the recording backend (`scene_null.py`) |
16
+ | `bmedit/` | Blender (`colour.py`: python3) | edit GLBs and make props: `load(id)` from `build/inputs/<id>.glb`, `paint` / `recolor` / `replace_colour` by face, `box` / `cylinder` / `cone` / `sphere`, `join`, `scatter`, `export(id)` to `build/assets/<id>.glb` — the vocabulary the web Creator's `blender` tool runs on the Asset Forger (`game/agent-docs/blender.md`) |
17
+ | `review_asset.py` | Blender | two three-quarter renders per GLB and a contact sheet: the review for props and edited assets |
16
18
  | `example_level.py` | either | a complete hut-and-courtyard level using every helper; the tests' fixture |
17
19
  | `audit.py` | Blender | ray-cast audit of the COLLIDERS: floor continuity, door and route clearance, sealed envelopes, z-fighting candidates |
18
- | `review.py` | Blender | deterministic review renders (overview, every door, every stair, every room) and a contact sheet; `--shot 'FROM -> TO'` aims a frame anywhere, `--only` renders just those, `--lighting game` swaps the review fill for the engine's interior preset and the level's own lamps |
20
+ | `review.py` | Blender | deterministic review renders (overview, every door, every stair, every room) and a contact sheet; `--shot 'FROM -> TO'` aims a frame anywhere, `--only` renders just those, `--lighting game` swaps the review fill for the engine's interior preset and the level's own lamps, `--engine cycles` renders without an OpenGL context |
19
21
  | `sync_world.py` | python3 | carries the doors, spawn and `terrain.shape: 'none'` into `src/work/world.json` |
20
22
  | `tests/` | python3 | `python3 -m unittest discover -s tests` — no Blender needed; `cli`'s `pnpm test` runs it |
21
23
 
@@ -0,0 +1,26 @@
1
+ """bmedit — edit a game's GLBs and make new low-poly props in a short Blender script.
2
+
3
+ from bmedit import load, palette, recolor, cone, box, join, export
4
+ ground = load('ground') # the game's asset, imported
5
+ palette(ground) # its face colours, most used first
6
+ recolor(ground, '#eef3f7', where=lambda p, n, c: n[1] > 0.6) # snow on everything flat
7
+ export('ground', ground) # replaces the asset, same id
8
+
9
+ post = join([box('post', (0.2, 1.2, 0.2), color='#6b4a2f'),
10
+ box('cap', (0.3, 0.1, 0.3), at=(0, 1.2, 0), color='#4a3220')], 'post')
11
+ export('fence-post', post) # a new asset, 1.3 m tall
12
+
13
+ Positions, sizes and normals are in GAME coordinates — metres, Y up, +Z forward, yaw in radians
14
+ about +Y — the same as bmlevel; the Blender axis flip happens inside. Colours are sRGB: '#rrggbb',
15
+ 0-255 or 0-1 triples. Painting is per face (flat low-poly look), stored as the mesh's COLOR_0.
16
+
17
+ `load()` and `export()` are the only file access a script has. The host decides where they point
18
+ (`configure()`): the Asset Forger's job directory, or the GDK project's `build/` folder.
19
+ """
20
+ try: # inside Blender
21
+ import bpy # noqa: F401
22
+ except ImportError: # plain python3: only bmedit.colour is usable (its tests run here)
23
+ __all__: list[str] = []
24
+ else:
25
+ from bmedit.blender import * # noqa: F401,F403
26
+ from bmedit.blender import __all__, configure, exports # noqa: F401
@@ -0,0 +1,469 @@
1
+ """The Blender half of bmedit: every builder, edit and the export. See __init__.py."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import os
6
+ import random
7
+ import re
8
+ from typing import Callable, Iterable, Optional, Sequence, Union
9
+
10
+ import bmesh
11
+ import bpy
12
+ from mathutils import Matrix, Vector
13
+
14
+ from bmedit.colour import Colour, distance, mix, to_hex, to_linear
15
+
16
+ __all__ = [
17
+ 'load', 'box', 'cylinder', 'cone', 'sphere', 'join', 'copy', 'move', 'remove', 'objects',
18
+ 'paint', 'recolor', 'replace_colour', 'palette', 'decimate', 'scatter', 'bounds', 'describe',
19
+ 'height_at', 'export', 'mix',
20
+ ]
21
+
22
+ MAX_EXPORTS = 8
23
+ _ID = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
24
+ _state: dict = {'inputs': os.path.abspath('build/inputs'), 'out': os.path.abspath('build/assets'), 'exports': []}
25
+
26
+ Objects = Union[bpy.types.Object, Iterable[bpy.types.Object]]
27
+ Vec = Sequence[float]
28
+
29
+
30
+ def configure(inputs_dir: str, out_dir: str) -> None:
31
+ """Where load() reads and export() writes. Called by the host before the script runs."""
32
+ _state['inputs'] = os.path.abspath(inputs_dir)
33
+ _state['out'] = os.path.abspath(out_dir)
34
+ _state['exports'] = []
35
+
36
+
37
+ def exports() -> list[dict]:
38
+ """What the script exported, for the host: [{id, file, targetHeight, triangles}]."""
39
+ return list(_state['exports'])
40
+
41
+
42
+ # ---- coordinates ---------------------------------------------------------------------------
43
+
44
+ def _to_b(p: Vec) -> Vector:
45
+ return Vector((p[0], -p[2], p[1]))
46
+
47
+
48
+ def _to_game(v: Vec) -> tuple[float, float, float]:
49
+ return (v[0], v[2], -v[1])
50
+
51
+
52
+ def _list(objs: Objects) -> list[bpy.types.Object]:
53
+ if isinstance(objs, bpy.types.Object):
54
+ return [objs]
55
+ return [o for o in objs if o.type == 'MESH']
56
+
57
+
58
+ def objects() -> list[bpy.types.Object]:
59
+ """Every mesh object in the scene."""
60
+ return [o for o in bpy.context.scene.objects if o.type == 'MESH']
61
+
62
+
63
+ # ---- load ----------------------------------------------------------------------------------
64
+
65
+ def load(input_id: str) -> list[bpy.types.Object]:
66
+ """Import one of the job's input GLBs (the game asset with that id) and return its meshes.
67
+
68
+ Transforms are applied, so every vertex position is where the game draws it."""
69
+ if not _ID.match(input_id):
70
+ raise ValueError(f'bad input id {input_id!r}')
71
+ path = os.path.join(_state['inputs'], f'{input_id}.glb')
72
+ if not os.path.exists(path):
73
+ have = sorted(f[:-4] for f in os.listdir(_state['inputs'])) if os.path.isdir(_state['inputs']) else []
74
+ raise FileNotFoundError(f'no input {input_id!r}; this job has {have}')
75
+ before = set(bpy.data.objects)
76
+ bpy.ops.import_scene.gltf(filepath=path)
77
+ new = [o for o in bpy.data.objects if o not in before]
78
+ meshes = [o for o in new if o.type == 'MESH']
79
+ for o in new:
80
+ if o.type != 'MESH':
81
+ continue
82
+ # Bake parent + own transform into the mesh, so positions are world positions.
83
+ world = o.matrix_world.copy()
84
+ o.parent = None
85
+ o.data.transform(world)
86
+ o.matrix_world = Matrix.Identity(4)
87
+ for o in new:
88
+ if o.type != 'MESH':
89
+ bpy.data.objects.remove(o, do_unlink=True)
90
+ for o in meshes:
91
+ _face_up(o)
92
+ return meshes
93
+
94
+
95
+ def _face_up(obj: bpy.types.Object) -> None:
96
+ """Flip a mesh whose faces mostly point DOWN. Some generated grounds are wound inside-out and
97
+ drawn double-sided; left as they are, `n[1] > 0.7` would find no flat ground to paint."""
98
+ mesh = obj.data
99
+ total = sum(p.area * p.normal.z for p in mesh.polygons)
100
+ area = sum(p.area for p in mesh.polygons)
101
+ if area > 0 and total / area < -0.3:
102
+ mesh.flip_normals()
103
+ print(f'[bmedit] {obj.name}: faces pointed down; flipped')
104
+
105
+
106
+ # ---- primitives ----------------------------------------------------------------------------
107
+
108
+ def _vertex_material() -> bpy.types.Material:
109
+ mat = bpy.data.materials.get('bmedit_vertex')
110
+ if mat is None:
111
+ mat = bpy.data.materials.new('bmedit_vertex')
112
+ mat.use_nodes = True
113
+ nodes = mat.node_tree.nodes
114
+ bsdf = nodes['Principled BSDF']
115
+ bsdf.inputs['Roughness'].default_value = 1.0
116
+ attr = nodes.new('ShaderNodeVertexColor')
117
+ attr.layer_name = 'Col'
118
+ mat.node_tree.links.new(attr.outputs['Color'], bsdf.inputs['Base Color'])
119
+ return mat
120
+
121
+
122
+ def _finish(name: str, bm: bmesh.types.BMesh, color: Colour, at: Vec, yaw: float) -> bpy.types.Object:
123
+ mesh = bpy.data.meshes.new(name)
124
+ bm.to_mesh(mesh)
125
+ bm.free()
126
+ for poly in mesh.polygons:
127
+ poly.use_smooth = False
128
+ mesh.materials.append(_vertex_material())
129
+ obj = bpy.data.objects.new(name, mesh)
130
+ bpy.context.scene.collection.objects.link(obj)
131
+ _colour_layer(obj)
132
+ _fill(obj, to_linear(color))
133
+ # Straight into the vertices: the object keeps an identity transform, so every position a
134
+ # later call reads (bounds, paint, scatter) is already where the game draws it.
135
+ mesh.transform(Matrix.Translation(_to_b(at)) @ Matrix.Rotation(yaw, 4, 'Z'))
136
+ return obj
137
+
138
+
139
+ def box(name: str, size: Vec, at: Vec = (0, 0, 0), color: Colour = '#999999', yaw: float = 0.0) -> bpy.types.Object:
140
+ """A box `size` = (width x, height y, depth z) whose BOTTOM centre sits at `at`."""
141
+ bm = bmesh.new()
142
+ bmesh.ops.create_cube(bm, size=1.0)
143
+ bmesh.ops.scale(bm, vec=Vector((size[0], size[2], size[1])), verts=bm.verts)
144
+ bmesh.ops.translate(bm, vec=Vector((0, 0, size[1] / 2)), verts=bm.verts)
145
+ return _finish(name, bm, color, at, yaw)
146
+
147
+
148
+ def cylinder(name: str, radius: float, height: float, at: Vec = (0, 0, 0), color: Colour = '#999999',
149
+ segments: int = 8, top_radius: Optional[float] = None, yaw: float = 0.0) -> bpy.types.Object:
150
+ """An upright cylinder (or frustum, with top_radius) standing on `at`."""
151
+ bm = bmesh.new()
152
+ bmesh.ops.create_cone(bm, cap_ends=True, cap_tris=False, segments=max(3, segments),
153
+ radius1=radius, radius2=radius if top_radius is None else top_radius, depth=height)
154
+ bmesh.ops.translate(bm, vec=Vector((0, 0, height / 2)), verts=bm.verts)
155
+ return _finish(name, bm, color, at, yaw)
156
+
157
+
158
+ def cone(name: str, radius: float, height: float, at: Vec = (0, 0, 0), color: Colour = '#999999',
159
+ segments: int = 8, yaw: float = 0.0) -> bpy.types.Object:
160
+ """An upright cone standing on `at` — a pine layer, a roof tip, a spike."""
161
+ return cylinder(name, radius, height, at, color, segments, top_radius=0.0, yaw=yaw)
162
+
163
+
164
+ def sphere(name: str, radius: float, at: Vec = (0, 0, 0), color: Colour = '#999999', detail: int = 1) -> bpy.types.Object:
165
+ """A faceted ball centred on `at` (detail 1 = 80 faces, 2 = 320)."""
166
+ bm = bmesh.new()
167
+ bmesh.ops.create_icosphere(bm, subdivisions=max(1, min(3, detail)), radius=radius)
168
+ return _finish(name, bm, color, at, 0.0)
169
+
170
+
171
+ # ---- object edits --------------------------------------------------------------------------
172
+
173
+ def join(objs: Objects, name: str) -> bpy.types.Object:
174
+ """Merge meshes into one object (one draw call in the game)."""
175
+ items = _list(objs)
176
+ if not items:
177
+ raise ValueError('join() needs at least one mesh')
178
+ for o in items:
179
+ _colour_layer(o)
180
+ with bpy.context.temp_override(active_object=items[0], selected_editable_objects=items, selected_objects=items):
181
+ bpy.ops.object.join()
182
+ items[0].name = name
183
+ items[0].data.name = name
184
+ return items[0]
185
+
186
+
187
+ def copy(obj: bpy.types.Object, at: Optional[Vec] = None, yaw: float = 0.0, scale: float = 1.0,
188
+ name: Optional[str] = None) -> bpy.types.Object:
189
+ """A copy of `obj`, rotated by `yaw` and scaled about its bottom centre, then moved so that
190
+ bottom centre sits at `at` (default: where it was)."""
191
+ dup = obj.copy()
192
+ dup.data = obj.data.copy()
193
+ dup.name = name or f'{obj.name}.copy'
194
+ bpy.context.scene.collection.objects.link(dup)
195
+ move(dup, at=at, yaw=yaw, scale=scale)
196
+ return dup
197
+
198
+
199
+ def move(obj: bpy.types.Object, at: Optional[Vec] = None, yaw: float = 0.0, scale: float = 1.0) -> None:
200
+ """Rotate/scale `obj` about its bottom centre, then put that point at `at` (game coords)."""
201
+ lo, hi = bounds(obj)
202
+ base = Vector(_to_b(((lo[0] + hi[0]) / 2, lo[1], (lo[2] + hi[2]) / 2)))
203
+ target = _to_b(at) if at is not None else base
204
+ m = Matrix.Translation(target) @ Matrix.Rotation(yaw, 4, 'Z') @ Matrix.Scale(scale, 4) @ Matrix.Translation(-base)
205
+ obj.data.transform(m)
206
+
207
+
208
+ def remove(objs: Objects) -> None:
209
+ for o in _list(objs):
210
+ bpy.data.objects.remove(o, do_unlink=True)
211
+
212
+
213
+ def decimate(objs: Objects, ratio: float) -> None:
214
+ """Keep about `ratio` (0..1) of each mesh's triangles — cheaper, chunkier."""
215
+ for o in _list(objs):
216
+ mod = o.modifiers.new('decimate', 'DECIMATE')
217
+ mod.ratio = max(0.01, min(1.0, ratio))
218
+ with bpy.context.temp_override(object=o, active_object=o):
219
+ bpy.ops.object.modifier_apply(modifier=mod.name)
220
+
221
+
222
+ # ---- colour --------------------------------------------------------------------------------
223
+
224
+ def _colour_layer(obj: bpy.types.Object):
225
+ """The object's face-corner colour attribute — an imported GLB's own COLOR_0 (byte or float:
226
+ both read and write linear `.color`), else a new one filled from its materials' colours, with
227
+ the materials switched to show it (white base, colour from the attribute)."""
228
+ mesh = obj.data
229
+ attrs = mesh.color_attributes
230
+ layer = attrs.active_color
231
+ if layer is not None and layer.domain == 'CORNER':
232
+ return layer
233
+ old = layer
234
+ new = attrs.new('Col', 'FLOAT_COLOR', 'CORNER')
235
+ base = [_material_colour(m) for m in mesh.materials] or [(0.8, 0.8, 0.8, 1.0)]
236
+ for poly in mesh.polygons:
237
+ for li in poly.loop_indices:
238
+ if old is not None: # a POINT-domain layer: each corner takes its vertex's colour
239
+ new.data[li].color = old.data[mesh.loops[li].vertex_index].color
240
+ else:
241
+ new.data[li].color = base[min(poly.material_index, len(base) - 1)]
242
+ attrs.active_color = new
243
+ # The glTF exporter's 'ACTIVE' mode writes the RENDER colour attribute.
244
+ attrs.default_color_name = new.name
245
+ for mat in mesh.materials:
246
+ _show_vertex_colour(mat, new.name)
247
+ return new
248
+
249
+
250
+ def _material_colour(mat) -> tuple[float, float, float, float]:
251
+ if mat is None:
252
+ return (0.8, 0.8, 0.8, 1.0)
253
+ if mat.use_nodes and 'Principled BSDF' in mat.node_tree.nodes:
254
+ c = mat.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value
255
+ return (c[0], c[1], c[2], 1.0)
256
+ c = mat.diffuse_color
257
+ return (c[0], c[1], c[2], 1.0)
258
+
259
+
260
+ def _show_vertex_colour(mat, layer_name: str) -> None:
261
+ """Base colour from the attribute, unless an image texture drives it (then the attribute tints)."""
262
+ if mat is None or not mat.use_nodes or 'Principled BSDF' not in mat.node_tree.nodes:
263
+ return
264
+ for node in mat.node_tree.nodes:
265
+ if node.bl_idname == 'ShaderNodeVertexColor':
266
+ node.layer_name = layer_name
267
+ return
268
+ bsdf = mat.node_tree.nodes['Principled BSDF']
269
+ base = bsdf.inputs['Base Color']
270
+ if base.is_linked:
271
+ return
272
+ base.default_value = (1.0, 1.0, 1.0, 1.0)
273
+ attr = mat.node_tree.nodes.new('ShaderNodeVertexColor')
274
+ attr.layer_name = layer_name
275
+ mat.node_tree.links.new(attr.outputs['Color'], base)
276
+
277
+
278
+ def _fill(obj: bpy.types.Object, rgba) -> None:
279
+ layer = _colour_layer(obj)
280
+ for d in layer.data:
281
+ d.color = rgba
282
+
283
+
284
+ def _faces(obj: bpy.types.Object):
285
+ """(polygon, game centre, game normal, current sRGB hex) per face."""
286
+ layer = _colour_layer(obj)
287
+ mesh = obj.data
288
+ # The shading normals (an imported GLB's own NORMAL data), not the winding: generated grounds
289
+ # are often wound the other way and drawn double-sided, so poly.normal can point down.
290
+ corners = mesh.corner_normals
291
+ for poly in mesh.polygons:
292
+ c = layer.data[poly.loop_indices[0]].color
293
+ n = Vector((0.0, 0.0, 0.0))
294
+ for li in poly.loop_indices:
295
+ n += corners[li].vector
296
+ n = n.normalized() if n.length > 1e-9 else poly.normal
297
+ yield poly, _to_game(poly.center), _to_game(n), to_hex(c), layer
298
+
299
+
300
+ def paint(objs: Objects, fn: Callable[[tuple, tuple, str], Optional[Colour]]) -> int:
301
+ """Call fn(position, normal, current_hex) for every face; a colour it returns repaints that
302
+ face, None keeps it. Position and normal are game coords (normal[1] > 0.7: roughly flat
303
+ ground; < 0.3: steep or a wall). Returns the number of faces repainted."""
304
+ changed = 0
305
+ for o in _list(objs):
306
+ for poly, pos, normal, current, layer in _faces(o):
307
+ out = fn(pos, normal, current)
308
+ if out is None:
309
+ continue
310
+ rgba = to_linear(out)
311
+ for li in poly.loop_indices:
312
+ layer.data[li].color = rgba
313
+ changed += 1
314
+ return changed
315
+
316
+
317
+ def recolor(objs: Objects, colour: Colour, where: Optional[Callable[[tuple, tuple, str], bool]] = None) -> int:
318
+ """Paint every face (or those `where(position, normal, current_hex)` accepts) one colour."""
319
+ return paint(objs, lambda p, n, c: colour if where is None or where(p, n, c) else None)
320
+
321
+
322
+ def replace_colour(objs: Objects, mapping: dict, tolerance: float = 0.08) -> int:
323
+ """Swap colours: {'#3f8f3a': '#eef3f7', ...}. A face within `tolerance` of a key takes its
324
+ value — the way to turn 'the green of the leaves' white without touching the bark."""
325
+ keys = list(mapping.items())
326
+
327
+ def swap(_p, _n, current):
328
+ best = min(keys, key=lambda kv: distance(kv[0], current))
329
+ return best[1] if distance(best[0], current) <= tolerance else None
330
+ return paint(objs, swap)
331
+
332
+
333
+ def palette(objs: Objects, top: int = 12) -> list[tuple[str, int]]:
334
+ """The face colours in use, most common first, as [(hex, faces)] — printed too, so the build
335
+ log shows what an edit has to work with. Near-identical colours are merged."""
336
+ counts: dict[str, int] = {}
337
+ for o in _list(objs):
338
+ for _poly, _p, _n, current, _l in _faces(o):
339
+ for known in counts:
340
+ if distance(known, current) < 0.03:
341
+ counts[known] += 1
342
+ break
343
+ else:
344
+ counts[current] = 1
345
+ ranked = sorted(counts.items(), key=lambda kv: -kv[1])[:top]
346
+ print('[bmedit] palette: ' + ', '.join(f'{h} x{n}' for h, n in ranked))
347
+ return ranked
348
+
349
+
350
+ # ---- placement -----------------------------------------------------------------------------
351
+
352
+ def bounds(objs: Objects) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
353
+ """(min, max) corners in game coords over every vertex."""
354
+ lo = [math.inf] * 3
355
+ hi = [-math.inf] * 3
356
+ for o in _list(objs):
357
+ for v in o.data.vertices:
358
+ g = _to_game(o.matrix_world @ v.co)
359
+ for i in range(3):
360
+ lo[i] = min(lo[i], g[i])
361
+ hi[i] = max(hi[i], g[i])
362
+ if lo[0] == math.inf:
363
+ return (0.0, 0.0, 0.0), (0.0, 0.0, 0.0)
364
+ return (lo[0], lo[1], lo[2]), (hi[0], hi[1], hi[2])
365
+
366
+
367
+ def height_at(objs: Objects, x: float, z: float) -> Optional[float]:
368
+ """The top surface's height at game (x, z), or None off the mesh."""
369
+ best = None
370
+ for o in _list(objs):
371
+ origin = _to_b((x, 1e4, z))
372
+ hit, loc, _n, _i = o.ray_cast(o.matrix_world.inverted() @ origin, Vector((0, 0, -1)))
373
+ if hit:
374
+ y = _to_game(o.matrix_world @ loc)[1]
375
+ best = y if best is None else max(best, y)
376
+ return best
377
+
378
+
379
+ def scatter(proto: bpy.types.Object, count: int, on: Optional[Objects] = None,
380
+ area: Sequence[float] = (0.0, 0.0, 20.0), seed: int = 1, min_spacing: float = 2.0,
381
+ scale: Sequence[float] = (0.8, 1.2), avoid: Sequence[Sequence[float]] = (),
382
+ max_slope: float = 0.5) -> list[bpy.types.Object]:
383
+ """Place up to `count` copies of `proto` at random inside the circle `area` = (x, z, radius),
384
+ random yaw and scale, standing on `on`'s surface (flat enough: normal-y >= 1 - max_slope).
385
+ `avoid` = [(x, z, radius), ...] keeps spots clear (the spawn, a path). Returns the copies."""
386
+ rng = random.Random(seed)
387
+ placed: list[tuple[float, float]] = []
388
+ out = []
389
+ ground = _list(on) if on is not None else []
390
+ for _ in range(count * 30):
391
+ if len(out) >= count:
392
+ break
393
+ a = rng.uniform(0, math.tau)
394
+ r = area[2] * math.sqrt(rng.random())
395
+ x, z = area[0] + r * math.cos(a), area[1] + r * math.sin(a)
396
+ if any((x - px) ** 2 + (z - pz) ** 2 < min_spacing ** 2 for px, pz in placed):
397
+ continue
398
+ if any((x - ax) ** 2 + (z - az) ** 2 < ar ** 2 for ax, az, ar in avoid):
399
+ continue
400
+ y = 0.0
401
+ if ground:
402
+ hit = _surface(ground, x, z)
403
+ if hit is None or hit[1] < 1.0 - max_slope:
404
+ continue
405
+ y = hit[0]
406
+ out.append(copy(proto, at=(x, y, z), yaw=rng.uniform(0, math.tau), scale=rng.uniform(*scale),
407
+ name=f'{proto.name}.{len(out)}'))
408
+ placed.append((x, z))
409
+ return out
410
+
411
+
412
+ def _surface(objs: list, x: float, z: float) -> Optional[tuple[float, float]]:
413
+ best = None
414
+ for o in objs:
415
+ origin = o.matrix_world.inverted() @ _to_b((x, 1e4, z))
416
+ hit, loc, normal, _i = o.ray_cast(origin, Vector((0, 0, -1)))
417
+ if hit:
418
+ y = _to_game(o.matrix_world @ loc)[1]
419
+ if best is None or y > best[0]:
420
+ best = (y, _to_game(normal)[1])
421
+ return best
422
+
423
+
424
+ def describe(objs: Objects) -> None:
425
+ """Print name, triangle count and size of each mesh — cheap orientation for an edit."""
426
+ for o in _list(objs):
427
+ lo, hi = bounds(o)
428
+ tris = sum(len(p.vertices) - 2 for p in o.data.polygons)
429
+ size = tuple(round(hi[i] - lo[i], 2) for i in range(3))
430
+ print(f'[bmedit] {o.name}: {tris} tris, size {size}, min {tuple(round(v, 2) for v in lo)}')
431
+
432
+
433
+ # ---- export --------------------------------------------------------------------------------
434
+
435
+ def export(asset_id: str, objs: Optional[Objects] = None) -> dict:
436
+ """Write `objs` (default: every mesh in the scene) as the game asset `asset_id`. An id the game
437
+ already has REPLACES that asset everywhere it is placed; a new id adds one, sized to the
438
+ meshes' real height. Returns {id, targetHeight, triangles}."""
439
+ if not _ID.match(asset_id):
440
+ raise ValueError(f'asset id {asset_id!r}: use lowercase letters, digits, - and _')
441
+ if any(e['id'] == asset_id for e in _state['exports']):
442
+ raise ValueError(f'{asset_id!r} was already exported in this script')
443
+ if len(_state['exports']) >= MAX_EXPORTS:
444
+ raise ValueError(f'at most {MAX_EXPORTS} exports per script')
445
+ items = _list(objs) if objs is not None else objects()
446
+ if not items:
447
+ raise ValueError('nothing to export')
448
+ for o in items:
449
+ _colour_layer(o)
450
+ lo, hi = bounds(items)
451
+ os.makedirs(_state['out'], exist_ok=True)
452
+ path = os.path.join(_state['out'], f'{asset_id}.glb')
453
+ with bpy.context.temp_override(selected_objects=items, selected_editable_objects=items):
454
+ bpy.ops.object.select_all(action='DESELECT')
455
+ for o in items:
456
+ o.select_set(True)
457
+ bpy.ops.export_scene.gltf(
458
+ filepath=path, export_format='GLB', use_selection=True, export_apply=True, export_yup=True,
459
+ export_normals=True, export_vertex_color='ACTIVE', export_lights=False, export_cameras=False,
460
+ )
461
+ record = {
462
+ 'id': asset_id,
463
+ 'file': f'{asset_id}.glb',
464
+ 'targetHeight': round(max(0.01, hi[1] - lo[1]), 3),
465
+ 'triangles': sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in items),
466
+ }
467
+ _state['exports'].append(record)
468
+ print(f'[bmedit] exported {asset_id}: {record["triangles"]} tris, {record["targetHeight"]} m tall')
469
+ return record
@@ -0,0 +1,61 @@
1
+ """Colour maths for bmedit, free of bpy so plain python3 tests cover it.
2
+
3
+ Scripts speak sRGB — the numbers a colour picker shows, as `'#d8e4ea'`, `(216, 228, 234)` or
4
+ `(0.85, 0.89, 0.92)`. Blender's float colour attributes and glTF's COLOR_0 are LINEAR, so every
5
+ value is converted once on the way in and back on the way out. Storing sRGB numbers as linear is
6
+ what turned early forged levels pastel.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Sequence, Union
11
+
12
+ Colour = Union[str, Sequence[float]]
13
+
14
+
15
+ def srgb_to_linear(c: float) -> float:
16
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
17
+
18
+
19
+ def linear_to_srgb(c: float) -> float:
20
+ c = max(0.0, min(1.0, c))
21
+ return c * 12.92 if c <= 0.0031308 else 1.055 * c ** (1 / 2.4) - 0.055
22
+
23
+
24
+ def parse_srgb(colour: Colour) -> tuple[float, float, float]:
25
+ """'#rrggbb', 0-255 ints or 0-1 floats → sRGB floats in 0..1."""
26
+ if isinstance(colour, str):
27
+ h = colour.lstrip('#')
28
+ if len(h) == 3:
29
+ h = ''.join(ch * 2 for ch in h)
30
+ if len(h) != 6:
31
+ raise ValueError(f'colour {colour!r} is not #rgb or #rrggbb')
32
+ return (int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255)
33
+ values = [float(v) for v in colour][:3]
34
+ if len(values) != 3:
35
+ raise ValueError(f'colour {colour!r} needs three channels')
36
+ if any(v > 1.0 for v in values):
37
+ values = [v / 255 for v in values]
38
+ return (values[0], values[1], values[2])
39
+
40
+
41
+ def to_linear(colour: Colour) -> tuple[float, float, float, float]:
42
+ r, g, b = parse_srgb(colour)
43
+ return (srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b), 1.0)
44
+
45
+
46
+ def to_hex(linear: Sequence[float]) -> str:
47
+ """A stored linear colour as the sRGB hex a script would write."""
48
+ return '#' + ''.join(f'{round(linear_to_srgb(c) * 255):02x}' for c in linear[:3])
49
+
50
+
51
+ def distance(a: Colour, b: Colour) -> float:
52
+ """Distance between two sRGB colours, 0..~1.7; under 0.1 reads as 'the same colour'."""
53
+ pa, pb = parse_srgb(a), parse_srgb(b)
54
+ return sum((x - y) ** 2 for x, y in zip(pa, pb)) ** 0.5
55
+
56
+
57
+ def mix(a: Colour, b: Colour, t: float) -> tuple[float, float, float]:
58
+ """sRGB blend of two colours; t=0 is a, t=1 is b."""
59
+ pa, pb = parse_srgb(a), parse_srgb(b)
60
+ t = max(0.0, min(1.0, t))
61
+ return (pa[0] + (pb[0] - pa[0]) * t, pa[1] + (pb[1] - pa[1]) * t, pa[2] + (pb[2] - pa[2]) * t)