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()