Skip to content

Commit 1d9e418

Browse files
authored
Merge pull request #41 from MHFU-ModKit/studio-qt
feat(studio): the studio moves to Qt
2 parents 79df91f + e5994ce commit 1d9e418

114 files changed

Lines changed: 15536 additions & 6393 deletions

Some content is hidden

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

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ jobs:
3939
enable-cache: true
4040
# Mesa for the studio's GL tests; X11 headers because glcontext has no wheel for 3.14
4141
- run: sudo apt-get update && sudo apt-get install -y libegl1 libegl-mesa0 libgl1 libgl1-mesa-dri libx11-dev
42-
- run: uv sync --locked
43-
- run: uv run pytest
42+
# no Qt tests here: no display and none of Qt's system libraries (they skip)
43+
- run: uv sync --locked --no-group qt
44+
- run: uv run --no-group qt pytest
4445

4546
# the Lua that ships against the mhfu API it declares (framework/lua/meta/mhfu.d.lua)
4647
lua:

‎apps/studio/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ One window for authoring MHFU mods offline: a ported monster (its clips, parts,
44
moves) and map edits (mesh, collision, textures), with the commands behind both.
55

66
```bash
7-
uv run studio # the window
8-
uv run studio --help # the commands: render, port, map
7+
uv run studio # the window
8+
uv run studio open PATH # the window with PATH open; also --workspace NAME, --size WxH
9+
uv run studio --help # the commands: open, render, port, map
910
```
1011

1112
Licensed GPL-3.0-or-later: it is built on `mhp-formats`, which contains code derived from

‎apps/studio/docker/Dockerfile‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
# The studio's tests and `studio render` without a display or a GPU: Python 3.11, uv and Mesa's
2-
# llvmpipe behind EGL. The repository is mounted at /repo, never copied (run.sh).
2+
# llvmpipe behind EGL, and Qt's offscreen platform. The repository is mounted at /repo, never
3+
# copied (run.sh).
34
# Base images are pinned by digest so the renderer string, and with it the exact-hash goldens,
45
# only change when this file does.
56
FROM ghcr.io/astral-sh/uv:0.11.5@sha256:555ac94f9a22e656fc5f2ce5dfee13b04e94d099e46bb8dd3a73ec7263f2e484 AS uv
67

