From 6dba9d9f24ace35c41f62ad605153baf025cfe9a Mon Sep 17 00:00:00 2001 From: The_miro Date: Mon, 24 Aug 2026 21:01:16 +0200 Subject: [PATCH] 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 --- README.md | 7 +- tools/ARMOUR_QUICKSTART.py | 170 ++++++++++++++++++++++++++++++++++--- 2 files changed, 163 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 31361ab..7cb9239 100644 --- a/README.md +++ b/README.md @@ -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 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` -block on the clipboard - the tool writes nothing itself, so the number is what -you leave with. +block on the clipboard, and `Write to source` puts it back in the module entry +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 under the body part its `origin` names, and those are declared in diff --git a/tools/ARMOUR_QUICKSTART.py b/tools/ARMOUR_QUICKSTART.py index 6591946..827d56b 100755 --- a/tools/ARMOUR_QUICKSTART.py +++ b/tools/ARMOUR_QUICKSTART.py @@ -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 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 -to be the one you want to change, ARMOUR_GUI edits its geometry and -ARMOUR_EDITOR poses it in a scene. +`Write to source` is the one thing here that changes a file: it puts those +numbers back into the module entry they were read from, leaving its scale and +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 @@ -92,7 +95,8 @@ class Entry: # a filing convention that only usually agrees. Anything that names no # part is drawn in item space, which is where icons and held pieces # 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 # move - so the declared one is kept to go back to. self.given = self.matrix.copy() @@ -135,16 +139,57 @@ def placements(res): continue for section in ('merge', 'replace'): entries = doc.get('data', {}).get(section, {}).get('model') or [] - for entry in entries if isinstance(entries, list) else []: - ref = entry.get('path') + entries = entries if isinstance(entries, list) else [entries] + for i, entry in enumerate(entries): + ref = isinstance(entry, dict) and entry.get('path') if not ref: continue transform = entry.get('transform') or {} - out[_key(ref)] = (transform.get('origin'), - mc.transform_matrix(transform), True) + key = _key(ref) + # 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 +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): """A model reference cut back to its folder. @@ -299,14 +344,16 @@ class Quickstart(QtWidgets.QMainWindow): form.addRow(axis, spin) lay.addLayout(form) - for label, slot in (('Reset to declared', self.reset_transform), - ('Copy as JSON', self.copy_transform)): + for label, slot in (('Write to source', self.write_to_source), + ('Reset to declared', self.reset_transform), + ('Copy as JSON', self.copy_transform), + ('Unwrap UVs', self.unwrap_uvs)): button = QtWidgets.QPushButton(label) button.clicked.connect(slot) lay.addWidget(button) - note = QtWidgets.QLabel('drag a handle in the viewport, or type; nothing ' - 'here is written to disk') + note = QtWidgets.QLabel('drag a handle in the viewport, or type. Only ' + '`Write to source` touches the tree') note.setWordWrap(True) note.setStyleSheet('color: #888;') lay.addWidget(note) @@ -348,6 +395,105 @@ class Quickstart(QtWidgets.QMainWindow): self._sync_transform() 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): """The transform block, ready to paste into the module that names it.""" entry = self._current()