working model cat backup
This commit is contained in:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user