Spaces:
Running
Running
Download engine/check/model-check.js from axonis22/muse-engine-32: direct link, hf CLI and curl.
- Browser
- Download file 12 kB
-
https://huggingface.co/spaces/axonis22/muse-engine-32/resolve/main/engine/check/model-check.js
- Command line
-
hf download hf://spaces/axonis22/muse-engine-32/engine/check/model-check.js
-
curl -L -o model-check.js https://huggingface.co/spaces/axonis22/muse-engine-32/resolve/main/engine/check/model-check.js
12 kB
| // engine/check/model-check.js - written by Muse Glimmer (Phase 32, part A, 27 Sep 2026; raw output in game/muse-authored/phase32/raw/), | |
| // reviewed by Grok Bot (coach) with game/muse-authored/phase32/harness/. Coach fixes are marked [coach fix P32-n], see FIXES-P32.md. | |
| (function () { | |
| // [coach fix P32-2] one LIMITS object: analyze() used a private copy, so the exported MuseModelCheck.LIMITS was not the one in force | |
| var LIMITS = { | |
| sceneTrisWarn: 300000, | |
| sceneTrisError: 1000000, | |
| charTrisWarn: 100000, | |
| charTrisError: 400000, | |
| partsWarn: 400, | |
| maxBonesDefault: 60, | |
| rootMotionMinM: 0.1, | |
| rootMotionFrac: 0.05, | |
| charHeightMin: 0.5, | |
| charHeightMax: 3.0, | |
| sceneExtentMin: 2, | |
| sceneExtentMax: 5000, | |
| fpsLow: 15, | |
| }; | |
| function medianLower(arr) { | |
| var n = arr.length; | |
| if (n === 0) return 0; | |
| var a = arr.slice().sort(function (a, b) { | |
| return a - b; | |
| }); | |
| var idx = Math.floor((n - 1) / 2); | |
| return a[idx]; | |
| } | |
| function estimateFps(times) { | |
| var t = Array.isArray(times) ? times : Array.from(times || []); // [coach fix P32-2] estimateFps() / estimateFps(undefined) threw | |
| var n = t.length; | |
| if (n === 0) { | |
| return { fps: 0, frames: 0, standard: false }; | |
| } | |
| var diffs = []; | |
| for (var i = 1; i < n; i++) { | |
| var d = t[i] - t[i - 1]; | |
| if (d >= 1e-6) diffs.push(d); | |
| } | |
| if (diffs.length === 0) { | |
| return { fps: 0, frames: n, standard: false }; | |
| } | |
| var dt = medianLower(diffs); | |
| var raw = 1 / dt; | |
| var S = [12, 15, 23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60, 100, 120]; | |
| var best = S[0]; | |
| var bestDiff = Math.abs(raw - best); | |
| for (var i = 1; i < S.length; i++) { | |
| var d = Math.abs(raw - S[i]); | |
| if (d < bestDiff - 1e-12) { | |
| bestDiff = d; | |
| best = S[i]; | |
| } | |
| } | |
| var s = best; | |
| var within = bestDiff <= 0.02 * s; | |
| var fps, standard; | |
| if (within) { | |
| fps = s; | |
| standard = true; | |
| } else { | |
| fps = Math.round(raw * 100) / 100; | |
| standard = false; | |
| } | |
| var duration = t[n - 1] - t[0]; | |
| var frames = fps === 0 ? n : Math.round(duration * fps) + 1; | |
| return { fps: fps, frames: frames, standard: standard }; | |
| } | |
| function analyze(facts) { | |
| var f = facts || {}; | |
| var kind = f.kind || ''; | |
| var name = f.name || ''; | |
| var format = f.format || ''; | |
| var units = f.units || {}; | |
| var up = f.up || {}; | |
| var triangles = f.triangles || 0; | |
| var parts = f.parts || 0; | |
| var sizeM = f.sizeM || [0, 0, 0]; | |
| var textures = f.textures || {}; | |
| var scene = f.scene || {}; | |
| var rig = f.rig || {}; | |
| var warnings = []; | |
| function push(code, level, msg, fix) { | |
| warnings.push({ code: code, level: level, msg: msg, fix: fix }); | |
| } | |
| // rule 1 | |
| if (kind === 'character') { | |
| var skinned = !!rig.skinned; | |
| if (!skinned) { | |
| push( | |
| 'NO_SKIN', | |
| 'warn', | |
| 'No skinned mesh / skeleton: drawn as a static model scaled into the enemy box (no animation).', | |
| 'Export with Skin/Skinning and Deformations enabled, bind the mesh to the skeleton.', | |
| ); | |
| // skip 2-6 | |
| } else { | |
| var bones = rig.bones || 0; | |
| var maxBones = rig.maxBones || LIMITS.maxBonesDefault; // [coach fix P32-1] spec: maxBones || default (0 / missing = 60); was != null, so 0 printed "62 bones > 0 GPU slots" | |
| var merged = rig.merged || []; | |
| if (bones > maxBones) { | |
| var first8 = merged.slice(0, 8); | |
| var mergedStr = first8.join(', '); | |
| if (merged.length > 8) mergedStr += ', …'; | |
| var msg = bones + ' bones > ' + maxBones + ' GPU slots: ' + merged.length + ' minor leaf bones merged into their parents'; | |
| if (merged.length) msg += ' (' + mergedStr + ')'; | |
| msg += '.'; | |
| push('BONES_OVER_LIMIT', 'warn', msg, 'Remove or skip fingers/twist/helper bones on export, or accept the merge.'); | |
| } | |
| var clips = rig.clips || []; | |
| if (clips.length === 0) { | |
| push( | |
| 'NO_CLIPS', | |
| 'warn', | |
| 'Skinned but no animation takes: the character stands in its bind pose.', | |
| 'Bake animation on export (Maya: Bake Animation; Max: Bake Animation) and include the takes.', | |
| ); | |
| } else { | |
| var threshold = Math.max(LIMITS.rootMotionMinM, LIMITS.rootMotionFrac * (rig.heightM || 1.8)); | |
| var moving = []; | |
| for (var i = 0; i < clips.length; i++) { | |
| var c = clips[i]; | |
| var rm = c.rootMotionM || 0; | |
| if (rm > threshold) { | |
| moving.push(c); | |
| } | |
| } | |
| if (moving.length) { | |
| var level = moving.every(function (c) { | |
| return !!c.stripped; | |
| }) | |
| ? 'info' | |
| : 'warn'; | |
| var partsMsg = moving | |
| .map(function (c) { | |
| return c.name + ' ' + c.rootMotionM.toFixed(2) + ' m'; | |
| }) | |
| .join(', '); | |
| var msg = | |
| 'Root motion in ' + | |
| moving.length + | |
| ' clip(s): ' + | |
| partsMsg + | |
| (level === 'info' ? ' (stripped: played in place).' : ' (not stripped: the character slides away from its capsule).'); | |
| push('ROOT_MOTION', level, msg, 'Keep the toggle "Strip root motion" on, or export in-place animations.'); | |
| } | |
| var roles = rig.roles || {}; | |
| var miss = []; | |
| ['idle', 'walk', 'run', 'shoot', 'death'].forEach(function (r) { | |
| var idx = roles[r]; | |
| if (idx < 0 || idx == null) miss.push(r); | |
| }); | |
| if (miss.length) { | |
| push( | |
| 'UNMAPPED_ROLES', | |
| 'info', | |
| 'No clip found for: ' + miss.join(', ') + ' (pick one in the clip picker, or name takes e.g. Idle, Walk, Run, Shoot, Death).', | |
| 'Rename the takes or pick clips in the panel.', | |
| ); | |
| } | |
| var low = []; | |
| var odd = []; | |
| for (var i = 0; i < clips.length; i++) { | |
| var c = clips[i]; | |
| var e = estimateFps(c.times || []); | |
| if (e.fps > 0 && e.fps < LIMITS.fpsLow) { | |
| low.push({ name: c.name, fps: e.fps }); | |
| } else if (e.fps > 0 && !e.standard) { | |
| odd.push({ name: c.name, fps: e.fps }); | |
| } | |
| } | |
| if (low.length) { | |
| var msg = | |
| 'Low key rate in: ' + | |
| low | |
| .map(function (o) { | |
| return o.name + ' ' + o.fps + ' fps'; | |
| }) | |
| .join(', ') + | |
| '.'; | |
| push('LOW_FPS', 'warn', msg, 'Bake at 30 fps (every frame).'); | |
| } else if (odd.length) { | |
| var msg = | |
| 'Unusual key rate in: ' + | |
| odd | |
| .map(function (o) { | |
| return o.name + ' ' + o.fps + ' fps'; | |
| }) | |
| .join(', ') + | |
| '.'; | |
| push('ODD_FPS', 'info', msg, 'Bake every frame at the scene frame rate (24/25/30/60).'); | |
| } | |
| } | |
| } | |
| } | |
| // rule 7 | |
| if (kind === 'character') { | |
| var h = rig.heightM || 0; | |
| if (h > 0 && (h < LIMITS.charHeightMin || h > LIMITS.charHeightMax)) { | |
| push( | |
| 'ODD_SIZE', | |
| 'warn', | |
| 'Character is ' + h.toFixed(2) + ' m tall after unit conversion: check the units / scale.', | |
| 'Set the units in the panel (Units) or export in centimetres with unit metadata.', | |
| ); | |
| } | |
| } else if (kind === 'scene') { | |
| var ext = Math.max(sizeM[0] || 0, sizeM[1] || 0, sizeM[2] || 0); | |
| if (ext > 0 && (ext < LIMITS.sceneExtentMin || ext > LIMITS.sceneExtentMax)) { | |
| push( | |
| 'ODD_SIZE', | |
| 'warn', | |
| 'Scene is ' + ext.toFixed(1) + ' m across after unit conversion: check the units / scale.', | |
| 'Set the units in the panel (Units) or export in centimetres with unit metadata.', | |
| ); | |
| } | |
| } | |
| // rule 8 | |
| if (units && units.source === 'guess') { | |
| var v = units.value || ''; | |
| push( | |
| 'NO_UNITS_INFO', | |
| 'warn', | |
| 'No unit information in the file: guessed ' + v + ' from the size.', | |
| 'FBX: keep the file units (Maya: Preferences > Settings > Working units; export with Automatic units). glTF is always metres.', | |
| ); | |
| } | |
| // rule 9 | |
| if (up && up.source === 'guess') { | |
| var uv = up.value || ''; | |
| push( | |
| 'NO_UP_INFO', | |
| 'warn', | |
| 'No up-axis information in the file: assumed ' + uv.toUpperCase() + '-up.', | |
| 'FBX: export with Up Axis set (Y or Z); the engine converts Z-up to Y-up.', | |
| ); | |
| } | |
| // rule 10 | |
| var missing = textures.missing || []; | |
| if (missing.length) { | |
| var n = missing.length; | |
| var first6 = missing.slice(0, 6); | |
| var names = first6.join(', '); | |
| if (n > 6) names += ', …'; | |
| push( | |
| 'MISSING_TEXTURES', | |
| 'warn', | |
| n + ' texture(s) not found: ' + names + '. Material colours used instead.', | |
| 'Embed media in the FBX (Embed Media) or select the texture files together with the model; for .gltf select the .bin and images too.', | |
| ); | |
| } | |
| // rule 11 | |
| var warnAt, errorAt; | |
| if (kind === 'scene') { | |
| warnAt = LIMITS.sceneTrisWarn; | |
| errorAt = LIMITS.sceneTrisError; | |
| } else { | |
| warnAt = LIMITS.charTrisWarn; | |
| errorAt = LIMITS.charTrisError; | |
| } | |
| if (triangles >= errorAt) { | |
| push( | |
| 'HEAVY_MESH', | |
| 'error', | |
| triangles.toLocaleString('en-US') + ' triangles: very heavy for WebGL1, expect low frame rates.', | |
| 'Reduce polygons (Maya: Mesh > Reduce; Max: ProOptimizer) or remove hidden detail.', | |
| ); | |
| } else if (triangles >= warnAt) { | |
| push( | |
| 'HEAVY_MESH', | |
| 'warn', | |
| triangles.toLocaleString('en-US') + ' triangles: heavy, frame rate may drop on laptops.', | |
| 'Reduce polygons (Maya: Mesh > Reduce; Max: ProOptimizer) or remove hidden detail.', | |
| ); | |
| } | |
| // rule 12 | |
| if (parts > LIMITS.partsWarn) { | |
| push( | |
| 'MANY_PARTS', | |
| 'info', | |
| parts + ' draw parts (meshes x materials): the engine batches parts that share a material.', | |
| 'Combine meshes that share a material before export.', | |
| ); | |
| } | |
| // rule 13 | |
| if (kind === 'scene' && scene.mode === 'level') { | |
| var walkable = scene.walkableTris || 0; | |
| if (walkable === 0) { | |
| push( | |
| 'NO_FLOOR', | |
| 'error', | |
| 'No walkable floor found (no upward-facing collision triangles): you will fall.', | |
| 'Check the up axis / units, and that floors are not named _nocol.', | |
| ); | |
| } | |
| var spawn = scene.spawn; | |
| if (spawn !== 'node') { | |
| push( | |
| 'NO_SPAWN', | |
| 'info', | |
| 'No PlayerStart node: spawning on the nearest open floor.', | |
| 'Add an empty/dummy named PlayerStart where the player should start.', | |
| ); | |
| } | |
| } | |
| var order = { error: 0, warn: 1, info: 2 }; | |
| warnings.sort(function (a, b) { | |
| var oa = order[a.level]; | |
| var ob = order[b.level]; | |
| if (oa !== ob) return oa - ob; | |
| return 0; | |
| }); | |
| var errors = 0, | |
| warns = 0, | |
| infos = 0; | |
| var codes = []; | |
| for (var i = 0; i < warnings.length; i++) { | |
| var w = warnings[i]; | |
| codes.push(w.code); | |
| if (w.level === 'error') errors++; | |
| else if (w.level === 'warn') warns++; | |
| else infos++; | |
| } | |
| var level = 'ok'; | |
| if (errors > 0) level = 'error'; | |
| else if (warns > 0) level = 'warn'; | |
| return { level: level, warnings: warnings, summary: { errors: errors, warns: warns, infos: infos, codes: codes } }; | |
| } | |
| var MuseModelCheck = { | |
| analyze: analyze, | |
| estimateFps: estimateFps, | |
| LIMITS: LIMITS, // [coach fix P32-2] | |
| }; | |
| if (typeof window !== 'undefined') window.MuseModelCheck = MuseModelCheck; | |
| if (typeof module !== 'undefined') module.exports = MuseModelCheck; | |
| })(); | |