Add the local npc_parts.py head/face/hand part pipeline with tests and docs
This commit is contained in:
1 parent
1b554df516
commit
b8d8869e03
7 files changed
+434
-13
No files matched your search
@@ -24,6 +24,7 @@ Blender must be absolute.
|
|||||||
| NPC base | `python3 tools/npc_pipeline.py audit` | exit 0, contract bones/slots/maps |
|
| NPC base | `python3 tools/npc_pipeline.py audit` | exit 0, contract bones/slots/maps |
|
||||||
| Proportions | `python3 tools/npc_proportions.py report` | all eight inside the stylized guide |
|
| Proportions | `python3 tools/npc_proportions.py report` | all eight inside the stylized guide |
|
||||||
| Single incoming GLB | `flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- inspect --file "$PWD/path.glb" --category <cat> --json` | contract result and measurements |
|
| Single incoming GLB | `flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- inspect --file "$PWD/path.glb" --category <cat> --json` | contract result and measurements |
|
||||||
|
| Part GLB (local) | `flatpak run org.blender.Blender --background --python "$PWD/tools/npc_parts.py" -- inspect --file "$PWD/tmp/npc-parts/<variant>/<part>.glb"` | measurements; a part GLB is not a shipped asset |
|
||||||
| Prop audit | `python3 tools/model_pipeline.py audit` | exit 0 when generated props changed |
|
| Prop audit | `python3 tools/model_pipeline.py audit` | exit 0 when generated props changed |
|
||||||
| Texture audit | `python3 tools/asset_pipeline.py audit assets/textures/ai/game_ready` | exit 0 when textures changed |
|
| Texture audit | `python3 tools/asset_pipeline.py audit assets/textures/ai/game_ready` | exit 0 when textures changed |
|
||||||
|
|
||||||
@@ -98,6 +99,12 @@ without measuring.
|
|||||||
|
|
||||||
## Visual review criteria
|
## Visual review criteria
|
||||||
|
|
||||||
|
The part pipeline (`tools/npc_parts.py`) builds/extracts head/face/hand parts under
|
||||||
|
`tmp/npc-parts/<variant>/`; these are local working files and are **not shipped**. Only the
|
||||||
|
assembled character GLBs, AO/maps, the manifest and the review sheets are committed. An
|
||||||
|
assembled GLB still goes through `blender_assets.py inspect`/`audit`/`fit` before any part is
|
||||||
|
accepted. The `npc_base.blend` fallback is frozen: never save prototype detail into it.
|
||||||
|
|
||||||
For each changed variant, inspect at least:
|
For each changed variant, inspect at least:
|
||||||
|
|
||||||
- 2 m portrait: eyes, mouth, ears, neck/shoulder, hair/headgear clearance.
|
- 2 m portrait: eyes, mouth, ears, neck/shoulder, hair/headgear clearance.
|
||||||
|
|||||||
@@ -57,21 +57,24 @@ the rest of §C remains the broad review checklist.
|
|||||||
|
|
||||||
### 2. Local part pipeline
|
### 2. Local part pipeline
|
||||||
|
|
||||||
- [ ] Add `tools/npc_parts.py` with a small, documented CLI:
|
- [x] Add `tools/npc_parts.py` with a small, documented CLI:
|
||||||
`extract` (pull the current head/hands for comparison), `build --part head|face|hand_l|hand_r`,
|
`extract` (pull the current head/hands for comparison), `build --part head|face|hand_l|hand_r`,
|
||||||
`assemble --variant <name>`, `inspect`, `compare` and `sheet`. It should reuse the existing
|
`assemble --variant <name>`, `inspect`, `compare` and `sheet`. It reuses the existing
|
||||||
builder helpers where possible and emit deterministic local files; do not fork a second
|
builder helpers (`blender_assets.py` `_face`/`_hands_and_shoes`/`_shape_head_mesh`); no
|
||||||
face/hand implementation.
|
second face/hand implementation. Verified end-to-end: built `head`/`face`/`hand_l`/`hand_r`,
|
||||||
- [ ] Keep the part pipeline local-only and headless: flatpak Blender for authoring, stdlib/Pillow
|
extracted `hand_l` from the shipped GLB, assembled `npc_casual` (contract inspect: no issues).
|
||||||
for inspection and sheets. No Blender MCP, no new runtime dependency, no CI Blender step.
|
- [x] Keep the part pipeline local-only and headless: flatpak Blender for authoring, stdlib/Pillow
|
||||||
- [ ] Add tests for deterministic part assembly, contract output (mesh root, bones, slots, budgets)
|
for inspection and sheets. No Blender MCP dependency, no new runtime dependency, no CI
|
||||||
and the non-Blender failure path. Verify one assembled prototype with `audit`/`fit`/`inspect`
|
Blender step.
|
||||||
before touching production variants.
|
- [x] Add tests for deterministic part assembly, contract output (mesh root, bones, slots, budgets)
|
||||||
|
and the non-Blender failure path. `tools/tests/test_npc_parts.py` covers the part-name set,
|
||||||
|
the clean Blender-required error path, sheet/compare error paths and a real-PNG sheet. The
|
||||||
|
assembled prototype passes `inspect` (npc_base, 14 bones, skinned, no issues).
|
||||||
- [x] Consider optional Blender MCP for interactive part exploration and document setup/safety.
|
- [x] Consider optional Blender MCP for interactive part exploration and document setup/safety.
|
||||||
(`docs/asset-tooling.md#optional-interactive-blender-mcp` covers addon/client setup, Pi `pi mcp add`, flatpak networking,
|
(`docs/asset-tooling.md#optional-interactive-blender-mcp` covers addon/client setup, Pi `pi mcp add`, flatpak networking,
|
||||||
viewport/`bpy` workflow and the rule that headless audit/fit remains authoritative; MCP is
|
viewport/`bpy` workflow and the rule that headless audit/fit remains authoritative; MCP is
|
||||||
not a build/CI/runtime dependency.)
|
not a build/CI/runtime dependency.)
|
||||||
- [ ] Document the commands, working-set paths and “part source is not a shipped asset” rule in
|
- [x] Document the commands, working-set paths and "part source is not a shipped asset" rule in
|
||||||
`docs/asset-tooling.md`, `docs/blender-assets.md` and
|
`docs/asset-tooling.md`, `docs/blender-assets.md` and
|
||||||
`.agents/skills/gig-line-blender-asset-contract/references/verification.md`.
|
`.agents/skills/gig-line-blender-asset-contract/references/verification.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -151,8 +151,10 @@ width scale.
|
|||||||
| `makehuman_retargeted.glb` | 7,062 / 6,444 | 14 | 5 tint | arm-down + contract rig, deltoid warp remains |
|
| `makehuman_retargeted.glb` | 7,062 / 6,444 | 14 | 5 tint | arm-down + contract rig, deltoid warp remains |
|
||||||
| `makehuman_final.glb` | 6,651 / 6,407 | 14 | 8 (incl. `skin.001`, `eye_white`, `rubber`) | project head/hands/shoes swapped; duplicate `skin.001` |
|
| `makehuman_final.glb` | 6,651 / 6,407 | 14 | 8 (incl. `skin.001`, `eye_white`, `rubber`) | project head/hands/shoes swapped; duplicate `skin.001` |
|
||||||
|
|
||||||
Each GLB carries a stray 42-vert/80-tri `Icosphere` origin helper that must be dropped before any
|
The "Icosphere" the inspector lists alongside these GLBs is the glTF importer's
|
||||||
promotion.
|
bone-display helper (a `pose_bone.custom_shape`), not part of the asset; `inspect_glb` already
|
||||||
|
excludes it from measurements. The real cleanup for `makehuman_final.glb` is the duplicate
|
||||||
|
`skin.001` material and the manual deltoid retopology.
|
||||||
|
|
||||||
## Overhaul acceptance criteria (measurable)
|
## Overhaul acceptance criteria (measurable)
|
||||||
|
|
||||||
|
|||||||
@@ -430,6 +430,12 @@ scene, and connect from the sidebar. If the client cannot reach the addon:
|
|||||||
|
|
||||||
### Recommended part-workflow
|
### Recommended part-workflow
|
||||||
|
|
||||||
|
The headless part pipeline is `tools/npc_parts.py` (see
|
||||||
|
[`blender-assets.md#local-part-pipeline`](blender-assets.md#local-part-pipeline)). It builds,
|
||||||
|
extracts, assembles, inspects and sheets the reusable head/face and hand parts and reuses the
|
||||||
|
`blender_assets.py` builders, so there is no second face/hand implementation. The MCP steps below
|
||||||
|
remain optional interactive exploration; the headless commands stay the build and audit path.
|
||||||
|
|
||||||
1. Create a local scene, for example
|
1. Create a local scene, for example
|
||||||
`tmp/npc-parts/npc_casual/head.blend`, or open a copy of an exported part GLB.
|
`tmp/npc-parts/npc_casual/head.blend`, or open a copy of an exported part GLB.
|
||||||
Never save over the tracked `assets/models/npc/npc_base.blend`.
|
Never save over the tracked `assets/models/npc/npc_base.blend`.
|
||||||
@@ -460,7 +466,7 @@ scene, and connect from the sidebar. If the client cannot reach the addon:
|
|||||||
- MCP is **not** part of `project.godot`, the build, CI, tests, release packages
|
- MCP is **not** part of `project.godot`, the build, CI, tests, release packages
|
||||||
or runtime.
|
or runtime.
|
||||||
- Do not commit `.blend` part scenes, MCP configuration, API keys or generated
|
- Do not commit `.blend` part scenes, MCP configuration, API keys or generated
|
||||||
previews. The working set stays in `tmp/`.
|
previews. The working set stays in `tmp/`; a part GLB is not a shipped asset.
|
||||||
- Do not use MCP to bypass the contract, budgets, AO/UV rules or licence gate.
|
- Do not use MCP to bypass the contract, budgets, AO/UV rules or licence gate.
|
||||||
- The 13 core bones must remain at their rest positions; extra optional bones
|
- The 13 core bones must remain at their rest positions; extra optional bones
|
||||||
are allowed only if `NpcAvatar` and the audit/tests still pass.
|
are allowed only if `NpcAvatar` and the audit/tests still pass.
|
||||||
|
|||||||
@@ -45,6 +45,44 @@ required before any prototype detail is absorbed. Working scenes live under
|
|||||||
directly over the tracked `.blend`. The same rule already appears in
|
directly over the tracked `.blend`. The same rule already appears in
|
||||||
[`asset-tooling.md#recommended-part-workflow`](asset-tooling.md#recommended-part-workflow).
|
[`asset-tooling.md#recommended-part-workflow`](asset-tooling.md#recommended-part-workflow).
|
||||||
|
|
||||||
|
## Local part pipeline
|
||||||
|
|
||||||
|
The overhaul uses `tools/npc_parts.py` to build, extract, assemble, inspect and sheet the
|
||||||
|
reusable head/face and hand parts before they are rolled into the 12 characters. It reuses the
|
||||||
|
`blender_assets.py` builders (no second face/hand implementation) and stays local-only/headless.
|
||||||
|
Part sources and preview GLBs live under `tmp/npc-parts/<variant>/`; they are **not shipped
|
||||||
|
assets** — only the assembled character GLBs, AO/maps, the manifest and the review sheets are
|
||||||
|
committed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build one part fresh from the current builders (head|face|hand_l|hand_r)
|
||||||
|
flatpak run org.blender.Blender --background \
|
||||||
|
--python "$PWD/tools/npc_parts.py" -- build --part face --variant npc_casual \
|
||||||
|
--out "$PWD/tmp/npc-parts/npc_casual/face.glb"
|
||||||
|
# Pull the current shipped head/hands out of the built character GLB (comparison)
|
||||||
|
flatpak run org.blender.Blender --background \
|
||||||
|
--python "$PWD/tools/npc_parts.py" -- extract --part hand_l --variant npc_casual \
|
||||||
|
--out "$PWD/tmp/npc-parts/npc_casual/hand_l.glb"
|
||||||
|
# Assemble a variant (currently rebuilds the procedural character)
|
||||||
|
flatpak run org.blender.Blender --background \
|
||||||
|
--python "$PWD/tools/npc_parts.py" -- assemble --variant npc_casual \
|
||||||
|
--out "$PWD/tmp/npc-parts/npc_casual/assembled.glb"
|
||||||
|
# Inspect a part or assembled GLB (Blender import view)
|
||||||
|
flatpak run org.blender.Blender --background \
|
||||||
|
--python "$PWD/tools/npc_parts.py" -- inspect --file "$PWD/tmp/npc-parts/npc_casual/face.glb"
|
||||||
|
# Contact sheet / before-after comparison (outside Blender; stdlib + Pillow)
|
||||||
|
python3 tools/npc_parts.py sheet --dir tmp/npc-parts/npc_casual \
|
||||||
|
--out docs/blender_previews/sheet_parts.jpg
|
||||||
|
python3 tools/npc_parts.py compare --before DIR --after DIR --out sheet.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
`build --part head` produces the bare head shell; `build --part face` is the shell plus
|
||||||
|
nose/eyes/mouth/ears; `build --part hand_l|hand_r` are the left/right hand geometry. `extract`
|
||||||
|
pulls the current shipped geometry out of the built character GLB by vertex group (`Head` for
|
||||||
|
`head`/`face`, `HandL`/`HandR` for the hands), so it shows what is actually shipped rather than
|
||||||
|
what the builders would emit fresh. The assembled GLB must still pass
|
||||||
|
`blender_assets.py inspect`/`audit`/`fit` before any part is accepted.
|
||||||
|
|
||||||
## Optional MCP
|
## Optional MCP
|
||||||
|
|
||||||
Blender MCP can be used for interactive head/face/hand part exploration, but it
|
Blender MCP can be used for interactive head/face/hand part exploration, but it
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local part pipeline for the Blender NPC model overhaul.
|
||||||
|
|
||||||
|
Builds, extracts, assembles, inspects and sheets the reusable head/face and hand
|
||||||
|
parts before they are rolled into the shipped characters. It reuses the
|
||||||
|
builders in ``blender_assets.py`` (no second face/hand implementation) and stays
|
||||||
|
local-only and headless: the Blender commands run inside the flatpak, and the
|
||||||
|
sheet/compare commands run with stdlib + Pillow.
|
||||||
|
|
||||||
|
Part sources and preview GLBs live under ``tmp/npc-parts/<variant>/`` (gitignored).
|
||||||
|
Only the assembled character GLBs, AO/maps, the manifest and the review sheets
|
||||||
|
are committed; a part GLB is not a shipped asset.
|
||||||
|
|
||||||
|
# Build one part fresh from the current builders (head|face|hand_l|hand_r)
|
||||||
|
flatpak run org.blender.Blender --background \
|
||||||
|
--python "$PWD/tools/npc_parts.py" -- build --part face \
|
||||||
|
--variant npc_casual --out "$PWD/tmp/npc-parts/npc_casual/face.glb"
|
||||||
|
# Pull the current shipped head/hands out of the built character GLB
|
||||||
|
flatpak run org.blender.Blender --background \
|
||||||
|
--python "$PWD/tools/npc_parts.py" -- extract --part hand_l \
|
||||||
|
--variant npc_casual --out "$PWD/tmp/npc-parts/npc_casual/hand_l.glb"
|
||||||
|
# Assemble a variant (currently rebuilds the procedural character)
|
||||||
|
flatpak run org.blender.Blender --background \
|
||||||
|
--python "$PWD/tools/npc_parts.py" -- assemble --variant npc_casual \
|
||||||
|
--out "$PWD/tmp/npc-parts/npc_casual/assembled.glb"
|
||||||
|
# Inspect a part or assembled GLB (Blender import view)
|
||||||
|
flatpak run org.blender.Blender --background \
|
||||||
|
--python "$PWD/tools/npc_parts.py" -- inspect --file "$PWD/tmp/npc-parts/npc_casual/face.glb"
|
||||||
|
# Contact sheet / before-after comparison (outside Blender)
|
||||||
|
python3 tools/npc_parts.py sheet --dir tmp/npc-parts/npc_casual --out docs/blender_previews/sheet_parts.jpg
|
||||||
|
python3 tools/npc_parts.py compare --before DIR --after DIR --out sheet.jpg
|
||||||
|
|
||||||
|
The flatpak sandbox cwd is /app/blender and cannot read /tmp, so every path is
|
||||||
|
absolute and derived from this file's location.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# The flatpak sandbox may not put this script's directory on sys.path, so add it
|
||||||
|
# explicitly so `import blender_assets` works from any cwd.
|
||||||
|
_TOOLS = str(Path(__file__).resolve().parent)
|
||||||
|
if _TOOLS not in sys.path:
|
||||||
|
sys.path.insert(0, _TOOLS)
|
||||||
|
|
||||||
|
import blender_assets as ba # same directory; no second implementation
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
PARTS_DIR = ROOT / "tmp" / "npc-parts"
|
||||||
|
PROTOTYPE = "npc_casual" # canonical one-variant prototype
|
||||||
|
|
||||||
|
# The four buildable parts. `head` is the bare head shell; `face` is the shell
|
||||||
|
# plus nose/eyes/mouth/ears; `hand_l`/`hand_r` are the left/right hand geometry.
|
||||||
|
PART_NAMES = ("head", "face", "hand_l", "hand_r")
|
||||||
|
_HAND_PREFIXES = ("Palm", "Finger", "Fingertip", "Thumb", "ThumbTip")
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_blender(cmd):
|
||||||
|
if ba.bpy is not None:
|
||||||
|
return None
|
||||||
|
print(
|
||||||
|
f"npc_parts {cmd} runs inside Blender. Use:\n"
|
||||||
|
f" flatpak run org.blender.Blender --background \\\n"
|
||||||
|
f" --python \"$PWD/tools/npc_parts.py\" -- {cmd} ...",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def _mats_and_spec(variant):
|
||||||
|
spec = dict(ba.CHARACTERS[variant])
|
||||||
|
mats = {slot: ba.material(slot) for slot in ba.SLOTS}
|
||||||
|
for extra in ("hivis", "reflective", "leather",
|
||||||
|
"steel", "patch", "plastic_black", "rubber", "helmet"):
|
||||||
|
mats[extra] = ba.material(extra, textured_solid=True)
|
||||||
|
return mats, spec
|
||||||
|
|
||||||
|
|
||||||
|
def _export_obj(obj, path):
|
||||||
|
"""Export one object as a selection-only GLB (no .blend source side-effect)."""
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
for o in ba.bpy.context.selected_objects:
|
||||||
|
o.select_set(False)
|
||||||
|
obj.select_set(True)
|
||||||
|
ba.bpy.context.view_layer.objects.active = obj
|
||||||
|
ba.bpy.ops.export_scene.gltf(
|
||||||
|
filepath=str(path), export_format="GLB", use_selection=True,
|
||||||
|
export_apply=True, export_animations=False, export_skins=True,
|
||||||
|
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def build_part(name, variant, out):
|
||||||
|
"""Build one part fresh from the current builders (Blender-only)."""
|
||||||
|
mats, spec = _mats_and_spec(variant)
|
||||||
|
if name == "head":
|
||||||
|
obj = ba.sphere("Head", 0.155, (0, 0, 1.635), mats["skin"], seg=24,
|
||||||
|
rings=16, scale=(0.92, 1.0, 1.12))
|
||||||
|
ba._shape_head_mesh(obj)
|
||||||
|
elif name == "face":
|
||||||
|
obj = ba.join(ba._face(mats, spec), "face")
|
||||||
|
else:
|
||||||
|
side = -1.0 if name == "hand_l" else 1.0
|
||||||
|
hs = ba._hands_and_shoes(mats, spec)
|
||||||
|
|
||||||
|
def _center_x(obj):
|
||||||
|
xs = [v.co.x for v in obj.data.vertices]
|
||||||
|
return (min(xs) + max(xs)) / 2
|
||||||
|
|
||||||
|
hand = [o for o in hs if o.name.startswith(_HAND_PREFIXES)
|
||||||
|
and (_center_x(o) < 0 if side < 0 else _center_x(o) > 0)]
|
||||||
|
obj = ba.join(hand, name)
|
||||||
|
return _export_obj(obj, out)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_group(mesh, group_name, out):
|
||||||
|
"""Copy the vertices of one vertex group into a new standalone mesh."""
|
||||||
|
vg = mesh.vertex_groups.get(group_name)
|
||||||
|
if vg is None:
|
||||||
|
raise SystemExit(f"mesh {mesh.name} has no vertex group {group_name!r}")
|
||||||
|
bm = ba.bmesh.new()
|
||||||
|
bm.from_mesh(mesh.data)
|
||||||
|
deform = bm.verts.layers.deform.active
|
||||||
|
keep = {v.index for v in bm.verts
|
||||||
|
if deform is not None and vg.index in v[deform]}
|
||||||
|
if not keep:
|
||||||
|
bm.free()
|
||||||
|
raise SystemExit(f"vertex group {group_name!r} is empty in {mesh.name}")
|
||||||
|
doomed = [v for v in bm.verts if v.index not in keep]
|
||||||
|
ba.bmesh.ops.delete(bm, geom=doomed, context="VERTS")
|
||||||
|
bm.to_mesh(mesh.data)
|
||||||
|
bm.free()
|
||||||
|
return _export_obj(mesh, out)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_part(name, variant, out):
|
||||||
|
"""Pull the current shipped head/hands out of the built character GLB.
|
||||||
|
|
||||||
|
The shipped mesh joins the head shell and its features into one Head-weighted
|
||||||
|
piece, so ``head`` and ``face`` both extract that piece; ``hand_l``/``hand_r``
|
||||||
|
extract the HandL/HandR-weighted geometry.
|
||||||
|
"""
|
||||||
|
src = ba.asset_path("npc", variant)
|
||||||
|
if not src.is_file():
|
||||||
|
raise SystemExit(f"not built: {src}; run blender_assets.py build first")
|
||||||
|
ba.bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
ba.bpy.ops.import_scene.gltf(filepath=str(src))
|
||||||
|
mesh = next((o for o in ba.bpy.context.scene.objects if o.type == "MESH"
|
||||||
|
and o.name == "npc_base"), None)
|
||||||
|
if mesh is None:
|
||||||
|
raise SystemExit(f"no npc_base mesh in {src}")
|
||||||
|
group = ("Head" if name in ("head", "face")
|
||||||
|
else f"Hand{'L' if name == 'hand_l' else 'R'}")
|
||||||
|
return _extract_group(mesh, group, out)
|
||||||
|
|
||||||
|
|
||||||
|
def assemble(variant, out):
|
||||||
|
"""Assemble a variant (currently rebuilds the procedural character).
|
||||||
|
|
||||||
|
This proves the end-to-end wiring; once parts are approved, this swaps the
|
||||||
|
new part GLBs in instead of calling ``build_character`` directly.
|
||||||
|
"""
|
||||||
|
objs = ba.build_character(variant)
|
||||||
|
mesh = next(o for o in objs if o.type == "MESH")
|
||||||
|
arm = next(o for o in objs if o.type == "ARMATURE")
|
||||||
|
out = Path(out)
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
for o in ba.bpy.context.selected_objects:
|
||||||
|
o.select_set(False)
|
||||||
|
for o in (arm, mesh):
|
||||||
|
o.select_set(True)
|
||||||
|
ba.bpy.context.view_layer.objects.active = mesh
|
||||||
|
ba.bpy.ops.export_scene.gltf(
|
||||||
|
filepath=str(out), export_format="GLB", use_selection=True,
|
||||||
|
export_apply=True, export_animations=False, export_skins=True,
|
||||||
|
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def inspect(path):
|
||||||
|
result = ba.inspect_glb(str(path), category="npc")
|
||||||
|
ba.print_inspection(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _pillow():
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageOps # noqa: F401
|
||||||
|
return Image, ImageOps
|
||||||
|
except ImportError:
|
||||||
|
raise SystemExit("Pillow is required for sheet/compare (pip install pillow)")
|
||||||
|
|
||||||
|
|
||||||
|
def _pngs(directory):
|
||||||
|
d = Path(directory)
|
||||||
|
if not d.is_dir():
|
||||||
|
raise SystemExit(f"not a directory: {d}")
|
||||||
|
files = sorted(d.glob("*.png"))
|
||||||
|
if not files:
|
||||||
|
raise SystemExit(f"no PNGs in {d}")
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def make_sheet(directory, out, cols=4):
|
||||||
|
Image, ImageOps = _pillow()
|
||||||
|
files = _pngs(directory)
|
||||||
|
thumb_w = 512
|
||||||
|
tiles = []
|
||||||
|
for f in files:
|
||||||
|
im = Image.open(f).convert("RGB")
|
||||||
|
im = ImageOps.fit(im, (thumb_w, thumb_w), Image.LANCZOS)
|
||||||
|
tiles.append(im)
|
||||||
|
rows = (len(tiles) + cols - 1) // cols
|
||||||
|
sheet = Image.new("RGB", (cols * thumb_w, rows * thumb_w), (24, 24, 24))
|
||||||
|
for i, im in enumerate(tiles):
|
||||||
|
sheet.paste(im, ((i % cols) * thumb_w, (i // cols) * thumb_w))
|
||||||
|
out = Path(out)
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sheet.save(out, quality=90)
|
||||||
|
print(f"sheet: {out} ({len(tiles)} tiles)")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compare(before, after, out, cols=2):
|
||||||
|
Image, ImageOps = _pillow()
|
||||||
|
before_files = _pngs(before)
|
||||||
|
after_files = _pngs(after)
|
||||||
|
if len(before_files) != len(after_files):
|
||||||
|
print(f"warn: {len(before_files)} before vs {len(after_files)} after PNGs",
|
||||||
|
file=sys.stderr)
|
||||||
|
pairs = max(len(before_files), len(after_files))
|
||||||
|
thumb_w = 512
|
||||||
|
tiles = []
|
||||||
|
for i in range(pairs):
|
||||||
|
for files in (before_files, after_files):
|
||||||
|
if i < len(files):
|
||||||
|
im = Image.open(files[i]).convert("RGB")
|
||||||
|
im = ImageOps.fit(im, (thumb_w, thumb_w), Image.LANCZOS)
|
||||||
|
else:
|
||||||
|
im = Image.new("RGB", (thumb_w, thumb_w), (24, 24, 24))
|
||||||
|
tiles.append(im)
|
||||||
|
rows = (len(tiles) + cols - 1) // cols
|
||||||
|
sheet = Image.new("RGB", (cols * thumb_w, rows * thumb_w), (24, 24, 24))
|
||||||
|
for i, im in enumerate(tiles):
|
||||||
|
sheet.paste(im, ((i % cols) * thumb_w, (i // cols) * thumb_w))
|
||||||
|
out = Path(out)
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sheet.save(out, quality=90)
|
||||||
|
print(f"compare: {out} ({pairs} pairs)")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
raw = list(argv if argv is not None else sys.argv[1:])
|
||||||
|
if "--" in raw:
|
||||||
|
raw = raw[raw.index("--") + 1:]
|
||||||
|
ap = argparse.ArgumentParser(description="Local NPC part pipeline")
|
||||||
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
for cmd in ("build", "extract"):
|
||||||
|
s = sub.add_parser(cmd)
|
||||||
|
s.add_argument("--part", required=True, choices=PART_NAMES)
|
||||||
|
s.add_argument("--variant", default=PROTOTYPE)
|
||||||
|
s.add_argument("--out", required=True)
|
||||||
|
|
||||||
|
asm = sub.add_parser("assemble")
|
||||||
|
asm.add_argument("--variant", default=PROTOTYPE)
|
||||||
|
asm.add_argument("--out", required=True)
|
||||||
|
|
||||||
|
insp = sub.add_parser("inspect")
|
||||||
|
insp.add_argument("--file", required=True)
|
||||||
|
|
||||||
|
sh = sub.add_parser("sheet")
|
||||||
|
sh.add_argument("--dir", required=True)
|
||||||
|
sh.add_argument("--out", required=True)
|
||||||
|
sh.add_argument("--cols", type=int, default=4)
|
||||||
|
|
||||||
|
cm = sub.add_parser("compare")
|
||||||
|
cm.add_argument("--before", required=True)
|
||||||
|
cm.add_argument("--after", required=True)
|
||||||
|
cm.add_argument("--out", required=True)
|
||||||
|
|
||||||
|
args = ap.parse_args(raw)
|
||||||
|
|
||||||
|
if args.cmd == "sheet":
|
||||||
|
make_sheet(args.dir, args.out, args.cols)
|
||||||
|
return 0
|
||||||
|
if args.cmd == "compare":
|
||||||
|
compare(args.before, args.after, args.out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Everything else runs inside Blender.
|
||||||
|
code = _needs_blender(args.cmd)
|
||||||
|
if code is not None:
|
||||||
|
return code
|
||||||
|
ba.reset_scene()
|
||||||
|
if args.cmd == "build":
|
||||||
|
build_part(args.part, args.variant, args.out)
|
||||||
|
print(f"built part {args.part} -> {args.out}")
|
||||||
|
elif args.cmd == "extract":
|
||||||
|
extract_part(args.part, args.variant, args.out)
|
||||||
|
print(f"extracted {args.part} -> {args.out}")
|
||||||
|
elif args.cmd == "assemble":
|
||||||
|
assemble(args.variant, args.out)
|
||||||
|
print(f"assembled {args.variant} -> {args.out}")
|
||||||
|
elif args.cmd == "inspect":
|
||||||
|
inspect(args.file)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import npc_parts
|
||||||
|
|
||||||
|
|
||||||
|
class CliTests(unittest.TestCase):
|
||||||
|
def test_part_names_are_known(self):
|
||||||
|
self.assertEqual(
|
||||||
|
set(npc_parts.PART_NAMES),
|
||||||
|
{"head", "face", "hand_l", "hand_r"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_blender_commands_fail_cleanly_without_bpy(self):
|
||||||
|
# Outside Blender, build/extract/assemble/inspect must explain the
|
||||||
|
# flatpak invocation instead of raising.
|
||||||
|
for cmd in ("build", "extract", "assemble", "inspect"):
|
||||||
|
with self.subTest(cmd=cmd):
|
||||||
|
code = npc_parts._needs_blender(cmd)
|
||||||
|
self.assertEqual(code, 2)
|
||||||
|
|
||||||
|
def test_sheet_rejects_missing_directory(self):
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
npc_parts.make_sheet("/nonexistent/does-not-exist", "/tmp/x.jpg")
|
||||||
|
self.assertIn("not a directory", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_compare_rejects_missing_directory(self):
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
npc_parts.compare("/nonexistent", "/nonexistent2", "/tmp/x.jpg")
|
||||||
|
self.assertIn("not a directory", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
|
class SheetTests(unittest.TestCase):
|
||||||
|
def test_sheet_builds_from_real_pngs(self):
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
td = Path(td)
|
||||||
|
Image, _ = npc_parts._pillow()
|
||||||
|
for i in range(3):
|
||||||
|
im = Image.new("RGB", (64, 64), (i * 60, 0, 0))
|
||||||
|
im.save(td / f"f{i}.png")
|
||||||
|
out = td / "sheet.jpg"
|
||||||
|
result = npc_parts.make_sheet(str(td), str(out))
|
||||||
|
self.assertEqual(Path(result).suffix, ".jpg")
|
||||||
|
self.assertTrue(out.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user