@nano133/nanoterra 0.2.0 → 0.3.0
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/CHANGELOG.md +24 -0
- package/README.md +82 -12
- package/ai/SPEC.md +5 -1
- package/dist/admin/panel/index.d.ts +1 -1
- package/dist/admin/panel/index.js +1 -1
- package/dist/admin/panel/shelf.d.ts +7 -0
- package/dist/admin/panel/shelf.js +24 -0
- package/dist/character/bases/bee.js +7 -5
- package/dist/character/bases/spec.d.ts +7 -0
- package/dist/character/create.d.ts +15 -0
- package/dist/character/create.js +69 -2
- package/dist/character/ctx.d.ts +12 -2
- package/dist/character/index.d.ts +2 -0
- package/dist/character/index.js +2 -0
- package/dist/character/make.js +2 -1
- package/dist/character/preview.d.ts +32 -0
- package/dist/character/preview.js +112 -0
- package/dist/character/sockets.d.ts +37 -0
- package/dist/character/sockets.js +66 -0
- package/dist/model/check.d.ts +59 -0
- package/dist/model/check.js +681 -0
- package/dist/model/index.d.ts +2 -0
- package/dist/model/index.js +5 -0
- package/dist/model/limits.d.ts +103 -0
- package/dist/model/limits.js +132 -0
- package/package.json +3 -2
- package/src/admin/panel/index.ts +1 -1
- package/src/admin/panel/shelf.ts +24 -0
- package/src/admin/panel/style.css +9 -0
- package/src/character/bases/bee.ts +7 -5
- package/src/character/bases/spec.ts +4 -0
- package/src/character/create.ts +73 -2
- package/src/character/ctx.ts +17 -2
- package/src/character/index.ts +2 -0
- package/src/character/make.ts +2 -1
- package/src/character/preview.ts +148 -0
- package/src/character/sockets.ts +81 -0
- package/src/model/check.ts +624 -0
- package/src/model/index.ts +5 -0
- package/src/model/limits.ts +193 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { createCharacter } from "./create.js";
|
|
3
|
+
function defaultBody(record) {
|
|
4
|
+
return () => {
|
|
5
|
+
const c = createCharacter({ record, reducedMotion: true });
|
|
6
|
+
return {
|
|
7
|
+
object: c.object,
|
|
8
|
+
dress(look) {
|
|
9
|
+
const body = look.change?.body;
|
|
10
|
+
if (body && (look.priceUsd ?? 1) > 0)
|
|
11
|
+
c.setColors({ shirt: body });
|
|
12
|
+
},
|
|
13
|
+
dispose: () => c.dispose(),
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** A picture maker. Make one for a page and keep it: it holds one WebGL context while it lives. */
|
|
18
|
+
export function createLookPreview(options = {}) {
|
|
19
|
+
const size = options.size ?? 112;
|
|
20
|
+
const makeBody = options.body ?? defaultBody(options.record);
|
|
21
|
+
const keyOf = options.key ?? ((l) => JSON.stringify(l));
|
|
22
|
+
const cache = new Map();
|
|
23
|
+
let renderer = null;
|
|
24
|
+
let failed = false;
|
|
25
|
+
let disposed = false;
|
|
26
|
+
let queue = Promise.resolve();
|
|
27
|
+
const scene = new THREE.Scene();
|
|
28
|
+
if (options.background != null)
|
|
29
|
+
scene.background = new THREE.Color(options.background);
|
|
30
|
+
scene.add(new THREE.HemisphereLight("#ffffff", "#8a8f9e", 1.6));
|
|
31
|
+
const sun = new THREE.DirectionalLight("#ffffff", 1.6);
|
|
32
|
+
sun.position.set(2, 4, 5);
|
|
33
|
+
scene.add(sun);
|
|
34
|
+
const camera = new THREE.PerspectiveCamera(30, 1, 0.05, 50);
|
|
35
|
+
function getRenderer() {
|
|
36
|
+
if (renderer || failed)
|
|
37
|
+
return renderer;
|
|
38
|
+
try {
|
|
39
|
+
// three makes its own canvas (in a page); with no page or no WebGL this throws, and the answer is null.
|
|
40
|
+
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true });
|
|
41
|
+
renderer.setPixelRatio(1);
|
|
42
|
+
renderer.setSize(size * 2, size * 2, false);
|
|
43
|
+
renderer.setClearColor(0x000000, 0);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
failed = true;
|
|
47
|
+
renderer = null;
|
|
48
|
+
}
|
|
49
|
+
return renderer;
|
|
50
|
+
}
|
|
51
|
+
async function draw(look) {
|
|
52
|
+
if (disposed)
|
|
53
|
+
return null;
|
|
54
|
+
const r = getRenderer();
|
|
55
|
+
if (!r)
|
|
56
|
+
return null;
|
|
57
|
+
const body = makeBody();
|
|
58
|
+
try {
|
|
59
|
+
await body.dress(look);
|
|
60
|
+
if (disposed)
|
|
61
|
+
return null;
|
|
62
|
+
scene.add(body.object);
|
|
63
|
+
// Frame the whole character (with what it holds) from the front, a little from its right and above.
|
|
64
|
+
body.object.updateMatrixWorld(true);
|
|
65
|
+
const box = new THREE.Box3().setFromObject(body.object);
|
|
66
|
+
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
|
67
|
+
const dir = new THREE.Vector3(-0.6, 0.15, 1).normalize();
|
|
68
|
+
const dist = sphere.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.02;
|
|
69
|
+
camera.position.copy(sphere.center).addScaledVector(dir, dist);
|
|
70
|
+
camera.lookAt(sphere.center);
|
|
71
|
+
camera.near = Math.max(0.01, dist - sphere.radius * 2);
|
|
72
|
+
camera.far = dist + sphere.radius * 2;
|
|
73
|
+
camera.updateProjectionMatrix();
|
|
74
|
+
r.render(scene, camera);
|
|
75
|
+
return r.domElement.toDataURL("image/png");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
scene.remove(body.object);
|
|
82
|
+
body.dispose();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
render(look) {
|
|
87
|
+
if (disposed)
|
|
88
|
+
return Promise.resolve(null);
|
|
89
|
+
const key = keyOf(look);
|
|
90
|
+
const hit = cache.get(key);
|
|
91
|
+
if (hit)
|
|
92
|
+
return hit;
|
|
93
|
+
// One at a time: the renderer and the scene are shared.
|
|
94
|
+
const p = queue.then(() => draw(look));
|
|
95
|
+
queue = p.catch(() => null);
|
|
96
|
+
cache.set(key, p);
|
|
97
|
+
return p;
|
|
98
|
+
},
|
|
99
|
+
get cached() {
|
|
100
|
+
return cache.size;
|
|
101
|
+
},
|
|
102
|
+
dispose() {
|
|
103
|
+
if (disposed)
|
|
104
|
+
return;
|
|
105
|
+
disposed = true;
|
|
106
|
+
cache.clear();
|
|
107
|
+
renderer?.dispose();
|
|
108
|
+
renderer?.forceContextLoss();
|
|
109
|
+
renderer = null;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import type { BaseSpec } from "./bases/spec.js";
|
|
3
|
+
import { BUILD_FACTORS } from "./make.js";
|
|
4
|
+
import type { BoneName } from "./skeleton.js";
|
|
5
|
+
/** The names of the sockets. */
|
|
6
|
+
export declare const SOCKETS: readonly ["head", "hand", "back"];
|
|
7
|
+
export type SocketName = (typeof SOCKETS)[number];
|
|
8
|
+
/** Where a socket sits: its bone, and its place, turn and scale in that bone's space (the units of the character's own space). */
|
|
9
|
+
export type SocketFrame = {
|
|
10
|
+
bone: BoneName;
|
|
11
|
+
position: THREE.Vector3;
|
|
12
|
+
quaternion: THREE.Quaternion;
|
|
13
|
+
scale: THREE.Vector3;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The hand's hold: a hand item is modelled standing up (+Y), its grip at the origin, its front at +Z. The hand holds it pointing forward,
|
|
17
|
+
* as a torch is held out: the item's +Y along the character's forward (+Z), its front (+Z) up, its +X to the character's right (-X).
|
|
18
|
+
*/
|
|
19
|
+
export declare const HAND_HOLD: Readonly<THREE.Quaternion>;
|
|
20
|
+
/** What `socketFrame` reads of a look: its build and its head's size. */
|
|
21
|
+
export type SocketLook = {
|
|
22
|
+
build: keyof typeof BUILD_FACTORS;
|
|
23
|
+
head: number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* A socket's frame on a base for a look.
|
|
27
|
+
* - "head": on the head bone, at the base's hat frame (`spec.hat`), scaled by the hat frame and the head's size: a thing made for the
|
|
28
|
+
* Classic's head, round the middle of the head (the game starter's hats are made so), fits every head.
|
|
29
|
+
* - "hand": on grip_R, in the middle of the right hand (the hands of a wide body are drawn further out than their bone), turned by
|
|
30
|
+
* `HAND_HOLD`.
|
|
31
|
+
* - "back": on the chest bone, on the back of the torso (`spec.back`, else the chest's height and the torso's radius there); its axes are the
|
|
32
|
+
* character's, so a back item is modelled with the side that touches the body at the origin and its bulk towards -Z.
|
|
33
|
+
*/
|
|
34
|
+
export declare function socketFrame(spec: BaseSpec, look: SocketLook, name: SocketName): SocketFrame;
|
|
35
|
+
/** Puts a socket object on its bone (from `bones`) at its frame. The object keeps its children: a hung item moves with it. */
|
|
36
|
+
export declare function placeSocket(socket: THREE.Object3D, bones: Partial<Record<BoneName, THREE.Object3D>>, spec: BaseSpec, look: SocketLook, name: SocketName): THREE.Object3D<THREE.Object3DEventMap>;
|
|
37
|
+
export declare const isSocketName: (v: unknown) => v is SocketName;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { BUILD_FACTORS } from "./make.js";
|
|
3
|
+
// The character's three attach points (sockets): the head, the right hand and the back. A socket is a THREE.Object3D on one bone of the
|
|
4
|
+
// island's skeleton, so a thing hung on it follows the walk, the run, the jump and the wave, and costs its own draws (a model of the game's,
|
|
5
|
+
// a .glb say). A worn thing that is merged into the body's geometry (ctx.ts `WornAdapter.add`) adds no draw and needs no socket.
|
|
6
|
+
//
|
|
7
|
+
// Each socket's place depends on the base and on the look (the build widens the body and moves the hands out; the head's size scales the
|
|
8
|
+
// head). `socketFrame` works it out; `createCharacter` keeps its sockets on the new bones after a rebuild (a new base, hair or clothes).
|
|
9
|
+
/** The names of the sockets. */
|
|
10
|
+
export const SOCKETS = ["head", "hand", "back"];
|
|
11
|
+
/** The head bone's rest height. */
|
|
12
|
+
const HEAD_BONE_Y = 1.5;
|
|
13
|
+
/** The chest bone's rest height. */
|
|
14
|
+
const CHEST_BONE_Y = 1.12;
|
|
15
|
+
/**
|
|
16
|
+
* The hand's hold: a hand item is modelled standing up (+Y), its grip at the origin, its front at +Z. The hand holds it pointing forward,
|
|
17
|
+
* as a torch is held out: the item's +Y along the character's forward (+Z), its front (+Z) up, its +X to the character's right (-X).
|
|
18
|
+
*/
|
|
19
|
+
export const HAND_HOLD = Object.freeze(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 1).normalize(), Math.PI));
|
|
20
|
+
/**
|
|
21
|
+
* A socket's frame on a base for a look.
|
|
22
|
+
* - "head": on the head bone, at the base's hat frame (`spec.hat`), scaled by the hat frame and the head's size: a thing made for the
|
|
23
|
+
* Classic's head, round the middle of the head (the game starter's hats are made so), fits every head.
|
|
24
|
+
* - "hand": on grip_R, in the middle of the right hand (the hands of a wide body are drawn further out than their bone), turned by
|
|
25
|
+
* `HAND_HOLD`.
|
|
26
|
+
* - "back": on the chest bone, on the back of the torso (`spec.back`, else the chest's height and the torso's radius there); its axes are the
|
|
27
|
+
* character's, so a back item is modelled with the side that touches the body at the origin and its bulk towards -Z.
|
|
28
|
+
*/
|
|
29
|
+
export function socketFrame(spec, look, name) {
|
|
30
|
+
const t = BUILD_FACTORS[look.build] ?? BUILD_FACTORS.regular;
|
|
31
|
+
const hs = look.head;
|
|
32
|
+
if (name === "head") {
|
|
33
|
+
const h = spec.hat;
|
|
34
|
+
return {
|
|
35
|
+
bone: "head",
|
|
36
|
+
position: new THREE.Vector3(0, (h.y - HEAD_BONE_Y) * hs, (h.z ?? 0) * hs),
|
|
37
|
+
quaternion: new THREE.Quaternion(),
|
|
38
|
+
scale: new THREE.Vector3(h.s[0] * hs, h.s[1] * hs, h.s[2] * hs),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (name === "hand") {
|
|
42
|
+
// limbs.ts draws the hand at 0.47 + 0.45 (torso - 1) + armOut from the middle; the bone stays at 0.47.
|
|
43
|
+
const out = 0.45 * (t.torso - 1) + (spec.limbs.armOut ?? 0);
|
|
44
|
+
const at = spec.handAt ?? [0, 0, 0];
|
|
45
|
+
return { bone: "grip_R", position: new THREE.Vector3(-out + at[0], at[1], at[2]), quaternion: HAND_HOLD.clone(), scale: new THREE.Vector3(1, 1, 1) };
|
|
46
|
+
}
|
|
47
|
+
if (name === "back") {
|
|
48
|
+
const y = spec.back?.y ?? CHEST_BONE_Y;
|
|
49
|
+
const z = spec.back?.z ?? -spec.torsoR(y) * t.torso;
|
|
50
|
+
return { bone: "chest", position: new THREE.Vector3(0, y - CHEST_BONE_Y, z), quaternion: new THREE.Quaternion(), scale: new THREE.Vector3(1, 1, 1) };
|
|
51
|
+
}
|
|
52
|
+
throw new TypeError(`@nano133/nanoterra: there is no socket ${JSON.stringify(name)} (the sockets are ${SOCKETS.join(", ")})`);
|
|
53
|
+
}
|
|
54
|
+
/** Puts a socket object on its bone (from `bones`) at its frame. The object keeps its children: a hung item moves with it. */
|
|
55
|
+
export function placeSocket(socket, bones, spec, look, name) {
|
|
56
|
+
const f = socketFrame(spec, look, name);
|
|
57
|
+
const bone = bones[f.bone];
|
|
58
|
+
if (!bone)
|
|
59
|
+
throw new TypeError(`@nano133/nanoterra: the skeleton has no bone ${f.bone} for the socket ${name}`);
|
|
60
|
+
socket.position.copy(f.position);
|
|
61
|
+
socket.quaternion.copy(f.quaternion);
|
|
62
|
+
socket.scale.copy(f.scale);
|
|
63
|
+
bone.add(socket);
|
|
64
|
+
return socket;
|
|
65
|
+
}
|
|
66
|
+
export const isSocketName = (v) => typeof v === "string" && SOCKETS.includes(v);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ModelLimits } from "./limits.js";
|
|
2
|
+
export type ModelRule = "file" | "size" | "embedded" | "extensions" | "lights" | "motion" | "triangles" | "textures" | "draws" | "zone" | "markers" | "time";
|
|
3
|
+
/** One thing wrong with the model: `fix` is one plain sentence or two, for the creator. */
|
|
4
|
+
export type ModelProblem = {
|
|
5
|
+
rule: ModelRule;
|
|
6
|
+
fix: string;
|
|
7
|
+
};
|
|
8
|
+
export type Vec3 = [number, number, number];
|
|
9
|
+
export type TextureFact = {
|
|
10
|
+
type: "image/png" | "image/jpeg" | "image/webp" | "image/ktx2";
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
bytes: number;
|
|
14
|
+
};
|
|
15
|
+
export type ModelFacts = {
|
|
16
|
+
bytes: number;
|
|
17
|
+
/** Triangles drawn (every instance counted; the `mesh` markers, as `nano_collide`, left out). Null when not counted. */
|
|
18
|
+
triangles: number | null;
|
|
19
|
+
textures: TextureFact[];
|
|
20
|
+
/**
|
|
21
|
+
* Draws: each part (glTF primitive) of each drawn mesh instance is one draw call, the `mesh` markers left out. Null when not counted.
|
|
22
|
+
* (A game may join parts or draw an ink outline; this counts the file as it is.)
|
|
23
|
+
*/
|
|
24
|
+
draws: number | null;
|
|
25
|
+
/** The materials that the drawn parts use (a part with no material uses the default one, which counts once). */
|
|
26
|
+
materials: number;
|
|
27
|
+
/**
|
|
28
|
+
* The model's box in its own axes (metres), every drawn mesh and every `mesh` marker in it. Null when not measured. When the limits name an
|
|
29
|
+
* `anchor` and the file has it, the box, the reach and the zone are measured from the anchor's point (`anchor`), not the file's origin.
|
|
30
|
+
*/
|
|
31
|
+
bounds: {
|
|
32
|
+
min: Vec3;
|
|
33
|
+
max: Vec3;
|
|
34
|
+
} | null;
|
|
35
|
+
/** The farthest drawn point from the y axis (x and z together, metres), the `mesh` markers in it. Null when not measured. */
|
|
36
|
+
reach: number | null;
|
|
37
|
+
/** True: the box and the reach come from every vertex. False: past the work cap, from each instance's box (larger, never smaller). */
|
|
38
|
+
exactBounds: boolean;
|
|
39
|
+
/** Where each marker that the file has stands (its node's origin, in the model's axes). */
|
|
40
|
+
markers: Record<string, Vec3>;
|
|
41
|
+
/** The anchor's point in the file's axes (the limits' `anchor` marker), or null: the file's origin is the attach point. */
|
|
42
|
+
anchor: Vec3 | null;
|
|
43
|
+
nodes: number;
|
|
44
|
+
meshes: number;
|
|
45
|
+
extensions: string[];
|
|
46
|
+
};
|
|
47
|
+
/** `advice`: plain lines that would make the model lighter (never a failure: `ok` does not read them). */
|
|
48
|
+
export type ModelCheck = {
|
|
49
|
+
ok: boolean;
|
|
50
|
+
problems: ModelProblem[];
|
|
51
|
+
facts: ModelFacts;
|
|
52
|
+
advice: string[];
|
|
53
|
+
};
|
|
54
|
+
/** The picture's type and size from its bytes (never from the file's word for it), or null. */
|
|
55
|
+
export declare function sniffTexture(b: Uint8Array): Omit<TextureFact, "bytes"> | null;
|
|
56
|
+
/** Checks a .glb against `limits`. Never throws: a file that cannot be read answers a problem. */
|
|
57
|
+
export declare function checkModel(input: Uint8Array, limits: ModelLimits): ModelCheck;
|
|
58
|
+
/** One short line of what was measured: "12,000 triangles, 3 textures, 1.2 MB, 20.0 x 15.0 x 10.0 m". */
|
|
59
|
+
export declare function modelSummary(f: ModelFacts): string;
|