refactor(parts): split catalog into parts_data.py + add category schema
Browse filesCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- src/buddy_fusion/parts.py +28 -64
- src/buddy_fusion/parts_data.py +69 -0
- tests/test_parts.py +11 -2
src/buddy_fusion/parts.py
CHANGED
|
@@ -1,81 +1,45 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
Each part's boxes are in LOCAL coordinates: x is the part's own left-right center
|
| 4 |
(0), z is centered on 0, y is the part's bottom (0 = part base). The assembler
|
| 5 |
(assembler.py) computes the target creature's bounding box and face-aligns the
|
| 6 |
-
part to the chosen anchor
|
| 7 |
-
|
| 8 |
See docs/superpowers/specs/2026-06-15-template-part-assembly-edit-design.md.
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
|
|
|
| 12 |
ANCHORS = ("top", "rear", "front", "left", "right", "bottom")
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
{"x": -1, "y": 0, "z": 0, "w": 1, "h": 2, "d": 1, "color": "#efe2c0"},
|
| 20 |
-
{"x": -1, "y": 2, "z": 1, "w": 1, "h": 1, "d": 1, "color": "#efe2c0"},
|
| 21 |
-
],
|
| 22 |
-
},
|
| 23 |
-
"wings": {
|
| 24 |
-
"desc": "a pair of wings on the sides",
|
| 25 |
-
"default_anchor": "left", "default_scale": 1, "symmetric": True,
|
| 26 |
-
"boxes": [
|
| 27 |
-
{"x": -1, "y": 1, "z": 0, "w": 1, "h": 3, "d": 4, "color": "#cfe6ff"},
|
| 28 |
-
],
|
| 29 |
-
},
|
| 30 |
-
"tail": {
|
| 31 |
-
"desc": "a long swishy tail at the back",
|
| 32 |
-
"default_anchor": "rear", "default_scale": 1, "symmetric": False,
|
| 33 |
-
"boxes": [
|
| 34 |
-
{"x": 0, "y": 1, "z": 0, "w": 1, "h": 1, "d": 3, "color": "#caa06f"},
|
| 35 |
-
{"x": 0, "y": 2, "z": 2, "w": 1, "h": 1, "d": 2, "color": "#caa06f"},
|
| 36 |
-
],
|
| 37 |
-
},
|
| 38 |
-
"ears": {
|
| 39 |
-
"desc": "two pointy ears on top",
|
| 40 |
-
"default_anchor": "top", "default_scale": 1, "symmetric": True,
|
| 41 |
-
"boxes": [
|
| 42 |
-
{"x": -1, "y": 0, "z": 0, "w": 1, "h": 2, "d": 1, "color": "#caa06f"},
|
| 43 |
-
],
|
| 44 |
-
},
|
| 45 |
-
"house": {
|
| 46 |
-
"desc": "a small house / hut",
|
| 47 |
-
"default_anchor": "rear", "default_scale": 2, "symmetric": False,
|
| 48 |
-
"boxes": [
|
| 49 |
-
{"x": 0, "y": 0, "z": 0, "w": 4, "h": 3, "d": 4, "color": "#d8b08c"},
|
| 50 |
-
{"x": 0, "y": 3, "z": 0, "w": 5, "h": 2, "d": 5, "color": "#b5532e"},
|
| 51 |
-
{"x": 0, "y": 0, "z": 2, "w": 1, "h": 2, "d": 1, "color": "#6b4423"},
|
| 52 |
-
],
|
| 53 |
-
},
|
| 54 |
-
"hat": {
|
| 55 |
-
"desc": "a hat / cap on top of the head",
|
| 56 |
-
"default_anchor": "top", "default_scale": 1, "symmetric": False,
|
| 57 |
-
"boxes": [
|
| 58 |
-
{"x": 0, "y": 0, "z": 0, "w": 5, "h": 1, "d": 5, "color": "#3a3550"},
|
| 59 |
-
{"x": 0, "y": 1, "z": 0, "w": 3, "h": 2, "d": 3, "color": "#3a3550"},
|
| 60 |
-
],
|
| 61 |
-
},
|
| 62 |
-
"crown": {
|
| 63 |
-
"desc": "a small crown on top",
|
| 64 |
-
"default_anchor": "top", "default_scale": 1, "symmetric": False,
|
| 65 |
-
"boxes": [
|
| 66 |
-
{"x": 0, "y": 0, "z": 0, "w": 4, "h": 1, "d": 4, "color": "#ffd34d"},
|
| 67 |
-
{"x": -1, "y": 1, "z": 0, "w": 1, "h": 1, "d": 1, "color": "#ffd34d"},
|
| 68 |
-
{"x": 1, "y": 1, "z": 0, "w": 1, "h": 1, "d": 1, "color": "#ffd34d"},
|
| 69 |
-
{"x": 0, "y": 1, "z": 0, "w": 1, "h": 1, "d": 1, "color": "#ffd34d"},
|
| 70 |
-
],
|
| 71 |
-
},
|
| 72 |
-
}
|
| 73 |
|
| 74 |
|
| 75 |
def part_ids() -> list[str]:
|
| 76 |
return list(PARTS.keys())
|
| 77 |
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
def catalog_text() -> str:
|
| 80 |
-
"""
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers over the part catalog (data lives in parts_data.py).
|
| 2 |
|
| 3 |
Each part's boxes are in LOCAL coordinates: x is the part's own left-right center
|
| 4 |
(0), z is centered on 0, y is the part's bottom (0 = part base). The assembler
|
| 5 |
(assembler.py) computes the target creature's bounding box and face-aligns the
|
| 6 |
+
part to the chosen anchor. `symmetric` parts author the LEFT half (x<=0); the
|
| 7 |
+
assembler mirrors across x.
|
| 8 |
See docs/superpowers/specs/2026-06-15-template-part-assembly-edit-design.md.
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
from .parts_data import PARTS
|
| 13 |
+
|
| 14 |
ANCHORS = ("top", "rear", "front", "left", "right", "bottom")
|
| 15 |
|
| 16 |
+
# Display/grouping order for the catalog + the selector prompt.
|
| 17 |
+
CATEGORIES = (
|
| 18 |
+
"horns", "ears", "tails", "wings", "headwear",
|
| 19 |
+
"face", "back", "limbs", "props", "accessories",
|
| 20 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def part_ids() -> list[str]:
|
| 24 |
return list(PARTS.keys())
|
| 25 |
|
| 26 |
|
| 27 |
+
def parts_by_category() -> dict[str, list[str]]:
|
| 28 |
+
"""category -> [part ids], in CATEGORIES order then insertion order. Any part
|
| 29 |
+
whose category is not in CATEGORIES is grouped under 'other'."""
|
| 30 |
+
out: dict[str, list[str]] = {c: [] for c in CATEGORIES}
|
| 31 |
+
for pid, p in PARTS.items():
|
| 32 |
+
cat = p.get("category", "other")
|
| 33 |
+
out.setdefault(cat, []).append(pid)
|
| 34 |
+
return {c: ids for c, ids in out.items() if ids}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
def catalog_text() -> str:
|
| 38 |
+
"""Catalog for the selector prompt, grouped under category headers so the
|
| 39 |
+
small model sees structure when picking from a large set."""
|
| 40 |
+
lines: list[str] = []
|
| 41 |
+
for cat, ids in parts_by_category().items():
|
| 42 |
+
lines.append(f"[{cat}]")
|
| 43 |
+
for pid in ids:
|
| 44 |
+
lines.append(f"- {pid}: {PARTS[pid]['desc']}")
|
| 45 |
+
return "\n".join(lines)
|
src/buddy_fusion/parts_data.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The part catalog data: ~100 hand-authored additive voxel parts, grouped by
|
| 2 |
+
`category`. Box-layouts are in LOCAL coordinates (x,z = center, y = bottom);
|
| 3 |
+
`symmetric` parts author the LEFT half (x<=0) and the assembler/renderer mirror
|
| 4 |
+
across x. Kept separate from parts.py (helpers) like compendium_data.py.
|
| 5 |
+
|
| 6 |
+
Schema per entry: {desc, category, default_anchor, default_scale, symmetric, boxes}.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
# Filled in batches by category. Seeded with the original 7 so nothing regresses.
|
| 11 |
+
PARTS: dict[str, dict] = {
|
| 12 |
+
"horns": {
|
| 13 |
+
"desc": "a pair of curved horns", "category": "horns",
|
| 14 |
+
"default_anchor": "top", "default_scale": 1, "symmetric": True,
|
| 15 |
+
"boxes": [
|
| 16 |
+
{"x": -1, "y": 0, "z": 0, "w": 1, "h": 2, "d": 1, "color": "#efe2c0"},
|
| 17 |
+
{"x": -1, "y": 2, "z": 1, "w": 1, "h": 1, "d": 1, "color": "#efe2c0"},
|
| 18 |
+
],
|
| 19 |
+
},
|
| 20 |
+
"wings": {
|
| 21 |
+
"desc": "a pair of wings on the sides", "category": "wings",
|
| 22 |
+
"default_anchor": "left", "default_scale": 1, "symmetric": True,
|
| 23 |
+
"boxes": [
|
| 24 |
+
{"x": -1, "y": 1, "z": 0, "w": 1, "h": 3, "d": 4, "color": "#cfe6ff"},
|
| 25 |
+
],
|
| 26 |
+
},
|
| 27 |
+
"tail": {
|
| 28 |
+
"desc": "a long swishy tail at the back", "category": "tails",
|
| 29 |
+
"default_anchor": "rear", "default_scale": 1, "symmetric": False,
|
| 30 |
+
"boxes": [
|
| 31 |
+
{"x": 0, "y": 1, "z": 0, "w": 1, "h": 1, "d": 3, "color": "#caa06f"},
|
| 32 |
+
{"x": 0, "y": 2, "z": 2, "w": 1, "h": 1, "d": 2, "color": "#caa06f"},
|
| 33 |
+
],
|
| 34 |
+
},
|
| 35 |
+
"ears": {
|
| 36 |
+
"desc": "two pointy ears on top", "category": "ears",
|
| 37 |
+
"default_anchor": "top", "default_scale": 1, "symmetric": True,
|
| 38 |
+
"boxes": [
|
| 39 |
+
{"x": -1, "y": 0, "z": 0, "w": 1, "h": 2, "d": 1, "color": "#caa06f"},
|
| 40 |
+
],
|
| 41 |
+
},
|
| 42 |
+
"house": {
|
| 43 |
+
"desc": "a small house / hut", "category": "props",
|
| 44 |
+
"default_anchor": "rear", "default_scale": 2, "symmetric": False,
|
| 45 |
+
"boxes": [
|
| 46 |
+
{"x": 0, "y": 0, "z": 0, "w": 4, "h": 3, "d": 4, "color": "#d8b08c"},
|
| 47 |
+
{"x": 0, "y": 3, "z": 0, "w": 5, "h": 2, "d": 5, "color": "#b5532e"},
|
| 48 |
+
{"x": 0, "y": 0, "z": 2, "w": 1, "h": 2, "d": 1, "color": "#6b4423"},
|
| 49 |
+
],
|
| 50 |
+
},
|
| 51 |
+
"hat": {
|
| 52 |
+
"desc": "a hat / cap on top of the head", "category": "headwear",
|
| 53 |
+
"default_anchor": "top", "default_scale": 1, "symmetric": False,
|
| 54 |
+
"boxes": [
|
| 55 |
+
{"x": 0, "y": 0, "z": 0, "w": 5, "h": 1, "d": 5, "color": "#3a3550"},
|
| 56 |
+
{"x": 0, "y": 1, "z": 0, "w": 3, "h": 2, "d": 3, "color": "#3a3550"},
|
| 57 |
+
],
|
| 58 |
+
},
|
| 59 |
+
"crown": {
|
| 60 |
+
"desc": "a small crown on top", "category": "headwear",
|
| 61 |
+
"default_anchor": "top", "default_scale": 1, "symmetric": False,
|
| 62 |
+
"boxes": [
|
| 63 |
+
{"x": 0, "y": 0, "z": 0, "w": 4, "h": 1, "d": 4, "color": "#ffd34d"},
|
| 64 |
+
{"x": -1, "y": 1, "z": 0, "w": 1, "h": 1, "d": 1, "color": "#ffd34d"},
|
| 65 |
+
{"x": 1, "y": 1, "z": 0, "w": 1, "h": 1, "d": 1, "color": "#ffd34d"},
|
| 66 |
+
{"x": 0, "y": 1, "z": 0, "w": 1, "h": 1, "d": 1, "color": "#ffd34d"},
|
| 67 |
+
],
|
| 68 |
+
},
|
| 69 |
+
}
|
tests/test_parts.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from buddy_fusion.parts import PARTS, part_ids, catalog_text, ANCHORS
|
| 2 |
|
| 3 |
|
| 4 |
def test_every_part_is_well_formed():
|
|
@@ -6,6 +6,8 @@ def test_every_part_is_well_formed():
|
|
| 6 |
for pid, p in PARTS.items():
|
| 7 |
assert p["desc"] and isinstance(p["desc"], str)
|
| 8 |
assert p["default_anchor"] in ANCHORS
|
|
|
|
|
|
|
| 9 |
assert isinstance(p["boxes"], list) and p["boxes"]
|
| 10 |
for b in p["boxes"]:
|
| 11 |
for k in ("x", "y", "z", "w", "h", "d"):
|
|
@@ -14,7 +16,14 @@ def test_every_part_is_well_formed():
|
|
| 14 |
assert isinstance(b["color"], str) and b["color"].startswith("#")
|
| 15 |
|
| 16 |
|
| 17 |
-
def
|
|
|
|
| 18 |
text = catalog_text()
|
| 19 |
for pid in part_ids():
|
| 20 |
assert pid in text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from buddy_fusion.parts import PARTS, part_ids, catalog_text, ANCHORS, CATEGORIES, parts_by_category
|
| 2 |
|
| 3 |
|
| 4 |
def test_every_part_is_well_formed():
|
|
|
|
| 6 |
for pid, p in PARTS.items():
|
| 7 |
assert p["desc"] and isinstance(p["desc"], str)
|
| 8 |
assert p["default_anchor"] in ANCHORS
|
| 9 |
+
assert p["category"] in CATEGORIES, f"{pid}: bad category {p.get('category')!r}"
|
| 10 |
+
assert isinstance(p["symmetric"], bool)
|
| 11 |
assert isinstance(p["boxes"], list) and p["boxes"]
|
| 12 |
for b in p["boxes"]:
|
| 13 |
for k in ("x", "y", "z", "w", "h", "d"):
|
|
|
|
| 16 |
assert isinstance(b["color"], str) and b["color"].startswith("#")
|
| 17 |
|
| 18 |
|
| 19 |
+
def test_ids_unique_and_catalog_lists_every_part():
|
| 20 |
+
assert len(part_ids()) == len(set(part_ids()))
|
| 21 |
text = catalog_text()
|
| 22 |
for pid in part_ids():
|
| 23 |
assert pid in text
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_parts_by_category_covers_all():
|
| 27 |
+
grouped = parts_by_category()
|
| 28 |
+
flat = [pid for ids in grouped.values() for pid in ids]
|
| 29 |
+
assert sorted(flat) == sorted(part_ids())
|