Two buttons that write: the transform back, and the UVs afresh

The quickstart could show that a model was in the wrong place and then leave you
to retype the number somewhere else. `Write to source` closes that: the panel's
translation and rotation go back into the module entry they were read from,
with its scale and its origin left as the module had them, because placement
lives in the module rather than in the geometry. A model no module names - a
sword part, an icon of its own - has nowhere to write to and says so instead of
guessing at a file. Whole numbers stay whole, so an axis a drag never touched
comes back reading `0` rather than `0.0`.

`Unwrap UVs` is the other half, and it is the same unwrap ARMOUR_GUI does when
it saves: every face gets a rectangle of its own, the atlas size is written
beside the boxes, and the faces keep the texture key they already name rather
than being pointed at `#0`. A template is painted to match only where there is
no texture yet - a guide is scaffolding, and overwriting art someone has
painted because the boxes moved would be the tool destroying the work it exists
for. A sprite has no boxes to cut, and says that rather than writing an empty
atlas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-08-24 21:01:16 +02:00
parent 9449af92b1
commit 6dba9d9f24
2 changed files with 163 additions and 14 deletions

View File

@ -464,8 +464,11 @@ The left panel is the selected model's MIAPI transform. Three arrows on the
model's origin move it along an axis and the rotate toggle swaps them for three model's origin move it along an axis and the rotate toggle swaps them for three
rings; the position and rotation boxes read what the drag did, in the units a rings; the position and rotation boxes read what the drag did, in the units a
module writes, and take typed numbers back. `Copy as JSON` puts the `transform` module writes, and take typed numbers back. `Copy as JSON` puts the `transform`
block on the clipboard - the tool writes nothing itself, so the number is what block on the clipboard, and `Write to source` puts it back in the module entry
you leave with. it was read from - scale and origin untouched, and nothing to write for a model
no module names. `Unwrap UVs` re-cuts the selected model's texture so every
face has a patch of its own, writes the atlas size beside the boxes, and paints
a template to match where there is no texture yet - never over one there is.
Where a model goes comes from the mod's own module data: MIAPI draws a model Where a model goes comes from the mod's own module data: MIAPI draws a model
under the body part its `origin` names, and those are declared in under the body part its `origin` names, and those are declared in

View File

