Skip to content

Commit 394f2a2

Browse files
authored
Merge pull request #63 from MHFU-ModKit/tail/attach
A port's tail tip: whole until the cut, then it drops
2 parents b706f67 + 7fdb5ae commit 394f2a2

76 files changed

Lines changed: 1747 additions & 471 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/blender/src/mhfu_blender/write.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
What Blender holds comes in as plain data: the bind joints, each group's geometry and each slot's
66
clip. An MHFU model keeps every byte no edit reaches: a group whose vertex count and triangles are
77
unchanged is written in place, at the PMO's own size; any other is rebuilt and the PMO laid out
8-
again. An MHP3rd model comes out as an MHFU PAC with geometry, skeleton and textures, no clips.
8+
again. An MHP3rd model comes out as an MHFU PAC with geometry, skeleton and textures, no clips;
9+
its tail tip, when its skeleton has one, in mesh record 1 (`mhfu_port.mesh.build`).
910
"""
1011

1112
from __future__ import annotations
@@ -16,7 +17,7 @@
1617

1718
import numpy as np
1819
from mhfu import inject
19-
from mhfu_port import constraints, fk, mesh, motion
20+
from mhfu_port import constraints, fk, mesh, motion, rig
2021
from mhfu_port.mesh import Part, Skinned
2122
from mhfu_port.model import ANIMATION, MHFU, MODEL, SKELETON, MeshGroup, Model
2223
from mhp_formats import fu, pmo
@@ -328,13 +329,13 @@ def _donor(model: Model, skeleton: Skeleton, groups: Mapping[int, Geometry]) ->
328329
f"{len(over)} positions outside the model's {_box(scale)} box, first group "
329330
f"{kept[o.group]} vertex {o.vertex}"
330331
)
332+
bones = [replace(b, name=None) for b in skeleton.bones]
333+
fu_skeleton = Skeleton(bones, list(skeleton.params), magic=FU_MAGIC)
331334
try:
332-
out = mesh.build(skinned, scale)
335+
out = mesh.build(skinned, scale, tip=rig.tip_of(fu_skeleton))
333336
except ValueError as e:
334337
raise ExportError(str(e)) from None
335338
notes[:0] = ["no clips: an in-game monster from an MHP3rd one is a port, built from a manifest"]
336-
bones = [replace(b, name=None) for b in skeleton.bones]
337-
fu_skeleton = Skeleton(bones, list(skeleton.params), magic=FU_MAGIC)
338339
results = _judge(constraints.validate(out, fu_skeleton), [])
339340
tmh = next((e for e in entries if Tmh.sniff(e)), b"")
340341
pac = [fu_skeleton.to_bytes(), out.to_bytes(), tmh, EMPTY_ANIM.to_bytes()]

‎apps/blender/tests/conftest.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def synthetic_pac() -> bytes:
9797
uvs = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0)]
9898
influences = [[(1, 1.0)], [(1, 0.25), (2, 0.75)], [(1, 1.0)]]
9999
part = Part(verts, [], uvs, [], [(0, 1, 2)], influences, 0)
100-
model = mesh.build([Skinned(part, part.influences)], (256.0, 256.0, 256.0))
100+
model = mesh.build([Skinned(part, part.influences)], (256.0, 256.0, 256.0), tip=None)
101101
root = Track(
102102
[
103103
Channel(0x008, keys((0, 0, 0, 300), (10, 2048, -100, 50), (20, 0))),
@@ -159,7 +159,7 @@ def donor_pac() -> bytes:
159159
weights = [[(1, 1.0)], [(1, 0.25), (2, 0.75)], [(1, 1.0)], [(2, 1.0)]]
160160
part = Part(verts, normals, uvs, [], [(0, 1, 2), (2, 1, 3)], weights, 0)
161161
scale = (256.0, 256.0, 256.0)
162-
built = mesh.build([Skinned(part, part.influences)], scale)
162+
built = mesh.build([Skinned(part, part.influences)], scale, tip=None)
163163
model = p3rd.Pmo(
164164
[p3rd.Mesh(built.groups(), [0], scale=scale)], built.materials, scale, remap_table=False
165165
)

‎apps/blender/tests/test_write.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99

1010
import numpy as np
1111
import pytest
12-
from mhfu_port import motion
12+
from mhfu_port import build, manifest, motion, verify
13+
from mhfu_port.data import Data
1314
from mhfu_port.model import Model
1415
from mhp_formats import Pac, Pmo, Tmh, fu
1516
from mhp_formats.anim import Channel, Clip, Keyframe, Track
@@ -209,6 +210,27 @@ def test_donor(write: ModuleType, donor: bytes) -> None:
209210
write.place(out.pac, model)
210211

211212

213+
@pytest.mark.parametrize("side", ["source", "port"])
214+
def test_zinogre_tip(write: ModuleType, mhfu_data: Path, mhp3rd_data: Path, side: str) -> None:
215+
"""An export of the Zinogre, its MHP3rd model or its port edited, keeps the tail tip in mesh
216+
record 1, which the Tigrex's dropped tail draws: verify's tip check passes."""
217+
if side == "source":
218+
model = Model.from_path(mhp3rd_data / "file_05339.bin")
219+
groups = None
220+
else:
221+
games = Data.find(mhfu_data, mhp3rd_data)
222+
ports = Path(__file__).parents[3] / "ports"
223+
pac = build.build(manifest.load(ports / "zinogre.toml"), games).pac
224+
model = Model.from_bytes(pac, "file_06185")
225+
groups = held(write, model)
226+
groups[0].positions[0] += (3.0, -2.0, 1.5)
227+
port = verify.Port(export(write, model, groups).pac)
228+
host = verify.Port((mhfu_data / "file_06185.bin").read_bytes())
229+
check = verify.tip_check(port, host)
230+
assert check.ok, check.detail
231+
assert len(port.model.meshes[1].groups) == 6
232+
233+
212234
def test_place(write: ModuleType, model: Model, tmp_path: Path) -> None:
213235
root = tmp_path / "memstick"
214236
(root / "PSP").mkdir(parents=True)

‎apps/studio/src/mhfu_studio/monster/clips.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -179,16 +179,16 @@ def coverage(
179179
port: AnimPack,
180180
host: AnimPack | None = None,
181181
donor: Mapping[int, anim.Clip] | None = None,
182-
layout: Mapping[int, int] | None = None,
182+
layout: layouts.Layout | None = None,
183183
) -> Coverage:
184-
"""Each entry of a built pack against the donor clip `layout` (entry -> MHP3rd id; by
185-
default each entry read as the clip of its own id) puts there and the host's pack; every
186-
entry is UNKNOWN without the donor."""
184+
"""Each entry of a built pack against the donor clip `layout` puts there (by default each
185+
entry read as the clip of its own id) and the host's pack; every entry is UNKNOWN without
186+
the donor."""
187187
table = clip_table(port)
188188
host_table = clip_table(host) if host is not None else {}
189189
donor = donor or {}
190190
if layout is None:
191-
layout = {cid: cid for cid in donor if cid in table}
191+
layout = layouts.Layout({cid: cid for cid in donor if cid in table})
192192
held = slots.correspondence(port, donor, layout) if donor else {}
193193
cov = Coverage(has_source=bool(donor), has_host=host is not None)
194194
for slot, fp in sorted(table.items()):
@@ -201,11 +201,11 @@ def coverage(
201201
kind = FILLER
202202
else:
203203
kind = HOST if host_table.get(slot) == fp else ALTERED
204-
cid = layout.get(slot)
204+
cid = layout.entries.get(slot)
205205
fp_src = fingerprint(donor[cid]) if cid is not None and cid in donor else None
206206
cov.slots[slot] = SlotCoverage(slot, kind, fp[0], fp[1], fp_src, cid)
207207
if host is not None:
208-
placed = set(layout.values())
208+
placed = set(layout.entries.values())
209209
cov.dropped = {cid: fingerprint(c) for cid, c in donor.items() if cid not in placed}
210210
return cov
211211

‎apps/studio/src/mhfu_studio/monster/commands.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def _render(groups: Groups) -> None:
117117
p.add_argument("--no-grid", action="store_true", help="hide the ground plane")
118118
p.add_argument("--hilite", type=_ints, default=(), metavar="J,J,..", help="paint joints red")
119119
p.add_argument("--only", choices=("tagged", "rest"), help="draw only --hilite, or the rest")
120+
p.add_argument("--severed", action="store_true", help="the stump: the tail tip hidden")
120121
data.add_arguments(p)
121122
flags.add_flags(p)
122123
p.set_defaults(run=run_render)
@@ -168,6 +169,7 @@ def run_render(args: argparse.Namespace) -> int:
168169
grid=not args.no_grid,
169170
hilite=args.hilite,
170171
only=args.only,
172+
severed=args.severed,
171173
)
172174
return flags.finish(args, shots.render(sc, o, args.size, args.samples))
173175

@@ -267,7 +269,7 @@ def run_clips(args: argparse.Namespace) -> int:
267269
port = slots.anim_of(b.pac)
268270
table = clips.clip_table(port)
269271
host, donor = inputs.host_anim(m, games), inputs.donor_clips(m, games)
270-
cov = clips.coverage(port, host, donor, layout.of(m, donor, host).entries)
272+
cov = clips.coverage(port, host, donor, layout.of(m, donor, host))
271273
if args.import_labels:
272274
labels = slots.read_labels(args.import_labels.read_text(encoding="utf-8"))
273275
provenance = args.labels_from or clips.UNRECORDED.format(args.import_labels.name)

‎apps/studio/src/mhfu_studio/monster/core/scene.py‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
# SPDX-License-Identifier: GPL-3.0-or-later
22
# SPDX-FileCopyrightText: 2026 sp00ktober
33
"""A monster PAC as the studio opens it: `mhfu_port.model.Model` with a port manifest's clip
4-
names, the file's build id and `pose()`."""
4+
names, the file's build id and `pose()`, its tail tip on the tail unless `severed`."""
55

