@bitmagic/cli 0.1.71-dev.4 → 0.1.71
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.
|
@@ -13,7 +13,7 @@ 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`) |
|
|
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`, `turn`, `scatter`, `export(id)` to `build/assets/<id>.glb`; smart props (moving parts the engine animates) with `part()`, `anchor()` and `export(..., smart=)` — the vocabulary the web Creator's `blender` tool runs on the Asset Forger (`game/agent-docs/blender.md`) |
|
|
17
17
|
| `review_asset.py` | Blender | two three-quarter renders per GLB and a contact sheet: the review for props and edited assets |
|
|
18
18
|
| `example_level.py` | either | a complete hut-and-courtyard level using every helper; the tests' fixture |
|
|
19
19
|
| `audit.py` | Blender | ray-cast audit of the COLLIDERS: floor continuity, door and route clearance, sealed envelopes, z-fighting candidates |
|
|
@@ -16,7 +16,7 @@ from bmedit.colour import Colour, distance, mix, to_hex, to_linear
|
|
|
16
16
|
__all__ = [
|
|
17
17
|
'load', 'box', 'cylinder', 'cone', 'sphere', 'join', 'copy', 'move', 'remove', 'objects',
|
|
18
18
|
'paint', 'recolor', 'replace_colour', 'palette', 'decimate', 'scatter', 'bounds', 'describe',
|
|
19
|
-
'height_at', 'export', 'mix',
|
|
19
|
+
'height_at', 'turn', 'part', 'anchor', 'export', 'mix',
|
|
20
20
|
]
|
|
21
21
|
|
|
22
22
|
MAX_EXPORTS = 8
|
|
@@ -205,6 +205,23 @@ def move(obj: bpy.types.Object, at: Optional[Vec] = None, yaw: float = 0.0, scal
|
|
|
205
205
|
obj.data.transform(m)
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
def turn(objs: Objects, angle: float, axis: Vec = (0, 1, 0), about: Optional[Vec] = None) -> None:
|
|
209
|
+
"""Rotate by `angle` radians about `axis` (game coords, right-handed: about +Z, +Y turns toward
|
|
210
|
+
-X) through the point `about` (default: the objects' box centre). A spoke is a box standing on
|
|
211
|
+
the hub turned about the hub; a leaning leg is turned about its foot."""
|
|
212
|
+
items = _list(objs)
|
|
213
|
+
if about is None:
|
|
214
|
+
lo, hi = bounds(items)
|
|
215
|
+
about = tuple((lo[k] + hi[k]) / 2 for k in range(3))
|
|
216
|
+
pivot = _to_b(about)
|
|
217
|
+
a = _to_b(axis)
|
|
218
|
+
m = Matrix.Translation(pivot) @ Matrix.Rotation(angle, 4, Vector(a).normalized()) @ Matrix.Translation(-pivot)
|
|
219
|
+
for o in items:
|
|
220
|
+
o.data.transform(m)
|
|
221
|
+
for child in o.children:
|
|
222
|
+
child.location = m @ child.location
|
|
223
|
+
|
|
224
|
+
|
|
208
225
|
def remove(objs: Objects) -> None:
|
|
209
226
|
for o in _list(objs):
|
|
210
227
|
bpy.data.objects.remove(o, do_unlink=True)
|
|
@@ -432,10 +449,155 @@ def describe(objs: Objects) -> None:
|
|
|
432
449
|
|
|
433
450
|
# ---- export --------------------------------------------------------------------------------
|
|
434
451
|
|
|
435
|
-
def
|
|
452
|
+
def _coplanar_overlaps(items: list, limit: int = 5) -> tuple[int, list[str]]:
|
|
453
|
+
"""Faces that lie in the same plane, facing the same way, and overlap: they z-fight (flicker
|
|
454
|
+
between their colours) in the game. Typical cause: trim laid flush on a surface — a rim whose
|
|
455
|
+
top cap sits at the platform's height, a stripe on a wall. Returns (count, examples)."""
|
|
456
|
+
faces = []
|
|
457
|
+
for o in items:
|
|
458
|
+
layer = o.data.color_attributes.active_color
|
|
459
|
+
for poly in o.data.polygons:
|
|
460
|
+
n = poly.normal
|
|
461
|
+
if n.length < 1e-6:
|
|
462
|
+
continue
|
|
463
|
+
pts = [o.data.vertices[v].co.copy() for v in poly.vertices]
|
|
464
|
+
colour = to_hex(layer.data[poly.loop_indices[0]].color) if layer and layer.domain == 'CORNER' else '?'
|
|
465
|
+
faces.append((n.normalized(), n.normalized().dot(pts[0]), pts, poly.center.copy(), poly.area, colour))
|
|
466
|
+
buckets: dict = {}
|
|
467
|
+
for i, (n, d, _p, _c, _a, _col) in enumerate(faces):
|
|
468
|
+
key = (round(n.x, 2), round(n.y, 2), round(n.z, 2), round(d * 200)) # same direction, within ~5 mm
|
|
469
|
+
buckets.setdefault(key, []).append(i)
|
|
470
|
+
|
|
471
|
+
def inside(point, pts, n) -> bool:
|
|
472
|
+
# Convex polygon test in its own plane: the point is on the inner side of every edge.
|
|
473
|
+
sign = 0
|
|
474
|
+
for k in range(len(pts)):
|
|
475
|
+
edge = pts[(k + 1) % len(pts)] - pts[k]
|
|
476
|
+
side = n.dot(edge.cross(point - pts[k]))
|
|
477
|
+
if abs(side) < 1e-7:
|
|
478
|
+
continue
|
|
479
|
+
if sign == 0:
|
|
480
|
+
sign = 1 if side > 0 else -1
|
|
481
|
+
elif (side > 0) != (sign > 0):
|
|
482
|
+
return False
|
|
483
|
+
return True
|
|
484
|
+
|
|
485
|
+
count, examples = 0, []
|
|
486
|
+
for group in buckets.values():
|
|
487
|
+
if len(group) < 2:
|
|
488
|
+
continue
|
|
489
|
+
for x in range(len(group)):
|
|
490
|
+
for y in range(x + 1, len(group)):
|
|
491
|
+
fa, fb = faces[group[x]], faces[group[y]]
|
|
492
|
+
small, big = (fa, fb) if fa[4] <= fb[4] else (fb, fa)
|
|
493
|
+
# Two faces of one colour flicker between identical pixels: invisible, not reported.
|
|
494
|
+
if small[5] == big[5] or not inside(small[3], big[2], big[0]):
|
|
495
|
+
continue
|
|
496
|
+
count += 1
|
|
497
|
+
if len(examples) < limit:
|
|
498
|
+
g = _to_game(small[3])
|
|
499
|
+
examples.append(f'({g[0]:.2f}, {g[1]:.2f}, {g[2]:.2f}) {small[5]} vs {big[5]}')
|
|
500
|
+
return count, examples
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
PART_PREFIX = 'BM_part_'
|
|
504
|
+
_PART_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9_]*$')
|
|
505
|
+
MOTIONS = ('spin', 'upright', 'pendulum', 'none')
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def part(obj: bpy.types.Object, name: str) -> bpy.types.Object:
|
|
509
|
+
"""Mark `obj` as the moving part `name` of a smart prop (export it with `smart=`). A part
|
|
510
|
+
is one object: join its pieces first. Names: letters, digits and _, starting with a letter."""
|
|
511
|
+
if not _PART_NAME.match(name):
|
|
512
|
+
raise ValueError(f'part name {name!r}: letters, digits and _, starting with a letter')
|
|
513
|
+
obj.name = f'{PART_PREFIX}{name}'
|
|
514
|
+
obj.data.name = obj.name
|
|
515
|
+
return obj
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
ANCHOR_PREFIX = 'BM_anchor_'
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def anchor(obj: bpy.types.Object, name: str, at: Vec) -> bpy.types.Object:
|
|
522
|
+
"""A named point game code will want: a door, an entrance, a seat, a cabin floor. `at` is in
|
|
523
|
+
game coordinates at rest. On a part (after part()) it rides with the part and code finds it as
|
|
524
|
+
`smart.pivotOf(id, 'cabin_3').getObjectByName('BM_anchor_floor')` — every part may reuse the
|
|
525
|
+
same names. On any other exported object it stays put, found on the placed object:
|
|
526
|
+
`getObjectIdService().getObjectById(id).getObjectByName('BM_anchor_door')` — so those names
|
|
527
|
+
must be unique within the prop."""
|
|
528
|
+
if not _PART_NAME.match(name):
|
|
529
|
+
raise ValueError(f'anchor name {name!r}: letters, digits and _, starting with a letter')
|
|
530
|
+
owner = obj.name[len(PART_PREFIX):] if obj.name.startswith(PART_PREFIX) else re.sub(r'[^A-Za-z0-9_]', '_', obj.name)
|
|
531
|
+
empty = bpy.data.objects.new(f'{ANCHOR_PREFIX}{owner}__{name}', None)
|
|
532
|
+
bpy.context.scene.collection.objects.link(empty)
|
|
533
|
+
empty.parent = obj # the part keeps an identity transform, so `at` is also its local position
|
|
534
|
+
empty.location = _to_b(at)
|
|
535
|
+
return empty
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _vec(value, what: str) -> dict:
|
|
539
|
+
if not (isinstance(value, (list, tuple)) and len(value) == 3):
|
|
540
|
+
raise ValueError(f'{what} must be (x, y, z)')
|
|
541
|
+
return {'x': float(value[0]), 'y': float(value[1]), 'z': float(value[2])}
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _smart_declaration(smart: dict, items: list, lo, hi) -> tuple[dict, list[dict]]:
|
|
545
|
+
"""Validate `smart` against the exported objects; return the GLB's bmSmartObject extras and
|
|
546
|
+
the asset-record light emitters (offsets in the asset frame: bottom-centre origin)."""
|
|
547
|
+
names = {o.name[len(PART_PREFIX):]: o for o in items if o.name.startswith(PART_PREFIX)}
|
|
548
|
+
parts = []
|
|
549
|
+
for p in smart.get('parts', []):
|
|
550
|
+
name = p.get('name')
|
|
551
|
+
if name not in names:
|
|
552
|
+
raise ValueError(f'smart part {name!r} has no object: call part(obj, {name!r}) on one of the exported objects')
|
|
553
|
+
motion = dict(p.get('motion') or {})
|
|
554
|
+
if motion.get('kind') not in MOTIONS:
|
|
555
|
+
raise ValueError(f'smart part {name!r}: motion kind must be one of {MOTIONS}')
|
|
556
|
+
if 'axis' in motion:
|
|
557
|
+
motion['axis'] = [float(v) for v in motion['axis']]
|
|
558
|
+
entry = {'name': name, 'motion': motion}
|
|
559
|
+
if p.get('parent'):
|
|
560
|
+
if p['parent'] not in names:
|
|
561
|
+
raise ValueError(f'smart part {name!r}: parent {p["parent"]!r} is not a part')
|
|
562
|
+
entry['parent'] = p['parent']
|
|
563
|
+
if p.get('pivot') is not None:
|
|
564
|
+
entry['pivot'] = _vec(p['pivot'], f'smart part {name!r} pivot')
|
|
565
|
+
parts.append(entry)
|
|
566
|
+
origin = ((lo[0] + hi[0]) / 2, lo[1], (lo[2] + hi[2]) / 2)
|
|
567
|
+
lights = []
|
|
568
|
+
for i, l in enumerate(smart.get('lights', [])):
|
|
569
|
+
if 'color' not in l:
|
|
570
|
+
raise ValueError('a smart light needs a color')
|
|
571
|
+
where = l.get('position')
|
|
572
|
+
if where is None:
|
|
573
|
+
if l.get('part') not in names:
|
|
574
|
+
raise ValueError('a smart light needs a position, or a part to sit at the centre of')
|
|
575
|
+
plo, phi = bounds(names[l['part']])
|
|
576
|
+
where = tuple((plo[k] + phi[k]) / 2 for k in range(3))
|
|
577
|
+
offset = {'x': where[0] - origin[0], 'y': where[1] - origin[1], 'z': where[2] - origin[2]}
|
|
578
|
+
emitter = {'color': to_hex(to_linear(l['color'])), 'offset': offset}
|
|
579
|
+
for key in ('intensity', 'distance'):
|
|
580
|
+
if key in l:
|
|
581
|
+
emitter[key] = float(l[key])
|
|
582
|
+
if l.get('part'):
|
|
583
|
+
if l['part'] not in names:
|
|
584
|
+
raise ValueError(f'smart light: part {l["part"]!r} is not a part')
|
|
585
|
+
emitter['part'] = l['part']
|
|
586
|
+
lights.append(emitter)
|
|
587
|
+
return {'parts': parts}, lights
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def export(asset_id: str, objs: Optional[Objects] = None, smart: Optional[dict] = None) -> dict:
|
|
436
591
|
"""Write `objs` (default: every mesh in the scene) as the game asset `asset_id`. An id the game
|
|
437
592
|
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}.
|
|
593
|
+
meshes' real height. Returns {id, targetHeight, triangles}.
|
|
594
|
+
|
|
595
|
+
`smart` makes it a SMART OBJECT the engine animates with no game code:
|
|
596
|
+
{'parts': [{'name': 'wheel', 'motion': {'kind': 'spin', 'axis': (0, 0, 1), 'rpm': 3}},
|
|
597
|
+
{'name': 'cabin_1', 'parent': 'wheel', 'motion': {'kind': 'upright'}}],
|
|
598
|
+
'lights': [{'color': '#ffd27a', 'part': 'cabin_1', 'intensity': 4}]}
|
|
599
|
+
Every part is an exported object marked with part(obj, name). Pivots default to the part's
|
|
600
|
+
box centre (the top centre for upright / pendulum); give 'pivot': (x, y, z) to override."""
|
|
439
601
|
if not _ID.match(asset_id):
|
|
440
602
|
raise ValueError(f'asset id {asset_id!r}: use lowercase letters, digits, - and _')
|
|
441
603
|
if any(e['id'] == asset_id for e in _state['exports']):
|
|
@@ -448,22 +610,43 @@ def export(asset_id: str, objs: Optional[Objects] = None) -> dict:
|
|
|
448
610
|
for o in items:
|
|
449
611
|
_colour_layer(o)
|
|
450
612
|
lo, hi = bounds(items)
|
|
613
|
+
overlaps, where = _coplanar_overlaps(items)
|
|
614
|
+
if overlaps:
|
|
615
|
+
print(f'[bmedit] WARNING {asset_id}: {overlaps} overlapping coplanar face pair(s) will FLICKER in the game '
|
|
616
|
+
f'(z-fighting), e.g. at {"; ".join(where)}. Lift the upper surface by at least 1 cm, or make '
|
|
617
|
+
f'the trim not cover the surface under it.')
|
|
618
|
+
extras, lights = _smart_declaration(smart, items, lo, hi) if smart else (None, [])
|
|
619
|
+
scene = bpy.context.scene
|
|
620
|
+
if extras is not None:
|
|
621
|
+
scene['bmSmartObject'] = extras # scene custom properties are the GLB's scene extras
|
|
451
622
|
os.makedirs(_state['out'], exist_ok=True)
|
|
452
623
|
path = os.path.join(_state['out'], f'{asset_id}.glb')
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
624
|
+
try:
|
|
625
|
+
anchors = [c for o in items for c in o.children if c.name.startswith(ANCHOR_PREFIX)]
|
|
626
|
+
chosen = items + anchors
|
|
627
|
+
with bpy.context.temp_override(selected_objects=chosen, selected_editable_objects=chosen):
|
|
628
|
+
bpy.ops.object.select_all(action='DESELECT')
|
|
629
|
+
for o in chosen:
|
|
630
|
+
o.select_set(True)
|
|
631
|
+
bpy.ops.export_scene.gltf(
|
|
632
|
+
filepath=path, export_format='GLB', use_selection=True, export_apply=True, export_yup=True,
|
|
633
|
+
export_normals=True, export_vertex_color='ACTIVE', export_lights=False, export_cameras=False,
|
|
634
|
+
export_extras=extras is not None,
|
|
635
|
+
)
|
|
636
|
+
finally:
|
|
637
|
+
if 'bmSmartObject' in scene:
|
|
638
|
+
del scene['bmSmartObject']
|
|
461
639
|
record = {
|
|
462
640
|
'id': asset_id,
|
|
463
641
|
'file': f'{asset_id}.glb',
|
|
464
642
|
'targetHeight': round(max(0.01, hi[1] - lo[1]), 3),
|
|
465
643
|
'triangles': sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in items),
|
|
466
644
|
}
|
|
645
|
+
if extras is not None:
|
|
646
|
+
record['smartParts'] = [p['name'] for p in extras['parts']]
|
|
647
|
+
if lights:
|
|
648
|
+
record['lights'] = lights
|
|
467
649
|
_state['exports'].append(record)
|
|
468
|
-
|
|
650
|
+
moving = f', smart: {", ".join(record["smartParts"])}' if extras else ''
|
|
651
|
+
print(f'[bmedit] exported {asset_id}: {record["triangles"]} tris, {record["targetHeight"]} m tall{moving}')
|
|
469
652
|
return record
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitmagic/cli",
|
|
3
|
-
"version": "0.1.71
|
|
3
|
+
"version": "0.1.71",
|
|
4
4
|
"description": "Build Bitmagic games from your own agent tool",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "Bitmagic Oy",
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"playwright-core": "^1.59.1",
|
|
26
26
|
"tar": "^7.4.3",
|
|
27
27
|
"ws": "^8.21.3",
|
|
28
|
-
"@bitmagic/
|
|
29
|
-
"@bitmagic/
|
|
30
|
-
"@bitmagic/world-forger": "0.1.31
|
|
28
|
+
"@bitmagic/asset-core": "0.2.26",
|
|
29
|
+
"@bitmagic/animation-forger": "0.1.26",
|
|
30
|
+
"@bitmagic/world-forger": "0.1.31"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@eslint/js": "^9.38.0",
|