@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.
- package/README.md +15 -6
- package/assets/blender-level/README.md +3 -1
- package/assets/blender-level/bmedit/__init__.py +26 -0
- package/assets/blender-level/bmedit/blender.py +469 -0
- package/assets/blender-level/bmedit/colour.py +61 -0
- package/assets/blender-level/review.py +42 -6
- package/assets/blender-level/review_asset.py +138 -0
- package/assets/blender-level/tests/test_bmedit_colour.py +36 -0
- package/assets/skills/publishing-to-poki/SKILL.md +14 -5
- package/dist/commands/forge.js +10 -2
- package/dist/commands/forge.js.map +1 -1
- package/dist/commands/publish.js +4 -0
- package/dist/commands/publish.js.map +1 -1
- package/dist/forge/edit-target.d.ts +3 -0
- package/dist/forge/edit-target.js +14 -2
- package/dist/forge/edit-target.js.map +1 -1
- package/dist/forge/run-pipeline.js +22 -11
- package/dist/forge/run-pipeline.js.map +1 -1
- package/dist/poki/assets.js +6 -2
- package/dist/poki/assets.js.map +1 -1
- package/dist/poki/check.js +29 -0
- package/dist/poki/check.js.map +1 -1
- package/dist/poki/handoff.js +4 -1
- package/dist/poki/handoff.js.map +1 -1
- package/dist/poki/publish.js +9 -2
- package/dist/poki/publish.js.map +1 -1
- package/dist/poki/service-urls.d.ts +4 -0
- package/dist/poki/service-urls.js +38 -0
- package/dist/poki/service-urls.js.map +1 -0
- package/dist/poki/source-diagnostics.d.ts +3 -0
- package/dist/poki/source-diagnostics.js +32 -3
- package/dist/poki/source-diagnostics.js.map +1 -1
- package/dist/publish/art-style.d.ts +10 -0
- package/dist/publish/art-style.js +17 -0
- package/dist/publish/art-style.js.map +1 -0
- package/dist/publish/client.d.ts +6 -0
- package/dist/publish/client.js.map +1 -1
- package/dist/scaffold/blender-level-skill-files.js +1 -0
- package/dist/scaffold/blender-level-skill-files.js.map +1 -1
- 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
|
|
1602
|
-
|
|
1603
|
-
sit behind custom chapter menus.
|
|
1604
|
-
|
|
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
|
|
1625
|
-
startup coverage.
|
|
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)
|