miijs 2.3.1 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +446 -158
- package/miiFemaleBody.glb +0 -0
- package/package.json +1 -1
- /package/{mii-body.glb → miiMaleBody.glb} +0 -0
package/index.js
CHANGED
|
@@ -1255,14 +1255,40 @@ let _fflRes;
|
|
|
1255
1255
|
function getFFLRes() {
|
|
1256
1256
|
// If we've already tried loading, just return the result
|
|
1257
1257
|
if (_fflRes !== undefined) return _fflRes;
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1258
|
+
|
|
1259
|
+
const searchPaths = [
|
|
1260
|
+
"./FFLResHigh.dat",
|
|
1261
|
+
"../FFLResHigh.dat",
|
|
1262
|
+
"../../FFLResHigh.dat",
|
|
1263
|
+
"./ffl/FFLResHigh.dat",
|
|
1264
|
+
"./afl/AFLResHigh.dat",
|
|
1265
|
+
"../ffl/FFLResHigh.dat",
|
|
1266
|
+
"../afl/AFLResHigh.dat",
|
|
1267
|
+
"../../ffl/FFLResHigh.dat",
|
|
1268
|
+
"../../afl/AFLResHigh.dat"
|
|
1269
|
+
];
|
|
1270
|
+
|
|
1271
|
+
for (const filePath of searchPaths) {
|
|
1272
|
+
try {
|
|
1273
|
+
if (fs.existsSync(filePath)) {
|
|
1274
|
+
const stats = fs.statSync(filePath);
|
|
1275
|
+
// Make sure it's a file, not a directory
|
|
1276
|
+
if (stats.isFile()) {
|
|
1277
|
+
// Convert Buffer to Uint8Array explicitly
|
|
1278
|
+
const buffer = fs.readFileSync(filePath);
|
|
1279
|
+
_fflRes = new Uint8Array(buffer);
|
|
1280
|
+
console.log(`Loaded FFLResHigh.dat from: ${filePath} (${_fflRes.length} bytes)`);
|
|
1281
|
+
return _fflRes;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
} catch (e) {
|
|
1285
|
+
// Silently continue to next path
|
|
1286
|
+
continue;
|
|
1263
1287
|
}
|
|
1264
1288
|
}
|
|
1289
|
+
|
|
1265
1290
|
// If no file found, mark as null
|
|
1291
|
+
console.warn('FFLResHigh.dat not found. Mii rendering will fall back to Mii Studio.');
|
|
1266
1292
|
return _fflRes = null;
|
|
1267
1293
|
}
|
|
1268
1294
|
|
|
@@ -1742,23 +1768,88 @@ function convertMii(jsonIn, typeTo) {
|
|
|
1742
1768
|
miiTo.console = "wii";
|
|
1743
1769
|
}
|
|
1744
1770
|
else if (typeFrom === "wii") {
|
|
1745
|
-
miiTo.perms.sharing = mii.
|
|
1746
|
-
miiTo.perms.copying = mii.
|
|
1747
|
-
|
|
1748
|
-
|
|
1771
|
+
miiTo.perms.sharing = mii.perms.mingle;
|
|
1772
|
+
miiTo.perms.copying = mii.perms.mingle;
|
|
1773
|
+
|
|
1774
|
+
// Convert hair
|
|
1775
|
+
const hairConv = convTables.hairWiiTo3DS[mii.hair.page][mii.hair.type];
|
|
1776
|
+
miiTo.hair.page = hairConv[0];
|
|
1777
|
+
miiTo.hair.type = hairConv[1];
|
|
1778
|
+
miiTo.hair.color = mii.hair.color;
|
|
1779
|
+
miiTo.hair.flipped = mii.hair.flipped;
|
|
1780
|
+
|
|
1781
|
+
// Convert face
|
|
1782
|
+
miiTo.face.type = convTables.faceWiiTo3DS[mii.face.type];
|
|
1783
|
+
miiTo.face.color = mii.face.color;
|
|
1749
1784
|
miiTo.face.makeup = 0;
|
|
1750
1785
|
miiTo.face.feature = 0;
|
|
1786
|
+
|
|
1787
|
+
// Handle facial features/makeup
|
|
1751
1788
|
if (typeof (convTables.featureWiiTo3DS[mii.face.feature]) === 'string') {
|
|
1752
|
-
miiTo.face.makeup =
|
|
1789
|
+
miiTo.face.makeup = +convTables.featureWiiTo3DS[mii.face.feature];
|
|
1753
1790
|
}
|
|
1754
1791
|
else {
|
|
1755
|
-
miiTo.face.feature =
|
|
1792
|
+
miiTo.face.feature = +convTables.featureWiiTo3DS[mii.face.feature];
|
|
1756
1793
|
}
|
|
1757
|
-
|
|
1758
|
-
|
|
1794
|
+
|
|
1795
|
+
// Convert eyes - preserve page/type structure
|
|
1796
|
+
miiTo.eyes.page = mii.eyes.page;
|
|
1797
|
+
miiTo.eyes.type = mii.eyes.type;
|
|
1798
|
+
miiTo.eyes.color = mii.eyes.color;
|
|
1799
|
+
miiTo.eyes.size = mii.eyes.size;
|
|
1800
|
+
miiTo.eyes.squash = 3; // Default for 3DS
|
|
1801
|
+
miiTo.eyes.rotation = mii.eyes.rotation;
|
|
1802
|
+
miiTo.eyes.distanceApart = mii.eyes.distanceApart;
|
|
1803
|
+
miiTo.eyes.yPosition = mii.eyes.yPosition;
|
|
1804
|
+
|
|
1805
|
+
// Convert eyebrows - preserve page/type structure
|
|
1806
|
+
miiTo.eyebrows.page = mii.eyebrows.page;
|
|
1807
|
+
miiTo.eyebrows.type = mii.eyebrows.type;
|
|
1808
|
+
miiTo.eyebrows.color = mii.eyebrows.color;
|
|
1809
|
+
miiTo.eyebrows.size = mii.eyebrows.size;
|
|
1810
|
+
miiTo.eyebrows.squash = 3; // Default for 3DS
|
|
1811
|
+
miiTo.eyebrows.rotation = mii.eyebrows.rotation;
|
|
1812
|
+
miiTo.eyebrows.distanceApart = mii.eyebrows.distanceApart;
|
|
1813
|
+
miiTo.eyebrows.yPosition = mii.eyebrows.yPosition;
|
|
1814
|
+
|
|
1815
|
+
// Convert nose - preserve page/type structure
|
|
1816
|
+
miiTo.nose.page = mii.nose.page || 0;
|
|
1817
|
+
miiTo.nose.type = mii.nose.type;
|
|
1818
|
+
miiTo.nose.size = mii.nose.size;
|
|
1819
|
+
miiTo.nose.yPosition = mii.nose.yPosition;
|
|
1820
|
+
|
|
1821
|
+
// Convert mouth - preserve page/type structure
|
|
1822
|
+
miiTo.mouth.page = mii.mouth.page;
|
|
1823
|
+
miiTo.mouth.type = mii.mouth.type;
|
|
1759
1824
|
miiTo.mouth.color = mii.mouth.color;
|
|
1760
|
-
miiTo.mouth.
|
|
1761
|
-
miiTo.
|
|
1825
|
+
miiTo.mouth.size = mii.mouth.size;
|
|
1826
|
+
miiTo.mouth.squash = 3; // Default for 3DS
|
|
1827
|
+
miiTo.mouth.yPosition = mii.mouth.yPosition;
|
|
1828
|
+
|
|
1829
|
+
// Convert glasses
|
|
1830
|
+
miiTo.glasses.type = mii.glasses.type;
|
|
1831
|
+
miiTo.glasses.color = mii.glasses.color;
|
|
1832
|
+
miiTo.glasses.size = mii.glasses.size;
|
|
1833
|
+
miiTo.glasses.yPosition = mii.glasses.yPosition;
|
|
1834
|
+
|
|
1835
|
+
// Convert beard
|
|
1836
|
+
miiTo.beard.mustache.type = mii.beard.mustache.type;
|
|
1837
|
+
miiTo.beard.mustache.size = mii.beard.mustache.size;
|
|
1838
|
+
miiTo.beard.mustache.yPosition = mii.beard.mustache.yPosition;
|
|
1839
|
+
miiTo.beard.type = mii.beard.type;
|
|
1840
|
+
miiTo.beard.color = mii.beard.color;
|
|
1841
|
+
|
|
1842
|
+
// Convert mole
|
|
1843
|
+
miiTo.mole.on = mii.mole.on;
|
|
1844
|
+
miiTo.mole.size = mii.mole.size;
|
|
1845
|
+
miiTo.mole.xPosition = mii.mole.xPosition;
|
|
1846
|
+
miiTo.mole.yPosition = mii.mole.yPosition;
|
|
1847
|
+
|
|
1848
|
+
// Copy general info
|
|
1849
|
+
miiTo.general = { ...mii.general };
|
|
1850
|
+
miiTo.meta = { ...mii.meta };
|
|
1851
|
+
|
|
1852
|
+
miiTo.console = "3DS";
|
|
1762
1853
|
}
|
|
1763
1854
|
return miiTo;
|
|
1764
1855
|
}
|
|
@@ -1838,7 +1929,11 @@ function convertStudioToMii(input) {
|
|
|
1838
1929
|
gender: s[0x16],
|
|
1839
1930
|
favoriteColor: s[0x15],
|
|
1840
1931
|
height: s[0x1E],
|
|
1841
|
-
weight: s[0x02]
|
|
1932
|
+
weight: s[0x02],
|
|
1933
|
+
|
|
1934
|
+
//The following is not provided by Studio codes and are hardcoded
|
|
1935
|
+
birthday: 0,
|
|
1936
|
+
birthMonth: 0
|
|
1842
1937
|
},
|
|
1843
1938
|
|
|
1844
1939
|
face: {
|
|
@@ -1937,7 +2032,8 @@ function convertStudioToMii(input) {
|
|
|
1937
2032
|
meta: {
|
|
1938
2033
|
name: "Studio Mii",
|
|
1939
2034
|
creatorName: "StudioUser",
|
|
1940
|
-
console: "3DS"
|
|
2035
|
+
console: "3DS",
|
|
2036
|
+
type: "Default"
|
|
1941
2037
|
},
|
|
1942
2038
|
|
|
1943
2039
|
perms: {
|
|
@@ -2255,182 +2351,372 @@ async function renderMiiWithStudio(jsonIn) {
|
|
|
2255
2351
|
var studioMii = convertMiiToStudio(jsonIn);
|
|
2256
2352
|
return await downloadImage('https://studio.mii.nintendo.com/miis/image.png?data=' + studioMii + "&width=270&type=face");
|
|
2257
2353
|
}
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2354
|
+
|
|
2355
|
+
|
|
2356
|
+
function flipPixelsVertically(src, w, h) {
|
|
2357
|
+
const dst = new Uint8Array(src.length);
|
|
2358
|
+
const row = w * 4;
|
|
2359
|
+
for (let y = 0; y < h; y++) {
|
|
2360
|
+
const a = y * row, b = (h - 1 - y) * row;
|
|
2361
|
+
dst.set(src.subarray(a, a + row), b);
|
|
2362
|
+
}
|
|
2363
|
+
return dst;
|
|
2364
|
+
}
|
|
2365
|
+
function invLerp(a, b, v) { return (v - a) / (b - a); }
|
|
2366
|
+
function clamp01(t) { return Math.max(0, Math.min(1, t)); }
|
|
2367
|
+
function easePow(t, p) { return Math.pow(t, p); }
|
|
2368
|
+
// ------------------------------
|
|
2369
|
+
// Small helpers
|
|
2370
|
+
// ------------------------------
|
|
2371
|
+
function remap01(v, min = 0, max = 127) {
|
|
2372
|
+
const cl = Math.min(max, Math.max(min, +v || 0));
|
|
2373
|
+
return (cl - min) / (max - min || 1);
|
|
2374
|
+
}
|
|
2375
|
+
function lerp(a, b, t) { return a + (b - a) * t; }
|
|
2376
|
+
|
|
2377
|
+
function offsetObjectAlongView(object3D, camera, delta) {
|
|
2378
|
+
const forward = new THREE.Vector3();
|
|
2379
|
+
camera.getWorldDirection(forward); // forward
|
|
2380
|
+
object3D.position.addScaledVector(forward, -delta); // -delta → toward camera
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
// Project a pixel Y offset at a given depth into world Y units
|
|
2384
|
+
function pixelYToWorldY(camera, depthZ, pixels, viewportHeightPx) {
|
|
2385
|
+
// Visible height at depth for a perspective camera:
|
|
2386
|
+
const fov = (camera.fov ?? 30) * Math.PI / 180;
|
|
2387
|
+
const visibleH = 2 * Math.abs(depthZ) * Math.tan(fov / 2);
|
|
2388
|
+
return (pixels / viewportHeightPx) * visibleH;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// Render a specific layer to a pixel buffer (optionally flipY)
|
|
2392
|
+
function renderLayerToPixels(renderer, scene, camera, gl, width, height, layerIndex, flipY) {
|
|
2393
|
+
const rt = new THREE.WebGLRenderTarget(width, height, { depthBuffer: true, stencilBuffer: false });
|
|
2394
|
+
camera.layers.set(layerIndex);
|
|
2395
|
+
renderer.setRenderTarget(rt);
|
|
2396
|
+
renderer.clear(true, true, true);
|
|
2397
|
+
renderer.render(scene, camera);
|
|
2398
|
+
|
|
2399
|
+
const pixels = new Uint8Array(width * height * 4);
|
|
2400
|
+
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
|
2401
|
+
|
|
2402
|
+
renderer.setRenderTarget(null);
|
|
2403
|
+
rt.dispose();
|
|
2404
|
+
|
|
2405
|
+
if (!flipY) return pixels;
|
|
2406
|
+
|
|
2407
|
+
// flipVert
|
|
2408
|
+
const rowBytes = width * 4;
|
|
2409
|
+
const out = new Uint8Array(pixels.length);
|
|
2410
|
+
for (let y = 0; y < height; y++) {
|
|
2411
|
+
const src = y * rowBytes;
|
|
2412
|
+
const dst = (height - 1 - y) * rowBytes;
|
|
2413
|
+
out.set(pixels.subarray(src, src + rowBytes), dst);
|
|
2414
|
+
}
|
|
2415
|
+
return out;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// Camera fit (unchanged)
|
|
2419
|
+
function fitCameraToObject(camera, object3D) {
|
|
2420
|
+
const padding = 0.525;
|
|
2421
|
+
const box = new THREE.Box3().setFromObject(object3D);
|
|
2422
|
+
const size = new THREE.Vector3();
|
|
2423
|
+
const center = new THREE.Vector3();
|
|
2424
|
+
box.getSize(size);
|
|
2425
|
+
box.getCenter(center);
|
|
2426
|
+
|
|
2427
|
+
const maxSize = Math.max(size.y, size.x / camera.aspect);
|
|
2428
|
+
const fov = (camera.fov ?? 30) * Math.PI / 180;
|
|
2429
|
+
const dist = (maxSize * padding) / Math.tan(fov / 2);
|
|
2430
|
+
|
|
2431
|
+
const dir = new THREE.Vector3();
|
|
2432
|
+
camera.getWorldDirection(dir); // forward
|
|
2433
|
+
dir.normalize().multiplyScalar(-dist); // back
|
|
2434
|
+
|
|
2435
|
+
camera.position.copy(center).add(dir);
|
|
2436
|
+
camera.near = Math.max(0.1, dist - maxSize * 3.0);
|
|
2437
|
+
camera.far = dist + maxSize * 3.0;
|
|
2438
|
+
camera.lookAt(center);
|
|
2439
|
+
camera.updateProjectionMatrix();
|
|
2440
|
+
}
|
|
2441
|
+
async function createFFLMiiIcon(data, options, shirtColor, fflRes) {
|
|
2442
|
+
options ||= {};
|
|
2443
|
+
const isFullBody = !!options.fullBody;
|
|
2444
|
+
|
|
2445
|
+
const width = 450;
|
|
2446
|
+
const height = 900;
|
|
2447
|
+
const BODY_SCALE_Y_RANGE = [0.55, 1.35];
|
|
2448
|
+
const FULLBODY_CROP_BOTTOM_PX_RANGE = [220, 40]; // [at minYScale, at maxYScale]
|
|
2449
|
+
|
|
2273
2450
|
const gl = createGL(width, height);
|
|
2274
|
-
if (!gl)
|
|
2275
|
-
|
|
2451
|
+
if (!gl) throw new Error("Failed to create WebGL 1 context");
|
|
2452
|
+
|
|
2453
|
+
// Normalize potential gender inputs; And the body files for females and males have different mesh names for some reason, so adjust for that too
|
|
2454
|
+
let shirtMesh = "mesh_1_";
|
|
2455
|
+
if (typeof options.gender === "string") {
|
|
2456
|
+
options.gender = options.gender.toLowerCase() === "female" ? "Female" : "Male";
|
|
2457
|
+
}
|
|
2458
|
+
else if (typeof options.gender === "number") {
|
|
2459
|
+
options.gender = options.gender === 1 ? "Female" : "Male";
|
|
2460
|
+
}
|
|
2461
|
+
else {
|
|
2462
|
+
options.gender = "Male";
|
|
2276
2463
|
}
|
|
2464
|
+
if (options.gender === "Female") shirtMesh = "mesh_0_";
|
|
2277
2465
|
|
|
2278
|
-
//
|
|
2466
|
+
// Fake canvas
|
|
2279
2467
|
const canvas = {
|
|
2280
2468
|
width, height, style: {},
|
|
2281
|
-
addEventListener() { },
|
|
2282
|
-
|
|
2283
|
-
// Return the context for 'webgl' (not webgl2)
|
|
2284
|
-
getContext: (type, _) => type === 'webgl' ? gl : null,
|
|
2469
|
+
addEventListener() { }, removeEventListener() { },
|
|
2470
|
+
getContext: (t) => (t === "webgl" ? gl : null),
|
|
2285
2471
|
};
|
|
2472
|
+
globalThis.self ??= { cancelAnimationFrame: () => { } };
|
|
2286
2473
|
|
|
2287
|
-
// WebGLRenderer constructor sets "self" as the context.
|
|
2288
|
-
// As of r162, it only tries to call cancelAnimationFrame frame on it.
|
|
2289
|
-
globalThis.self ??= {
|
|
2290
|
-
// Mock window functions called by Three.js.
|
|
2291
|
-
cancelAnimationFrame: () => { },
|
|
2292
|
-
};
|
|
2293
|
-
// Create the Three.js renderer and scene.
|
|
2294
2474
|
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, alpha: true });
|
|
2295
|
-
|
|
2475
|
+
renderer.setSize(width, height, false);
|
|
2476
|
+
setIsWebGL1State(!renderer.capabilities.isWebGL2);
|
|
2477
|
+
|
|
2478
|
+
// Color mgmt + silence warnings
|
|
2479
|
+
THREE.ColorManagement.enabled = true;
|
|
2480
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
2481
|
+
const _warn = console.warn;
|
|
2482
|
+
console.warn = function (...args) {
|
|
2483
|
+
const s = String(args[0] ?? "");
|
|
2484
|
+
if (s.includes("ImageUtils.sRGBToLinear(): Unsupported image type")) return;
|
|
2485
|
+
if (s.includes("Texture is not power of two")) return;
|
|
2486
|
+
return _warn.apply(this, args);
|
|
2487
|
+
};
|
|
2296
2488
|
|
|
2297
2489
|
const scene = new THREE.Scene();
|
|
2298
|
-
scene.background = null;
|
|
2299
|
-
|
|
2300
|
-
if (useBody) {
|
|
2301
|
-
// After: const scene = new THREE.Scene(); scene.background = null;
|
|
2302
|
-
const ambient = new THREE.AmbientLight(0xffffff, 0.15);
|
|
2303
|
-
scene.add(ambient);
|
|
2304
|
-
|
|
2305
|
-
const rim = new THREE.DirectionalLight(0xffffff, 3);
|
|
2306
|
-
rim.position.set(0.5, -7, -1.0);
|
|
2307
|
-
scene.add(rim);
|
|
2308
|
-
|
|
2309
|
-
}
|
|
2490
|
+
scene.background = null;
|
|
2310
2491
|
|
|
2311
2492
|
let ffl, currentCharModel;
|
|
2312
|
-
|
|
2313
|
-
const _realConsoleDebug = console.debug;
|
|
2493
|
+
const _realDebug = console.debug;
|
|
2314
2494
|
console.debug = () => { };
|
|
2495
|
+
|
|
2315
2496
|
try {
|
|
2316
|
-
//
|
|
2497
|
+
// Head (FFL)
|
|
2317
2498
|
ffl = await initializeFFL(fflRes, ModuleFFL);
|
|
2318
|
-
|
|
2319
|
-
// Create Mii model and add to the scene.
|
|
2320
|
-
const studioRaw = parseHexOrB64ToUint8Array(data); // Parse studio data
|
|
2321
|
-
|
|
2322
|
-
// Convert Uint8Array to Buffer for struct-fu compatibility
|
|
2499
|
+
const studioRaw = parseHexOrB64ToUint8Array(data);
|
|
2323
2500
|
const studioBuffer = Buffer.from(studioRaw);
|
|
2324
|
-
|
|
2325
2501
|
currentCharModel = createCharModel(studioBuffer, null, FFLShaderMaterial, ffl.module);
|
|
2326
|
-
initCharModelTextures(currentCharModel, renderer);
|
|
2327
|
-
scene.add(currentCharModel.meshes); // Add to scene
|
|
2328
|
-
|
|
2329
|
-
//Add body
|
|
2330
|
-
if (useBody) {
|
|
2331
|
-
if (typeof GLTFLoader === 'undefined' || !GLTFLoader) {
|
|
2332
|
-
const mod = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
|
2333
|
-
GLTFLoader = mod.GLTFLoader;
|
|
2334
|
-
}
|
|
2335
|
-
//Read GLB from disk and parse (avoids URL/fetch issues)
|
|
2336
|
-
const absPath = path.resolve(__dirname, './mii-body.glb');
|
|
2337
|
-
const buf = fs.readFileSync(absPath);
|
|
2338
|
-
const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
2339
|
-
const loader = new GLTFLoader();
|
|
2340
|
-
const gltf = await new Promise((resolve, reject) => {
|
|
2341
|
-
loader.parse(
|
|
2342
|
-
arrayBuffer,
|
|
2343
|
-
path.dirname(absPath) + path.sep,
|
|
2344
|
-
resolve,
|
|
2345
|
-
reject
|
|
2346
|
-
);
|
|
2347
|
-
});
|
|
2502
|
+
initCharModelTextures(currentCharModel, renderer);
|
|
2348
2503
|
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2504
|
+
// Body GLTF (for baking)
|
|
2505
|
+
if (typeof GLTFLoader === "undefined" || !GLTFLoader) {
|
|
2506
|
+
const mod = await import("three/examples/jsm/loaders/GLTFLoader.js");
|
|
2507
|
+
GLTFLoader = mod.GLTFLoader;
|
|
2508
|
+
}
|
|
2509
|
+
const absPath = path.resolve(__dirname, `./mii${options.gender}Body.glb`);
|
|
2510
|
+
const buf = fs.readFileSync(absPath);
|
|
2511
|
+
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
2512
|
+
const loader = new GLTFLoader();
|
|
2513
|
+
const gltf = await new Promise((res, rej) =>
|
|
2514
|
+
loader.parse(ab, path.dirname(absPath) + path.sep, res, rej)
|
|
2515
|
+
);
|
|
2516
|
+
const body = gltf.scene;
|
|
2517
|
+
body.position.y -= 110;
|
|
2518
|
+
body.userData.isMiiBody = true;
|
|
2519
|
+
|
|
2520
|
+
// Recolor body (bakes into texture)
|
|
2521
|
+
var pantsColor=[0x808080,0xFFC000,0x89CFF0,0x913831][["default","special","foreign","favorite","favorited"].indexOf(options.pantsType.toLowerCase())];
|
|
2522
|
+
body.traverse((o) => {
|
|
2523
|
+
if (o.isMesh) {
|
|
2524
|
+
if (!o.geometry.attributes.normal) o.geometry.computeVertexNormals();
|
|
2525
|
+
const isShirt = o.name === shirtMesh;
|
|
2526
|
+
o.material?.dispose?.();
|
|
2527
|
+
o.material = new THREE.MeshLambertMaterial({
|
|
2528
|
+
color: isShirt
|
|
2529
|
+
? [
|
|
2530
|
+
0xff2400, 0xf08000, 0xffd700, 0xaaff00, 0x008000, 0x0000ff,
|
|
2531
|
+
0x00d7ff, 0xff69b4, 0x7f00ff, 0x6f4e37, 0xffffff, 0x303030,
|
|
2532
|
+
][shirtColor]
|
|
2533
|
+
: pantsColor,
|
|
2534
|
+
emissive: isShirt ? 0x330000 : 0x222222,
|
|
2535
|
+
emissiveIntensity: 0.0,
|
|
2536
|
+
side: THREE.DoubleSide,
|
|
2537
|
+
});
|
|
2538
|
+
o.material.needsUpdate = true;
|
|
2539
|
+
}
|
|
2540
|
+
});
|
|
2372
2541
|
|
|
2542
|
+
// Graph (only used for framing / body bbox)
|
|
2543
|
+
const wholeMii = new THREE.Group();
|
|
2544
|
+
wholeMii.add(body);
|
|
2545
|
+
wholeMii.add(currentCharModel.meshes);
|
|
2546
|
+
scene.add(wholeMii);
|
|
2373
2547
|
|
|
2548
|
+
// Layers for baking head/body
|
|
2549
|
+
body.traverse(obj => obj.layers?.set(1));
|
|
2550
|
+
currentCharModel.meshes.traverse(obj => obj.layers?.set(2)); // head only on 2
|
|
2374
2551
|
|
|
2375
|
-
|
|
2376
|
-
|
|
2552
|
+
// Camera
|
|
2553
|
+
const camera = getCameraForViewType(ViewType.MakeIcon);
|
|
2554
|
+
camera.aspect = width / height;
|
|
2555
|
+
camera.updateProjectionMatrix();
|
|
2556
|
+
fitCameraToObject(camera, wholeMii);
|
|
2557
|
+
|
|
2558
|
+
// --- Body world bounds (for plane sizing/placement)
|
|
2559
|
+
const bodyBox = new THREE.Box3().setFromObject(body);
|
|
2560
|
+
const bodySize = new THREE.Vector3();
|
|
2561
|
+
const bodyCenter = new THREE.Vector3();
|
|
2562
|
+
bodyBox.getSize(bodySize);
|
|
2563
|
+
bodyBox.getCenter(bodyCenter);
|
|
2564
|
+
|
|
2565
|
+
// --- BODY BAKE (lights for body only, depend on mode)
|
|
2566
|
+
const bakeAmbient = new THREE.AmbientLight(0xffffff, 0.15);
|
|
2567
|
+
const bakeRim = new THREE.DirectionalLight(
|
|
2568
|
+
0xffffff,
|
|
2569
|
+
1.5
|
|
2570
|
+
);
|
|
2571
|
+
bakeRim.position.set(-3, 7, 1.0);
|
|
2572
|
+
bakeAmbient.layers.enable(1);
|
|
2573
|
+
bakeRim.layers.enable(1);
|
|
2574
|
+
scene.add(bakeAmbient, bakeRim);
|
|
2575
|
+
|
|
2576
|
+
// Pass: body layer → pixels (no CPU flip; we'll let Three flip on texture)
|
|
2577
|
+
const bodyPixels = renderLayerToPixels(renderer, scene, camera, gl, width, height, /*layer*/1, /*flipY*/false);
|
|
2578
|
+
const bodyCanvas = createCanvas(width, height);
|
|
2579
|
+
bodyCanvas.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(bodyPixels), width, height), 0, 0);
|
|
2580
|
+
|
|
2581
|
+
// Remove bake lights & 3D body; we’ll insert a plane instead
|
|
2582
|
+
scene.remove(bakeAmbient, bakeRim);
|
|
2583
|
+
wholeMii.remove(body);
|
|
2584
|
+
bakeAmbient.dispose?.(); bakeRim.dispose?.();
|
|
2585
|
+
|
|
2586
|
+
// --- BODY PLANE (unlit; texture carries shading)
|
|
2587
|
+
const bodyTex = new THREE.CanvasTexture(bodyCanvas);
|
|
2588
|
+
bodyTex.colorSpace = THREE.SRGBColorSpace;
|
|
2589
|
+
bodyTex.generateMipmaps = false;
|
|
2590
|
+
bodyTex.minFilter = THREE.LinearFilter;
|
|
2591
|
+
bodyTex.magFilter = THREE.LinearFilter;
|
|
2592
|
+
bodyTex.wrapS = THREE.ClampToEdgeWrapping;
|
|
2593
|
+
bodyTex.wrapT = THREE.ClampToEdgeWrapping;
|
|
2594
|
+
bodyTex.flipY = true; // let Three handle UV-space flip
|
|
2595
|
+
bodyTex.premultiplyAlpha = true;
|
|
2596
|
+
bodyTex.needsUpdate = true;
|
|
2597
|
+
bodyTex.flipY = false;
|
|
2598
|
+
|
|
2599
|
+
const planeW = Math.max(1e-4, bodySize.x);
|
|
2600
|
+
const planeH = Math.max(1e-4, bodySize.y);
|
|
2601
|
+
const planeGeo = new THREE.PlaneGeometry(planeW, planeH);
|
|
2602
|
+
const planeMat = new THREE.MeshBasicMaterial({ map: bodyTex, transparent: true, depthWrite: true, depthTest: true });
|
|
2603
|
+
const bodyPlane = new THREE.Mesh(planeGeo, planeMat);
|
|
2604
|
+
|
|
2605
|
+
// Place plane at body world center so the neck peg aligns into head
|
|
2606
|
+
bodyPlane.position.copy(bodyCenter);
|
|
2607
|
+
bodyPlane.layers.set(2); // render with head
|
|
2608
|
+
|
|
2609
|
+
// === Apply height/weight scaling in BOTH modes ===
|
|
2610
|
+
const w01 = remap01(options.weight ?? 64);
|
|
2611
|
+
const h01 = remap01(options.height ?? 64);
|
|
2612
|
+
const scaleX = lerp(0.55, 1.50, w01);
|
|
2613
|
+
const scaleY = lerp(0.55, 1.35, h01);
|
|
2614
|
+
bodyPlane.scale.set(scaleX, scaleY, 1);
|
|
2615
|
+
|
|
2616
|
+
// --- Auto vertical offset (per-mode) + manual per-mode knob ---
|
|
2617
|
+
var tYraw = invLerp(BODY_SCALE_Y_RANGE[0], BODY_SCALE_Y_RANGE[1], scaleY);
|
|
2618
|
+
var tY = clamp01(easePow(tYraw, 1));
|
|
2619
|
+
|
|
2620
|
+
const autoRange = [150, 125];
|
|
2621
|
+
|
|
2622
|
+
const autoOffsetYPx = autoRange[0] + (autoRange[1] - autoRange[0]) * tY;
|
|
2623
|
+
|
|
2624
|
+
// Manual knobs (mode-specific; falls back to legacy bodyOffsetYPx)
|
|
2625
|
+
const manualPx = (options.bodyOffsetYPxFull ?? options.bodyOffsetYPx ?? 0);
|
|
2626
|
+
|
|
2627
|
+
const combinedOffsetYPx = Math.round(manualPx + autoOffsetYPx);
|
|
2628
|
+
|
|
2629
|
+
// Convert screen-px → world-Y at the plane depth & apply
|
|
2630
|
+
if (combinedOffsetYPx) {
|
|
2631
|
+
const planeDepthFromCam = bodyPlane.position.clone().sub(camera.position).length();
|
|
2632
|
+
const worldYOffset = pixelYToWorldY(camera, planeDepthFromCam, combinedOffsetYPx, height);
|
|
2633
|
+
bodyPlane.position.y += worldYOffset;
|
|
2377
2634
|
}
|
|
2378
2635
|
|
|
2379
2636
|
|
|
2380
|
-
//
|
|
2381
|
-
const
|
|
2637
|
+
// Optional depth nudge
|
|
2638
|
+
const bodyDepthOffset = Number(options.bodyDepthOffset ?? 0);
|
|
2639
|
+
if (bodyDepthOffset) offsetObjectAlongView(bodyPlane, camera, bodyDepthOffset);
|
|
2382
2640
|
|
|
2383
|
-
|
|
2384
|
-
camera.projectionMatrix.elements[5] *= -1; // Flip the camera Y axis.
|
|
2385
|
-
// When flipping the camera, the triangles are in the wrong direction.
|
|
2386
|
-
scene.traverse(mesh => {
|
|
2387
|
-
if (
|
|
2388
|
-
mesh.isMesh &&
|
|
2389
|
-
mesh.material.side === THREE.FrontSide &&
|
|
2390
|
-
!mesh.userData.isMiiBody
|
|
2391
|
-
) {
|
|
2392
|
-
mesh.material.side = THREE.BackSide;
|
|
2393
|
-
}
|
|
2394
|
-
});
|
|
2641
|
+
scene.add(bodyPlane);
|
|
2395
2642
|
|
|
2643
|
+
// Ensure head renders with the plane on the same layer and with NO head lights
|
|
2644
|
+
currentCharModel.meshes.traverse(o => o.layers?.set(2));
|
|
2396
2645
|
|
|
2397
|
-
//
|
|
2646
|
+
// Final pass: render layer 2 (head + body plane), then flip pixels for PNG
|
|
2647
|
+
camera.layers.set(2);
|
|
2648
|
+
renderer.setRenderTarget(null);
|
|
2649
|
+
renderer.clear(true, true, true);
|
|
2398
2650
|
renderer.render(scene, camera);
|
|
2399
|
-
const pixels = new Uint8Array(width * height * 4);
|
|
2400
|
-
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
|
2401
|
-
|
|
2402
|
-
// Draw the pixels to a new canvas.
|
|
2403
|
-
const canvas = createCanvas(width, height);
|
|
2404
|
-
const img = new ImageData(new Uint8ClampedArray(pixels), width, height);
|
|
2405
|
-
canvas.getContext('2d').putImageData(img, 0, 0);
|
|
2406
|
-
|
|
2407
|
-
return canvas.toBuffer('image/png'); // Encode image to PNG
|
|
2408
2651
|
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2652
|
+
// Read back & flip to top-left
|
|
2653
|
+
const finalPixels = new Uint8Array(width * height * 4);
|
|
2654
|
+
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, finalPixels);
|
|
2655
|
+
const upright = flipPixelsVertically(finalPixels, width, height);
|
|
2656
|
+
|
|
2657
|
+
// Stage onto a canvas
|
|
2658
|
+
const stage = createCanvas(width, height);
|
|
2659
|
+
stage.getContext("2d").putImageData(
|
|
2660
|
+
new ImageData(new Uint8ClampedArray(upright), width, height),
|
|
2661
|
+
0, 0
|
|
2662
|
+
);
|
|
2663
|
+
|
|
2664
|
+
// === FullBody-only: crop from the BOTTOM based on scaleY ===
|
|
2665
|
+
let cropBottom = 0;
|
|
2666
|
+
tYraw = invLerp(BODY_SCALE_Y_RANGE[0], BODY_SCALE_Y_RANGE[1], scaleY);
|
|
2667
|
+
tY = clamp01(easePow(tYraw, 1));
|
|
2668
|
+
|
|
2669
|
+
// Interpolate bottom crop across the configured range
|
|
2670
|
+
const bottomPx = Math.round(
|
|
2671
|
+
FULLBODY_CROP_BOTTOM_PX_RANGE[0] +
|
|
2672
|
+
(FULLBODY_CROP_BOTTOM_PX_RANGE[1] - FULLBODY_CROP_BOTTOM_PX_RANGE[0]) * tY
|
|
2673
|
+
);
|
|
2674
|
+
|
|
2675
|
+
cropBottom = Math.max(0, Math.min(height - 1, bottomPx + (options.fullBodyCropExtraBottomPx ?? 0)));
|
|
2676
|
+
|
|
2677
|
+
// Output with bottom crop applied (no top crop)
|
|
2678
|
+
const outH = Math.max(1, isFullBody?height - cropBottom:450);
|
|
2679
|
+
const outCanvas = createCanvas(width, outH);
|
|
2680
|
+
const ctxOut = outCanvas.getContext("2d");
|
|
2681
|
+
|
|
2682
|
+
// Source: take the top `outH` rows (i.e., drop `cropBottom` pixels at the bottom)
|
|
2683
|
+
ctxOut.drawImage(
|
|
2684
|
+
stage,
|
|
2685
|
+
0, 0, // sx, sy
|
|
2686
|
+
width, outH, // sw, sh
|
|
2687
|
+
0, 0, // dx, dy
|
|
2688
|
+
width, outH // dw, dh
|
|
2689
|
+
);
|
|
2690
|
+
|
|
2691
|
+
return outCanvas.toBuffer("image/png");
|
|
2692
|
+
|
|
2693
|
+
} catch (err) {
|
|
2694
|
+
console.error("Error rendering Mii:", err);
|
|
2695
|
+
throw err;
|
|
2412
2696
|
} finally {
|
|
2413
|
-
// Clean up.
|
|
2414
2697
|
try {
|
|
2415
|
-
|
|
2416
|
-
exitFFL(ffl
|
|
2417
|
-
renderer.dispose();
|
|
2698
|
+
currentCharModel?.dispose?.();
|
|
2699
|
+
exitFFL(ffl?.module, ffl?.resourceDesc);
|
|
2700
|
+
renderer.dispose();
|
|
2418
2701
|
gl.finish();
|
|
2419
|
-
} catch
|
|
2420
|
-
|
|
2421
|
-
}// finally {
|
|
2422
|
-
// console.debug = _realConsoleDebug;
|
|
2423
|
-
//}
|
|
2702
|
+
} catch { }
|
|
2703
|
+
console.debug = _realDebug;
|
|
2424
2704
|
}
|
|
2425
2705
|
}
|
|
2426
|
-
|
|
2706
|
+
|
|
2707
|
+
async function renderMii(jsonIn, options = {}, fflRes = getFFLRes()) {
|
|
2427
2708
|
if (!["3ds", "wii u"].includes(jsonIn.console?.toLowerCase())) {
|
|
2428
2709
|
jsonIn = convertMii(jsonIn);
|
|
2429
2710
|
}
|
|
2430
2711
|
const studioMii = convertMiiToStudio(jsonIn);
|
|
2431
|
-
|
|
2712
|
+
options = Object.assign(options, {
|
|
2713
|
+
gender: jsonIn.general.gender,
|
|
2714
|
+
height: jsonIn.general.height,
|
|
2715
|
+
weight: jsonIn.general.weight,
|
|
2716
|
+
pantsType: jsonIn.meta?.type||"Default"
|
|
2717
|
+
});
|
|
2432
2718
|
|
|
2433
|
-
return createFFLMiiIcon(studioMii,
|
|
2719
|
+
return createFFLMiiIcon(studioMii, options, jsonIn.general.favoriteColor, fflRes);
|
|
2434
2720
|
}
|
|
2435
2721
|
async function writeWiiBin(jsonIn, outPath) {
|
|
2436
2722
|
if (jsonIn.console?.toLowerCase() !== "wii") {
|
|
@@ -2674,7 +2960,9 @@ async function write3DSQR(miiJson, outPath, returnBin, fflRes = getFFLRes()) {
|
|
|
2674
2960
|
}
|
|
2675
2961
|
const buffer = Buffer.from(buffers);
|
|
2676
2962
|
var encryptedData = Buffer.from(encodeAesCcm(new Uint8Array(buffer)));
|
|
2677
|
-
|
|
2963
|
+
if (returnBin) {
|
|
2964
|
+
return encryptedData;
|
|
2965
|
+
}
|
|
2678
2966
|
//Prepare a QR code
|
|
2679
2967
|
const options = {
|
|
2680
2968
|
width: 300,
|
|
@@ -3051,9 +3339,9 @@ module.exports = {
|
|
|
3051
3339
|
miiWeightToRealWeight,//EXPERIMENTAL
|
|
3052
3340
|
|
|
3053
3341
|
/*
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3342
|
+
Handle Amiibo Functions
|
|
3343
|
+
insertMiiIntoAmiibo(amiiboDump, decrypted3DSMiiBuffer),
|
|
3344
|
+
extractMiiFromAmiibo(amiiboDump)
|
|
3057
3345
|
*/
|
|
3058
3346
|
...require("./amiiboHandler.js")
|
|
3059
3347
|
}
|
|
Binary file
|
package/package.json
CHANGED
|
File without changes
|