@gtanczyk/diorama 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +157 -2
- package/app/bin/diorama.mjs +13 -0
- package/app/src/actions/budget.ts +160 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +267 -0
- package/app/src/actions/depth.ts +95 -0
- package/app/src/actions/extend.ts +36 -0
- package/app/src/actions/faces.ts +160 -0
- package/app/src/actions/feedback.ts +87 -0
- package/app/src/actions/fix.ts +382 -0
- package/app/src/actions/history.ts +93 -0
- package/app/src/actions/idea-preview.ts +65 -0
- package/app/src/actions/idea-review.ts +102 -0
- package/app/src/actions/idea-sketch.ts +172 -0
- package/app/src/actions/index.ts +32 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -0
- package/app/src/actions/language.ts +52 -0
- package/app/src/actions/models.ts +64 -0
- package/app/src/actions/motion.ts +119 -0
- package/app/src/actions/pictures.ts +49 -0
- package/app/src/actions/reactions.ts +36 -0
- package/app/src/actions/registry.ts +101 -0
- package/app/src/actions/share.ts +32 -0
- package/app/src/actions/sound.ts +73 -0
- package/app/src/actions/start.ts +154 -0
- package/app/src/actions/step-work.ts +150 -0
- package/app/src/actions/story.ts +101 -0
- package/app/src/actions/studio.ts +133 -0
- package/app/src/actions/style.ts +72 -0
- package/app/src/actions/video.ts +46 -0
- package/app/src/actions/view.ts +109 -0
- package/app/src/actions/weather.ts +38 -0
- package/app/src/actions/world.ts +290 -0
- package/app/src/commands/action.ts +39 -0
- package/app/src/commands/backdrop.ts +188 -0
- package/app/src/commands/browsers.ts +21 -0
- package/app/src/commands/build.ts +503 -0
- package/app/src/commands/cast.ts +161 -0
- package/app/src/commands/clip.ts +431 -0
- package/app/src/commands/derive.ts +167 -0
- package/app/src/commands/doctor.ts +74 -0
- package/app/src/commands/explore.ts +123 -0
- package/app/src/commands/export.ts +158 -0
- package/app/src/commands/extend.ts +75 -0
- package/app/src/commands/faces.ts +80 -0
- package/app/src/commands/icons.ts +69 -0
- package/app/src/commands/index.ts +37 -0
- package/app/src/commands/language.ts +87 -0
- package/app/src/commands/layout.ts +31 -0
- package/app/src/commands/masks.ts +117 -0
- package/app/src/commands/mcp.ts +52 -0
- package/app/src/commands/model.ts +284 -0
- package/app/src/commands/paint.ts +182 -0
- package/app/src/commands/plates.ts +97 -0
- package/app/src/commands/play.ts +195 -0
- package/app/src/commands/playthrough.ts +111 -0
- package/app/src/commands/publish.ts +78 -0
- package/app/src/commands/reactions.ts +40 -0
- package/app/src/commands/reel.ts +46 -0
- package/app/src/commands/setup.ts +85 -0
- package/app/src/commands/sound.ts +100 -0
- package/app/src/commands/sprites.ts +232 -0
- package/app/src/commands/story.ts +37 -0
- package/app/src/commands/studio.ts +65 -0
- package/app/src/commands/style.ts +87 -0
- package/app/src/commands/verify.ts +45 -0
- package/app/src/commands/voice-retry.ts +14 -0
- package/app/src/commands/voices.ts +145 -0
- package/app/src/commands/weather.ts +52 -0
- package/app/src/commands/world.ts +104 -0
- package/app/src/film/clip.ts +386 -0
- package/app/src/film/reel.ts +126 -0
- package/app/src/film/screencast.ts +123 -0
- package/app/src/image/mask.ts +29 -0
- package/app/src/image/overlay.ts +38 -0
- package/app/src/image/raster.ts +224 -0
- package/app/src/main.ts +28 -0
- package/app/src/meta/choice.ts +27 -0
- package/app/src/meta/http.ts +99 -0
- package/app/src/meta/memo.ts +105 -0
- package/app/src/meta/muse.ts +70 -0
- package/app/src/meta/openrouter.ts +93 -0
- package/app/src/meta/prices.ts +25 -0
- package/app/src/meta/sam.ts +83 -0
- package/app/src/meta/schema.ts +63 -0
- package/app/src/meta/spark.ts +220 -0
- package/app/src/meta/step.ts +14 -0
- package/app/src/meta/voice-file.ts +78 -0
- package/app/src/meta/voice.ts +103 -0
- package/app/src/model/animation.ts +38 -0
- package/app/src/model/blender/relax.py +284 -0
- package/app/src/model/blender.ts +115 -0
- package/app/src/model/build.ts +50 -0
- package/app/src/model/face.ts +21 -0
- package/app/src/model/fit.ts +181 -0
- package/app/src/model/glb.ts +241 -0
- package/app/src/model/plan.ts +151 -0
- package/app/src/model/quality.ts +48 -0
- package/app/src/model/space.ts +168 -0
- package/app/src/model/sprites.ts +293 -0
- package/app/src/model/views.ts +163 -0
- package/app/src/play/creature.ts +43 -0
- package/app/src/play/depth-occlusion.ts +150 -0
- package/app/src/play/exit-outline.ts +48 -0
- package/app/src/play/face3d.ts +34 -0
- package/app/src/play/focus.ts +78 -0
- package/app/src/play/geometry.ts +272 -0
- package/app/src/play/gestures.ts +23 -0
- package/app/src/play/glow.ts +187 -0
- package/app/src/play/lipsync.ts +69 -0
- package/app/src/play/living.ts +83 -0
- package/app/src/play/outline-path.ts +130 -0
- package/app/src/play/parallax.ts +200 -0
- package/app/src/play/path.ts +262 -0
- package/app/src/play/preview.ts +71 -0
- package/app/src/play/reactions.ts +92 -0
- package/app/src/play/rig.ts +172 -0
- package/app/src/play/shadow.ts +253 -0
- package/app/src/play/sound.ts +51 -0
- package/app/src/play/weather.ts +58 -0
- package/app/src/qa/explore.ts +136 -0
- package/app/src/qa/probe.ts +139 -0
- package/app/src/qa/route.ts +118 -0
- package/app/src/registry.ts +23 -0
- package/app/src/scene/animation-source.ts +36 -0
- package/app/src/scene/assets.ts +37 -0
- package/app/src/scene/autofix.ts +133 -0
- package/app/src/scene/backdrop.ts +326 -0
- package/app/src/scene/blockin.ts +189 -0
- package/app/src/scene/brief.ts +74 -0
- package/app/src/scene/carry.ts +177 -0
- package/app/src/scene/cast-fixes.ts +33 -0
- package/app/src/scene/cast.ts +199 -0
- package/app/src/scene/corrections.ts +164 -0
- package/app/src/scene/critic.ts +169 -0
- package/app/src/scene/depthmap.ts +149 -0
- package/app/src/scene/derive.ts +475 -0
- package/app/src/scene/diagnose.ts +132 -0
- package/app/src/scene/face-calibration.ts +65 -0
- package/app/src/scene/face-registration.ts +21 -0
- package/app/src/scene/face-work.ts +71 -0
- package/app/src/scene/faces-data.ts +48 -0
- package/app/src/scene/faces-settings.ts +20 -0
- package/app/src/scene/faces.ts +435 -0
- package/app/src/scene/glows.ts +153 -0
- package/app/src/scene/held.ts +109 -0
- package/app/src/scene/icons.ts +185 -0
- package/app/src/scene/layers.ts +822 -0
- package/app/src/scene/layout.ts +216 -0
- package/app/src/scene/locate.ts +118 -0
- package/app/src/scene/maskcheck.ts +43 -0
- package/app/src/scene/masks.ts +392 -0
- package/app/src/scene/occluders.ts +123 -0
- package/app/src/scene/occlusion-check.ts +138 -0
- package/app/src/scene/paint-layout.ts +135 -0
- package/app/src/scene/paint.ts +81 -0
- package/app/src/scene/photo.ts +123 -0
- package/app/src/scene/plates.ts +256 -0
- package/app/src/scene/playbook.ts +121 -0
- package/app/src/scene/preview.ts +23 -0
- package/app/src/scene/reactions.ts +118 -0
- package/app/src/scene/restyle.ts +53 -0
- package/app/src/scene/rig-check.ts +68 -0
- package/app/src/scene/rig.ts +379 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +215 -0
- package/app/src/scene/stairs.ts +183 -0
- package/app/src/scene/status.ts +32 -0
- package/app/src/scene/verify.ts +156 -0
- package/app/src/scene/walk.ts +326 -0
- package/app/src/scene/warp.ts +419 -0
- package/app/src/scene/weather.ts +118 -0
- package/app/src/scene/words.ts +65 -0
- package/app/src/secrets.ts +219 -0
- package/app/src/setup/checks.ts +110 -0
- package/app/src/sound/cost.ts +74 -0
- package/app/src/sound/data.ts +89 -0
- package/app/src/sound/fixes.ts +23 -0
- package/app/src/sound/issues.ts +21 -0
- package/app/src/sound/language.ts +11 -0
- package/app/src/sound/lipsync-make.ts +98 -0
- package/app/src/sound/lipsync.ts +24 -0
- package/app/src/sound/make.ts +229 -0
- package/app/src/sound/musicgen.py +20 -0
- package/app/src/sound/pcm.ts +280 -0
- package/app/src/sound/plan.ts +154 -0
- package/app/src/sound/retry.ts +43 -0
- package/app/src/sound/settings.ts +28 -0
- package/app/src/sound/sources.ts +81 -0
- package/app/src/sound/spoken.ts +70 -0
- package/app/src/sound/voices.ts +579 -0
- package/app/src/speech/transcribe.ts +38 -0
- package/app/src/speech/types.ts +18 -0
- package/app/src/story/check.ts +230 -0
- package/app/src/story/design.ts +108 -0
- package/app/src/story/engine.ts +233 -0
- package/app/src/story/fixes.ts +54 -0
- package/app/src/story/npc-guidance.ts +7 -0
- package/app/src/story/phrases.ts +4 -0
- package/app/src/story/schema.ts +92 -0
- package/app/src/studio/agent.ts +555 -0
- package/app/src/studio/discovery.ts +28 -0
- package/app/src/studio/events.ts +48 -0
- package/app/src/studio/history.ts +124 -0
- package/app/src/studio/jobs.ts +291 -0
- package/app/src/studio/locks.ts +33 -0
- package/app/src/studio/mcp.ts +86 -0
- package/app/src/studio/operation.ts +18 -0
- package/app/src/studio/repair.ts +31 -0
- package/app/src/studio/server.ts +423 -0
- package/app/src/studio/trouble.ts +48 -0
- package/app/src/world/design.ts +72 -0
- package/app/src/world/drawings.ts +198 -0
- package/app/src/world/extend-stage.ts +109 -0
- package/app/src/world/extend.ts +123 -0
- package/app/src/world/extension-progress.ts +10 -0
- package/app/src/world/files.ts +62 -0
- package/app/src/world/idea-review.ts +32 -0
- package/app/src/world/language.ts +116 -0
- package/app/src/world/photo-limits.ts +2 -0
- package/app/src/world/pool.ts +41 -0
- package/app/src/world/schedule.ts +64 -0
- package/app/src/world/schema.ts +74 -0
- package/app/src/world/sketch.ts +81 -0
- package/app/src/world/style-plan.ts +90 -0
- package/app/src/world/style-reference.ts +16 -0
- package/app/src/world/styled.ts +51 -0
- package/app/src/world/told.ts +105 -0
- package/app/src/world/video-limits.ts +3 -0
- package/app/src/world/video.ts +98 -0
- package/app/studio/activity.tsx +124 -0
- package/app/studio/agent.tsx +384 -0
- package/app/studio/api.ts +111 -0
- package/app/studio/atelier.tsx +234 -0
- package/app/studio/audio-missing.tsx +25 -0
- package/app/studio/audio-setup.tsx +33 -0
- package/app/studio/blender-setup.tsx +30 -0
- package/app/studio/build.tsx +679 -0
- package/app/studio/camera.tsx +59 -0
- package/app/studio/cast-work.tsx +207 -0
- package/app/studio/cast.tsx +178 -0
- package/app/studio/concept-edit.tsx +47 -0
- package/app/studio/creator-idea.tsx +46 -0
- package/app/studio/custom-look.tsx +36 -0
- package/app/studio/faces-setup.tsx +28 -0
- package/app/studio/favicon.svg +11 -0
- package/app/studio/figure.tsx +314 -0
- package/app/studio/fonts/OFL.txt +93 -0
- package/app/studio/fonts/fonts.css +7 -0
- package/app/studio/fonts/fraunces-italic-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-italic-latin.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/fraunces-normal-latin.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin-ext.woff2 +0 -0
- package/app/studio/fonts/instrument-sans-normal-latin.woff2 +0 -0
- package/app/studio/icons.tsx +42 -0
- package/app/studio/idea-card.tsx +42 -0
- package/app/studio/idea-review.tsx +181 -0
- package/app/studio/idea-storyboard.tsx +134 -0
- package/app/studio/index.html +15 -0
- package/app/studio/job-words.ts +33 -0
- package/app/studio/lightbox.tsx +49 -0
- package/app/studio/main.tsx +1329 -0
- package/app/studio/nav.ts +14 -0
- package/app/studio/place.tsx +264 -0
- package/app/studio/review-sketch.tsx +47 -0
- package/app/studio/say-it.tsx +49 -0
- package/app/studio/step-work.tsx +134 -0
- package/app/studio/story.tsx +394 -0
- package/app/studio/style-menu.tsx +81 -0
- package/app/studio/style.css +1390 -0
- package/app/studio/sure.tsx +54 -0
- package/app/studio/tip.tsx +74 -0
- package/app/studio/tsconfig.json +5 -0
- package/app/studio/video.tsx +73 -0
- package/app/studio/world.tsx +188 -0
- package/app/tsconfig.json +13 -0
- package/app/web/audio.ts +315 -0
- package/app/web/face.ts +131 -0
- package/app/web/face3d.ts +35 -0
- package/app/web/glows.ts +387 -0
- package/app/web/hero3d.ts +728 -0
- package/app/web/index.html +118 -0
- package/app/web/living.ts +404 -0
- package/app/web/main.ts +3769 -0
- package/app/web/pad.ts +174 -0
- package/app/web/reactions.ts +79 -0
- package/app/web/sheet.ts +36 -0
- package/app/web/synth.ts +140 -0
- package/app/web/tsconfig.json +5 -0
- package/app/web/ui.ts +263 -0
- package/app/web/weather.ts +306 -0
- package/app/web/words.ts +12 -0
- package/docs/characters.md +58 -0
- package/docs/npc-motion.md +69 -0
- package/docs/scenes.md +75 -0
- package/docs/setup.md +60 -0
- package/docs/sound.md +23 -0
- package/docs/studio.md +62 -0
- package/package.json +70 -4
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Where each painted view of the hero lands on the model: a scale and offset per image axis, found by
|
|
2
|
+
// laying the model's silhouette over the figure in the painting. The runtime projects with the same
|
|
3
|
+
// numbers, so the paint sits on the coat while it walks.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import sharp from 'sharp';
|
|
7
|
+
import { normalise, readShape, type Shape } from './glb.ts';
|
|
8
|
+
|
|
9
|
+
/** Which way each view looks at the hero (who faces +z): the model axis that runs left to right in the picture, and its sign. */
|
|
10
|
+
export const VIEWS = {
|
|
11
|
+
front: { axis: 0, sign: 1 },
|
|
12
|
+
back: { axis: 0, sign: -1 },
|
|
13
|
+
left: { axis: 2, sign: -1 }, // facing the picture's left: the camera stands at +x
|
|
14
|
+
right: { axis: 2, sign: 1 },
|
|
15
|
+
} as const;
|
|
16
|
+
export type ViewName = keyof typeof VIEWS;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* u = a * axis + b, v = c * y + d, in image units (0..1, v from the top), model in hero units. bands: [a, b] for
|
|
20
|
+
* each of BANDS slices of the height from the feet up, fitted on that slice alone (the runtime blends them
|
|
21
|
+
* between slice centres). Muse paints a profile's head, body and legs each a little off from the model, and a
|
|
22
|
+
* fit of the whole silhouette leaves the profile's face on the model's cheek, a second face beside the front's.
|
|
23
|
+
*/
|
|
24
|
+
export type ViewFit = { u: [number, number]; v: [number, number]; iou: number; bands?: [number, number][] };
|
|
25
|
+
|
|
26
|
+
export const BANDS = 20;
|
|
27
|
+
|
|
28
|
+
const R = 160;
|
|
29
|
+
|
|
30
|
+
/** How wide the silhouette grid is across, in heights: 1 for a person, more for a creature wider than it is tall (a cat side on). */
|
|
31
|
+
function across(shape: Shape): number {
|
|
32
|
+
let most = 0;
|
|
33
|
+
for (let i = 0; i < shape.positions.length; i += 3) most = Math.max(most, Math.abs(shape.positions[i]), Math.abs(shape.positions[i + 2]));
|
|
34
|
+
return Math.max(1, 2 * most + 0.04);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function silhouette(shape: Shape, view: ViewName, W: number): Uint8Array {
|
|
38
|
+
const { axis, sign } = VIEWS[view];
|
|
39
|
+
const p = shape.positions;
|
|
40
|
+
const px = (i: number) => (sign * p[i * 3 + axis] / W + 0.5) * R;
|
|
41
|
+
const py = (i: number) => (1 - p[i * 3 + 1]) * R;
|
|
42
|
+
const out = new Uint8Array(R * R);
|
|
43
|
+
const t = shape.triangles;
|
|
44
|
+
for (let k = 0; k < t.length; k += 3) {
|
|
45
|
+
const [x0, y0, x1, y1, x2, y2] = [px(t[k]), py(t[k]), px(t[k + 1]), py(t[k + 1]), px(t[k + 2]), py(t[k + 2])];
|
|
46
|
+
const area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
|
|
47
|
+
if (Math.abs(area) < 1e-9) continue;
|
|
48
|
+
for (let y = Math.max(0, Math.floor(Math.min(y0, y1, y2))); y <= Math.min(R - 1, Math.ceil(Math.max(y0, y1, y2))); y++) {
|
|
49
|
+
for (let x = Math.max(0, Math.floor(Math.min(x0, x1, x2))); x <= Math.min(R - 1, Math.ceil(Math.max(x0, x1, x2))); x++) {
|
|
50
|
+
const [cx, cy] = [x + 0.5, y + 0.5];
|
|
51
|
+
const w0 = ((x1 - cx) * (y2 - cy) - (x2 - cx) * (y1 - cy)) / area;
|
|
52
|
+
const w1 = ((x2 - cx) * (y0 - cy) - (x0 - cx) * (y2 - cy)) / area;
|
|
53
|
+
if (w0 >= 0 && w1 >= 0 && w0 + w1 <= 1) out[y * R + x] = 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The figure in a painted view on its flat background, at R x R, plus the image's aspect (height / width). */
|
|
61
|
+
async function figureMask(png: Buffer): Promise<{ mask: Uint8Array; aspect: number }> {
|
|
62
|
+
const meta = await sharp(png).metadata();
|
|
63
|
+
const { data } = await sharp(png).removeAlpha().resize(R, R, { fit: 'fill' }).raw().toBuffer({ resolveWithObject: true });
|
|
64
|
+
const border: number[][] = [];
|
|
65
|
+
for (let i = 0; i < R; i++) for (const [x, y] of [[i, 0], [i, R - 1], [0, i], [R - 1, i]]) border.push([...data.subarray((y * R + x) * 3, (y * R + x) * 3 + 3)]);
|
|
66
|
+
const bg = [0, 1, 2].map((c) => border.map((b) => b[c]).sort((a, b) => a - b)[border.length >> 1]);
|
|
67
|
+
const mask = new Uint8Array(R * R);
|
|
68
|
+
for (let i = 0; i < R * R; i++) mask[i] = Math.abs(data[i * 3] - bg[0]) + Math.abs(data[i * 3 + 1] - bg[1]) + Math.abs(data[i * 3 + 2] - bg[2]) > 40 ? 1 : 0;
|
|
69
|
+
// A pale shirt is close to the grey background, so the cut leaves holes in the figure. The background is
|
|
70
|
+
// what joins the border; everything else is figure.
|
|
71
|
+
const outside = new Uint8Array(R * R);
|
|
72
|
+
const queue: number[] = [];
|
|
73
|
+
for (let i = 0; i < R; i++) for (const at of [i, (R - 1) * R + i, i * R, i * R + R - 1]) if (!mask[at] && !outside[at]) { outside[at] = 1; queue.push(at); }
|
|
74
|
+
while (queue.length) {
|
|
75
|
+
const at = queue.pop()!;
|
|
76
|
+
const [x, y] = [at % R, Math.floor(at / R)];
|
|
77
|
+
for (const [nx, ny] of [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]]) {
|
|
78
|
+
const next = ny * R + nx;
|
|
79
|
+
if (nx >= 0 && nx < R && ny >= 0 && ny < R && !mask[next] && !outside[next]) { outside[next] = 1; queue.push(next); }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (let i = 0; i < R * R; i++) mask[i] = outside[i] ? 0 : 1;
|
|
83
|
+
return { mask, aspect: (meta.height ?? 1) / (meta.width ?? 1) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* How well the painted figure lies over the model's silhouette. `bare` weighs model left unpainted against paint
|
|
88
|
+
* off the model: above 1, a fit that covers the model wins, since a bare edge is painted from another view
|
|
89
|
+
* (its nose, its mouth) while paint off the model shows nowhere.
|
|
90
|
+
*/
|
|
91
|
+
function iou(sil: Uint8Array, mask: Uint8Array, a: number, b: number, c: number, d: number, rows: [number, number] = [0, R], bare = 1, W = 1): number {
|
|
92
|
+
let both = 0;
|
|
93
|
+
let silOnly = 0;
|
|
94
|
+
let maskOnly = 0;
|
|
95
|
+
for (let y = rows[0]; y < rows[1]; y++) {
|
|
96
|
+
const v = c * (1 - (y + 0.5) / R) + d;
|
|
97
|
+
const my = Math.floor(v * R);
|
|
98
|
+
for (let x = 0; x < R; x++) {
|
|
99
|
+
const u = a * W * ((x + 0.5) / R - 0.5) + b;
|
|
100
|
+
const mx = Math.floor(u * R);
|
|
101
|
+
const m = mx >= 0 && mx < R && my >= 0 && my < R ? mask[my * R + mx] : 0;
|
|
102
|
+
const s = sil[y * R + x];
|
|
103
|
+
both += s & m;
|
|
104
|
+
silOnly += s & (1 - m);
|
|
105
|
+
maskOnly += m & (1 - s);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return both ? both / (both + bare * silOnly + maskOnly) : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function fitView(shape: Shape, view: ViewName, png: Buffer): Promise<ViewFit> {
|
|
112
|
+
const W = across(shape);
|
|
113
|
+
const sil = silhouette(shape, view, W);
|
|
114
|
+
const { mask, aspect } = await figureMask(png);
|
|
115
|
+
let top = R;
|
|
116
|
+
let bottom = -1;
|
|
117
|
+
let sumX = 0;
|
|
118
|
+
let n = 0;
|
|
119
|
+
for (let y = 0; y < R; y++) for (let x = 0; x < R; x++) if (mask[y * R + x]) { top = Math.min(top, y); bottom = Math.max(bottom, y); sumX += x; n++; }
|
|
120
|
+
if (!n) throw new Error(`${view}: no figure found on the background`);
|
|
121
|
+
const [vt, vb, uc] = [top / R, (bottom + 1) / R, (sumX / n + 0.5) / R];
|
|
122
|
+
let best: ViewFit = { u: [0, 0], v: [0, 0], iou: -1 };
|
|
123
|
+
for (let ks = 0.9; ks <= 1.1001; ks += 0.01) {
|
|
124
|
+
const c = -(vb - vt) * ks;
|
|
125
|
+
const d = vb;
|
|
126
|
+
for (let kx = 0.8; kx <= 1.2501; kx += 0.015) {
|
|
127
|
+
const a = (vb - vt) * ks * aspect * kx;
|
|
128
|
+
for (let db = -0.04; db <= 0.0401; db += 0.005) {
|
|
129
|
+
const score = iou(sil, mask, a, uc + db, c, d, [0, R], 1, W);
|
|
130
|
+
if (score > best.iou) best = { u: [a, uc + db], v: [c, d], iou: score };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { ...best, iou: Math.round(best.iou * 1000) / 1000, bands: fitBands(sil, mask, best, W) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Each slice's own scale and offset across, near the whole fit, then smoothed with its neighbours. */
|
|
138
|
+
function fitBands(sil: Uint8Array, mask: Uint8Array, whole: ViewFit, W: number): [number, number][] {
|
|
139
|
+
const [a0, b0] = whole.u;
|
|
140
|
+
const [c, d] = whole.v;
|
|
141
|
+
const raw = [...Array(BANDS).keys()].map((k): [number, number] => {
|
|
142
|
+
// Model heights k/BANDS..(k+1)/BANDS are silhouette rows from (1 - (k+1)/BANDS) R down.
|
|
143
|
+
const rows: [number, number] = [Math.max(0, Math.floor((1 - (k + 1) / BANDS) * R)), Math.min(R, Math.ceil((1 - k / BANDS) * R))];
|
|
144
|
+
let area = 0;
|
|
145
|
+
for (let y = rows[0]; y < rows[1]; y++) for (let x = 0; x < R; x++) area += sil[y * R + x];
|
|
146
|
+
if (area < 20) return [a0, b0];
|
|
147
|
+
let best: [number, number] = [a0, b0];
|
|
148
|
+
let top = -Infinity;
|
|
149
|
+
for (let ka = 0.85; ka <= 1.1501; ka += 0.015) {
|
|
150
|
+
for (let db = -0.08; db <= 0.0801; db += 0.004) {
|
|
151
|
+
// A slight pull to the whole fit, so a slice with little to go on stays with its neighbours.
|
|
152
|
+
const score = iou(sil, mask, a0 * ka, b0 + db, c, d, rows, 2, W) - 0.05 * (Math.abs(ka - 1) + 4 * Math.abs(db));
|
|
153
|
+
if (score > top) [top, best] = [score, [a0 * ka, b0 + db]];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return best;
|
|
157
|
+
});
|
|
158
|
+
return raw.map((_, k) => {
|
|
159
|
+
const near = [raw[Math.max(0, k - 1)], raw[k], raw[k], raw[Math.min(BANDS - 1, k + 1)]];
|
|
160
|
+
return [near.reduce((s, n) => s + n[0], 0) / 4, near.reduce((s, n) => s + n[1], 0) / 4];
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Fits every view of a model; model.json's `views`. */
|
|
165
|
+
export async function fitViews(glb: Buffer, views: Record<ViewName, Buffer>): Promise<Record<ViewName, { image: string } & ViewFit>> {
|
|
166
|
+
const shape = normalise(readShape(glb, true));
|
|
167
|
+
const fits = {} as Record<ViewName, { image: string } & ViewFit>;
|
|
168
|
+
for (const v of Object.keys(VIEWS) as ViewName[]) fits[v] = { image: `${v}.png`, ...(await fitView(shape, v, views[v])) };
|
|
169
|
+
return fits;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A model fitted before views were fitted slice by slice is fitted again (no Muse, no TRELLIS: its own files). */
|
|
173
|
+
export async function refitIfOld(modelDir: string, always = false): Promise<boolean> {
|
|
174
|
+
const file = path.join(modelDir, 'model.json');
|
|
175
|
+
const info = JSON.parse(fs.readFileSync(file, 'utf8')) as { glb: string; views: Record<ViewName, { image: string } & ViewFit> };
|
|
176
|
+
if (!always && Object.values(info.views).every((v) => v.bands?.length === BANDS)) return false;
|
|
177
|
+
const views = Object.fromEntries(Object.entries(info.views).map(([k, v]) => [k, fs.readFileSync(path.join(modelDir, v.image))])) as Record<ViewName, Buffer>;
|
|
178
|
+
info.views = await fitViews(fs.readFileSync(path.join(modelDir, info.glb)), views);
|
|
179
|
+
fs.writeFileSync(file, JSON.stringify(info, null, 2));
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Reads the bind-pose shape of a skinned glTF binary: vertex positions in scene space and triangles.
|
|
2
|
+
// Enough for measuring a silhouette; the runtime loads the file itself with three.js.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { shapeProblem } from './quality.ts';
|
|
6
|
+
export { MIN_DEPTH } from './quality.ts';
|
|
7
|
+
|
|
8
|
+
type Node = { mesh?: number; skin?: number; children?: number[]; matrix?: number[]; translation?: number[]; rotation?: number[]; scale?: number[] };
|
|
9
|
+
type Accessor = { bufferView: number; byteOffset?: number; componentType: number; count: number; type: string };
|
|
10
|
+
type Gltf = {
|
|
11
|
+
nodes: Node[];
|
|
12
|
+
skins?: { joints: number[]; inverseBindMatrices?: number }[];
|
|
13
|
+
meshes: { primitives: { attributes: Record<string, number>; indices?: number }[] }[];
|
|
14
|
+
accessors: Accessor[];
|
|
15
|
+
bufferViews: { byteOffset?: number; byteStride?: number; byteLength: number }[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type Shape = { positions: Float32Array; triangles: Uint32Array };
|
|
19
|
+
|
|
20
|
+
type M4 = number[];
|
|
21
|
+
const identity = (): M4 => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
22
|
+
function multiply(a: M4, b: M4): M4 {
|
|
23
|
+
const o = new Array(16).fill(0);
|
|
24
|
+
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
|
|
25
|
+
return o;
|
|
26
|
+
}
|
|
27
|
+
function local(n: Node): M4 {
|
|
28
|
+
if (n.matrix) return n.matrix;
|
|
29
|
+
const [x, y, z, w] = n.rotation ?? [0, 0, 0, 1];
|
|
30
|
+
const [sx, sy, sz] = n.scale ?? [1, 1, 1];
|
|
31
|
+
const [tx, ty, tz] = n.translation ?? [0, 0, 0];
|
|
32
|
+
// Column-major T * R * S.
|
|
33
|
+
return [
|
|
34
|
+
(1 - 2 * (y * y + z * z)) * sx, 2 * (x * y + z * w) * sx, 2 * (x * z - y * w) * sx, 0,
|
|
35
|
+
2 * (x * y - z * w) * sy, (1 - 2 * (x * x + z * z)) * sy, 2 * (y * z + x * w) * sy, 0,
|
|
36
|
+
2 * (x * z + y * w) * sz, 2 * (y * z - x * w) * sz, (1 - 2 * (x * x + y * y)) * sz, 0,
|
|
37
|
+
tx, ty, tz, 1,
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readAccessor(gltf: Gltf, bin: Buffer, index: number): Float32Array | Uint32Array {
|
|
42
|
+
const a = gltf.accessors[index];
|
|
43
|
+
const view = gltf.bufferViews[a.bufferView];
|
|
44
|
+
const width = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 }[a.type] ?? 1;
|
|
45
|
+
const size = { 5126: 4, 5125: 4, 5123: 2, 5121: 1 }[a.componentType] ?? 4;
|
|
46
|
+
const stride = view.byteStride ?? width * size;
|
|
47
|
+
const start = (view.byteOffset ?? 0) + (a.byteOffset ?? 0);
|
|
48
|
+
const out = a.componentType === 5126 ? new Float32Array(a.count * width) : new Uint32Array(a.count * width);
|
|
49
|
+
for (let i = 0; i < a.count; i++) for (let j = 0; j < width; j++) {
|
|
50
|
+
const at = start + i * stride + j * size;
|
|
51
|
+
out[i * width + j] = a.componentType === 5126 ? bin.readFloatLE(at) : a.componentType === 5125 ? bin.readUInt32LE(at) : a.componentType === 5123 ? bin.readUInt16LE(at) : bin.readUInt8(at);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Native sprite rendering needs UVs and a self-contained base-colour image on every mesh primitive. */
|
|
57
|
+
export function hasModelTexture(glb: Buffer): boolean {
|
|
58
|
+
try {
|
|
59
|
+
if (glb.readUInt32LE(0) !== 0x46546c67) return false;
|
|
60
|
+
const length = glb.readUInt32LE(12);
|
|
61
|
+
const g = JSON.parse(glb.subarray(20, 20 + length).toString('utf8'));
|
|
62
|
+
const primitives = (g.meshes ?? []).flatMap((m: { primitives?: unknown[] }) => m.primitives ?? []);
|
|
63
|
+
return primitives.length > 0 && primitives.every((p: { attributes?: Record<string, number>; material?: number }) => {
|
|
64
|
+
const map = g.materials?.[p.material!]?.pbrMetallicRoughness?.baseColorTexture;
|
|
65
|
+
const uv = g.accessors?.[p.attributes?.[`TEXCOORD_${map?.texCoord ?? 0}`]!];
|
|
66
|
+
const position = g.accessors?.[p.attributes?.POSITION!];
|
|
67
|
+
const image = g.images?.[g.textures?.[map?.index]?.source];
|
|
68
|
+
const embedded = image && (typeof image.uri === 'string' && /^data:image\/(png|jpeg);base64,/.test(image.uri)
|
|
69
|
+
|| Number.isInteger(image.bufferView) && g.bufferViews?.[image.bufferView]?.byteLength > 0);
|
|
70
|
+
return !!(map && (map.texCoord ?? 0) === 0 && uv?.type === 'VEC2' && uv.count > 0 && uv.count === position?.count && embedded);
|
|
71
|
+
});
|
|
72
|
+
} catch { return false; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The first mesh in the file (the skinned one when there is a skin), in scene space. `original`: the surface as
|
|
77
|
+
* TRELLIS built it, where Blender relaxed the pose (src/model/blender.ts keeps it as the _REST attribute); the
|
|
78
|
+
* painted views were fitted to that surface.
|
|
79
|
+
*/
|
|
80
|
+
export function readShape(glb: Buffer, original = false): Shape {
|
|
81
|
+
if (glb.readUInt32LE(0) !== 0x46546c67) throw new Error('not a glTF binary');
|
|
82
|
+
const jsonLength = glb.readUInt32LE(12);
|
|
83
|
+
const gltf = JSON.parse(glb.subarray(20, 20 + jsonLength).toString('utf8')) as Gltf;
|
|
84
|
+
const binStart = 20 + jsonLength;
|
|
85
|
+
const bin = glb.subarray(binStart + 8, binStart + 8 + glb.readUInt32LE(binStart));
|
|
86
|
+
const parent = new Map<number, number>();
|
|
87
|
+
gltf.nodes.forEach((n, i) => n.children?.forEach((c) => parent.set(c, i)));
|
|
88
|
+
const nodeIndex = gltf.nodes.findIndex((n) => n.mesh !== undefined && n.skin !== undefined) >= 0
|
|
89
|
+
? gltf.nodes.findIndex((n) => n.mesh !== undefined && n.skin !== undefined)
|
|
90
|
+
: gltf.nodes.findIndex((n) => n.mesh !== undefined);
|
|
91
|
+
if (nodeIndex < 0) throw new Error('no mesh in the file');
|
|
92
|
+
let world = identity();
|
|
93
|
+
for (let i: number | undefined = nodeIndex; i !== undefined; i = parent.get(i)) world = multiply(local(gltf.nodes[i]), world);
|
|
94
|
+
const positions: number[] = [];
|
|
95
|
+
const triangles: number[] = [];
|
|
96
|
+
for (const prim of gltf.meshes[gltf.nodes[nodeIndex].mesh!].primitives) {
|
|
97
|
+
const base = positions.length / 3;
|
|
98
|
+
const p = readAccessor(gltf, bin, original && prim.attributes._REST !== undefined ? prim.attributes._REST : prim.attributes.POSITION) as Float32Array;
|
|
99
|
+
for (let i = 0; i < p.length; i += 3) {
|
|
100
|
+
const [x, y, z] = [p[i], p[i + 1], p[i + 2]];
|
|
101
|
+
positions.push(world[0] * x + world[4] * y + world[8] * z + world[12], world[1] * x + world[5] * y + world[9] * z + world[13], world[2] * x + world[6] * y + world[10] * z + world[14]);
|
|
102
|
+
}
|
|
103
|
+
const idx = prim.indices !== undefined ? readAccessor(gltf, bin, prim.indices) : Uint32Array.from({ length: p.length / 3 }, (_, i) => i);
|
|
104
|
+
for (const k of idx) triangles.push(base + k);
|
|
105
|
+
}
|
|
106
|
+
return { positions: Float32Array.from(positions), triangles: Uint32Array.from(triangles) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Inverse of a column-major 4x4 matrix (cofactors). */
|
|
110
|
+
function invert(m: M4): M4 {
|
|
111
|
+
const o = new Array(16).fill(0);
|
|
112
|
+
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
|
|
113
|
+
// Cofactor of m[row c][col r], transposed into o[col c][row r].
|
|
114
|
+
const rows = [0, 1, 2, 3].filter((i) => i !== c);
|
|
115
|
+
const cols = [0, 1, 2, 3].filter((i) => i !== r);
|
|
116
|
+
const at = (i: number, j: number) => m[cols[j] * 4 + rows[i]];
|
|
117
|
+
const det3 = at(0, 0) * (at(1, 1) * at(2, 2) - at(1, 2) * at(2, 1)) - at(0, 1) * (at(1, 0) * at(2, 2) - at(1, 2) * at(2, 0)) + at(0, 2) * (at(1, 0) * at(2, 1) - at(1, 1) * at(2, 0));
|
|
118
|
+
o[c * 4 + r] = ((r + c) % 2 ? -1 : 1) * det3;
|
|
119
|
+
}
|
|
120
|
+
const det = m[0] * o[0] + m[4] * o[1] + m[8] * o[2] + m[12] * o[3];
|
|
121
|
+
return o.map((v) => v / det);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Why the auto-rig's skeleton cannot walk this body, or null. Make-It-Animatable guesses the joints from the
|
|
126
|
+
* shape alone; on a floor-length robe it once put both feet at hip height and the knees below them, so every
|
|
127
|
+
* step tore the robe apart and pulled the shoes up into poles. Heights are shares of the body's height, at bind.
|
|
128
|
+
*/
|
|
129
|
+
export function rigProblem(glb: Buffer): string | null {
|
|
130
|
+
const jsonLength = glb.readUInt32LE(12);
|
|
131
|
+
const gltf = JSON.parse(glb.subarray(20, 20 + jsonLength).toString('utf8')) as Gltf;
|
|
132
|
+
const skin = gltf.skins?.[0];
|
|
133
|
+
if (!skin?.inverseBindMatrices && skin?.inverseBindMatrices !== 0) return null;
|
|
134
|
+
const binStart = 20 + jsonLength;
|
|
135
|
+
const bin = glb.subarray(binStart + 8, binStart + 8 + glb.readUInt32LE(binStart));
|
|
136
|
+
const parent = new Map<number, number>();
|
|
137
|
+
gltf.nodes.forEach((n, i) => n.children?.forEach((c) => parent.set(c, i)));
|
|
138
|
+
const meshNode = gltf.nodes.findIndex((n) => n.mesh !== undefined && n.skin === 0);
|
|
139
|
+
let world = identity();
|
|
140
|
+
for (let i: number | undefined = meshNode; i !== undefined && i >= 0; i = parent.get(i)) world = multiply(local(gltf.nodes[i]), world);
|
|
141
|
+
const ibm = readAccessor(gltf, bin, skin.inverseBindMatrices);
|
|
142
|
+
const y: Record<string, number> = {};
|
|
143
|
+
skin.joints.forEach((node, j) => {
|
|
144
|
+
const bind = multiply(world, invert(Array.from(ibm.subarray(j * 16, j * 16 + 16))));
|
|
145
|
+
y[((gltf.nodes[node] as { name?: string }).name ?? '').replace(/^mixamorig:?/, '')] = bind[13];
|
|
146
|
+
});
|
|
147
|
+
const shape = readShape(glb).positions;
|
|
148
|
+
let [lo, hi] = [Infinity, -Infinity];
|
|
149
|
+
for (let i = 1; i < shape.length; i += 3) { lo = Math.min(lo, shape[i]); hi = Math.max(hi, shape[i]); }
|
|
150
|
+
const at = (bone: string) => (y[bone] - lo) / (hi - lo);
|
|
151
|
+
const need = ['Hips', 'Neck', 'Head', 'LeftLeg', 'RightLeg', 'LeftFoot', 'RightFoot'];
|
|
152
|
+
if (need.some((b) => y[b] === undefined)) return null;
|
|
153
|
+
const pct = (v: number) => `${Math.round(v * 100)}%`;
|
|
154
|
+
if (!(at('Hips') < at('Neck') && at('Neck') < at('Head'))) return `skeleton upside down: hips at ${pct(at('Hips'))}, head at ${pct(at('Head'))} of the height`;
|
|
155
|
+
for (const side of ['Left', 'Right']) {
|
|
156
|
+
const [knee, foot] = [at(`${side}Leg`), at(`${side}Foot`)];
|
|
157
|
+
// Feet sit at 4-13% in every rig that walks; a kicking or stepping pose reaches about 35%.
|
|
158
|
+
if (foot > 0.4 || foot >= knee || knee >= at('Hips')) return `skeleton misplaced: ${side.toLowerCase()} foot at ${pct(foot)}, knee at ${pct(knee)}, hips at ${pct(at('Hips'))} of the height`;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Feet at y 0, height 1, centred on x and z: the hero-unit space the runtime projects views in. */
|
|
164
|
+
export function normalise({ positions, triangles }: Shape): Shape {
|
|
165
|
+
const lo = [Infinity, Infinity, Infinity];
|
|
166
|
+
const hi = [-Infinity, -Infinity, -Infinity];
|
|
167
|
+
for (let i = 0; i < positions.length; i++) {
|
|
168
|
+
lo[i % 3] = Math.min(lo[i % 3], positions[i]);
|
|
169
|
+
hi[i % 3] = Math.max(hi[i % 3], positions[i]);
|
|
170
|
+
}
|
|
171
|
+
const h = hi[1] - lo[1];
|
|
172
|
+
const centre = [(lo[0] + hi[0]) / 2, lo[1], (lo[2] + hi[2]) / 2];
|
|
173
|
+
return { positions: positions.map((v, i) => (v - centre[i % 3]) / h), triangles };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Why a model cannot stand in for the character, or null. Shared with the browser's loader. */
|
|
177
|
+
export function badModel(shape: Shape): string | null {
|
|
178
|
+
return shapeProblem(shape.positions);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Why cast/<key>/model/ cannot be used (a model kept before badModel was checked), or null. */
|
|
182
|
+
export function modelProblem(modelDir: string): string | null {
|
|
183
|
+
const glb = path.join(modelDir, 'model.glb');
|
|
184
|
+
if (!fs.existsSync(glb)) return 'no model.glb';
|
|
185
|
+
try {
|
|
186
|
+
const file = fs.readFileSync(glb);
|
|
187
|
+
return badModel(readShape(file)) ?? rigProblem(file);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return error instanceof Error ? error.message : String(error);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** How far the body leans: the slope of x and of z against height, over the body between shins and brow. */
|
|
194
|
+
export function lean(shape: Shape): { x: number; z: number } {
|
|
195
|
+
const { positions: p } = normalise(shape);
|
|
196
|
+
let [n, sy, syy, sx, sxy, sz, szy] = [0, 0, 0, 0, 0, 0, 0];
|
|
197
|
+
for (let i = 0; i < p.length; i += 3) {
|
|
198
|
+
const y = p[i + 1];
|
|
199
|
+
if (y < 0.1 || y > 0.9) continue;
|
|
200
|
+
n++; sy += y; syy += y * y; sx += p[i]; sxy += p[i] * y; sz += p[i + 2]; szy += p[i + 2] * y;
|
|
201
|
+
}
|
|
202
|
+
const d = n * syy - sy * sy;
|
|
203
|
+
return d > 0 ? { x: (n * sxy - sx * sy) / d, z: (n * szy - sz * sy) / d } : { x: 0, z: 0 };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const qmul = ([ax, ay, az, aw]: number[], [bx, by, bz, bw]: number[]) => [
|
|
207
|
+
aw * bx + ax * bw + ay * bz - az * by, aw * by - ax * bz + ay * bw + az * bx, aw * bz + ax * by - ay * bx + az * bw, aw * bw - ax * bx - ay * by - az * bz,
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
/** The same file with its scene turned so the body stands straight up. A model reconstructed from paintings
|
|
211
|
+
* can come out pitched or rolled by a few degrees; the fit has no rotation, and a hero should not walk tilted. */
|
|
212
|
+
export function standUp(glb: Buffer): Buffer {
|
|
213
|
+
const { x, z } = lean(readShape(glb));
|
|
214
|
+
// Turning about x by -atan(z) takes the forward lean out; about z by atan(x) the sideways one.
|
|
215
|
+
const [tx, tz] = [-Math.atan(z) / 2, Math.atan(x) / 2];
|
|
216
|
+
const fix = qmul([0, 0, Math.sin(tz), Math.cos(tz)], [Math.sin(tx), 0, 0, Math.cos(tx)]);
|
|
217
|
+
if (Math.abs(fix[3]) > Math.cos(0.5 * Math.PI / 180 / 2)) return glb;
|
|
218
|
+
const jsonLength = glb.readUInt32LE(12);
|
|
219
|
+
const gltf = JSON.parse(glb.subarray(20, 20 + jsonLength).toString('utf8')) as Gltf & { scenes?: { nodes: number[] }[]; scene?: number };
|
|
220
|
+
const children = new Set(gltf.nodes.flatMap((n) => n.children ?? []));
|
|
221
|
+
const roots = gltf.scenes?.[gltf.scene ?? 0]?.nodes ?? gltf.nodes.map((_, i) => i).filter((i) => !children.has(i));
|
|
222
|
+
const [fx, fy, fz, fw] = fix;
|
|
223
|
+
const turn = local({ rotation: fix });
|
|
224
|
+
for (const i of roots) {
|
|
225
|
+
const n = gltf.nodes[i];
|
|
226
|
+
if (n.matrix) { n.matrix = multiply(turn, n.matrix); continue; }
|
|
227
|
+
n.rotation = qmul([fx, fy, fz, fw], n.rotation ?? [0, 0, 0, 1]);
|
|
228
|
+
const [px, py, pz] = n.translation ?? [0, 0, 0];
|
|
229
|
+
if (px || py || pz) n.translation = [0, 1, 2].map((r) => turn[r] * px + turn[4 + r] * py + turn[8 + r] * pz);
|
|
230
|
+
}
|
|
231
|
+
let json = Buffer.from(JSON.stringify(gltf), 'utf8');
|
|
232
|
+
json = Buffer.concat([json, Buffer.alloc((4 - (json.length % 4)) % 4, ' ')]);
|
|
233
|
+
const rest = glb.subarray(20 + jsonLength);
|
|
234
|
+
const head = Buffer.alloc(20);
|
|
235
|
+
head.writeUInt32LE(0x46546c67, 0);
|
|
236
|
+
head.writeUInt32LE(glb.readUInt32LE(4), 4);
|
|
237
|
+
head.writeUInt32LE(20 + json.length + rest.length, 8);
|
|
238
|
+
head.writeUInt32LE(json.length, 12);
|
|
239
|
+
head.writeUInt32LE(0x4e4f534a, 16);
|
|
240
|
+
return Buffer.concat([head, json, rest]);
|
|
241
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// What sprites each character needs, read from the world and its story, so nothing is drawn that the game
|
|
2
|
+
// never shows: walk facings only where the hero's routes go, a talking sheet only for who speaks, in the
|
|
3
|
+
// one facing each conversation is held in, with only the gestures its lines use.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { askSparkJson } from '../meta/spark.ts';
|
|
6
|
+
import type { Memo } from '../meta/memo.ts';
|
|
7
|
+
import { GESTURES, gestureIndex } from '../play/gestures.ts';
|
|
8
|
+
import { Geometry } from '../play/geometry.ts';
|
|
9
|
+
import { WalkGrid, type Point } from '../play/path.ts';
|
|
10
|
+
import type { Story } from '../story/schema.ts';
|
|
11
|
+
import type { WorldFiles } from '../world/files.ts';
|
|
12
|
+
import { readBodies } from '../scene/cast.ts';
|
|
13
|
+
import { speakerOf } from '../story/engine.ts';
|
|
14
|
+
import { FACINGS, SHEET, type Facing } from './sprites.ts';
|
|
15
|
+
|
|
16
|
+
export type CharacterPlan = {
|
|
17
|
+
key: string;
|
|
18
|
+
/** Walk sheets: the facings the routes use (the left ones mirrored). Empty for someone who never walks. */
|
|
19
|
+
walk: Facing[];
|
|
20
|
+
/** Talking sheets by facing, each with its gestures (cell order; `listen` first). */
|
|
21
|
+
talk: Partial<Record<Facing, string[]>>;
|
|
22
|
+
/** Gesture for each line this character says, by its text. */
|
|
23
|
+
lines: Record<string, string>;
|
|
24
|
+
/** Not upright on two legs (cast/figures.json): a 3D model with no skeleton that hops and squashes (src/play/creature.ts), no sheets. */
|
|
25
|
+
creature?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Facing for a yaw (0 faces the viewer, PI/2 screen right); left-facing yaws mirror onto the right ones. */
|
|
29
|
+
export function facingOf(yaw: number): Facing {
|
|
30
|
+
const a = Math.abs(Math.atan2(Math.sin(yaw), Math.cos(yaw)));
|
|
31
|
+
return (Object.keys(FACINGS) as Facing[]).reduce((best, f) => (Math.abs(FACINGS[f] - a) < Math.abs(FACINGS[best] - a) ? f : best));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A share of the walked distance below this does not earn a sheet; the nearest drawn facing stands in. */
|
|
35
|
+
const WALK_SHARE = 0.08;
|
|
36
|
+
|
|
37
|
+
/** Walked distance per facing over every route between the start and the spots where things are used, in every place. */
|
|
38
|
+
export function walkFacings(geometries: Geometry[]): Facing[] {
|
|
39
|
+
const length: Partial<Record<Facing, number>> = {};
|
|
40
|
+
for (const g of geometries) {
|
|
41
|
+
const grid = new WalkGrid(g);
|
|
42
|
+
const spots: Point[] = [[g.start[0], g.start[1]], ...g.things.map((t) => [t.stand[0], t.stand[1]] as Point)];
|
|
43
|
+
for (let i = 0; i < spots.length; i++) {
|
|
44
|
+
for (let j = i + 1; j < spots.length; j++) {
|
|
45
|
+
let at = spots[i];
|
|
46
|
+
for (const next of grid.route(spots[i], spots[j])) {
|
|
47
|
+
const [dx, dy] = [next[0] - at[0], next[1] - at[1]];
|
|
48
|
+
const f = facingOf(Math.atan2(dx, dy));
|
|
49
|
+
length[f] = (length[f] ?? 0) + Math.hypot(dx, dy);
|
|
50
|
+
at = next;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const total = Object.values(length).reduce((a, b) => a + b, 0);
|
|
56
|
+
const used = (Object.keys(FACINGS) as Facing[]).filter((f) => (length[f] ?? 0) >= total * WALK_SHARE);
|
|
57
|
+
return used.length ? used : ['right'];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Facings across a conversation: the hero at the NPC's stand spot, each turned to the other. */
|
|
61
|
+
function conversationFacings(g: Geometry, npc: string): { hero: Facing; npc: Facing } {
|
|
62
|
+
const t = g.things.find((x) => x.key === npc);
|
|
63
|
+
if (!t) return { hero: 'right', npc: 'front-right' };
|
|
64
|
+
const feet: Point = [(t.box[0] + t.box[2]) / 2, t.box[3]];
|
|
65
|
+
const [dx, dy] = [feet[0] - t.stand[0], feet[1] - t.stand[1]];
|
|
66
|
+
// Standing right next to each other (or the spot is under the feet): side by side, the NPC a little towards the viewer.
|
|
67
|
+
if (Math.hypot(dx, dy) < 40) return { hero: 'right', npc: 'front-right' };
|
|
68
|
+
// Someone talked to from behind still turns their face to the viewer: a talk sheet never shows a back.
|
|
69
|
+
const faces = facingOf(Math.atan2(-dx, -dy));
|
|
70
|
+
return { hero: facingOf(Math.atan2(dx, dy)), npc: faces === 'back' ? 'front' : faces === 'back-right' ? 'front-right' : faces };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type SpokenLine = { speaker: string; text: string };
|
|
74
|
+
|
|
75
|
+
/** Spark picks a gesture for each spoken line, from the gestures the model can do. */
|
|
76
|
+
async function gesturesFor(memo: Memo, lines: SpokenLine[]): Promise<string[]> {
|
|
77
|
+
if (!lines.length) return [];
|
|
78
|
+
const menu = GESTURES.map((g) => `- ${g.name}: ${g.means}`).join('\n');
|
|
79
|
+
const asked = lines.map((l, i) => `${i}. ${l.speaker}: "${l.text}"`).join('\n');
|
|
80
|
+
const reply = await askSparkJson<{ gestures: string[] }>(memo, {
|
|
81
|
+
label: 'sprites-gestures',
|
|
82
|
+
prompt: `Characters in a point-and-click game say these lines. For each line pick the one gesture the speaker makes while saying it, from this list:\n${menu}\n\nLines:\n${asked}\n\n` +
|
|
83
|
+
'Use "listen" for plain lines; keep it small, the same few gestures across a character are better than many. Answer JSON only: {"gestures": ["<name for line 0>", ...]} with exactly one name per line.',
|
|
84
|
+
});
|
|
85
|
+
return lines.map((_, i) => (gestureIndex(reply.gestures?.[i] ?? '') >= 0 ? reply.gestures[i] : 'listen'));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Each character who needs sprites: the hero, and NPCs who speak (a conversation or their own talk lines). */
|
|
89
|
+
export async function planSprites(files: WorldFiles, story: Story | undefined): Promise<CharacterPlan[]> {
|
|
90
|
+
const world = files.world();
|
|
91
|
+
const hero = world.hero.key;
|
|
92
|
+
const geometry = (place: string): Geometry | undefined => {
|
|
93
|
+
const at = files.place(place, 'geometry.json');
|
|
94
|
+
return fs.existsSync(at) ? Geometry.parse(JSON.parse(fs.readFileSync(at, 'utf8'))) : undefined;
|
|
95
|
+
};
|
|
96
|
+
const geometries = world.places.map((p) => geometry(p.key)).filter((g): g is Geometry => !!g);
|
|
97
|
+
const plans = new Map<string, CharacterPlan>([[hero, { key: hero, walk: walkFacings(geometries), talk: {}, lines: {} }]]);
|
|
98
|
+
const walkers = new Set([...(story?.patrols ?? []).map((p) => p.npc),
|
|
99
|
+
...[...(story?.interactions ?? []).flatMap((i) => i.do), ...(story?.dialogues ?? []).flatMap((d) => d.lines.flatMap((l) => l.choices.flatMap((c) => c.do)))]
|
|
100
|
+
.flatMap((e) => 'moveNpc' in e ? [e.moveNpc.npc] : [])]);
|
|
101
|
+
for (const npc of walkers) {
|
|
102
|
+
// A patrol repeats in both directions; include their opposite depth facings as well.
|
|
103
|
+
const walk = new Set(walkFacings(geometries));
|
|
104
|
+
for (const f of [...walk]) walk.add(f === 'front' ? 'back' : f === 'back' ? 'front' : f === 'front-right' ? 'back-right' : f === 'back-right' ? 'front-right' : f);
|
|
105
|
+
plans.set(npc, { key: npc, walk: [...walk], talk: {}, lines: {} });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Who says what, and facing which way while saying it.
|
|
109
|
+
const spoken: (SpokenLine & { facing: Facing })[] = [];
|
|
110
|
+
for (const d of story?.dialogues ?? []) {
|
|
111
|
+
const g = geometry(d.place);
|
|
112
|
+
const facing = g ? conversationFacings(g, d.npc) : { hero: 'right' as Facing, npc: 'front-right' as Facing };
|
|
113
|
+
if (!plans.has(d.npc)) plans.set(d.npc, { key: d.npc, walk: [], talk: {}, lines: {} });
|
|
114
|
+
for (const l of d.lines) {
|
|
115
|
+
spoken.push({ speaker: d.npc, text: l.says, facing: facing.npc });
|
|
116
|
+
for (const c of l.choices) spoken.push({ speaker: hero, text: c.text, facing: facing.hero });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// A person's own talk lines, without a conversation, are said to the hero the same way.
|
|
120
|
+
for (const it of story?.interactions ?? []) {
|
|
121
|
+
if (speakerOf(world, it) === hero) continue;
|
|
122
|
+
const g = geometry(it.place);
|
|
123
|
+
if (!plans.has(it.thing)) plans.set(it.thing, { key: it.thing, walk: [], talk: {}, lines: {} });
|
|
124
|
+
spoken.push({ speaker: it.thing, text: it.say, facing: (g ? conversationFacings(g, it.thing) : { npc: 'front-right' as Facing }).npc });
|
|
125
|
+
}
|
|
126
|
+
// Keep existing line/gesture choices when rerendering a character's sheets. Only new or
|
|
127
|
+
// invalid assignments need planning; a frame-count change must not depend on an AI service.
|
|
128
|
+
const saved = new Map<string, Record<string, string>>();
|
|
129
|
+
for (const speaker of new Set(spoken.map((l) => l.speaker))) {
|
|
130
|
+
const file = files.path('cast', speaker, 'sprites', 'sprites.json');
|
|
131
|
+
const info = fs.existsSync(file) ? files.readJson(file) as { lines?: Record<string, string> } : undefined;
|
|
132
|
+
saved.set(speaker, info?.lines ?? {});
|
|
133
|
+
}
|
|
134
|
+
const gestures = spoken.map((l) => {
|
|
135
|
+
const name = saved.get(l.speaker)?.[l.text];
|
|
136
|
+
return typeof name === 'string' && gestureIndex(name) >= 0 ? name : undefined;
|
|
137
|
+
});
|
|
138
|
+
const missing = spoken.filter((_, i) => gestures[i] === undefined);
|
|
139
|
+
const planned = await gesturesFor(files.memo, missing);
|
|
140
|
+
let next = 0;
|
|
141
|
+
for (let i = 0; i < gestures.length; i++) gestures[i] ??= planned[next++];
|
|
142
|
+
spoken.forEach((l, i) => {
|
|
143
|
+
const plan = plans.get(l.speaker)!;
|
|
144
|
+
plan.lines[l.text] = gestures[i]!;
|
|
145
|
+
const sheet = (plan.talk[l.facing] ??= ['listen']);
|
|
146
|
+
if (!sheet.includes(gestures[i]!) && sheet.length < SHEET.cols * SHEET.rows) sheet.push(gestures[i]!);
|
|
147
|
+
});
|
|
148
|
+
// A cat or a round eye has no person's poses to draw over: it gets a 3D model that moves as one body.
|
|
149
|
+
const bodies = readBodies(files, fs.existsSync);
|
|
150
|
+
return [...plans.values()].map((p) => (bodies[p.key]?.upright === false ? { ...p, creature: true } : p));
|
|
151
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Geometry checks shared by the build, saved-world loader and browser (no filesystem imports).
|
|
2
|
+
export const MIN_DEPTH = 0.08;
|
|
3
|
+
|
|
4
|
+
/** Reject degenerate meshes and paper-like bodies, including cards lying down or tilted. */
|
|
5
|
+
export function shapeProblem(p: ArrayLike<number>): string | null {
|
|
6
|
+
if (p.length < 12 || p.length % 3) return 'invalid: too few vertices';
|
|
7
|
+
const axes: number[][] = [[], [], []];
|
|
8
|
+
for (let i = 0; i < p.length; i++) {
|
|
9
|
+
if (!Number.isFinite(p[i])) return 'invalid: non-finite vertex';
|
|
10
|
+
axes[i % 3].push(p[i]);
|
|
11
|
+
}
|
|
12
|
+
const spans = axes.map((a) => {
|
|
13
|
+
a.sort((x, y) => x - y);
|
|
14
|
+
return a[Math.floor(a.length * 0.98)] - a[Math.floor(a.length * 0.02)];
|
|
15
|
+
});
|
|
16
|
+
if (spans.some((s) => s <= 0)) return 'flat: zero body extent';
|
|
17
|
+
if (spans[2] / spans[1] < MIN_DEPTH) return `flat: ${(spans[2] / spans[1]).toFixed(2)} of its height deep`;
|
|
18
|
+
|
|
19
|
+
// Principal axes expose a thin sheet even when all three world-axis extents look substantial.
|
|
20
|
+
// Compare thickness to the middle axis, not the longest: a long animal can still have a round body.
|
|
21
|
+
const n = p.length / 3, mean = axes.map((a) => a.reduce((sum, x) => sum + x, 0) / n);
|
|
22
|
+
const scale = Math.max(...spans);
|
|
23
|
+
const cov = Array.from({ length: 3 }, () => [0, 0, 0]);
|
|
24
|
+
for (let i = 0; i < p.length; i += 3) {
|
|
25
|
+
const v = [0, 1, 2].map((a) => (p[i + a] - mean[a]) / scale);
|
|
26
|
+
for (let a = 0; a < 3; a++) for (let b = 0; b < 3; b++) cov[a][b] += v[a] * v[b] / n;
|
|
27
|
+
}
|
|
28
|
+
// Jacobi diagonalisation of the symmetric 3x3 covariance matrix.
|
|
29
|
+
for (let step = 0; step < 20; step++) {
|
|
30
|
+
let a = 0, b = 1;
|
|
31
|
+
for (const [x, y] of [[0, 2], [1, 2]]) if (Math.abs(cov[x][y]) > Math.abs(cov[a][b])) [a, b] = [x, y];
|
|
32
|
+
if (Math.abs(cov[a][b]) < 1e-12) break;
|
|
33
|
+
const angle = 0.5 * Math.atan2(2 * cov[a][b], cov[b][b] - cov[a][a]);
|
|
34
|
+
const c = Math.cos(angle), s = Math.sin(angle), aa = cov[a][a], bb = cov[b][b], ab = cov[a][b];
|
|
35
|
+
for (let k = 0; k < 3; k++) if (k !== a && k !== b) {
|
|
36
|
+
const ka = cov[k][a], kb = cov[k][b];
|
|
37
|
+
cov[k][a] = cov[a][k] = c * ka - s * kb;
|
|
38
|
+
cov[k][b] = cov[b][k] = s * ka + c * kb;
|
|
39
|
+
}
|
|
40
|
+
cov[a][a] = c * c * aa - 2 * c * s * ab + s * s * bb;
|
|
41
|
+
cov[b][b] = s * s * aa + 2 * c * s * ab + c * c * bb;
|
|
42
|
+
cov[a][b] = cov[b][a] = 0;
|
|
43
|
+
}
|
|
44
|
+
const variance = [cov[0][0], cov[1][1], cov[2][2]].sort((a, b) => a - b);
|
|
45
|
+
const thickness = Math.sqrt(Math.max(0, variance[0]) / variance[1]);
|
|
46
|
+
if (!Number.isFinite(thickness) || thickness < MIN_DEPTH) return `flat: body thickness ratio ${thickness.toFixed(3)} (minimum ${MIN_DEPTH})`;
|
|
47
|
+
return null;
|
|
48
|
+
}
|