working model cat backup

This commit is contained in:
JakeBreath
2026-08-04 21:47:37 -05:00
parent 50a70bfe31
commit be8c1a9ea8
6 changed files with 156 additions and 47 deletions
+2 -2
View File
@@ -636,7 +636,7 @@ def _reparse_version(version):
else: else:
version.animation_id = '' version.animation_id = ''
version.animation_manifest = read_animation_manifest(path) or {} version.animation_manifest = read_animation_manifest(path) or {}
version.models_manifest = read_models_manifest(path) or {} version.models_manifest = read_models_manifest(path, version.project.category) or {}
version.logical_manifest = read_logical_manifest(path) or {} version.logical_manifest = read_logical_manifest(path) or {}
manifest = parse_mods_manifest(path, release.file.original_filename) manifest = parse_mods_manifest(path, release.file.original_filename)
version.mods_manifest = manifest if manifest.get('files') else {} version.mods_manifest = manifest if manifest.get('files') else {}
@@ -694,7 +694,7 @@ def _finalize_version(project, version_name, changelog, temp_uploads, actor):
if animation_manifest is None: if animation_manifest is None:
animation_manifest = read_animation_manifest(path) animation_manifest = read_animation_manifest(path)
if models_manifest is None: if models_manifest is None:
models_manifest = read_models_manifest(path) models_manifest = read_models_manifest(path, project.category)
if logical_manifest is None: if logical_manifest is None:
logical_manifest = read_logical_manifest(path) logical_manifest = read_logical_manifest(path)
if not mods_manifest: if not mods_manifest:
+68 -31
View File
@@ -12,6 +12,9 @@ import zipfile
INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'} INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'}
PROBLEM_TAGS = {'broken', 'bugged', 'borked'} PROBLEM_TAGS = {'broken', 'bugged', 'borked'}
# Blockbench-compatible model formats accepted by the explicit Model category.
MODEL_FILE_EXTENSIONS = ('.bbmodel', '.geo.json', '.jem', '.gltf', '.glb')
def _decode(data): def _decode(data):
"""Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs).""" """Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs)."""
@@ -239,53 +242,61 @@ def _resource_to_member(resource):
return f'assets/{resource}' return f'assets/{resource}'
def read_models_manifest(path): def read_models_manifest(path, category='non_pack'):
"""Build the Models/Textures manifest from GeckoLib geo models. """Build the Models/Textures manifest.
Returns None when the pack has no geo models. Each model records its geo For the explicit ``model`` category this scans the archive for any
filename, identifier, bone/cube counts, and the afw_bone_textures map Blockbench-compatible model file (``MODEL_FILE_EXTENSIONS``) anywhere in
(bone → texture zip member) so the viewer can texture it.""" the zip. For other categories it keeps the NoN GeckoLib geo-model
detection (``geckolib/models/*.geo.json``). Returns None when no models
are found. Each model records its filename, zip member, format, bone/cube
counts, and the afw_bone_textures map (bone → texture zip member).
"""
zf = _open_zip(path) zf = _open_zip(path)
if zf is None: if zf is None:
return None return None
try: try:
names = set(zf.namelist()) names = set(zf.namelist())
geo_names = sorted( if category == 'model':
n for n in names if '/geckolib/models/' in n and n.endswith('.geo.json') model_names = sorted(
) n for n in names
if not geo_names: if not n.endswith('/') and n.lower().endswith(MODEL_FILE_EXTENSIONS)
)
else:
model_names = sorted(
n for n in names if '/geckolib/models/' in n and n.endswith('.geo.json')
)
if not model_names:
return None return None
models = [] models = []
for name in geo_names: for name in model_names:
ext = _model_ext(name)
data = None
try: try:
data = _decode(zf.read(name)) data = _decode(zf.read(name))
except (KeyError, json.JSONDecodeError): except (KeyError, json.JSONDecodeError, UnicodeDecodeError):
continue data = None
bone_textures = {} bone_textures = {}
for bone, tex in (data.get('afw_bone_textures') or {}).items():
member = _resource_to_member(str(tex))
if member in names:
bone_textures[str(bone)] = member
bone_count = 0 bone_count = 0
cube_count = 0 cube_count = 0
identifier = '' identifier = ''
for geom in data.get('minecraft:geometry') or []: if data and isinstance(data, dict):
identifier = identifier or (geom.get('description') or {}).get('identifier', '') for bone, tex in (data.get('afw_bone_textures') or {}).items():
for bone in geom.get('bones') or []: member = _resource_to_member(str(tex))
bone_count += 1 if member in names:
cube_count += len(bone.get('cubes') or []) bone_textures[str(bone)] = member
if ext == '.geo.json':
for geom in data.get('minecraft:geometry') or []:
identifier = identifier or (geom.get('description') or {}).get('identifier', '')
for bone in geom.get('bones') or []:
bone_count += 1
cube_count += len(bone.get('cubes') or [])
elif ext == '.jem':
bone_count = len(data.get('models') or [])
entity = name.rsplit('/', 1)[-1] entity = _entity_name(name)
if entity.endswith('.geo.json'):
entity = entity[:-len('.geo.json')]
for suffix in ('.mf', '.fm', '.m', '.f', '.g'):
if entity.endswith(suffix):
entity = entity[:-len(suffix)]
break
# A pack-provided full skin is the model's default texture when the
# model has no per-bone textures of its own.
default_texture = '' default_texture = ''
if not bone_textures and entity: if not bone_textures and entity:
for n in names: for n in names:
@@ -295,11 +306,14 @@ def read_models_manifest(path):
models.append({ models.append({
'name': name.rsplit('/', 1)[-1], 'name': name.rsplit('/', 1)[-1],
'member': name, 'member': name,
'ext': ext,
'mode': 'pack',
'identifier': identifier, 'identifier': identifier,
'entity': entity, 'entity': entity,
'default_texture': default_texture, 'default_texture': default_texture,
'bones': bone_count, 'bones': bone_count,
'cubes': cube_count, 'cubes': cube_count,
'has_embedded_texture': ext in ('.bbmodel', '.gltf', '.glb'),
'bone_textures': bone_textures, 'bone_textures': bone_textures,
'textures': sorted(set(bone_textures.values())), 'textures': sorted(set(bone_textures.values())),
}) })
@@ -310,6 +324,29 @@ def read_models_manifest(path):
zf.close() zf.close()
def _model_ext(name):
"""The matching MODEL_FILE_EXTENSIONS suffix for a member, or ''."""
lower = name.lower()
for ext in MODEL_FILE_EXTENSIONS:
if lower.endswith(ext):
return ext
return ''
def _entity_name(name):
"""Derive the entity name from a model filename (strip ext + gender suffix)."""
base = name.rsplit('/', 1)[-1]
for ext in MODEL_FILE_EXTENSIONS:
if base.lower().endswith(ext):
base = base[: -len(ext)]
break
for suffix in ('.mf', '.fm', '.m', '.f', '.g'):
if base.endswith(suffix):
base = base[:-len(suffix)]
break
return base
def _item_name_from_member(member): def _item_name_from_member(member):
parts = member.split('/') parts = member.split('/')
try: try:
+31
View File
@@ -3401,3 +3401,34 @@ a.deletelink {
.mods-badge.server { background: #e6e0ff; color: #3a2d8f; } .mods-badge.server { background: #e6e0ff; color: #3a2d8f; }
.mods-link { color: inherit; text-decoration: none; opacity: 0.6; } .mods-link { color: inherit; text-decoration: none; opacity: 0.6; }
.mods-link:hover { opacity: 1; } .mods-link:hover { opacity: 1; }
/* Model category format badges */
.model-format-badge {
display: inline-block;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
font-weight: 600;
margin-right: 0.35rem;
}
.model-tex-badge.embedded {
display: inline-block;
border-radius: 999px;
background: #d1fae5;
color: #065f46;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
font-weight: 600;
margin-right: 0.35rem;
}
.model-incompatible {
display: inline-block;
border-radius: 999px;
background: #fee2e2;
color: #991b1b;
padding: 0.15rem 0.6rem;
font-size: 0.72rem;
font-weight: 600;
}
+32 -13
View File
@@ -83,6 +83,17 @@
return r.json(); return r.json();
} }
async function fetchModelText(member, baseUrl) {
const r = await fetch(assetUrl(baseUrl, member), { headers: { 'Accept': 'application/json' } });
if (!r.ok) throw new Error('Could not load the model file');
return r.text();
}
// Formats whose textures are baked into the file (nothing to resolve).
// glTF/GLB are NOT here — import needs the desktop-only glTF Importer
// plugin, so those models are marked "Incompatible" instead.
const EMBEDDED_FORMATS = ['.bbmodel'];
async function fetchVanillaIndex(vanillaBaseUrl) { async function fetchVanillaIndex(vanillaBaseUrl) {
try { try {
const r = await fetch(assetUrl(vanillaBaseUrl, 'entity_index.json')); const r = await fetch(assetUrl(vanillaBaseUrl, 'entity_index.json'));
@@ -165,6 +176,7 @@
async function open(btn) { async function open(btn) {
const member = btn.dataset.member; const member = btn.dataset.member;
const name = btn.dataset.name || member; const name = btn.dataset.name || member;
const modelFormat = btn.dataset.modelFormat || '.geo.json';
const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null }; const opts = { baseUrl: btn.dataset.baseUrl, vanillaBaseUrl: btn.dataset.vanillaBaseUrl, defaultTexture: btn.dataset.defaultTexture || null };
if (!member || !opts.baseUrl || busy) return; if (!member || !opts.baseUrl || busy) return;
busy = true; busy = true;
@@ -205,19 +217,26 @@
setStatus('Loading model…'); setStatus('Loading model…');
try { try {
const [geo, index] = await Promise.all([ const msg = { type: 'packs-open-model', name: name };
fetchGeo(member, opts.baseUrl), if (modelFormat === '.gltf' || modelFormat === '.glb') {
fetchVanillaIndex(opts.vanillaBaseUrl), setStatus('Incompatible — glTF/GLB preview needs the desktop glTF Importer plugin.');
]); busy = false;
const entity = entityNameFromMember(member); return;
const textures = await resolveTextures(geo, entity, opts, index); }
iframe.contentWindow.postMessage({ if (EMBEDDED_FORMATS.indexOf(modelFormat) !== -1) {
type: 'packs-open-model', // Textures baked into the file — hand it to Blockbench as-is.
geo: geo, msg.modelFile = { format: modelFormat, content: await fetchModelText(member, opts.baseUrl) };
name: name, } else {
textures: textures, const [geo, index] = await Promise.all([
}, window.location.origin); fetchGeo(member, opts.baseUrl),
setStatus('Applying textures…'); fetchVanillaIndex(opts.vanillaBaseUrl),
]);
const entity = entityNameFromMember(member);
msg.geo = geo;
msg.textures = await resolveTextures(geo, entity, opts, index);
}
iframe.contentWindow.postMessage(msg, window.location.origin);
setStatus('Applying…');
} catch (e) { } catch (e) {
setStatus('Could not load model: ' + e.message); setStatus('Could not load model: ' + e.message);
} }
@@ -62,6 +62,19 @@
async function openModel(msg) { async function openModel(msg) {
await waitFor(() => typeof loadModelFile === 'function', 15000); await waitFor(() => typeof loadModelFile === 'function', 15000);
const name = msg.name || 'model'; const name = msg.name || 'model';
if (msg.modelFile) {
// Standalone Blockbench model (bbmodel) with baked textures.
// glTF/GLB is not sent here — those are marked "Incompatible" by the
// parent page (import needs the desktop-only glTF Importer plugin).
if (msg.modelFile.content) {
await loadModelFile({ content: msg.modelFile.content, name: name, path: name });
}
await new Promise((r) => setTimeout(r, 400));
notify('model-open', { name: name });
return;
}
await loadModelFile({ content: JSON.stringify(msg.geo), name: name, path: name }); await loadModelFile({ content: JSON.stringify(msg.geo), name: name, path: name });
// Give the format importer a beat, then set the feature-bone overrides // Give the format importer a beat, then set the feature-bone overrides
// (before adding textures, so the plugin's add_texture listener applies // (before adding textures, so the plugin's add_texture listener applies
+10 -1
View File
@@ -245,13 +245,22 @@
<div class="model-card"> <div class="model-card">
<div class="model-card-head"> <div class="model-card-head">
<strong>{{ model.name }}</strong> <strong>{{ model.name }}</strong>
{% if model.ext == '.bbmodel' or model.ext == '.geo.json' or model.ext == '.jem' %}
<button type="button" class="btn btn-secondary btn-sm model-render-btn" <button type="button" class="btn btn-secondary btn-sm model-render-btn"
data-member="{{ model.member }}" data-name="{{ model.name }}" data-member="{{ model.member }}" data-name="{{ model.name }}"
data-model-format="{{ model.ext }}"
data-default-texture="{{ model.default_texture }}" data-default-texture="{{ model.default_texture }}"
data-base-url="{% url 'library:pack_asset' project.slug versions.0.pk 'ASSET' %}" data-base-url="{% url 'library:pack_asset' project.slug versions.0.pk 'ASSET' %}"
data-vanilla-base-url="/static/vanilla/ASSET"><i class="fas fa-cube"></i> Render</button> data-vanilla-base-url="/static/vanilla/ASSET"><i class="fas fa-cube"></i> Render</button>
{% else %}
<span class="model-incompatible" title="glTF/GLB preview needs the desktop glTF Importer plugin, which the web viewer does not bundle">Incompatible</span>
{% endif %}
</div> </div>
<p class="model-meta">{{ model.bones }} bones · {{ model.cubes }} cubes</p> <p class="model-meta">
{% if model.ext %}<span class="model-format-badge">{{ model.ext }}</span>{% endif %}
{% if model.has_embedded_texture %}<span class="model-tex-badge embedded">textures embedded</span>{% endif %}
{{ model.bones }} bones · {{ model.cubes }} cubes
</p>
<div class="model-textures"> <div class="model-textures">
{% for member in model.textures %} {% for member in model.textures %}
<img src="{% url 'library:pack_asset' project.slug versions.0.pk member %}" alt="{{ member }}" loading="lazy" title="{{ member }}"> <img src="{% url 'library:pack_asset' project.slug versions.0.pk member %}" alt="{{ member }}" loading="lazy" title="{{ member }}">