66
from __future__ import annotations
77

88
from collections.abc import Sequence
9+
from functools import cached_property
910
from pathlib import Path
1011
from typing import Any, Self
1112

12-
from mhfu_port import build, layout
13+
from mhfu_port import build, fk, layout, mesh, rig
1314
from mhfu_port.data import Data
1415
from mhfu_port.manifest import Manifest
1516
from mhfu_port.model import MHP3RD, Clip, Model
@@ -32,6 +33,8 @@ def __init__(self, *args: Any, **kw: Any) -> None:
3233
None if self.path is None or self.pac is None else build_id(self.path.name, self.pac)
3334
)
3435
"""`name@digest` of the file, what clip labels are keyed to."""
36+
self.severed = False
37+
"""Draw the stump: the tail tip hidden, not posed on the tail."""
3538

3639
@classmethod
3740
def from_bytes(
@@ -97,17 +100,51 @@ def attach_manifest(self, m: Manifest) -> None:
97100
by_slot.setdefault(at, []).append(name)
98101
self.rename(by_slot)
99102

103+
# the tail tip
104+
105+
@cached_property
106+
def tip(self) -> rig.Tip | None:
107+
"""`rig.tip_of` the skeleton; None, with a note, where no offset pairs the chain."""
108+
try:
109+
return rig.tip_of(self.skeleton)
110+
except ValueError as e:
111+
self.notes.append(f"the tail tip stays where it is stored: {e}")
112+
return None
113+
114+
def tip_groups(self) -> list[int]:
115+
"""The groups riding only the tip's chain (`mesh.tip_parts`)."""
116+
if self.tip is None:
117+
return []
118+
rows = [
119+
[
120+
list(zip(js, ws, strict=True))
121+
for js, ws in zip(g.skin.joints.tolist(), g.skin.weights.tolist(), strict=True)
122+
]
123+
for g in self.groups
124+
]
125+
return mesh.tip_parts(rows, self.tip.joints)
126+
127+
def hidden(self) -> set[int]:
128+
"""The groups not drawn: the tip's, when `severed`."""
129+
return set(self.tip_groups()) if self.severed else set()
130+
131+
def world(self, rot: fk.Floats | None = None, loc: fk.Floats | None = None) -> fk.Floats:
132+
"""`fk.Rig.world`, the tip's chain posed as its carriers unless `severed`, as the game
133+
does while the tail is whole."""
134+
world = self.rig.world(rot, loc)
135+
return world if self.severed or self.tip is None else fk.attach(world, self.tip.pairs)
136+
100137
def clip_table(self) -> dict[int, tuple[int, bool]]:
101138
return {c.slot: (c.frames, c.loop) for c in self.clips}
102139

103140
def pose(self, key: int | str | Clip, frame: float = 0.0) -> Pose:
104141
"""Frames outside the clip hold per channel."""
105142
c = self.clip(key)
106143
rot, loc = self.curves(c).at(float(frame))
107-
return Pose(self.rig, float(frame), self.rig.world(rot, loc), c.slot)
144+
return Pose(self.rig, float(frame), self.world(rot, loc), c.slot)
108145

109146
def bind_pose(self) -> Pose:
110-
return Pose(self.rig, 0.0, self.rig.bind_world)
147+
return Pose(self.rig, 0.0, self.world())
111148

112149
# reporting
113150

‎apps/studio/src/mhfu_studio/monster/render/mesh.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,10 +258,12 @@ def _upload_textures(ctx: moderngl.Context, scene: Scene) -> dict[int, moderngl.
258258

259259

260260
def _index_batches(ctx: moderngl.Context, scene: Scene) -> dict[int | None, moderngl.Buffer]:
261-
"""One index buffer per texture position; None holds the untextured groups."""
261+
"""One index buffer per texture position, `Scene.hidden` left out; None holds the
262+
untextured groups."""
262263
parts: dict[int | None, list[npt.NDArray[np.int64]]] = {}
264+
hidden = scene.hidden()
263265
for i, g in enumerate(scene.groups):
264-
if g.n_faces:
266+
if g.n_faces and i not in hidden:
265267
lo, _ = scene.group_range(i)
266268
parts.setdefault(g.texture, []).append(g.triangles.astype(np.int64) + lo)
267269
return {t: ctx.buffer(np.concatenate(p).astype("i4").tobytes()) for t, p in parts.items()}

‎apps/studio/src/mhfu_studio/monster/render/playback.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ def pose_at(
191191
if js:
192192
loc = loc.copy()
193193
loc[js] = curves.at(0.0)[1][js]
194-
world = scene.rig.world(rot, loc)
194+
world = scene.world(rot, loc)
195195
th = yaw_at(scene, clip, frame, steer, speed)
196196
if th:
197197
world = yaw_matrix(th) @ world

‎apps/studio/src/mhfu_studio/monster/render/shots.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ class Options:
3636
grid: bool = True
3737
hilite: Sequence[int] = ()
3838
only: str | None = None
39+
severed: bool = False
40+
"""The stump: the tail tip hidden (`Scene.severed`)."""
3941

4042

4143
def configure(vp: MonsterViewport, o: Options) -> None:
@@ -77,6 +79,7 @@ def render(
7779
"""`<clip>_f<frame>_<view>` per frame and view (`bind_<view>` for the bind pose), each framed
7880
on its own pose."""
7981
images: dict[str, Image8] = {}
82+
scene.severed = o.severed
8083
with offscreen(lambda c: MonsterViewport(c, size, samples), ctx) as vp:
8184
vp.set_scene(scene)
8285
configure(vp, o)

‎apps/studio/src/mhfu_studio/monster/render/viewport.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def pose(self, clip: Clip | None, frame: float = 0.0) -> None:
7676
steer=self.steer,
7777
speed=self.playback.speed,
7878
)
79-
self.mesh.set_pose(None if clip is None else p)
79+
self.mesh.set_pose(p) # bind included: the tail tip moves onto the tail
8080
self.skeleton.set_positions(p.joints)
8181
for ov in (self.hitboxes, self.attacks):
8282
if ov is not None:

0 commit comments

Comments
 (0)