@ -34,9 +34,12 @@ toggle swaps them for three rings. Dragging one and typing a number are the
same edit seen from two sides, so the panel always reads what the model is same edit seen from two sides, so the panel always reads what the model is
doing, and `Copy as JSON` hands over the block to paste into the module. doing, and `Copy as JSON` hands over the block to paste into the module.
Nothing here writes: what you leave with is the number. When a model turns out `Write to source` is the one thing here that changes a file: it puts those
to be the one you want to change, ARMOUR_GUI edits its geometry and numbers back into the module entry they were read from, leaving its scale and
ARMOUR_EDITOR poses it in a scene. its origin alone. Placement lives in the module, so a model no module names -
a sword part, an icon of its own - has nowhere to write to and says so. When it
is the geometry itself that wants moving, ARMOUR_GUI edits the boxes and
ARMOUR_EDITOR poses the result in a scene.
""" """
from __future__ import annotations from __future__ import annotations
@ -92,7 +95,8 @@ class Entry:
# a filing convention that only usually agrees. Anything that names no # a filing convention that only usually agrees. Anything that names no
# part is drawn in item space, which is where icons and held pieces # part is drawn in item space, which is where icons and held pieces
# are modelled anyway. # are modelled anyway.
origin, self.matrix, self.declared = placement or (None, np.eye(4), False) origin, self.matrix, self.declared, self.source = \
placement or (None, np.eye(4), False, None)
# The transform is editable - that is what the handles in the viewport # The transform is editable - that is what the handles in the viewport
# move - so the declared one is kept to go back to. # move - so the declared one is kept to go back to.
self.given = self.matrix.copy() self.given = self.matrix.copy()
@ -135,16 +139,57 @@ def placements(res):
continue continue
for section in ('merge', 'replace'): for section in ('merge', 'replace'):
entries = doc.get('data', {}).get(section, {}).get('model') or [] entries = doc.get('data', {}).get(section, {}).get('model') or []
for entry in entries if isinstance(entries, list) else []: entries = entries if isinstance(entries, list) else [entries]
ref = entry.get('path') for i, entry in enumerate(entries):
ref = isinstance(entry, dict) and entry.get('path')
if not ref: if not ref:
continue continue
transform = entry.get('transform') or {} transform = entry.get('transform') or {}
out[_key(ref)] = (transform.get('origin'), key = _key(ref)
mc.transform_matrix(transform), True) # A module can name the same model twice - once for the body
# part and once, origin-less, for the inventory icon. The one
# that names a part is the placement being looked at, so it
# wins; the icon entry only fills in when it is all there is.
if key in out and not transform.get('origin'):
continue
out[key] = (transform.get('origin'), mc.transform_matrix(transform),
True, (path, section, i))
return out return out
def _texture_key(doc):
"""The `#key` a model's faces are textured through, or the only one there is."""
for element in doc.get('elements') or []:
for face in (element.get('faces') or {}).values():
if isinstance(face, dict) and face.get('texture'):
return face['texture']
for name in doc.get('textures') or {}:
if name != 'particle':
return f'#{name}'
return '#0'
def _texture_path(ref):
"""Where a namespaced texture id lives, as a path into the resource tree."""
ns, _, rest = ref.partition(':')
if not _:
ns, rest = 'minecraft', ref
if rest.startswith('textures/'):
rest = rest[len('textures/'):]
return f'assets/{ns}/textures/{rest}.png'
def _round(value):
"""A number fit to be written down.
No float dust and no negative zero, and a whole number stays whole - a
module that said `0` should not come back saying `0.0` for the sake of a
drag that never touched that axis.
"""
value = round(float(value), 4) + 0.0
return int(value) if value == int(value) else value
def _key(ref): def _key(ref):
"""A model reference cut back to its folder. """A model reference cut back to its folder.
@ -299,14 +344,16 @@ class Quickstart(QtWidgets.QMainWindow):
form.addRow(axis, spin) form.addRow(axis, spin)
lay.addLayout(form) lay.addLayout(form)
for label, slot in (('Reset to declared', self.reset_transform), for label, slot in (('Write to source', self.write_to_source),
('Copy as JSON', self.copy_transform)): ('Reset to declared', self.reset_transform),
('Copy as JSON', self.copy_transform),
('Unwrap UVs', self.unwrap_uvs)):
button = QtWidgets.QPushButton(label) button = QtWidgets.QPushButton(label)
button.clicked.connect(slot) button.clicked.connect(slot)
lay.addWidget(button) lay.addWidget(button)
note = QtWidgets.QLabel('drag a handle in the viewport, or type; nothing ' note = QtWidgets.QLabel('drag a handle in the viewport, or type. Only '
'here is written to disk') '`Write to source` touches the tree')
note.setWordWrap(True) note.setWordWrap(True)
note.setStyleSheet('color: #888;') note.setStyleSheet('color: #888;')
lay.addWidget(note) lay.addWidget(note)
@ -348,6 +395,105 @@ class Quickstart(QtWidgets.QMainWindow):
self._sync_transform() self._sync_transform()
self.rebuild() self.rebuild()
def write_to_source(self):
"""Put the panel's numbers back into the module that declares the model.
Placement lives in the module rather than in the geometry, so that is
what is written: the `translation` and `rotation` of the model entry
the tool read this placement from, with its scale and its origin left
as the module had them. A model no module names has nowhere to write
to - its geometry is its position - and says so rather than guessing.
This is the one thing here that changes a file.
"""
entry = self._current()
if entry is None:
return
if entry.source is None or not entry.loose:
self.statusBar().showMessage(
f'{entry.label}: no module in this tree names it, so there is no'
' transform to write - move the boxes in ARMOUR_GUI instead')
return
path, section, index = entry.source
full = self._on_disk(path)
if full is None:
self.statusBar().showMessage(f'{path}: not in a directory to write to')
return
with open(full) as fh:
doc = json.load(fh)
model = doc['data'][section]['model']
target = (model[index] if isinstance(model, list) else model)
transform = target.setdefault('transform', {})
written = mc.decompose(entry.matrix)
for group in ('translation', 'rotation'):
transform[group] = {a: _round(written[group][a]) for a in 'xyz'}
with open(full, 'w') as fh:
fh.write(ae._dumps(doc))
fh.write('\n')
# What the file says is what is declared now, so this is the state
# `Reset to declared` should come back to.
entry.given = entry.matrix.copy()
entry.declared = True
self.statusBar().showMessage(f'wrote the transform to {path}')
def unwrap_uvs(self, _checked=False):
"""Re-cut the selected model's texture so every face has its own patch.
The same unwrap ARMOUR_GUI does when it saves, on a model that is
already drawn rather than one being built: every face gets a rectangle
of its own, the atlas size is written back beside the boxes, and a
template is painted to match - but only where there is no texture yet.
A guide is scaffolding, and overwriting art someone has painted because
the boxes moved would be the tool destroying the work it exists for.
"""
entry = self._current()
if entry is None:
return
model_path = f'assets/{entry.ns}/models/{entry.rest}.json'
full = self._on_disk(model_path) if entry.loose else None
if full is None:
self.statusBar().showMessage(f'{entry.label}: not a file in this tree')
return
with open(full) as fh:
doc = json.load(fh)
if not doc.get('elements'):
self.statusBar().showMessage(
f'{entry.label}: a sprite, not boxes - there is nothing to unwrap')
return
# Unwrap onto the key the model's own faces already name, so a model
# textured through `#1` does not come back pointing at `#0`.
key = _texture_key(doc)
size, nets = mc.unwrap(doc['elements'], texture=key)
doc['texture_size'] = [int(size[0]), int(size[1])]
with open(full, 'w') as fh:
fh.write(ae._dumps(doc))
fh.write('\n')
written = [model_path]
ref = (doc.get('textures') or {}).get(key.lstrip('#'))
template = _texture_path(ref) if ref else None
if template and self._on_disk(template) is None:
beside = os.path.join(full[:-len(model_path)], template)
os.makedirs(os.path.dirname(beside), exist_ok=True)
mc.unwrap_template(size, nets, doc['elements']).save(beside)
written.append(template)
self.rebuild()
self.statusBar().showMessage(
f'unwrapped onto {int(size[0])}x{int(size[1])} - wrote '
+ ', '.join(written))
def _on_disk(self, path):
"""The writable file behind a resource path, if one of the sources has it."""
for directory in self.res.dirs:
full = os.path.join(directory, path)
if os.path.isfile(full):
return full
return None
def copy_transform(self): def copy_transform(self):
"""The transform block, ready to paste into the module that names it.""" """The transform block, ready to paste into the module that names it."""
entry = self._current() entry = self._current()