78
FROM --platform=linux/amd64 python:3.11-slim-trixie@sha256:e41613d42d4891e4930f79523f93f81bbc7632584ec65e36ab055f41a800b41e
8-
# Debian's archive as of one date, so Mesa (whose version is in the renderer string) is fixed too
9+
# Debian's archive as of one date, so Mesa (whose version is in the renderer string) is fixed too.
10+
# Qt's own libraries come next, then its xcb platform and Xvfb for MHFU_UI_DISPLAY (run.sh).
911
ARG SNAPSHOT=20260920T000000Z
1012
RUN apt-get update --snapshot "$SNAPSHOT" -o Acquire::Check-Valid-Until=false \
1113
&& apt-get install -y --no-install-recommends --snapshot "$SNAPSHOT" \
1214
libegl1 libegl-mesa0 libgl1 libgl1-mesa-dri git ca-certificates \
15+
libdbus-1-3 libfontconfig1 libglib2.0-0t64 libxkbcommon0 \
16+
libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-render-util0 \
17+
libxcb-shape0 libxcb-util1 libxcb-xkb1 libxkbcommon-x11-0 xauth xvfb \
1318
&& rm -rf /var/lib/apt/lists/*
1419
COPY --from=uv /uv /uvx /usr/local/bin/
1520

@@ -26,4 +31,6 @@ ENV UV_PROJECT_ENVIRONMENT=/venv \
2631
PYTHONDONTWRITEBYTECODE=1 \
2732
STUDIO_MOUNT=/repo
2833
WORKDIR /repo
34+
# with MHFU_UI_DISPLAY set the command runs on an Xvfb display, where Qt takes xcb and GL
35+
ENTRYPOINT ["sh", "-c", "exec ${MHFU_UI_DISPLAY:+xvfb-run -a} \"$@\"", "sh"]
2936
CMD ["uv", "run", "pytest", "apps/studio", "-q", "-p", "no:cacheprovider"]

‎apps/studio/docker/run.sh‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env bash
22
# Runs a command in the studio's GL container with this repository mounted at /repo.
33
# apps/studio/docker/run.sh # the studio's tests
4+
# MHFU_UI_DISPLAY=1 apps/studio/docker/run.sh # with Qt on Xvfb: the GL ones too
45
# apps/studio/docker/run.sh uv run studio render selftest -o out/
56
# Output paths must lie inside the repository: anything else is written inside the container
67
# and is gone when it exits. MHFU_DATA and MHP3RD_DATA, when set, are mounted read-only.
@@ -11,8 +12,10 @@ image=${STUDIO_IMAGE:-mhfu-studio-gl}
1112

1213
docker build --quiet --platform linux/amd64 -t "$image" "$here" >/dev/null
1314

14-
flags=(--rm --platform linux/amd64 --user "$(id -u):$(id -g)"
15+
# --init: xvfb-run in PID 1 never hears that Xvfb is up
16+
flags=(--rm --init --platform linux/amd64 --user "$(id -u):$(id -g)"
1517
-v "$repo:/repo" -v mhfu-studio-venv:/venv -v mhfu-studio-uv:/cache/uv)
18+
if [ -n "${MHFU_UI_DISPLAY:-}" ]; then flags+=(-e MHFU_UI_DISPLAY); fi
1619
for var in MHFU_DATA MHP3RD_DATA; do
1720
if [ -n "${!var:-}" ]; then
1821
# mount the whole extraction: the variable may name it or its data_files

‎apps/studio/pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ dependencies = [
1111
"mhfu-port",
1212
"numpy>=2",
1313
"moderngl>=5.12",
14-
"imgui-bundle>=1.92",
14+
"pyside6-essentials>=6.11",
15+
"qtawesome>=1.4",
1516
"pillow>=11",
1617
"tomli-w>=1.2",
1718
]

‎apps/studio/src/mhfu_studio/cli.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
if TYPE_CHECKING:
1414
Subparsers: TypeAlias = argparse._SubParsersAction[argparse.ArgumentParser]
1515

16-
AREAS = ("shell", "harness", "monster", "map", "stage")
16+
AREAS = ("harness", "map", "monster", "stage", "ui")
1717

1818

1919
@dataclass(frozen=True)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# SPDX-License-Identifier: GPL-3.0-or-later
2+
# SPDX-FileCopyrightText: 2026 sp00ktober
3+
"""What the Add panel adds, and where: a shape and its sizes, or a copy of the selection,
4+
`pack`ed into a group's free primitives and those of the objects it replaces."""
5+
6+
from __future__ import annotations
7+
8+
from pathlib import Path
9+
from typing import TYPE_CHECKING, Any
10+
11+
import numpy as np
12+
13+
from .core import shapes
14+
from .core.edit import OBJECT, EditError, Selection
15+
from .core.scene import Array, MeshGroup
16+
17+
if TYPE_CHECKING:
18+
from .workspace import MapWorkspace
19+
20+
COPY = "copy"
21+
KINDS = (*shapes.SHAPES, COPY)
22+
#: where it goes: the selection's bottom centre, the last click, or the typed point
23+
PLACES = ("selection", "click", "typed")
24+
UV_MODES = ("planar", "keep", "obj")
25+
SOLIDS: tuple[bool | str | None, ...] = (None, "box", True)
26+
27+
28+
class AddForm:
29+
"""The Add panel's values; the workspace keeps them, so a panel can come and go."""
30+
31+
def __init__(self, ws: MapWorkspace) -> None:
32+
self.ws = ws
33+
#: indices into KINDS, PLACES, UV_MODES and SOLIDS
34+
self.shape = 0
35+
self.at_mode = 0
36+
self.uv = 0
37+
self.solid = 1
38+
self.size = [400.0, 300.0, 400.0]
39+
self.radius = 200.0
40+
self.height = 400.0
41+
self.segments = 8
42+
self.rings = 6
43+
self.divisions = 1
44+
self.rotate = 0.0
45+
self.at = [0.0, 0.0, 0.0]
46+
#: the target group, an index into the scene's groups
47+
self.group = 0
48+
self.replace = True
49+
self.colour = True
50+
self.copy_scale = 1.0
51+
#: the last add's outcome
52+
self.message = ""
53+
54+
@property
55+
def kind(self) -> str:
56+
return KINDS[self.shape]
57+
58+
def placement(self) -> Array:
59+
"""The selection's bottom centre, the last click, or the typed point."""
60+
ws = self.ws
61+
if self.at_mode == 0 and not ws.selection.empty and ws.scene is not None:
62+
lo, hi = ws.selection.bounds(ws.scene)
63+
return np.array([(lo[0] + hi[0]) / 2, lo[1], (lo[2] + hi[2]) / 2], np.float64)
64+
if self.at_mode == 1 and ws.tools.last_pick is not None:
65+
return np.asarray(ws.tools.last_pick.point, np.float64)
66+
return np.asarray(self.at, np.float64)
67+
68+
def shape_args(self, kind: str) -> dict[str, Any]:
69+
if kind == "box" or kind == "ramp":
70+
return {"size": tuple(self.size)}
71+
if kind == "plane":
72+
return {"size": (self.size[0], self.size[2]), "divisions": self.divisions}
73+
if kind == "cylinder":
74+
return {"radius": self.radius, "height": self.height, "segments": self.segments}
75+
if kind == "sphere":
76+
return {"radius": self.radius, "rings": self.rings, "segments": self.segments}
77+
return {}
78+
79+
def target(self) -> MeshGroup | None:
80+
"""The group it goes into."""
81+
sc = self.ws.scene
82+
if sc is None or not sc.groups:
83+
return None
84+
return sc.groups[max(0, min(self.group, len(sc.groups) - 1))]
85+
86+
def sacrifice(self) -> Selection | None:
87+
"""The selected objects whose primitives the shape may take."""
88+
g, sel = self.target(), self.ws.selection
89+
if g is None or not self.replace or self.kind == COPY or g.key not in sel.vertices:
90+
return None
91+
return sel
92+
93+
def needs(self) -> int:
94+
"""Triangles the shape or the copy has."""
95+
sc = self.ws.scene
96+
if self.kind == COPY:
97+
return self.ws.selection.n_faces(sc) if sc is not None else 0
98+
return shapes.triangle_count(self.kind, **self.shape_args(self.kind))
99+
100+
def capacity(self) -> tuple[int, int]:
101+
"""Triangles that fit: with the sacrifice, and in free primitives alone."""
102+
g, sess = self.target(), self.ws.session
103+
if g is None or sess is None:
104+
return 0, 0
105+
return sess.capacity(g.key, self.sacrifice()), sess.capacity(g.key, None)
106+
107+
def add(self) -> Selection | None:
108+
"""Writes the shape into the document's assets/ and packs it; the new object, or None
109+
with `message` saying why not."""
110+
ws = self.ws
111+
sc, sess, g = ws.scene, ws.session, self.target()
112+
if sc is None or sess is None or g is None:
113+
return None
114+
if sess.base_dir is None:
115+
self.message = "save the document first: the shape is written into its assets/"
116+
return None
117+
kind, at = self.kind, tuple(self.placement())
118+
try:
119+
if kind in ("cylinder", "sphere"):
120+
v, t, uv = shapes.make(kind, at=at, **self.shape_args(kind))
121+
stem = kind
122+
elif kind != COPY:
123+
v, t, uv = shapes.make(kind, at=at, rotate_y=self.rotate, **self.shape_args(kind))
124+
stem = kind
125+
else:
126+
if ws.selection.empty:
127+
self.message = "nothing selected to copy"
128+
return None
129+
k = next(iter(ws.selection.vertices))
130+
ids = ws.selection.vertices[k]
131+
v, t, uv = shapes.from_group(
132+
sc, k, ids, at=at, scale=self.copy_scale, rotate_y=self.rotate
133+
)
134+
stem = f"copy_g{k[1]}"
135+
except (KeyError, ValueError) as e:
136+
self.message = f"shape: {e}"
137+
return None
138+
name = shapes.next_asset_name(sess.base_dir, stem)
139+
shapes.write_obj(sess.base_dir / name, v, t, uv, stem)
140+
try:
141+
new = sess.add_mesh(
142+
g.key,
143+
name,
144+
len(t),
145+
sacrifice=self.sacrifice(),
146+
uv=UV_MODES[self.uv],
147+
colour=self._colour(),
148+
solid=SOLIDS[self.solid],
149+
)
150+
except EditError as e:
151+
self.message = f"refused: {e}"
152+
(sess.base_dir / name).unlink(missing_ok=True)
153+
return None
154+
self.message = f"added {Path(name).name} ({len(t)} triangles) into {g.label} as {name}"
155+
warnings = sess.warnings()
156+
if warnings:
157+
self.message += " [!] " + warnings[0]
158+
ws.after_commit(sess.ops[-1:])
159+
ws.tools.kind = OBJECT
160+
ws.tools.select(new)
161+
return new
162+
163+
def _colour(self) -> list[int] | None:
164+
"""The selection's mean vertex colour, when asked for and there is one."""
165+
ws = self.ws
166+
if not self.colour or ws.selection.empty or ws.scene is None:
167+
return None
168+
sc = ws.scene
169+
cols = [
170+
cs[ids]
171+
for k, ids in ws.selection.vertices.items()
172+
if (cs := sc.group(*k).colours) is not None
173+
]
174+
return [int(x) for x in np.concatenate(cols).mean(0).round()] if cols else None

‎apps/studio/src/mhfu_studio/map/core/edit.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ def ids_of(p: int) -> Array:
175175
def empty(self) -> bool:
176176
return not any(len(v) for v in self.vertices.values())
177177

178+
def same(self, other: Selection) -> bool:
179+
"""The same vertices, whatever parts chose them."""
180+
a, b = self.vertices, other.vertices
181+
return a.keys() == b.keys() and all(np.array_equal(a[k], b[k]) for k in a)
182+
178183
@property
179184
def n_vertices(self) -> int:
180185
return int(sum(len(v) for v in self.vertices.values()))

‎apps/studio/src/mhfu_studio/map/document.py‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
ops = "st098.json" # the schema of `stage.ops`
1212
1313
Stages naming the same list (a night twin) share it: editing one edits both. The loaded
14-
stage's list is its edit session's own, so undo, redo and dirty go through the session.
14+
stage's list is its edit session's own, so undo, redo and dirty go through the session. A stage
15+
whose list is empty (every edit undone) is neither saved nor a change.
1516
"""
1617

1718
from __future__ import annotations
@@ -99,10 +100,15 @@ def stage(self, number: int) -> StageEntry | None:
99100
return next((s for s in self.stages if s.number == number), None)
100101

101102
def ensure_stage(
102-
self, number: int, ops_file: str | None = None, ops: list[Op] | None = None
103+
self,
104+
number: int,
105+
ops_file: str | None = None,
106+
ops: list[Op] | None = None,
107+
row: int | None = None,
103108
) -> StageEntry:
104109
"""The stage's entry, added when missing: over `ops` (a session's own list), else the
105-
list another stage keeps in that file, else a new one."""
110+
list another stage keeps in that file, else a new one. A document without a row takes
111+
`row`, the map the stage was loaded from."""
106112
s = self.stage(number)
107113
if s is None:
108114
file = ops_file or default_file(number)
@@ -111,9 +117,15 @@ def ensure_stage(
111117
ops = shared if shared is not None else []
112118
s = StageEntry(number, file, ops)
113119
self.stages.append(s)
120+
if self.row is None:
121+
self.row = row
114122
self.touch()
115123
return s
116124

125+
def kept(self) -> list[StageEntry]:
126+
"""The stages a save writes: those with edits."""
127+
return [s for s in self.stages if s.ops]
128+
117129
def shared_lists(self) -> dict[str, list[int]]:
118130
"""Per ops file, the stages using it."""
119131
out: dict[str, list[int]] = {}
@@ -177,7 +189,7 @@ def save(self, path: Path | None = None) -> Path:
177189
raise ValueError("the document has no folder yet: pick one (Save As, or Document)")
178190
d = self.directory
179191
d.mkdir(parents=True, exist_ok=True)
180-
for file, ops in {s.ops_file: s.ops for s in self.stages}.items():
192+
for file, ops in self._lists().items():
181193
(d / file).parent.mkdir(parents=True, exist_ok=True)
182194
(d / file).write_text(json.dumps(ops, indent=1) + "\n", encoding="utf-8")
183195
p = d / MANIFEST
@@ -196,12 +208,18 @@ def manifest(self) -> dict[str, Any]:
196208
if self.description:
197209
table["description"] = self.description
198210
table.update(self.extra)
199-
stages = [{"number": s.number, "ops": s.ops_file} for s in self.stages]
211+
stages = [{"number": s.number, "ops": s.ops_file} for s in self.kept()]
200212
return {"map": table, "stage": stages} if stages else {"map": table}
201213

214+
def _lists(self) -> dict[str, list[Op]]:
215+
return {s.ops_file: s.ops for s in self.kept()}
216+
202217
def _state(self) -> str:
203-
lists = {s.ops_file: s.ops for s in self.stages}
204-
return json.dumps([self.manifest(), lists], sort_keys=True)
218+
"""What a save writes; the row only with a stage it places (the first edit sets it)."""
219+
m = self.manifest()
220+
if "stage" not in m:
221+
m["map"].pop("row", None)
222+
return json.dumps([m, self._lists()], sort_keys=True)
205223

206224
@property
207225
def dirty(self) -> bool:
@@ -274,7 +292,7 @@ def export(self, out_dir: Path) -> dict[str, Any]:
274292
base = self.directory or Path(".")
275293
manifest: dict[str, Any] = {"schema": SCHEMA, "name": self.name, "row": self.row}
276294
stages = []
277-
for s in self.stages:
295+
for s in self.kept():
278296
sf = self.file(s.number)
279297
rec: dict[str, Any] = {"stage": s.number, "ops": len(s.ops), "mesh": {}}
280298
rec["collision"] = rec["texture"] = None

0 commit comments

Comments
 (0)