@gtanczyk/diorama 0.0.0-stage → 0.1.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 +152 -0
- package/app/src/actions/build.ts +158 -0
- package/app/src/actions/cast.ts +248 -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 +31 -0
- package/app/src/actions/items.ts +94 -0
- package/app/src/actions/kids.ts +215 -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 +289 -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 +142 -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 +142 -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 +36 -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 +192 -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 +90 -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 +91 -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 +31 -0
- package/app/src/scene/cast.ts +192 -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 +55 -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 +375 -0
- package/app/src/scene/scale.ts +148 -0
- package/app/src/scene/shadows.ts +207 -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 +417 -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 +554 -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 +421 -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/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 +677 -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 +1328 -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 +3751 -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,187 @@
|
|
|
1
|
+
// The painted lights of a place (layout.lights) come alive: a window's glow breathes, a lantern or a fire
|
|
2
|
+
// flickers, a town's far lights twinkle, a lighthouse lamp turns its beam round. Read by the light's name, so
|
|
3
|
+
// any new world gets it with no extra step and no model call.
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { hashSeed } from './weather.ts';
|
|
6
|
+
|
|
7
|
+
export type GlowKind = 'beam' | 'flame' | 'window' | 'twinkle' | 'lamp';
|
|
8
|
+
|
|
9
|
+
export const Glow = z.object({ key: z.string(), x: z.number(), y: z.number(), colour: z.string(), kind: z.enum(['beam', 'flame', 'window', 'twinkle', 'lamp']) });
|
|
10
|
+
export type Glow = z.infer<typeof Glow>;
|
|
11
|
+
|
|
12
|
+
// First match wins, the most telling words first: a lighthouse's windows are windows, not its beam, and a far lamp
|
|
13
|
+
// post is a lamp, not a town's twinkle.
|
|
14
|
+
const KINDS: Array<[GlowKind, RegExp]> = [
|
|
15
|
+
['beam', /\b(beam|beacon|searchlight)\b/],
|
|
16
|
+
['window', /\b(windows?|porthole|shopfront|doorway|glass)\b/],
|
|
17
|
+
['beam', /\blighthouse\b/],
|
|
18
|
+
['flame', /\b(fire|flame|candles?|torch|campfire|hearth|oven|brazier|lantern|embers?)\b/],
|
|
19
|
+
['lamp', /\b(lamps?|lamppost|streetlamp|streetlight)\b/],
|
|
20
|
+
['twinkle', /\b(town|city|village|distant|far|stars?|lights)\b/],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** What kind of light a layout light is, by the words of its key ("hut-window", "lighthouse-lamp"). */
|
|
24
|
+
export function glowKind(key: string): GlowKind {
|
|
25
|
+
const words = key.toLowerCase().replace(/[-_]+/g, ' ');
|
|
26
|
+
return KINDS.find(([, re]) => re.test(words))?.[0] ?? 'lamp';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function glowsOf(lights: Array<{ key: string; x: number; y: number; colour: string }>): Glow[] {
|
|
30
|
+
return lights.map((l) => ({ ...l, kind: glowKind(l.key) }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How bright a glow is at time t, 0..1 around its rest of ~0.5: slow breathing for a window, a quick restless
|
|
35
|
+
* flicker for a flame, short sparkles for far lights. Each light has its own phase, so a row of windows never
|
|
36
|
+
* pulses together.
|
|
37
|
+
*/
|
|
38
|
+
export function glowLevel(g: Glow, t: number): number {
|
|
39
|
+
const s = (hashSeed(g.key) % 1000) / 1000 * Math.PI * 2;
|
|
40
|
+
switch (g.kind) {
|
|
41
|
+
case 'flame': return 0.5 + 0.22 * Math.sin(t * 7.3 + s) + 0.14 * Math.sin(t * 12.9 + 2 * s) + 0.1 * Math.sin(t * 2.3 + s);
|
|
42
|
+
case 'window': return 0.5 + 0.18 * Math.sin(t * 0.7 + s) + 0.08 * Math.sin(t * 2.9 + 3 * s) + (Math.sin(t * 0.21 + s) > 0.97 ? -0.25 : 0);
|
|
43
|
+
case 'twinkle': return 0.5 + 0.3 * Math.sin(t * 3.7 + s) * Math.sin(t * 1.3 + 2 * s);
|
|
44
|
+
case 'beam': return 0.5 + 0.15 * Math.sin(t * 1.1 + s);
|
|
45
|
+
// A lamp breathes slowly and visibly, with a faint quick shiver in its paper.
|
|
46
|
+
default: return 0.5 + 0.26 * Math.sin(t * 1.9 + s) + 0.07 * Math.sin(t * 6.3 + 2 * s) + 0.04 * Math.sin(t * 17 + s);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A turning beam: its angle at time t (one turn per BEAM_TURN s, from the light's own phase). */
|
|
51
|
+
export const BEAM_TURN = 3.6;
|
|
52
|
+
export function beamAngle(g: Glow, t: number): number {
|
|
53
|
+
return ((hashSeed(g.key) % 360) * Math.PI) / 180 + (t / BEAM_TURN) * Math.PI * 2;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The light paper a lighthouse lamp was painted with: golden rays bursting round it, or a tissue-paper beam
|
|
58
|
+
* reaching out of it. Bright warm paint joined to the ring round the lamp (not the lamp itself, which stays
|
|
59
|
+
* painted), leaving out other lights' own halos. The runtime lifts it off the painting as a card and turns it,
|
|
60
|
+
* so the painted light moves instead of a second beam being laid over a frozen one. Pixels only.
|
|
61
|
+
*
|
|
62
|
+
* `px` is RGBA at full frame size. The result's alpha is per pixel of `box` ([x0, y0, x1, y1), row-major).
|
|
63
|
+
*/
|
|
64
|
+
export type BeamPaper = { box: [number, number, number, number]; alpha: Float32Array; hole: Uint8Array; burst: boolean; share: number };
|
|
65
|
+
export function beamPaper(px: Uint8ClampedArray | Uint8Array, W: number, H: number, lamp: { x: number; y: number }, others: Array<{ x: number; y: number }> = []): BeamPaper | undefined {
|
|
66
|
+
const k = new Float32Array(W * H);
|
|
67
|
+
for (let i = 0; i < W * H; i++) {
|
|
68
|
+
const [r, g, b] = [px[i * 4], px[i * 4 + 1], px[i * 4 + 2]];
|
|
69
|
+
const luma = 0.3 * r + 0.59 * g + 0.11 * b;
|
|
70
|
+
k[i] = Math.max(0, Math.min(1, (luma - 150) / 40)) * Math.max(0, Math.min(1, (r - b - 30) / 50));
|
|
71
|
+
}
|
|
72
|
+
const near = (x: number, y: number) => Math.hypot(x - lamp.x, y - lamp.y);
|
|
73
|
+
const away = (x: number, y: number) => near(x, y) > LAMP_KEPT && !others.some((o) => Math.hypot(x - o.x, y - o.y) < 90);
|
|
74
|
+
// Flood from the ring round the lamp through paper that is clearly lit (k over a quarter).
|
|
75
|
+
const inside = new Uint8Array(W * H);
|
|
76
|
+
const stack: number[] = [];
|
|
77
|
+
const [cx, cy] = [Math.round(lamp.x), Math.round(lamp.y)];
|
|
78
|
+
for (let y = Math.max(0, cy - SEED_RING); y < Math.min(H, cy + SEED_RING); y++) for (let x = Math.max(0, cx - SEED_RING); x < Math.min(W, cx + SEED_RING); x++) {
|
|
79
|
+
const i = y * W + x;
|
|
80
|
+
if (k[i] > 0.25 && near(x, y) < SEED_RING && away(x, y)) { inside[i] = 1; stack.push(i); }
|
|
81
|
+
}
|
|
82
|
+
while (stack.length) {
|
|
83
|
+
const i = stack.pop()!;
|
|
84
|
+
const x = i % W, y = (i - x) / W;
|
|
85
|
+
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
86
|
+
const u = x + dx, v = y + dy;
|
|
87
|
+
if (u < 0 || v < 0 || u >= W || v >= H) continue;
|
|
88
|
+
const j = v * W + u;
|
|
89
|
+
if (!inside[j] && k[j] > 0.25 && away(u, v)) { inside[j] = 1; stack.push(j); }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Its soft rim too: the faint paper two pixels round what was found; and the hole it leaves wider still, taking
|
|
93
|
+
// the paper's darker cut edge and its shadow.
|
|
94
|
+
let [x0, y0, x1, y1, n] = [W, H, 0, 0, 0];
|
|
95
|
+
const bins = new Float32Array(24);
|
|
96
|
+
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
|
97
|
+
if (!inside[y * W + x]) continue;
|
|
98
|
+
n++;
|
|
99
|
+
[x0, y0, x1, y1] = [Math.min(x0, x), Math.min(y0, y), Math.max(x1, x + 1), Math.max(y1, y + 1)];
|
|
100
|
+
bins[Math.floor(((Math.atan2(y - lamp.y, x - lamp.x) + Math.PI) / (2 * Math.PI)) * 24) % 24]++;
|
|
101
|
+
}
|
|
102
|
+
if (n < MIN_PAPER) return undefined;
|
|
103
|
+
[x0, y0, x1, y1] = [Math.max(0, x0 - RIM), Math.max(0, y0 - RIM), Math.min(W, x1 + RIM), Math.min(H, y1 + RIM)];
|
|
104
|
+
const bw = x1 - x0;
|
|
105
|
+
const alpha = new Float32Array(bw * (y1 - y0));
|
|
106
|
+
const hole = new Uint8Array(bw * (y1 - y0));
|
|
107
|
+
// Distance to the paper (in pixels, by rows then columns: a square reach is near enough).
|
|
108
|
+
const rows = new Uint8Array(bw * (y1 - y0)).fill(255);
|
|
109
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
110
|
+
let d = 255;
|
|
111
|
+
for (let dx = -RIM; dx <= RIM; dx++) if (x + dx >= 0 && x + dx < W && inside[y * W + x + dx]) d = Math.min(d, Math.abs(dx));
|
|
112
|
+
rows[(y - y0) * bw + x - x0] = d;
|
|
113
|
+
}
|
|
114
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
115
|
+
let d = 255;
|
|
116
|
+
for (let dy = -RIM; dy <= RIM; dy++) if (y + dy >= y0 && y + dy < y1) d = Math.min(d, Math.max(Math.abs(dy), rows[(y + dy - y0) * bw + x - x0]));
|
|
117
|
+
if (d > RIM || !away(x, y)) continue;
|
|
118
|
+
const i = (y - y0) * bw + x - x0;
|
|
119
|
+
hole[i] = 1;
|
|
120
|
+
if (d <= 2) alpha[i] = Math.min(1, k[y * W + x] * 1.4);
|
|
121
|
+
}
|
|
122
|
+
// A burst spreads all round the lamp; a beam reaches out one way.
|
|
123
|
+
const burst = [...bins].filter((b) => b > n * 0.015).length >= 12;
|
|
124
|
+
return { box: [x0, y0, x1, y1], alpha, hole, burst, share: n / (W * H) };
|
|
125
|
+
}
|
|
126
|
+
/** The hole the paper leaves reaches this far past it (px). */
|
|
127
|
+
const RIM = 4;
|
|
128
|
+
/** The lamp itself (this far round its point) stays painted. */
|
|
129
|
+
const LAMP_KEPT = 22;
|
|
130
|
+
/** Light paper is looked for from this far round the lamp. */
|
|
131
|
+
const SEED_RING = 130;
|
|
132
|
+
/** Fewer lit pixels than this joined to the lamp is no painted light paper. */
|
|
133
|
+
const MIN_PAPER = 1500;
|
|
134
|
+
|
|
135
|
+
/** A painted beam sends a wave of light out along itself this often (s). */
|
|
136
|
+
export const BEAM_PULSE = 3;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* How a lighthouse's light paper stands at time t. A burst of rays is lifted off and turns a little to and fro,
|
|
140
|
+
* breathing. A painted beam stays where it was painted (lifted, the long hole under it would show the painting
|
|
141
|
+
* smeared) and pulses instead: a wave of light leaves the lamp with a flare and runs out along the beam to its end
|
|
142
|
+
* (`wave`, 0 at the lamp .. 1 at the far end, beyond it gone).
|
|
143
|
+
*/
|
|
144
|
+
export function paperPose(g: Glow, burst: boolean, t: number): { turn: number; scale: number; alpha: number; flare: number; wave: number } {
|
|
145
|
+
const s = (hashSeed(g.key) % 1000) / 1000 * Math.PI * 2;
|
|
146
|
+
if (burst) {
|
|
147
|
+
const breathe = Math.sin(t * 1.3 + s);
|
|
148
|
+
return { turn: 0.12 * Math.sin(t * 0.42 + s), scale: 1 + 0.035 * breathe, alpha: 0.85 + 0.15 * breathe, flare: 0.2 * (1 + breathe), wave: -1 };
|
|
149
|
+
}
|
|
150
|
+
const wave = (((t + (s / (Math.PI * 2)) * BEAM_PULSE) % BEAM_PULSE) / BEAM_PULSE) * 1.4;
|
|
151
|
+
return { turn: 0, scale: 1, alpha: 1, flare: Math.max(0, 1 - wave * 4) ** 2, wave };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The paint under lifted light paper, filled from round it: each pixel of the hole takes the paint met turning
|
|
156
|
+
* round the lamp to either side of the paper, at the same distance from it. Rays and beams come out of the lamp,
|
|
157
|
+
* so this crosses them the short way and carries the clouds' and waves' own texture in, where a blur would leave
|
|
158
|
+
* a soft smear. RGBA for `paper.box`, opaque only in the hole.
|
|
159
|
+
*/
|
|
160
|
+
export function fillUnder(px: Uint8ClampedArray | Uint8Array, W: number, lamp: { x: number; y: number }, paper: Pick<BeamPaper, 'box' | 'hole'>): Uint8ClampedArray {
|
|
161
|
+
const [x0, y0, x1, y1] = paper.box;
|
|
162
|
+
const bw = x1 - x0;
|
|
163
|
+
const H = px.length / 4 / W;
|
|
164
|
+
const out = new Uint8ClampedArray(bw * (y1 - y0) * 4);
|
|
165
|
+
const open = (x: number, y: number) => x >= 0 && y >= 0 && x < W && y < H && !(x >= x0 && y >= y0 && x < x1 && y < y1 && paper.hole[(y - y0) * bw + x - x0]);
|
|
166
|
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
|
|
167
|
+
if (!paper.hole[(y - y0) * bw + x - x0]) continue;
|
|
168
|
+
const r = Math.max(1, Math.hypot(x - lamp.x, y - lamp.y));
|
|
169
|
+
const a0 = Math.atan2(y - lamp.y, x - lamp.x);
|
|
170
|
+
const sides: Array<[number, number]> = [];
|
|
171
|
+
for (const dir of [1, -1]) {
|
|
172
|
+
for (let s = 1; s <= Math.min(600, r * 0.6); s++) {
|
|
173
|
+
const a = a0 + (dir * s) / r;
|
|
174
|
+
const [u, v] = [Math.round(lamp.x + r * Math.cos(a)), Math.round(lamp.y + r * Math.sin(a))];
|
|
175
|
+
if (u < 0 || v < 0 || u >= W || v >= H) break;
|
|
176
|
+
if (open(u, v)) { sides.push([(v * W + u) * 4, s]); break; }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const o = ((y - y0) * bw + x - x0) * 4;
|
|
180
|
+
if (!sides.length) { out.set([px[(y * W + x) * 4], px[(y * W + x) * 4 + 1], px[(y * W + x) * 4 + 2], 255], o); continue; }
|
|
181
|
+
// Nearer side counts more, so the two meet smoothly in the middle.
|
|
182
|
+
let [r0, g0, b0, wt] = [0, 0, 0, 0];
|
|
183
|
+
for (const [i, s] of sides) { const k = 1 / s; r0 += px[i] * k; g0 += px[i + 1] * k; b0 += px[i + 2] * k; wt += k; }
|
|
184
|
+
out.set([r0 / wt, g0 / wt, b0 / wt, 255], o);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Lip-sync is timed against the playing audio, independent of the simulation clock.
|
|
2
|
+
export type Mouth = 'X' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H';
|
|
3
|
+
export type MouthCue = { start: number; end: number; value: Mouth };
|
|
4
|
+
export type LipSync = { cues: MouthCue[]; envelope?: number[]; step?: number };
|
|
5
|
+
export type Box = [number, number, number, number]; // x, y, width, height within a sprite cell
|
|
6
|
+
export type FacePose = { mouth: Box; eyes: Box[]; lips?: Box; suggestedLips?: Box; lipConfidence?: number; sourceOpen?: boolean; atlas?: string };
|
|
7
|
+
export type Articulation = { mouth: Mouth; previous: Mouth; mix: number; energy: number };
|
|
8
|
+
export type FaceState = Articulation & { blink: number; brow: number; jaw: number; round: number };
|
|
9
|
+
export type FaceDefinition = {
|
|
10
|
+
image: string;
|
|
11
|
+
/** Source rectangles for X, A..H; the generated heads need individual registration. */
|
|
12
|
+
mouths: Record<Mouth, Box>;
|
|
13
|
+
sheets: Record<string, Array<FacePose | null>>;
|
|
14
|
+
atlases?: Record<string, Record<Mouth, Box>>;
|
|
15
|
+
/** Neutral lip corners in each atlas patch, normalized to that patch. */
|
|
16
|
+
anchors?: Record<string, Box>;
|
|
17
|
+
frames?: Record<string, Record<Mouth, Box>>;
|
|
18
|
+
imagePose?: FacePose;
|
|
19
|
+
rigPose?: FacePose;
|
|
20
|
+
rigHash?: string;
|
|
21
|
+
imageHash?: string;
|
|
22
|
+
seed?: number;
|
|
23
|
+
sourceVersions?: Record<string, string>;
|
|
24
|
+
sheetHashes?: Record<string, string>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const MOUTHS: Mouth[] = ['X', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
|
|
28
|
+
|
|
29
|
+
/** Gaps, missing sound and completion use the relaxed mouth. Crossfade over the first 35 ms. */
|
|
30
|
+
export function mouthAt(sync: LipSync | undefined, seconds: number | undefined): { mouth: Mouth; previous: Mouth; mix: number; energy: number } {
|
|
31
|
+
const rest = { mouth: 'X' as Mouth, previous: 'X' as Mouth, mix: 1, energy: 0 };
|
|
32
|
+
if (!sync || seconds === undefined || seconds < 0 || !Number.isFinite(seconds)) return rest;
|
|
33
|
+
let low = 0, high = sync.cues.length;
|
|
34
|
+
while (low < high) {
|
|
35
|
+
const mid = (low + high) >>> 1;
|
|
36
|
+
if (sync.cues[mid].start <= seconds) low = mid + 1;
|
|
37
|
+
else high = mid;
|
|
38
|
+
}
|
|
39
|
+
const index = low - 1, cue = sync.cues[index];
|
|
40
|
+
if (!cue || seconds >= cue.end) return rest;
|
|
41
|
+
const before = sync.cues[index - 1];
|
|
42
|
+
const previous = before && Math.abs(before.end - cue.start) < 0.002 ? before.value : 'X';
|
|
43
|
+
const mix = Math.min(1, (seconds - cue.start) / Math.min(0.035, (cue.end - cue.start) / 2));
|
|
44
|
+
const energy = sync.envelope?.[Math.floor(seconds / (sync.step ?? 0.04))] ?? 0;
|
|
45
|
+
return { mouth: cue.value, previous, mix, energy };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Two short eyelid movements with a variable interval, including while listening. */
|
|
49
|
+
export function blinkAt(seconds: number): number {
|
|
50
|
+
const cycle = seconds % 5.3;
|
|
51
|
+
const blink = (at: number) => Math.max(0, 1 - Math.abs(cycle - at) / 0.095);
|
|
52
|
+
return Math.max(blink(1.8), blink(5.0) * 0.85);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const mouthOpening: Record<Mouth, number> = { X: 0, A: 0, B: 0.12, C: 0.5, D: 0.9, E: 0.55, F: 0.28, G: 0.05, H: 0.4 };
|
|
56
|
+
export const mouthRoundness = (mouth: Mouth) => mouth === 'E' || mouth === 'F' ? 1 : 0;
|
|
57
|
+
|
|
58
|
+
/** One performance state, consumed by the painted and native 3D renderers. */
|
|
59
|
+
export function faceAt(articulation: Articulation, seconds: number, seed = 0): FaceState {
|
|
60
|
+
return { ...articulation, blink: blinkAt(seconds + seed), brow: Math.max(0, Math.min(1, articulation.energy)) * 0.35,
|
|
61
|
+
jaw: mouthOpening[articulation.previous] * (1 - articulation.mix) + mouthOpening[articulation.mouth] * articulation.mix,
|
|
62
|
+
round: mouthRoundness(articulation.previous) * (1 - articulation.mix) + mouthRoundness(articulation.mouth) * articulation.mix };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Rigged paintings keep their face on the torso as it rises and falls. */
|
|
66
|
+
export function offsetFace(pose: FacePose, dx: number, dy: number): FacePose {
|
|
67
|
+
const offset = ([x, y, w, h]: Box): Box => [x + dx, y + dy, w, h];
|
|
68
|
+
return { ...pose, mouth: offset(pose.mouth), eyes: pose.eyes.map(offset), ...(pose.lips ? { lips: offset(pose.lips) } : {}), ...(pose.suggestedLips ? {suggestedLips:offset(pose.suggestedLips)} : {}) };
|
|
69
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// living: how the painted sea, waterfalls, animals and clouds move, as plain numbers for the page (app/web/living.ts)
|
|
2
|
+
// to draw with. Pure, so it is tested without a canvas.
|
|
3
|
+
|
|
4
|
+
/** How stormy a place is, 0 calm .. 1 a gale: from the weather's fall and wind. A calm place still swells a little. */
|
|
5
|
+
export function stormOf(w?: { fall: string; strength: number; wind: number }): number {
|
|
6
|
+
if (!w) return 0;
|
|
7
|
+
const wet = w.fall === 'none' ? 0 : Math.max(0, (w.strength - 0.3) / 0.6);
|
|
8
|
+
return Math.min(1, 0.55 * wet + 0.6 * Math.abs(w.wind));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The swell of still water (the sea, a lake) at a point of its box: u is the row's place down the water (0 far,
|
|
13
|
+
* 1 near). Waves roll toward the viewer (their phase runs down the rows, wider apart near, as in perspective) and
|
|
14
|
+
* slant a little across; near rows heave more. Returns where to read the texture from, relative to the point,
|
|
15
|
+
* and how high on a crest it is (-1 trough .. 1 crest).
|
|
16
|
+
*/
|
|
17
|
+
export function swellAt(x: number, u: number, t: number, storm: number): { dx: number; dy: number; crest: number } {
|
|
18
|
+
const wave = 5.5 * Math.log(1 + 6 * u) - 1.5 * t * (1 + 0.5 * storm) + x * 0.006 + 0.8 * Math.sin(x * 0.0021 + t * 0.23);
|
|
19
|
+
const crest = Math.sin(wave);
|
|
20
|
+
const big = 0.7 + 1.3 * storm;
|
|
21
|
+
const dy = crest * (0.6 + 7.5 * u) * big;
|
|
22
|
+
const dx = (Math.sin(t * 0.9 + u * 40 + x * 0.003) * (1.2 + 5 * u) + Math.sin(t * 0.37 + u * 11) * (1 + 3 * u)) * (0.8 + 0.8 * storm);
|
|
23
|
+
return { dx, dy, crest };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Points along the water's edge where it meets land, rock or a hull (not the horizon above it, not the frame's
|
|
28
|
+
* edge), every `step` px: where foam churns. `alpha` is the water's soft mask, w x h, 0..255.
|
|
29
|
+
*/
|
|
30
|
+
export function shorePoints(alpha: Uint8Array | Uint8ClampedArray, w: number, h: number, step = 10, stride = 1): Array<{ x: number; y: number; nx: number; ny: number }> {
|
|
31
|
+
const at = (x: number, y: number) => (x < 0 || y < 0 || x >= w || y >= h ? -1 : alpha[(y * w + x) * stride + (stride - 1)]);
|
|
32
|
+
const out: Array<{ x: number; y: number; nx: number; ny: number }> = [];
|
|
33
|
+
const R = 8;
|
|
34
|
+
for (let y = R; y < h - R; y += 3) {
|
|
35
|
+
for (let x = R; x < w - R; x += 3) {
|
|
36
|
+
if (at(x, y) < 160) continue;
|
|
37
|
+
// The dry side: the neighbour R px away that is not water.
|
|
38
|
+
let [nx, ny] = [0, 0];
|
|
39
|
+
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) if (at(x + dx * R, y + dy * R) >= 0 && at(x + dx * R, y + dy * R) < 40) [nx, ny] = [nx + dx, ny + dy];
|
|
40
|
+
if (!nx && !ny) continue;
|
|
41
|
+
// Land only above it, in the far third: that is the horizon or a far bank, no surf there.
|
|
42
|
+
if (ny < 0 && !nx && y < h * 0.35) continue;
|
|
43
|
+
if (out.some((p) => Math.abs(p.x - x) < step && Math.abs(p.y - y) < step)) continue;
|
|
44
|
+
const len = Math.hypot(nx, ny);
|
|
45
|
+
out.push({ x, y, nx: nx / len, ny: ny / len });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A painted animal's swim (or flight) at time t: its offset, which way it faces now (scaleX, through 0 as it turns), its tilt, and a leap. */
|
|
52
|
+
export type Swim = { dx: number; dy: number; scaleX: number; rot: number; leap: number; sinceLeap: number };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Swims back and forth about where it was painted, turning (a paper card flipping) at each end, bobbing, and now
|
|
56
|
+
* and then (in the water) leaping out in an arc: `leap` is 0 in the water, 0..1 through the leap. `facing`: the way
|
|
57
|
+
* it was painted (1 right). `k`: which animal, for its own phase. `width`: its size in px, for how far it goes.
|
|
58
|
+
*/
|
|
59
|
+
export function swimAt(t: number, k: number, facing: 1 | -1, width: number, medium: 'water' | 'air'): Swim {
|
|
60
|
+
const reach = Math.max(14, Math.min(70, width * (medium === 'air' ? 1.2 : 0.55)));
|
|
61
|
+
const w = (medium === 'air' ? 0.32 : 0.22) + 0.05 * ((k * 7) % 5) / 5;
|
|
62
|
+
const ph = t * w + k * 2.3;
|
|
63
|
+
const dx = reach * Math.sin(ph);
|
|
64
|
+
const v = Math.cos(ph);
|
|
65
|
+
const scaleX = facing * Math.max(-1, Math.min(1, 3 * v));
|
|
66
|
+
const dy = (medium === 'air' ? 9 : 2.5) * Math.sin(t * 1.3 + k * 1.7);
|
|
67
|
+
let rot = 0.05 * Math.sin(t * 2.1 + k);
|
|
68
|
+
// A leap every 11..17 s, 1.1 s long, at its own time.
|
|
69
|
+
let [leap, sinceLeap] = [0, Infinity];
|
|
70
|
+
if (medium === 'water') {
|
|
71
|
+
const period = 11 + ((k * 5) % 7);
|
|
72
|
+
const at = (t + k * 3.7) % period;
|
|
73
|
+
sinceLeap = at;
|
|
74
|
+
if (at < 1.1) {
|
|
75
|
+
leap = at / 1.1;
|
|
76
|
+
rot += Math.sign(v || 1) * (leap - 0.5) * 0.9;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { dx, dy, scaleX, rot, leap, sinceLeap };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** How high a leap carries, px, at 0..1 through it. */
|
|
83
|
+
export const leapHeight = (leap: number, height: number) => Math.sin(Math.PI * leap) * Math.max(30, height * 1.6);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The hover outline as smooth closed lines along the edge of a cut-out's alpha: marching squares, then Chaikin.
|
|
2
|
+
export type Loop = Array<[number, number]>;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The closed contours of where `alpha` (w x h, one byte per pixel) is at least `threshold`, in pixel units (pixel
|
|
6
|
+
* centres at +0.5). Specks smaller than `minArea` px and holes smaller than `minHole` px are dropped, so a cut-out's
|
|
7
|
+
* speckled inside (rain, a halo of light) gives one clean line round it; the rest are smoothed by `smooth` rounds of
|
|
8
|
+
* corner cutting. Outer loops run clockwise on screen (positive area), holes the other way.
|
|
9
|
+
*/
|
|
10
|
+
export function outlineLoops(alpha: ArrayLike<number>, w: number, h: number, { threshold = 128, minArea = 60, minHole = 60, smooth = 3 }: { threshold?: number; minArea?: number; minHole?: number; smooth?: number } = {}): Loop[] {
|
|
11
|
+
const at = (x: number, y: number) => (x < 0 || y < 0 || x >= w || y >= h ? 0 : alpha[y * w + x]);
|
|
12
|
+
const inside = (x: number, y: number) => at(x, y) >= threshold;
|
|
13
|
+
// Where the edge crosses between two pixel centres, by their values.
|
|
14
|
+
const cross = (a: number, b: number) => {
|
|
15
|
+
const t = (threshold - a) / (b - a || 1);
|
|
16
|
+
return Math.max(0.05, Math.min(0.95, t));
|
|
17
|
+
};
|
|
18
|
+
// Segments between edge points keyed by "x,y" of the point; each cell of 2x2 pixel centres gives 0-2 of them.
|
|
19
|
+
// Points sit on cell sides: H (between (x,y) and (x+1,y)) or V (between (x,y) and (x,y+1)).
|
|
20
|
+
const next = new Map<string, string>();
|
|
21
|
+
const pos = new Map<string, [number, number]>();
|
|
22
|
+
const point = (kind: 'h' | 'v', x: number, y: number): string => {
|
|
23
|
+
const key = `${kind}${x},${y}`;
|
|
24
|
+
if (!pos.has(key)) {
|
|
25
|
+
pos.set(key, kind === 'h' ? [x + 0.5 + cross(at(x, y), at(x + 1, y)), y + 0.5] : [x + 0.5, y + 0.5 + cross(at(x, y), at(x, y + 1))]);
|
|
26
|
+
}
|
|
27
|
+
return key;
|
|
28
|
+
};
|
|
29
|
+
// Oriented so the inside is always on the same side: loops chain head to tail.
|
|
30
|
+
const link = (a: string, b: string) => next.set(a, b);
|
|
31
|
+
for (let y = -1; y < h; y++) {
|
|
32
|
+
for (let x = -1; x < w; x++) {
|
|
33
|
+
const tl = inside(x, y), tr = inside(x + 1, y), br = inside(x + 1, y + 1), bl = inside(x, y + 1);
|
|
34
|
+
const c = (tl ? 8 : 0) | (tr ? 4 : 0) | (br ? 2 : 0) | (bl ? 1 : 0);
|
|
35
|
+
if (c === 0 || c === 15) continue;
|
|
36
|
+
const top = () => point('h', x, y), right = () => point('v', x + 1, y), bottom = () => point('h', x, y + 1), left = () => point('v', x, y);
|
|
37
|
+
switch (c) {
|
|
38
|
+
case 1: link(left(), bottom()); break;
|
|
39
|
+
case 2: link(bottom(), right()); break;
|
|
40
|
+
case 3: link(left(), right()); break;
|
|
41
|
+
case 4: link(right(), top()); break;
|
|
42
|
+
case 5: link(left(), top()); link(right(), bottom()); break;
|
|
43
|
+
case 6: link(bottom(), top()); break;
|
|
44
|
+
case 7: link(left(), top()); break;
|
|
45
|
+
case 8: link(top(), left()); break;
|
|
46
|
+
case 9: link(top(), bottom()); break;
|
|
47
|
+
case 10: link(top(), right()); link(bottom(), left()); break;
|
|
48
|
+
case 11: link(top(), right()); break;
|
|
49
|
+
case 12: link(right(), left()); break;
|
|
50
|
+
case 13: link(right(), bottom()); break;
|
|
51
|
+
case 14: link(bottom(), left()); break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const loops: Loop[] = [];
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
for (const start of next.keys()) {
|
|
58
|
+
if (seen.has(start)) continue;
|
|
59
|
+
const loop: Loop = [];
|
|
60
|
+
let k: string | undefined = start;
|
|
61
|
+
while (k && !seen.has(k)) {
|
|
62
|
+
seen.add(k);
|
|
63
|
+
loop.push(pos.get(k)!);
|
|
64
|
+
k = next.get(k);
|
|
65
|
+
}
|
|
66
|
+
const a = loop.length >= 3 ? area(loop) : 0;
|
|
67
|
+
if (a >= minArea || -a >= minHole) loops.push(chaikin(loop, smooth));
|
|
68
|
+
}
|
|
69
|
+
return loops;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Signed area (shoelace). */
|
|
73
|
+
export function area(loop: Loop): number {
|
|
74
|
+
let s = 0;
|
|
75
|
+
for (let i = 0; i < loop.length; i++) {
|
|
76
|
+
const [x0, y0] = loop[i];
|
|
77
|
+
const [x1, y1] = loop[(i + 1) % loop.length];
|
|
78
|
+
s += x0 * y1 - x1 * y0;
|
|
79
|
+
}
|
|
80
|
+
return s / 2;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Corner cutting on a closed loop: each round keeps 1/4 and 3/4 along every side. */
|
|
84
|
+
export function chaikin(loop: Loop, rounds: number): Loop {
|
|
85
|
+
let pts = loop;
|
|
86
|
+
for (let r = 0; r < rounds; r++) {
|
|
87
|
+
const out: Loop = [];
|
|
88
|
+
for (let i = 0; i < pts.length; i++) {
|
|
89
|
+
const [x0, y0] = pts[i];
|
|
90
|
+
const [x1, y1] = pts[(i + 1) % pts.length];
|
|
91
|
+
out.push([0.75 * x0 + 0.25 * x1, 0.75 * y0 + 0.25 * y1], [0.25 * x0 + 0.75 * x1, 0.25 * y0 + 0.75 * y1]);
|
|
92
|
+
}
|
|
93
|
+
pts = out;
|
|
94
|
+
}
|
|
95
|
+
return pts;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The alpha of a cut-out made ready for its outline: a box blur of radius `r` (twice, near a gaussian) rounds the
|
|
100
|
+
* jagged steps of a mask and swallows hairline gaps, so the line follows the shape and not its pixels.
|
|
101
|
+
*/
|
|
102
|
+
export function softAlpha(alpha: ArrayLike<number>, w: number, h: number, r = 2): Uint8Array {
|
|
103
|
+
let a = Float32Array.from(alpha as ArrayLike<number>);
|
|
104
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
105
|
+
const tmp = new Float32Array(w * h);
|
|
106
|
+
for (let y = 0; y < h; y++) {
|
|
107
|
+
let s = 0;
|
|
108
|
+
for (let x = -r; x <= r; x++) s += x >= 0 && x < w ? a[y * w + x] : 0;
|
|
109
|
+
for (let x = 0; x < w; x++) {
|
|
110
|
+
tmp[y * w + x] = s / (2 * r + 1);
|
|
111
|
+
const add = x + r + 1, sub = x - r;
|
|
112
|
+
if (add < w) s += a[y * w + add];
|
|
113
|
+
if (sub >= 0) s -= a[y * w + sub];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const out = new Float32Array(w * h);
|
|
117
|
+
for (let x = 0; x < w; x++) {
|
|
118
|
+
let s = 0;
|
|
119
|
+
for (let y = -r; y <= r; y++) s += y >= 0 && y < h ? tmp[y * w + x] : 0;
|
|
120
|
+
for (let y = 0; y < h; y++) {
|
|
121
|
+
out[y * w + x] = s / (2 * r + 1);
|
|
122
|
+
const add = y + r + 1, sub = y - r;
|
|
123
|
+
if (add < h) s += tmp[add * w + x];
|
|
124
|
+
if (sub >= 0) s -= tmp[sub * w + x];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
a = out;
|
|
128
|
+
}
|
|
129
|
+
return Uint8Array.from(a, (v) => Math.round(v));
|
|
130
|
+
}
|