@homie-rocks/studio 0.21.0 → 0.22.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 +39 -1
- package/README.md +55 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +47 -1
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +25 -2
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/optimise.mjs +380 -0
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +26 -0
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +38 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/package.json +14 -2
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2086 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/index.mjs +5 -3
- package/worker/pages.mjs +13 -5
- package/worker/qr.mjs +11 -0
- package/worker/site.mjs +5 -3
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* THE STILL RENDERER (lib/render3d.mjs bundles this into one page and drives it in headless Chrome): the engine's
|
|
3
|
+
* own pictures of art direction, made on the creator's computer for free.
|
|
4
|
+
*
|
|
5
|
+
* swatch(tokens, models) a style direction drawn as the game would draw it: its palette on a ground and a sky, its
|
|
6
|
+
* light, fog and camera, its material model (flat, toon with ink outlines, painted, PBR,
|
|
7
|
+
* pixel), a character stand-in at the decided proportions, a tree, a rock, a pick-up and the
|
|
8
|
+
* library family's own pieces, with the title in the display font and the palette as chips
|
|
9
|
+
* lineup(models, tokens) every asset side by side at true scale in front of a 1 m grid, front and three-quarter,
|
|
10
|
+
* under the game's light, plus each one's silhouette black on white at 64 px tall
|
|
11
|
+
* thumb(model) one asset, three-quarter, on a neutral ground (the library's thumbnails)
|
|
12
|
+
*
|
|
13
|
+
* Models arrive as base64 GLB bytes and are parsed here (meshopt decoded); nothing is fetched but Google Fonts.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
AmbientLight, BackSide, Box3, BufferGeometry, CanvasTexture, Float32BufferAttribute, Color, DirectionalLight, DoubleSide, Fog, Group, HemisphereLight, Mesh, MeshBasicMaterial,
|
|
17
|
+
MeshLambertMaterial, MeshStandardMaterial, MeshToonMaterial, NearestFilter, Object3D, OrthographicCamera, PerspectiveCamera, PlaneGeometry,
|
|
18
|
+
Scene, SRGBColorSpace, Vector3, WebGLRenderer, PointLight, CylinderGeometry, ConeGeometry, IcosahedronGeometry, SphereGeometry, CapsuleGeometry,
|
|
19
|
+
OctahedronGeometry, DataTexture, RedFormat, GridHelper, ACESFilmicToneMapping, NoToneMapping, PCFSoftShadowMap, type Material, type Texture,
|
|
20
|
+
} from 'three';
|
|
21
|
+
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
22
|
+
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
|
|
23
|
+
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
24
|
+
|
|
25
|
+
interface Palette { bg: string; ink: string; accent: string; accent2: string; danger: string; good: string; gold: string; ramp?: string[] }
|
|
26
|
+
interface Tokens {
|
|
27
|
+
palette: Palette;
|
|
28
|
+
light?: { time?: string; key?: number[]; intensity?: number; hardness?: number; sky?: string; ground?: string; fog?: number; bloom?: number };
|
|
29
|
+
camera?: { angle?: string; projection?: string; pitch?: number; distance?: number; fov?: number };
|
|
30
|
+
render?: string;
|
|
31
|
+
materials?: { model?: string; outline?: boolean };
|
|
32
|
+
fonts?: { display?: string; body?: string };
|
|
33
|
+
shape?: { language?: string; bevel?: number };
|
|
34
|
+
proportions?: { heads?: number; heightM?: number };
|
|
35
|
+
}
|
|
36
|
+
interface ModelIn { id: string; glb: string; label?: string; place?: 'hero' | 'prop' | 'dressing'; scale?: number; retint?: boolean; tint?: string; pull?: number }
|
|
37
|
+
|
|
38
|
+
const canvas = document.createElement('canvas');
|
|
39
|
+
document.body.appendChild(canvas);
|
|
40
|
+
const renderer = new WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true, alpha: false });
|
|
41
|
+
renderer.outputColorSpace = SRGBColorSpace;
|
|
42
|
+
renderer.shadowMap.enabled = true;
|
|
43
|
+
renderer.shadowMap.type = PCFSoftShadowMap;
|
|
44
|
+
const loader = new GLTFLoader();
|
|
45
|
+
loader.setMeshoptDecoder(MeshoptDecoder);
|
|
46
|
+
|
|
47
|
+
const b64 = (s: string): ArrayBuffer => { const bin = atob(s); const u = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i); return u.buffer; };
|
|
48
|
+
async function parse(m: ModelIn): Promise<Group> {
|
|
49
|
+
const gltf = await new Promise<{ scene: Group }>((res, rej) => loader.parse(b64(m.glb), '', res as never, rej));
|
|
50
|
+
gltf.scene.name = m.id;
|
|
51
|
+
return gltf.scene;
|
|
52
|
+
}
|
|
53
|
+
const col = (hex: string | undefined, fallback = '#888888'): Color => new Color(/^#[0-9a-f]{3,8}$/i.test(String(hex)) ? hex as string : fallback);
|
|
54
|
+
const boxOf = (o: Object3D): Box3 => new Box3().setFromObject(o);
|
|
55
|
+
|
|
56
|
+
/** A toon ramp of `steps` bands. */
|
|
57
|
+
function ramp(steps: number): DataTexture {
|
|
58
|
+
const n = Math.max(2, Math.min(5, steps));
|
|
59
|
+
const data = new Uint8Array(n);
|
|
60
|
+
for (let i = 0; i < n; i++) data[i] = Math.round(((i + 0.6) / n) * 255);
|
|
61
|
+
const t = new DataTexture(data, n, 1, RedFormat);
|
|
62
|
+
t.minFilter = NearestFilter; t.magFilter = NearestFilter; t.needsUpdate = true;
|
|
63
|
+
return t;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The direction's material model applied to one mesh's material (colour and picture kept). */
|
|
67
|
+
function styled(mat: Material, t: Tokens): Material {
|
|
68
|
+
const src = mat as MeshStandardMaterial;
|
|
69
|
+
const color = src.color ? src.color.clone() : new Color('#cccccc');
|
|
70
|
+
const map = (src.map ?? null) as Texture | null;
|
|
71
|
+
const model = t.materials?.model ?? 'flat';
|
|
72
|
+
if (model === 'toon') return new MeshToonMaterial({ color, map, side: src.side, gradientMap: ramp(3) });
|
|
73
|
+
if (model === 'flat' || model === 'pixel') return new MeshLambertMaterial({ color, map, side: src.side, flatShading: true });
|
|
74
|
+
if (model === 'hand-painted') {
|
|
75
|
+
const m = new MeshStandardMaterial({ color, map, side: src.side, roughness: 0.95, metalness: 0 });
|
|
76
|
+
// A painted look: the top of every surface a little lighter, the bottom a little cooler (light baked gently in).
|
|
77
|
+
m.onBeforeCompile = (s) => {
|
|
78
|
+
s.vertexShader = s.vertexShader.replace('#include <common>', '#include <common>\nvarying float vH;').replace('#include <worldpos_vertex>', '#include <worldpos_vertex>\nvH = (modelMatrix * vec4(transformed, 1.0)).y;');
|
|
79
|
+
s.fragmentShader = s.fragmentShader.replace('#include <common>', '#include <common>\nvarying float vH;').replace('#include <color_fragment>', '#include <color_fragment>\ndiffuseColor.rgb *= mix(vec3(0.82, 0.86, 0.98), vec3(1.12, 1.06, 0.96), clamp(vH * 0.6 + 0.4, 0.0, 1.0));');
|
|
80
|
+
};
|
|
81
|
+
return m;
|
|
82
|
+
}
|
|
83
|
+
return new MeshStandardMaterial({ color, map, side: src.side, roughness: src.roughness ?? 0.6, metalness: src.metalness ?? 0 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The outline's shell: positions only, corners welded, smooth normals (an unbroken line on a flat-shaded model). */
|
|
87
|
+
function hullGeometry(g: BufferGeometry): BufferGeometry | null {
|
|
88
|
+
const only = new BufferGeometry();
|
|
89
|
+
// Plain float positions (an optimised model's are quantized, often interleaved), read through the accessor.
|
|
90
|
+
const src = g.getAttribute('position');
|
|
91
|
+
const xyz = new Float32Array(src.count * 3);
|
|
92
|
+
for (let i = 0; i < src.count; i++) { xyz[i * 3] = src.getX(i); xyz[i * 3 + 1] = src.getY(i); xyz[i * 3 + 2] = src.getZ(i); }
|
|
93
|
+
only.setAttribute('position', new Float32BufferAttribute(xyz, 3));
|
|
94
|
+
if (g.index) only.setIndex(Array.from(g.index.array as ArrayLike<number>));
|
|
95
|
+
const h = mergeVertices(only);
|
|
96
|
+
if (openShell(h) || paperThin(h)) return null;
|
|
97
|
+
h.computeVertexNormals();
|
|
98
|
+
return h;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* How thick a closed shell is, about (3 x volume / area: a slab of thickness t gives 1.5 t), in its own units.
|
|
103
|
+
*/
|
|
104
|
+
function thicknessOf(h: BufferGeometry): number {
|
|
105
|
+
const p = h.getAttribute('position'); const a = (h.index as NonNullable<BufferGeometry['index']>).array as ArrayLike<number>;
|
|
106
|
+
let vol = 0; let area = 0;
|
|
107
|
+
for (let i = 0; i + 2 < a.length; i += 3) {
|
|
108
|
+
const i0 = a[i] as number; const i1 = a[i + 1] as number; const i2 = a[i + 2] as number;
|
|
109
|
+
const ax = p.getX(i0); const ay = p.getY(i0); const az = p.getZ(i0);
|
|
110
|
+
const bx = p.getX(i1); const by = p.getY(i1); const bz = p.getZ(i1);
|
|
111
|
+
const cx = p.getX(i2); const cy = p.getY(i2); const cz = p.getZ(i2);
|
|
112
|
+
vol += (ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)) / 6;
|
|
113
|
+
const ux = bx - ax; const uy = by - ay; const uz = bz - az; const vx = cx - ax; const vy = cy - ay; const vz = cz - az;
|
|
114
|
+
area += Math.hypot(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx) / 2;
|
|
115
|
+
}
|
|
116
|
+
return area > 0 ? (3 * Math.abs(vol)) / area : 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Two cards back to back (leaves, grass): closed, but with no inside, so a shell would z-fight it black. */
|
|
120
|
+
function paperThin(h: BufferGeometry): boolean {
|
|
121
|
+
h.computeBoundingBox();
|
|
122
|
+
const size = (h.boundingBox as NonNullable<BufferGeometry['boundingBox']>).getSize(new Vector3());
|
|
123
|
+
return thicknessOf(h) < 0.01 * Math.max(size.x, size.y, size.z);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** An open surface (cards of leaves, grass, a sail): many edges belong to one triangle only. No outline shell for it. */
|
|
127
|
+
function openShell(h: BufferGeometry): boolean {
|
|
128
|
+
const idx = h.index;
|
|
129
|
+
if (!idx) return true;
|
|
130
|
+
const edges = new Map<string, number>();
|
|
131
|
+
const a = idx.array as ArrayLike<number>;
|
|
132
|
+
for (let i = 0; i + 2 < a.length; i += 3) {
|
|
133
|
+
for (const [u, v] of [[a[i], a[i + 1]], [a[i + 1], a[i + 2]], [a[i + 2], a[i]]] as [number, number][]) {
|
|
134
|
+
const k = u < v ? `${u}_${v}` : `${v}_${u}`;
|
|
135
|
+
edges.set(k, (edges.get(k) ?? 0) + 1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let open = 0;
|
|
139
|
+
for (const n of edges.values()) if (n === 1) open += 1;
|
|
140
|
+
return edges.size > 0 && open / edges.size > 0.15;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The ink shell's vertex step: pushed out along its normal by `width` WORLD units (metres in a game), after every
|
|
145
|
+
* transform (the model's scale, a quantized mesh's node, an instance's matrix), so a line is as thick on a flower as on
|
|
146
|
+
* a tree and nothing about the model's own units matters.
|
|
147
|
+
*/
|
|
148
|
+
function inkShader(width: number): (sh: { vertexShader: string }) => void {
|
|
149
|
+
const w = Math.max(0, width).toExponential(4);
|
|
150
|
+
return (sh) => {
|
|
151
|
+
sh.vertexShader = sh.vertexShader.replace('#include <project_vertex>', [
|
|
152
|
+
'vec4 mvPosition = vec4( transformed, 1.0 );',
|
|
153
|
+
'vec3 inkN = normal;',
|
|
154
|
+
'#ifdef USE_INSTANCING',
|
|
155
|
+
'mvPosition = instanceMatrix * mvPosition; inkN = mat3( instanceMatrix ) * inkN;',
|
|
156
|
+
'#endif',
|
|
157
|
+
'vec4 inkW = modelMatrix * mvPosition;',
|
|
158
|
+
`inkW.xyz += normalize( mat3( modelMatrix ) * inkN ) * ${w};`,
|
|
159
|
+
'mvPosition = viewMatrix * inkW;',
|
|
160
|
+
'gl_Position = projectionMatrix * mvPosition;',
|
|
161
|
+
].join('\n'));
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A part too thin for the line (a flower's stem, a post): its thickness as drawn under three line widths would turn it
|
|
167
|
+
* into a dark stick, so it goes without. The shell's own units are measured against the mesh's world size.
|
|
168
|
+
*/
|
|
169
|
+
function tooThin(m: Mesh, shell: BufferGeometry, width: number, drawn: number): boolean {
|
|
170
|
+
shell.computeBoundingBox();
|
|
171
|
+
const local = (shell.boundingBox as Box3).getSize(new Vector3());
|
|
172
|
+
const world = new Box3().setFromObject(m, true).getSize(new Vector3());
|
|
173
|
+
const units = Math.max(world.x, world.y, world.z) / Math.max(1e-9, local.x, local.y, local.z);
|
|
174
|
+
return thicknessOf(shell) * units * drawn < 3 * width;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Ink outlines (an inverted hull pushed out along the normals): the toon look's line. */
|
|
178
|
+
function outline(root: Object3D, ink: string, width: number, drawn = 1): void {
|
|
179
|
+
const hulls: Mesh[] = [];
|
|
180
|
+
root.updateMatrixWorld(true);
|
|
181
|
+
const mat = new MeshBasicMaterial({ color: col(ink), side: BackSide });
|
|
182
|
+
mat.onBeforeCompile = inkShader(width);
|
|
183
|
+
mat.customProgramCacheKey = () => `ink:${width}`;
|
|
184
|
+
root.traverse((o) => {
|
|
185
|
+
const m = o as Mesh;
|
|
186
|
+
if (!m.isMesh || m.name === 'hull') return;
|
|
187
|
+
const shell = hullGeometry(m.geometry);
|
|
188
|
+
if (!shell || tooThin(m, shell, width, drawn)) return;
|
|
189
|
+
const h = new Mesh(shell, mat);
|
|
190
|
+
h.name = 'hull';
|
|
191
|
+
hulls.push(h);
|
|
192
|
+
(m as unknown as { __hull?: Mesh }).__hull = h;
|
|
193
|
+
});
|
|
194
|
+
root.traverse((o) => { const h = (o as unknown as { __hull?: Mesh }).__hull; if (h) o.add(h); });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A palette swap (the free fix when a palette changes): every colour of a model, its materials' and its pictures'
|
|
199
|
+
* texels, moved to the nearest colour of the ramp in lightness and hue, keeping its own shading.
|
|
200
|
+
*/
|
|
201
|
+
function lab(r: number, g: number, b: number): [number, number, number] {
|
|
202
|
+
const lin = (v: number): number => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
|
203
|
+
const R = lin(r); const G = lin(g); const B = lin(b);
|
|
204
|
+
const f = (t: number): number => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116);
|
|
205
|
+
const x = f((R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047); const y = f(R * 0.2126 + G * 0.7152 + B * 0.0722); const z = f((R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883);
|
|
206
|
+
return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
|
|
207
|
+
}
|
|
208
|
+
function fromLab(L: number, A: number, B: number): [number, number, number] {
|
|
209
|
+
const fy = (L + 16) / 116; const fx = fy + A / 500; const fz = fy - B / 200;
|
|
210
|
+
const inv = (t: number): number => (t ** 3 > 216 / 24389 ? t ** 3 : (116 * t - 16) / (24389 / 27));
|
|
211
|
+
const X = inv(fx) * 0.95047; const Y = inv(fy); const Z = inv(fz) * 1.08883;
|
|
212
|
+
const R = X * 3.2406 - Y * 1.5372 - Z * 0.4986; const G = -X * 0.9689 + Y * 1.8758 + Z * 0.0415; const Bb = X * 0.0557 - Y * 0.204 + Z * 1.057;
|
|
213
|
+
const enc = (v: number): number => Math.max(0, Math.min(1, v <= 0.0031308 ? 12.92 * v : 1.055 * Math.max(0, v) ** (1 / 2.4) - 0.055));
|
|
214
|
+
return [enc(R), enc(G), enc(Bb)];
|
|
215
|
+
}
|
|
216
|
+
type Rgb = { r: number; g: number; b: number };
|
|
217
|
+
const lchCache = new Map<Rgb, [number, number, number]>();
|
|
218
|
+
const lchOf = (c: Rgb): [number, number, number] => { let v = lchCache.get(c); if (!v) { const [L, A, B] = lab(c.r, c.g, c.b); v = [L, Math.hypot(A, B), Math.atan2(B, A)]; lchCache.set(c, v); } return v; };
|
|
219
|
+
/**
|
|
220
|
+
* A colour moved into the ramp: a coloured one takes the hue of the nearest coloured ramp entry (its own lightness and
|
|
221
|
+
* most of its own strength kept, so shading survives); a grey one takes the nearest grey-ish entry's tint.
|
|
222
|
+
*/
|
|
223
|
+
function nearestOf(ramp: Rgb[], r: number, g: number, b: number): [number, number, number] {
|
|
224
|
+
const [L, A, B] = lab(r, g, b);
|
|
225
|
+
const C = Math.hypot(A, B); const H = Math.atan2(B, A);
|
|
226
|
+
const pool = ramp.map((c) => [c, lchOf(c)] as const);
|
|
227
|
+
if (C < 16) {
|
|
228
|
+
const greys = pool.filter(([, v]) => v[1] < 24);
|
|
229
|
+
const [, v] = (greys.length ? greys : pool).reduce((x, y) => (Math.abs(y[1][0] - L) < Math.abs(x[1][0] - L) ? y : x));
|
|
230
|
+
return fromLab(L, Math.cos(v[2]) * Math.min(C, 6, v[1]), Math.sin(v[2]) * Math.min(C, 6, v[1]));
|
|
231
|
+
}
|
|
232
|
+
const hues = pool.filter(([, v]) => v[1] >= 18);
|
|
233
|
+
if (!hues.length) return [r, g, b];
|
|
234
|
+
let best = hues[0] as (typeof hues)[number]; let bd = Infinity;
|
|
235
|
+
for (const h of hues) { const d = Math.abs(Math.atan2(Math.sin(h[1][2] - H), Math.cos(h[1][2] - H))) + Math.abs(h[1][0] - L) / 400; if (d < bd) { bd = d; best = h; } }
|
|
236
|
+
const [L2, C2, H2] = best[1];
|
|
237
|
+
const LL = L * 0.6 + L2 * 0.4; const CC = C * 0.45 + C2 * 0.55;
|
|
238
|
+
return fromLab(LL, Math.cos(H2) * CC, Math.sin(H2) * CC);
|
|
239
|
+
}
|
|
240
|
+
export function retint(root: Object3D, hexes: string[], strength = 1): void {
|
|
241
|
+
// sRGB 0..1, read straight from the hex (three.js's Color would hold linear values).
|
|
242
|
+
const ramp: Rgb[] = hexes.filter((h) => /^#[0-9a-f]{6}$/i.test(h)).map((h) => ({ r: parseInt(h.slice(1, 3), 16) / 255, g: parseInt(h.slice(3, 5), 16) / 255, b: parseInt(h.slice(5, 7), 16) / 255 }));
|
|
243
|
+
if (!ramp.length) return;
|
|
244
|
+
const done = new Map<unknown, unknown>();
|
|
245
|
+
root.traverse((o) => {
|
|
246
|
+
const m = o as Mesh;
|
|
247
|
+
if (!m.isMesh) return;
|
|
248
|
+
for (const mat of (Array.isArray(m.material) ? m.material : [m.material]) as MeshStandardMaterial[]) {
|
|
249
|
+
if (done.has(mat)) continue;
|
|
250
|
+
done.set(mat, true);
|
|
251
|
+
if (mat.map?.image && !mat.map.userData.retinted) {
|
|
252
|
+
const img = mat.map.image as CanvasImageSource & { width: number; height: number };
|
|
253
|
+
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
|
|
254
|
+
const g = c.getContext('2d') as CanvasRenderingContext2D; g.drawImage(img, 0, 0);
|
|
255
|
+
const d = g.getImageData(0, 0, c.width, c.height);
|
|
256
|
+
const k = Math.max(0, Math.min(1, strength));
|
|
257
|
+
for (let i = 0; i < d.data.length; i += 4) {
|
|
258
|
+
const r = (d.data[i] as number) / 255; const gg = (d.data[i + 1] as number) / 255; const b = (d.data[i + 2] as number) / 255;
|
|
259
|
+
const n = nearestOf(ramp, r, gg, b);
|
|
260
|
+
d.data[i] = (r + (n[0] - r) * k) * 255; d.data[i + 1] = (gg + (n[1] - gg) * k) * 255; d.data[i + 2] = (b + (n[2] - b) * k) * 255;
|
|
261
|
+
}
|
|
262
|
+
g.putImageData(d, 0, 0);
|
|
263
|
+
const t = new CanvasTexture(c); t.colorSpace = SRGBColorSpace; t.flipY = mat.map.flipY; t.userData.retinted = true;
|
|
264
|
+
mat.map = t; mat.needsUpdate = true;
|
|
265
|
+
} else if (mat.color && !mat.map) {
|
|
266
|
+
const s = mat.color.clone().convertLinearToSRGB();
|
|
267
|
+
const n = nearestOf(ramp, s.r, s.g, s.b);
|
|
268
|
+
const k = Math.max(0, Math.min(1, strength));
|
|
269
|
+
mat.color.setRGB(s.r + (n[0] - s.r) * k, s.g + (n[1] - s.g) * k, s.b + (n[2] - s.b) * k).convertSRGBToLinear();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** One colour for the whole model (a game that paints it so: a gem in the palette's gold), its own shading kept by the light. */
|
|
276
|
+
function tintAll(root: Object3D, hex: string): void {
|
|
277
|
+
root.traverse((o) => {
|
|
278
|
+
const m = o as Mesh;
|
|
279
|
+
if (!m.isMesh) return;
|
|
280
|
+
for (const mat of (Array.isArray(m.material) ? m.material : [m.material]) as MeshStandardMaterial[]) { mat.map = null; mat.color = col(hex); mat.needsUpdate = true; }
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** `line`: the outline's thickness in world units (the board: a share of its size; the lineup: 5 cm at true scale). */
|
|
285
|
+
function restyle(root: Object3D, t: Tokens, size: number, line?: number, drawn = 1): void {
|
|
286
|
+
root.traverse((o) => {
|
|
287
|
+
const m = o as Mesh;
|
|
288
|
+
if (!m.isMesh) return;
|
|
289
|
+
m.castShadow = true; m.receiveShadow = true;
|
|
290
|
+
m.material = Array.isArray(m.material) ? m.material.map((x) => styled(x, t)) : styled(m.material, t);
|
|
291
|
+
});
|
|
292
|
+
if (t.materials?.outline) outline(root, mixHex(t.palette.bg, '#000000', 0.7), line ?? Math.max(0.012, size * 0.03), drawn);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function mixHex(a: string, b: string, k: number): string { return `#${col(a).lerp(col(b), k).getHexString()}`; }
|
|
296
|
+
|
|
297
|
+
/** A sky: a vertical gradient from the light's sky colour to the palette's background. */
|
|
298
|
+
function skyTexture(top: string, bottom: string): CanvasTexture {
|
|
299
|
+
const c = document.createElement('canvas'); c.width = 4; c.height = 256;
|
|
300
|
+
const g = c.getContext('2d') as CanvasRenderingContext2D;
|
|
301
|
+
const gr = g.createLinearGradient(0, 0, 0, 256); gr.addColorStop(0, top); gr.addColorStop(1, bottom);
|
|
302
|
+
g.fillStyle = gr; g.fillRect(0, 0, 4, 256);
|
|
303
|
+
const t = new CanvasTexture(c); t.colorSpace = SRGBColorSpace;
|
|
304
|
+
return t;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function lights(scene: Scene, t: Tokens, span: number): void {
|
|
308
|
+
const L = t.light ?? {};
|
|
309
|
+
const key = L.key ?? [-0.5, -1, -0.35];
|
|
310
|
+
const sun = new DirectionalLight(0xffffff, (L.intensity ?? 2.2) * 1.05);
|
|
311
|
+
sun.position.set(-key[0] * span, -key[1] * span, -key[2] * span);
|
|
312
|
+
sun.color = col(L.time === 'night' ? mixHex(t.palette.accent2, '#ffffff', 0.55) : L.time === 'golden' ? mixHex(t.palette.gold, '#ffffff', 0.4) : '#ffffff');
|
|
313
|
+
sun.castShadow = true;
|
|
314
|
+
sun.shadow.mapSize.set(2048, 2048);
|
|
315
|
+
const s = span * 0.9;
|
|
316
|
+
Object.assign(sun.shadow.camera, { left: -s, right: s, top: s, bottom: -s, near: 0.1, far: span * 4 });
|
|
317
|
+
sun.shadow.radius = 2 + (1 - (L.hardness ?? 0.4)) * 6;
|
|
318
|
+
scene.add(sun);
|
|
319
|
+
scene.add(new HemisphereLight(col(L.time === 'night' ? mixHex(L.sky ?? t.palette.bg, '#9fb4ff', 0.5) : L.sky ?? t.palette.bg), col(L.ground ?? t.palette.accent2), L.time === 'night' ? 1.6 : 1.3));
|
|
320
|
+
scene.add(new AmbientLight(0xffffff, L.time === 'night' ? 0.35 : 0.25));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function camera(t: Tokens, aspect: number, target: Vector3, span: number): PerspectiveCamera | OrthographicCamera {
|
|
324
|
+
const c = t.camera ?? {};
|
|
325
|
+
const pitch = ((c.pitch ?? 50) * Math.PI) / 180;
|
|
326
|
+
const dist = Math.max(span * 0.55, (c.distance ?? 20) * (span / 20));
|
|
327
|
+
const yaw = c.angle === 'side' ? 0 : c.angle === 'iso' ? Math.PI / 4 : Math.PI / 7;
|
|
328
|
+
const pos = new Vector3(Math.sin(yaw) * Math.cos(pitch) * dist, Math.sin(pitch) * dist, Math.cos(yaw) * Math.cos(pitch) * dist).add(target);
|
|
329
|
+
let cam: PerspectiveCamera | OrthographicCamera;
|
|
330
|
+
if (c.projection === 'orthographic') { const h = span * 0.42; cam = new OrthographicCamera(-h * aspect, h * aspect, h, -h, 0.1, dist * 4); }
|
|
331
|
+
else cam = new PerspectiveCamera(c.fov ?? 40, aspect, 0.1, dist * 6);
|
|
332
|
+
cam.position.copy(pos); cam.lookAt(target);
|
|
333
|
+
return cam;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The procedural stand-ins: a character at the decided proportions, a tree, a rock and a pick-up, in the palette. */
|
|
337
|
+
function standIns(t: Tokens): { character: Group; tree: Group; rock: Mesh; gem: Mesh } {
|
|
338
|
+
const p = t.palette;
|
|
339
|
+
const heads = t.proportions?.heads ?? 2.5; const h = t.proportions?.heightM ?? 1;
|
|
340
|
+
const head = h / heads;
|
|
341
|
+
const round = (t.shape?.language ?? 'round') === 'round'; const blocky = t.shape?.language === 'blocky';
|
|
342
|
+
const ch = new Group();
|
|
343
|
+
const body = new Mesh(blocky ? new CylinderGeometry(head * 0.55, head * 0.6, h - head, 4) : new CapsuleGeometry(head * 0.5, Math.max(0.01, h - head * 2), 6, round ? 14 : 6), new MeshStandardMaterial({ color: col(p.accent) }));
|
|
344
|
+
body.position.y = (h - head) / 2 + (blocky ? 0 : head * 0.15);
|
|
345
|
+
const hd = new Mesh(blocky ? new CylinderGeometry(head * 0.55, head * 0.55, head, 4) : new SphereGeometry(head * 0.55, round ? 18 : 7, round ? 14 : 5), new MeshStandardMaterial({ color: col(p.ramp?.[10] ?? p.ink) }));
|
|
346
|
+
hd.position.y = h - head * 0.5;
|
|
347
|
+
const eye = new MeshStandardMaterial({ color: col(p.bg) });
|
|
348
|
+
for (const s of [-1, 1]) { const e = new Mesh(new SphereGeometry(head * 0.08, 8, 6), eye); e.position.set(s * head * 0.2, h - head * 0.45, head * 0.48); ch.add(e); }
|
|
349
|
+
ch.add(body, hd);
|
|
350
|
+
const tree = new Group();
|
|
351
|
+
const trunk = new Mesh(new CylinderGeometry(0.12, 0.18, 1.2, round ? 8 : 5), new MeshStandardMaterial({ color: col(p.ramp?.[6] ?? '#6b4a2f') }));
|
|
352
|
+
trunk.position.y = 0.6;
|
|
353
|
+
const crown = new Mesh(round ? new IcosahedronGeometry(1.0, 1) : new ConeGeometry(0.9, 2.2, 6), new MeshStandardMaterial({ color: col(p.accent2) }));
|
|
354
|
+
crown.position.y = round ? 1.9 : 2.2;
|
|
355
|
+
tree.add(trunk, crown);
|
|
356
|
+
const rock = new Mesh(new IcosahedronGeometry(0.55, 0), new MeshStandardMaterial({ color: col(mixHex(p.bg, '#9a9a9a', 0.55)) }));
|
|
357
|
+
rock.scale.set(1.3, 0.75, 1.1); rock.position.y = 0.32;
|
|
358
|
+
const gem = new Mesh(new OctahedronGeometry(0.28, 0), new MeshStandardMaterial({ color: col(p.gold), emissive: col(p.gold), emissiveIntensity: t.light?.time === 'night' ? 1.4 : (t.light?.bloom ?? 0) > 0.2 ? 0.6 : 0.15 }));
|
|
359
|
+
gem.position.y = 0.55;
|
|
360
|
+
return { character: ch, tree, rock, gem };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function fontsReady(t: Tokens): Promise<void> {
|
|
364
|
+
const fams = [t.fonts?.display, t.fonts?.body].filter(Boolean) as string[];
|
|
365
|
+
if (!fams.length) return;
|
|
366
|
+
const href = `https://fonts.googleapis.com/css2?${fams.map((f) => `family=${encodeURIComponent(f).replace(/%20/g, '+')}`).join('&')}&display=block`;
|
|
367
|
+
if (!document.querySelector(`link[href="${href}"]`)) {
|
|
368
|
+
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href;
|
|
369
|
+
// The @font-face rules exist only once the stylesheet has loaded: wait for it, then for the faces.
|
|
370
|
+
await new Promise((r) => { l.onload = r; l.onerror = r; setTimeout(r, 5000); document.head.appendChild(l); });
|
|
371
|
+
}
|
|
372
|
+
await Promise.race([Promise.all(fams.map((f) => document.fonts.load(`700 40px "${f}"`))), new Promise((r) => setTimeout(r, 5000))]).catch(() => {});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function snapshot(w: number, h: number, scene: Scene, cam: PerspectiveCamera | OrthographicCamera, pixel = false): HTMLCanvasElement {
|
|
376
|
+
const scale = pixel ? 0.25 : 1;
|
|
377
|
+
renderer.setPixelRatio(1);
|
|
378
|
+
renderer.setSize(Math.round(w * scale), Math.round(h * scale), false);
|
|
379
|
+
renderer.render(scene, cam);
|
|
380
|
+
const out = document.createElement('canvas'); out.width = w; out.height = h;
|
|
381
|
+
const g = out.getContext('2d') as CanvasRenderingContext2D;
|
|
382
|
+
g.imageSmoothingEnabled = !pixel;
|
|
383
|
+
g.drawImage(canvas, 0, 0, w, h);
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* ------------------------------------------------------------------ swatch */
|
|
388
|
+
|
|
389
|
+
async function swatch(t: Tokens, models: ModelIn[] = [], opts: { title?: string; w?: number; h?: number } = {}): Promise<string> {
|
|
390
|
+
const w = opts.w ?? 960; const h = opts.h ?? 540;
|
|
391
|
+
const p = t.palette;
|
|
392
|
+
const scene = new Scene();
|
|
393
|
+
const L = t.light ?? {};
|
|
394
|
+
scene.background = skyTexture(L.sky ?? mixHex(p.bg, '#ffffff', 0.3), mixHex(L.sky ?? p.bg, p.bg, 0.6));
|
|
395
|
+
renderer.toneMapping = t.materials?.model === 'pbr' ? ACESFilmicToneMapping : NoToneMapping;
|
|
396
|
+
const span = 9;
|
|
397
|
+
scene.fog = new Fog(col(L.sky ?? p.bg), span * (2.6 - (L.fog ?? 0.3) * 1.2), span * (5.2 - (L.fog ?? 0.3) * 2));
|
|
398
|
+
lights(scene, t, span * 2);
|
|
399
|
+
const ground = new Mesh(new PlaneGeometry(80, 80), new MeshStandardMaterial({ color: col(L.ground ?? p.accent2), roughness: 1 }));
|
|
400
|
+
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true;
|
|
401
|
+
scene.add(ground);
|
|
402
|
+
const world = new Group();
|
|
403
|
+
const s = standIns(t);
|
|
404
|
+
s.character.position.set(0, 0, 0.6);
|
|
405
|
+
s.tree.position.set(-2.6, 0, -1.6);
|
|
406
|
+
s.rock.position.set(2.2, 0.32, -0.6);
|
|
407
|
+
s.gem.position.set(1.0, 0.55, 1.6);
|
|
408
|
+
world.add(s.character, s.tree, s.rock, s.gem);
|
|
409
|
+
// At night (or with a lot of glow) the world is lit by its own lamps: a warm light by the pick-up and one by the path.
|
|
410
|
+
if (L.time === 'night' || (L.bloom ?? 0) > 0.3) {
|
|
411
|
+
for (const [x, y, z, k] of [[1.0, 1.2, 1.6, 6], [-1.2, 1.6, -0.4, 4]] as const) { const pl = new PointLight(col(mixHex(p.gold, '#ffffff', 0.25)), k, 7, 1.6); pl.position.set(x, y, z); scene.add(pl); }
|
|
412
|
+
}
|
|
413
|
+
// A second tree and rock, so the scene reads as a place.
|
|
414
|
+
const t2 = s.tree.clone(); t2.position.set(3.4, 0, -3.2); t2.scale.setScalar(1.25); world.add(t2);
|
|
415
|
+
const r2 = s.rock.clone(); r2.position.set(-1.4, 0.2, 2.4); r2.scale.multiplyScalar(0.6); world.add(r2);
|
|
416
|
+
// The library family's own pieces, at their true size, around the edge.
|
|
417
|
+
const spots = [[-4.6, -0.2], [4.8, 0.8], [-3.2, 2.6], [2.8, 3.0], [-0.6, -3.6], [5.6, -2.2]];
|
|
418
|
+
let i = 0;
|
|
419
|
+
for (const m of models.slice(0, spots.length)) {
|
|
420
|
+
try {
|
|
421
|
+
const g = await parse(m);
|
|
422
|
+
const b = boxOf(g); const size = b.getSize(new Vector3());
|
|
423
|
+
const fit = size.y > 4 ? 4 / size.y : 1; g.scale.setScalar(fit);
|
|
424
|
+
const [x, z] = spots[i++] as [number, number];
|
|
425
|
+
g.position.set(x, -b.min.y * fit, z);
|
|
426
|
+
world.add(g);
|
|
427
|
+
} catch { /* a model that does not parse is left out of the picture */ }
|
|
428
|
+
}
|
|
429
|
+
// The family's own pieces in this direction's palette: what a palette swap does for free.
|
|
430
|
+
for (const piece of world.children.filter((c) => c.name && c.name !== '' && models.some((m) => m.id === c.name))) retint(piece, [...(t.palette.ramp ?? []), t.palette.accent, t.palette.accent2, t.palette.gold, t.palette.good]);
|
|
431
|
+
restyle(world, t, span / 9);
|
|
432
|
+
scene.add(world);
|
|
433
|
+
const cam = camera(t, w / h, new Vector3(0, 0.8, 0), span);
|
|
434
|
+
const shot = snapshot(w, h, scene, cam, t.render === 'pixel-hd2d');
|
|
435
|
+
await fontsReady(t);
|
|
436
|
+
const g = shot.getContext('2d') as CanvasRenderingContext2D;
|
|
437
|
+
// The title in the display font, and the palette as chips: what the codex, landing and HUD will wear.
|
|
438
|
+
const display = t.fonts?.display ? `"${t.fonts.display}", ` : '';
|
|
439
|
+
g.fillStyle = 'rgba(0,0,0,0.0)';
|
|
440
|
+
const band = Math.round(h * 0.2);
|
|
441
|
+
const grad = g.createLinearGradient(0, h - band, 0, h); grad.addColorStop(0, 'rgba(0,0,0,0)'); grad.addColorStop(1, `${p.bg}e6`);
|
|
442
|
+
g.fillStyle = grad; g.fillRect(0, h - band, w, band);
|
|
443
|
+
g.font = `700 ${Math.round(h * 0.075)}px ${display}system-ui, sans-serif`;
|
|
444
|
+
g.fillStyle = p.ink; g.textBaseline = 'alphabetic';
|
|
445
|
+
g.fillText(opts.title ?? 'Your game', Math.round(w * 0.035), h - Math.round(h * 0.07));
|
|
446
|
+
const chips = [p.bg, p.ink, p.accent, p.accent2, p.danger, p.good, p.gold];
|
|
447
|
+
const cs = Math.round(h * 0.05);
|
|
448
|
+
chips.forEach((c, k) => { g.fillStyle = c; g.strokeStyle = 'rgba(255,255,255,0.6)'; g.lineWidth = 2; const x = w - Math.round(w * 0.035) - (chips.length - k) * (cs + 6); const y = h - Math.round(h * 0.07) - cs; g.beginPath(); g.roundRect(x, y, cs, cs, 6); g.fill(); g.stroke(); });
|
|
449
|
+
disposeScene(scene);
|
|
450
|
+
return shot.toDataURL('image/jpeg', 0.86);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* ------------------------------------------------------------------ lineup */
|
|
454
|
+
|
|
455
|
+
interface LineupRow { id: string; label: string; size: number[]; silhouette: { fill: number; widthPx: number; heightPx: number } }
|
|
456
|
+
|
|
457
|
+
async function lineup(models: ModelIn[], t: Tokens, opts: { w?: number; h?: number } = {}): Promise<{ front: string; quarter: string; silhouettes: string; rows: LineupRow[] }> {
|
|
458
|
+
const w = opts.w ?? 1280; const h = opts.h ?? 640;
|
|
459
|
+
const p = t.palette;
|
|
460
|
+
const parsed: { m: ModelIn; g: Group; size: Vector3 }[] = [];
|
|
461
|
+
for (const m of models) {
|
|
462
|
+
try { const g = await parse(m); if (m.tint) tintAll(g, m.tint); else if (m.retint || m.pull) retint(g, [...(p.ramp ?? []), p.accent, p.accent2, p.gold, p.good], m.pull ?? 1); restyle(g, t, Math.max(0.1, ...boxOf(g).getSize(new Vector3()).toArray()), Math.min(0.05, 0.06 * Math.max(...boxOf(g).getSize(new Vector3()).toArray()) * (m.scale && m.scale > 0 ? m.scale : 1)), m.scale && m.scale > 0 ? m.scale : 1); if (m.scale && m.scale > 0) { g.scale.multiplyScalar(m.scale); g.updateMatrixWorld(true); } const b = boxOf(g); g.position.y -= b.min.y; g.position.x -= (b.min.x + b.max.x) / 2; g.position.z -= (b.min.z + b.max.z) / 2; parsed.push({ m, g, size: b.getSize(new Vector3()) }); } catch { /* skipped */ }
|
|
463
|
+
}
|
|
464
|
+
const gap = 0.5;
|
|
465
|
+
const room = 0.45; // under each shelf, for its labels
|
|
466
|
+
const widthOf = (it: { size: Vector3 }): number => Math.max(0.3, it.size.x);
|
|
467
|
+
const rowWidth = (row: typeof parsed): number => row.reduce((n, it) => n + widthOf(it), 0) + gap * Math.max(0, row.length - 1);
|
|
468
|
+
const rowTall = (row: typeof parsed): number => Math.max(0.5, ...row.map((it) => it.size.y));
|
|
469
|
+
// Many assets: shelves. The row is cut, in order, into the number of shelves that draws them largest, every shelf at
|
|
470
|
+
// the same scale against the same 1 m grid (front view); in the three-quarter view the shelves stand as ranks.
|
|
471
|
+
const split = (n: number): (typeof parsed)[] => {
|
|
472
|
+
const target = rowWidth(parsed) / n;
|
|
473
|
+
const out: (typeof parsed)[] = [[]];
|
|
474
|
+
for (const it of parsed) {
|
|
475
|
+
const cur = out[out.length - 1] as typeof parsed;
|
|
476
|
+
if (cur.length && out.length < n && rowWidth([...cur, it]) > target * 1.04) out.push([it]); else cur.push(it);
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
};
|
|
480
|
+
let shelves: (typeof parsed)[] = [parsed];
|
|
481
|
+
let best = 0;
|
|
482
|
+
for (let n = 1; n <= Math.min(6, parsed.length); n++) {
|
|
483
|
+
const cut = split(n);
|
|
484
|
+
const fit = Math.min(w / Math.max(...cut.map(rowWidth)), h / cut.reduce((a, r) => a + rowTall(r) + room, 0));
|
|
485
|
+
if (fit > best * 1.05) { best = fit; shelves = cut; }
|
|
486
|
+
}
|
|
487
|
+
const total = Math.max(...shelves.map(rowWidth));
|
|
488
|
+
const tall = Math.max(1, ...parsed.map((x) => x.size.y));
|
|
489
|
+
const stackH = shelves.reduce((a, r) => a + rowTall(r) + room, 0);
|
|
490
|
+
const deep = Math.max(1, ...parsed.map((x) => x.size.z));
|
|
491
|
+
const extent = Math.max(total, stackH, shelves.length * (deep + 1.2));
|
|
492
|
+
const scene = new Scene();
|
|
493
|
+
scene.background = col(mixHex(p.bg, '#ffffff', 0.08));
|
|
494
|
+
renderer.toneMapping = NoToneMapping;
|
|
495
|
+
lights(scene, t, extent * 1.5);
|
|
496
|
+
const cells = Math.ceil(extent + 4);
|
|
497
|
+
const grid = new GridHelper(cells, cells, col(mixHex(p.ink, p.bg, 0.4)), col(mixHex(p.ink, p.bg, 0.75)));
|
|
498
|
+
scene.add(grid);
|
|
499
|
+
// A 1 m grid stood up behind the row: heights read against it.
|
|
500
|
+
const back = new GridHelper(cells + (cells % 2), cells + (cells % 2), col(mixHex(p.ink, p.bg, 0.5)), col(mixHex(p.ink, p.bg, 0.8)));
|
|
501
|
+
back.rotation.x = Math.PI / 2;
|
|
502
|
+
scene.add(back);
|
|
503
|
+
const floor = new Mesh(new PlaneGeometry(200, 200), new MeshStandardMaterial({ color: col(mixHex(p.bg, '#ffffff', 0.12)), roughness: 1, side: DoubleSide }));
|
|
504
|
+
floor.rotation.x = -Math.PI / 2; floor.position.y = -0.002; floor.receiveShadow = true;
|
|
505
|
+
scene.add(floor);
|
|
506
|
+
const base = new Map(parsed.map((it) => [it, it.g.position.clone()]));
|
|
507
|
+
const row = new Group();
|
|
508
|
+
for (const it of parsed) row.add(it.g);
|
|
509
|
+
row.traverse((o) => { const m = o as Mesh; if (m.isMesh) { m.castShadow = true; m.receiveShadow = true; } });
|
|
510
|
+
scene.add(row);
|
|
511
|
+
// place(shelf k at height y, depth z): x across, centred; returns each asset's centre x for its label.
|
|
512
|
+
const placed: { it: (typeof parsed)[number]; cx: number; y: number }[] = [];
|
|
513
|
+
const place = (front: boolean): void => {
|
|
514
|
+
placed.length = 0;
|
|
515
|
+
let y = stackH;
|
|
516
|
+
shelves.forEach((r, k) => {
|
|
517
|
+
y -= rowTall(r) + room;
|
|
518
|
+
const by = front ? y + room : 0;
|
|
519
|
+
const z = front ? 0 : -k * (deep + 1.2);
|
|
520
|
+
let x = -rowWidth(r) / 2;
|
|
521
|
+
for (const it of r) {
|
|
522
|
+
const wd = widthOf(it); const b0 = base.get(it) as Vector3;
|
|
523
|
+
it.g.position.set(b0.x + x + wd / 2, b0.y + by, b0.z + z);
|
|
524
|
+
placed.push({ it, cx: x + wd / 2, y: by });
|
|
525
|
+
x += wd + gap;
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
floor.visible = !front || shelves.length === 1;
|
|
529
|
+
grid.visible = floor.visible;
|
|
530
|
+
// The standing grid behind the last rank.
|
|
531
|
+
back.position.set(0, (cells + (cells % 2)) / 2, (front ? 0 : -(shelves.length - 1) * (deep + 1.2)) - deep / 2 - 0.3);
|
|
532
|
+
};
|
|
533
|
+
// The front view fits the widest shelf and the stack, the lowest floor near the bottom edge.
|
|
534
|
+
place(true);
|
|
535
|
+
const halfH = Math.max((total / 2) * 1.06 * (h / w), (stackH / 2) * 1.07 + 0.05, shelves.length === 1 ? tall * 0.6 : 0);
|
|
536
|
+
const halfW = halfH * (w / h);
|
|
537
|
+
const camY = shelves.length === 1 ? halfH - tall * 0.08 : stackH / 2;
|
|
538
|
+
const front = new OrthographicCamera(-halfW, halfW, halfH, -halfH, 0.1, 200);
|
|
539
|
+
front.position.set(0, camY, 50); front.lookAt(0, camY, 0);
|
|
540
|
+
const f = snapshot(w, h, scene, front);
|
|
541
|
+
place(false);
|
|
542
|
+
const span = Math.max(total, tall * 1.6, shelves.length * (deep + 1.2));
|
|
543
|
+
const quarter = new PerspectiveCamera(30, w / h, 0.1, 400);
|
|
544
|
+
const d = span * (shelves.length > 1 ? 1.7 : 1.55);
|
|
545
|
+
const mid = -((shelves.length - 1) * (deep + 1.2)) / 2;
|
|
546
|
+
quarter.position.set(d * 0.45, d * 0.42, mid + d * 0.78); quarter.lookAt(0, tall * 0.35, mid);
|
|
547
|
+
const q = snapshot(w, h, scene, quarter);
|
|
548
|
+
place(true);
|
|
549
|
+
// Labels under each asset in the front view (several lines when the names are wide for their place).
|
|
550
|
+
const fg = f.getContext('2d') as CanvasRenderingContext2D;
|
|
551
|
+
const px = w / (2 * halfW);
|
|
552
|
+
const fontPx = Math.max(10, Math.min(15, Math.round(px * 0.28)));
|
|
553
|
+
fg.font = `600 ${fontPx}px system-ui, sans-serif`; fg.fillStyle = p.ink; fg.textAlign = 'center';
|
|
554
|
+
for (const { it, cx, y } of placed) {
|
|
555
|
+
const sx = (cx + halfW) * px;
|
|
556
|
+
const sy = (camY + halfH - y) * px + fontPx + 2;
|
|
557
|
+
const room2 = Math.max(36, (widthOf(it) + gap) * px * 0.92);
|
|
558
|
+
const name = it.m.label ?? it.m.id; const size = `${it.size.y.toFixed(2)} m`;
|
|
559
|
+
if (fg.measureText(`${name} · ${size}`).width <= room2) fg.fillText(`${name} · ${size}`, sx, Math.min(h - 6, sy));
|
|
560
|
+
else {
|
|
561
|
+
let short = name;
|
|
562
|
+
while (short.length > 3 && fg.measureText(short).width > room2) short = `${short.slice(0, -2)}…`;
|
|
563
|
+
fg.fillText(short, sx, Math.min(h - fontPx - 8, sy)); fg.fillText(size, sx, Math.min(h - 6, sy + fontPx + 1));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
// Silhouettes: each alone, black on white, 64 px tall, side by side.
|
|
567
|
+
const cell = 96;
|
|
568
|
+
const sil = document.createElement('canvas'); sil.width = Math.max(cell, parsed.length * cell); sil.height = cell + 22;
|
|
569
|
+
const sg = sil.getContext('2d') as CanvasRenderingContext2D;
|
|
570
|
+
sg.fillStyle = '#ffffff'; sg.fillRect(0, 0, sil.width, sil.height);
|
|
571
|
+
const rows: LineupRow[] = [];
|
|
572
|
+
const black = new MeshBasicMaterial({ color: 0x000000 });
|
|
573
|
+
const solo = new Scene(); solo.background = new Color('#ffffff');
|
|
574
|
+
for (let k = 0; k < parsed.length; k++) {
|
|
575
|
+
const it = parsed[k] as (typeof parsed)[number];
|
|
576
|
+
const clone = it.g.clone(true);
|
|
577
|
+
clone.position.set(0, 0, 0);
|
|
578
|
+
clone.traverse((o) => { const m = o as Mesh; if (m.isMesh) m.material = black; });
|
|
579
|
+
solo.add(clone);
|
|
580
|
+
const hh = it.size.y; const ww = Math.max(it.size.x, it.size.z);
|
|
581
|
+
const half = Math.max(hh, ww) / 2 * 1.02;
|
|
582
|
+
const oc = new OrthographicCamera(-half, half, half, -half, 0.01, 100);
|
|
583
|
+
oc.position.set(it.size.x * 0.4, hh / 2, 20); oc.lookAt(0, hh / 2, 0);
|
|
584
|
+
renderer.setPixelRatio(1); renderer.setSize(64, 64, false); renderer.render(solo, oc);
|
|
585
|
+
const px = new Uint8Array(64 * 64 * 4);
|
|
586
|
+
const gl = renderer.getContext(); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, px);
|
|
587
|
+
let filled = 0; let minX = 64; let maxX = -1; let minY = 64; let maxY = -1;
|
|
588
|
+
for (let yy = 0; yy < 64; yy++) for (let x2 = 0; x2 < 64; x2++) { const i = (yy * 64 + x2) * 4; if ((px[i] as number) < 128) { filled++; minX = Math.min(minX, x2); maxX = Math.max(maxX, x2); minY = Math.min(minY, yy); maxY = Math.max(maxY, yy); } }
|
|
589
|
+
const bw = maxX - minX + 1; const bh = maxY - minY + 1;
|
|
590
|
+
sg.drawImage(canvas, k * cell + 16, 8, 64, 64);
|
|
591
|
+
sg.fillStyle = '#222'; sg.font = '600 11px system-ui, sans-serif'; sg.textAlign = 'center'; sg.fillText((it.m.label ?? it.m.id).slice(0, 14), k * cell + cell / 2, cell + 14);
|
|
592
|
+
rows.push({ id: it.m.id, label: it.m.label ?? it.m.id, size: [it.size.x, it.size.y, it.size.z].map((v) => +v.toFixed(3)), silhouette: { fill: bw > 0 && bh > 0 ? +(filled / (bw * bh)).toFixed(3) : 0, widthPx: Math.max(0, bw), heightPx: Math.max(0, bh) } });
|
|
593
|
+
solo.remove(clone);
|
|
594
|
+
}
|
|
595
|
+
disposeScene(scene);
|
|
596
|
+
return { front: f.toDataURL('image/jpeg', 0.88), quarter: q.toDataURL('image/jpeg', 0.88), silhouettes: sil.toDataURL('image/png'), rows };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/* ------------------------------------------------------------------ thumbnail */
|
|
600
|
+
|
|
601
|
+
async function thumb(m: ModelIn, opts: { size?: number; bg?: string } = {}): Promise<{ image: string; size: number[] }> {
|
|
602
|
+
const s = opts.size ?? 256;
|
|
603
|
+
const g = await parse(m);
|
|
604
|
+
const b = boxOf(g); const size = b.getSize(new Vector3()); const c = b.getCenter(new Vector3());
|
|
605
|
+
g.position.sub(new Vector3(c.x, b.min.y, c.z));
|
|
606
|
+
const scene = new Scene(); scene.background = col(opts.bg ?? '#e9e6df');
|
|
607
|
+
renderer.toneMapping = NoToneMapping;
|
|
608
|
+
const r = Math.max(size.x, size.y, size.z);
|
|
609
|
+
lights(scene, { palette: { bg: '#e9e6df', ink: '#222', accent: '#888', accent2: '#cfc8b8', danger: '#c33', good: '#3a3', gold: '#ec3' }, light: { key: [-0.6, -1, -0.5], intensity: 2.4, hardness: 0.4, sky: '#ffffff', ground: '#bfb6a6' } }, r * 3);
|
|
610
|
+
const floor = new Mesh(new PlaneGeometry(r * 20, r * 20), new MeshStandardMaterial({ color: col(opts.bg ?? '#e9e6df'), roughness: 1 }));
|
|
611
|
+
floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; scene.add(floor);
|
|
612
|
+
g.traverse((o) => { const mm = o as Mesh; if (mm.isMesh) { mm.castShadow = true; mm.receiveShadow = true; } });
|
|
613
|
+
scene.add(g);
|
|
614
|
+
// Framed by the bounding sphere, so a box-shaped model's near corner is never cut.
|
|
615
|
+
const radius = Math.max(0.001, size.length() / 2);
|
|
616
|
+
const cam = new PerspectiveCamera(30, 1, radius * 0.01, radius * 60);
|
|
617
|
+
const d = (radius / Math.sin((30 * Math.PI) / 360)) * 1.08;
|
|
618
|
+
const dir = new Vector3(0.6, 0.45, 0.75).normalize();
|
|
619
|
+
cam.position.copy(dir.multiplyScalar(d)).add(new Vector3(0, size.y / 2, 0)); cam.lookAt(0, size.y / 2, 0);
|
|
620
|
+
const shot = snapshot(s, s, scene, cam);
|
|
621
|
+
disposeScene(scene);
|
|
622
|
+
return { image: shot.toDataURL('image/webp', 0.82), size: [size.x, size.y, size.z].map((v) => +v.toFixed(3)) };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function disposeScene(scene: Scene): void {
|
|
626
|
+
scene.traverse((o) => { const m = o as Mesh; if (m.isMesh) { m.geometry?.dispose(); const mats = Array.isArray(m.material) ? m.material : [m.material]; for (const x of mats) x?.dispose(); } });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
(window as unknown as { homieRender: unknown }).homieRender = { swatch, lineup, thumb, ready: true };
|