@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 export(asset_id: str, objs: Optional[Objects] = None) -> dict:
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
- 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
- )
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
- print(f'[bmedit] exported {asset_id}: {record["triangles"]} tris, {record["targetHeight"]} m tall')
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-dev.4",
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/animation-forger": "0.1.26-dev.4",
29
- "@bitmagic/asset-core": "0.2.26-dev.4",
30
- "@bitmagic/world-forger": "0.1.31-dev.4"
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",