working model cat backup
This commit is contained in:
@@ -636,7 +636,7 @@ def _reparse_version(version):
|
||||
else:
|
||||
version.animation_id = ''
|
||||
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 {}
|
||||
manifest = parse_mods_manifest(path, release.file.original_filename)
|
||||
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:
|
||||
animation_manifest = read_animation_manifest(path)
|
||||
if models_manifest is None:
|
||||
models_manifest = read_models_manifest(path)
|
||||
models_manifest = read_models_manifest(path, project.category)
|
||||
if logical_manifest is None:
|
||||
logical_manifest = read_logical_manifest(path)
|
||||
if not mods_manifest:
|
||||
|
||||
+68
-31
@@ -12,6 +12,9 @@ import zipfile
|
||||
INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'}
|
||||
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):
|
||||
"""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}'
|
||||
|
||||
|
||||
def read_models_manifest(path):
|
||||
"""Build the Models/Textures manifest from GeckoLib geo models.
|
||||
def read_models_manifest(path, category='non_pack'):
|
||||
"""Build the Models/Textures manifest.
|
||||
|
||||
Returns None when the pack has no geo models. Each model records its geo
|
||||
filename, identifier, bone/cube counts, and the afw_bone_textures map
|
||||
(bone → texture zip member) so the viewer can texture it."""
|
||||
For the explicit ``model`` category this scans the archive for any
|
||||
Blockbench-compatible model file (``MODEL_FILE_EXTENSIONS``) anywhere in
|
||||
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)
|
||||
if zf is None:
|
||||
return None
|
||||
try:
|
||||
names = set(zf.namelist())
|
||||
geo_names = sorted(
|
||||
n for n in names if '/geckolib/models/' in n and n.endswith('.geo.json')
|
||||
)
|
||||
if not geo_names:
|
||||
if category == 'model':
|
||||
model_names = sorted(
|
||||
n for n in 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
|
||||
|
||||
models = []
|
||||
for name in geo_names:
|
||||
for name in model_names:
|
||||
ext = _model_ext(name)
|
||||
data = None
|
||||
try:
|
||||
data = _decode(zf.read(name))
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
continue
|
||||
except (KeyError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
data = None
|
||||
|
||||
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
|
||||
cube_count = 0
|
||||
identifier = ''
|
||||
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 [])
|
||||
if data and isinstance(data, dict):
|
||||
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
|
||||
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]
|
||||
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.
|
||||
entity = _entity_name(name)
|
||||
default_texture = ''
|
||||
if not bone_textures and entity:
|
||||
for n in names:
|
||||
@@ -295,11 +306,14 @@ def read_models_manifest(path):
|
||||
models.append({
|
||||
'name': name.rsplit('/', 1)[-1],
|
||||
'member': name,
|
||||
'ext': ext,
|
||||
'mode': 'pack',
|
||||
'identifier': identifier,
|
||||
'entity': entity,
|
||||
'default_texture': default_texture,
|
||||
'bones': bone_count,
|
||||
'cubes': cube_count,
|
||||
'has_embedded_texture': ext in ('.bbmodel', '.gltf', '.glb'),
|
||||
'bone_textures': bone_textures,
|
||||
'textures': sorted(set(bone_textures.values())),
|
||||
})
|
||||
@@ -310,6 +324,29 @@ def read_models_manifest(path):
|
||||
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):
|
||||
parts = member.split('/')
|
||||
try:
|
||||
|
||||
@@ -3401,3 +3401,34 @@ a.deletelink {
|
||||
.mods-badge.server { background: #e6e0ff; color: #3a2d8f; }
|
||||
.mods-link { color: inherit; text-decoration: none; opacity: 0.6; }
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,17 @@
|
||||
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) {
|
||||
try {
|
||||
const r = await fetch(assetUrl(vanillaBaseUrl, 'entity_index.json'));
|
||||
@@ -165,6 +176,7 @@
|
||||
async function open(btn) {
|
||||
const member = btn.dataset.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 };
|
||||
if (!member || !opts.baseUrl || busy) return;
|
||||
busy = true;
|
||||
@@ -205,19 +217,26 @@
|
||||
|
||||
setStatus('Loading model…');
|
||||
try {
|
||||
const [geo, index] = await Promise.all([
|
||||
fetchGeo(member, opts.baseUrl),
|
||||
fetchVanillaIndex(opts.vanillaBaseUrl),
|
||||
]);
|
||||
const entity = entityNameFromMember(member);
|
||||
const textures = await resolveTextures(geo, entity, opts, index);
|
||||
iframe.contentWindow.postMessage({
|
||||
type: 'packs-open-model',
|
||||
geo: geo,
|
||||
name: name,
|
||||
textures: textures,
|
||||
}, window.location.origin);
|
||||
setStatus('Applying textures…');
|
||||
const msg = { type: 'packs-open-model', name: name };
|
||||
if (modelFormat === '.gltf' || modelFormat === '.glb') {
|
||||
setStatus('Incompatible — glTF/GLB preview needs the desktop glTF Importer plugin.');
|
||||
busy = false;
|
||||
return;
|
||||
}
|
||||
if (EMBEDDED_FORMATS.indexOf(modelFormat) !== -1) {
|
||||
// Textures baked into the file — hand it to Blockbench as-is.
|
||||
msg.modelFile = { format: modelFormat, content: await fetchModelText(member, opts.baseUrl) };
|
||||
} else {
|
||||
const [geo, index] = await Promise.all([
|
||||
fetchGeo(member, opts.baseUrl),
|
||||
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) {
|
||||
setStatus('Could not load model: ' + e.message);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,19 @@
|
||||
async function openModel(msg) {
|
||||
await waitFor(() => typeof loadModelFile === 'function', 15000);
|
||||
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 });
|
||||
// Give the format importer a beat, then set the feature-bone overrides
|
||||
// (before adding textures, so the plugin's add_texture listener applies
|
||||
|
||||
@@ -245,13 +245,22 @@
|
||||
<div class="model-card">
|
||||
<div class="model-card-head">
|
||||
<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"
|
||||
data-member="{{ model.member }}" data-name="{{ model.name }}"
|
||||
data-model-format="{{ model.ext }}"
|
||||
data-default-texture="{{ model.default_texture }}"
|
||||
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>
|
||||
{% 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>
|
||||
<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">
|
||||
{% for member in model.textures %}
|
||||
<img src="{% url 'library:pack_asset' project.slug versions.0.pk member %}" alt="{{ member }}" loading="lazy" title="{{ member }}">
|
||||
|
||||
Reference in New Issue
Block a user