diff --git a/client/src/dolls/README.md b/client/src/dolls/README.md index e2bb7c62..8e43c74a 100644 --- a/client/src/dolls/README.md +++ b/client/src/dolls/README.md @@ -20,7 +20,14 @@ const preview = await makeDoll(scene, x, y, 'draft', await flatDoll(appearance), a job painted. `isFigure` tells the two apart. - **`makeFigure(scene, x, y, id, figure, height?)`** draws a `SpriteFigure` once its atlas has loaded. Its origin is on the ground under the middle of its head, and `height` is how many - pixels the standing figure is. + pixels the standing figure is. **`wear(figure)`** draws another figure the same size once its + atlas has loaded. +- **The player with bionics** (`bionics.ts`): `playerFigureId(body)` is the player's figure for + what a `Body` has fitted, `player` with nothing and `player_legs_datajack_eye` with everything. + The eye is painted where it shows, so either socket is the same figure. `PlayerBody` keeps the + last body and says when its figure changes: `world/assets.ts` feeds one from `player_state`, + `dollFor('player')` picks the player's figure by it, and the side view's player wears the new + figure when a bionic is fitted. - **`SpriteFigure.setPose(pose, facing, phase, options)`** is `PaperDoll`'s: `pose` is the sim's `Pose`, or the player's `'jumping'` or `'landing'`, and `phase` how far through its cycle it is, any number. Each pose plays its painted frames by phase (`sprite.ts`): diff --git a/client/src/dolls/assets.test.ts b/client/src/dolls/assets.test.ts index 9b5b6d83..7e5585ca 100644 --- a/client/src/dolls/assets.test.ts +++ b/client/src/dolls/assets.test.ts @@ -22,6 +22,13 @@ describe('the committed figures', () => { 'mirela', 'odalys', 'player', + 'player_datajack', + 'player_datajack_eye', + 'player_eye', + 'player_legs', + 'player_legs_datajack', + 'player_legs_datajack_eye', + 'player_legs_eye', 'priya', 'raymond', 'tobias', diff --git a/client/src/dolls/bionics.test.ts b/client/src/dolls/bionics.test.ts new file mode 100644 index 00000000..6c62b17b --- /dev/null +++ b/client/src/dolls/bionics.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Body } from '../net/protocol'; +import { dolls } from './assets'; +import { FITTED, isPlayerFigure, PlayerBody, playerFigureId } from './bionics'; + +const whole: Body = { + legs: 'present', + vertebra: 'present', + left_eye: 'present', + right_eye: 'present', + left_kidney: 'present', + right_kidney: 'present', +}; + +describe('the player’s figure', () => { + it.each<[Partial, string]>([ + [{}, 'player'], + [{ left_kidney: 'sold', right_eye: 'sold' }, 'player'], + [{ legs: 'hydraulic_legs' }, 'player_legs'], + [{ vertebra: 'datajack' }, 'player_datajack'], + [{ left_eye: 'infrared_eye' }, 'player_eye'], + [{ right_eye: 'infrared_eye', legs: 'hydraulic_legs' }, 'player_legs_eye'], + [{ right_eye: 'infrared_eye', vertebra: 'datajack', legs: 'hydraulic_legs' }, 'player_legs_datajack_eye'], + ])('with %o fitted is %s', (fitted, id) => { + expect(playerFigureId({ ...whole, ...fitted })).toBe(id); + }); + + it('is the whole player before the body is known', () => { + expect(playerFigureId()).toBe('player'); + }); + + it('is committed for every combination of bionics, with the same frames and poses', () => { + const combinations = FITTED.reduce((all, part) => [...all, ...all.map((c) => [...c, part])], [[]]); + for (const parts of combinations) { + const body: Body = { ...whole }; + if (parts.includes('hydraulic_legs')) body.legs = 'hydraulic_legs'; + if (parts.includes('datajack')) body.vertebra = 'datajack'; + if (parts.includes('infrared_eye')) body.left_eye = 'infrared_eye'; + const figure = dolls[playerFigureId(body)]; + expect(figure, playerFigureId(body)).toBeDefined(); + expect(Object.keys(figure.frames).sort()).toEqual(Object.keys(dolls.player.frames).sort()); + expect(figure.poses).toEqual(dolls.player.poses); + expect(figure.height).toBe(dolls.player.height); + } + }); + + it('tells the player’s figures from the residents’', () => { + expect(isPlayerFigure('player')).toBe(true); + expect(isPlayerFigure('player_legs_eye')).toBe(true); + expect(isPlayerFigure('priya')).toBe(false); + expect(isPlayerFigure(undefined)).toBe(false); + }); +}); + +describe('PlayerBody', () => { + it('says when the figure changes, and only then', () => { + const body = new PlayerBody(); + const heard = vi.fn(); + const stop = body.onChange(heard); + + body.set(whole); + body.set({ ...whole, left_kidney: 'sold' }); + body.set({ ...whole, legs: 'hydraulic_legs' }); + body.set({ ...whole, legs: 'hydraulic_legs', right_eye: 'infrared_eye' }); + stop(); + body.set(whole); + + expect(heard.mock.calls).toEqual([['player_legs'], ['player_legs_eye']]); + expect(body.figure).toBe('player'); + }); +}); diff --git a/client/src/dolls/bionics.ts b/client/src/dolls/bionics.ts new file mode 100644 index 00000000..c1e98bf9 --- /dev/null +++ b/client/src/dolls/bionics.ts @@ -0,0 +1,41 @@ +import type { Bionic, Body } from '../net/protocol'; + +export const PLAYER_FIGURE = 'player'; +/** In the order a figure's id names them. */ +export const FITTED: readonly Bionic[] = ['hydraulic_legs', 'datajack', 'infrared_eye']; +const NAMES: Record = { hydraulic_legs: 'legs', datajack: 'datajack', infrared_eye: 'eye' }; + +/** The bionics a body has fitted, in `FITTED`'s order. */ +export function fitted(body?: Body): Bionic[] { + const parts = new Set(body ? Object.values(body) : []); + return FITTED.filter((bionic) => parts.has(bionic)); +} + +/** The player's figure for a body: `player` with nothing fitted, `player_legs_eye` and so on. */ +export const playerFigureId = (body?: Body): string => + [PLAYER_FIGURE, ...fitted(body).map((bionic) => NAMES[bionic])].join('_'); + +export const isPlayerFigure = (id?: string): boolean => + id === PLAYER_FIGURE || !!id?.startsWith(`${PLAYER_FIGURE}_`); + +/** The player's body as last heard, and who to tell when what's fitted changes. */ +export class PlayerBody { + private body?: Body; + private readonly listeners = new Set<(figure: string) => void>(); + + get figure(): string { + return playerFigureId(this.body); + } + + set(body: Body): void { + const before = this.figure; + this.body = body; + if (this.figure !== before) this.listeners.forEach((listener) => listener(this.figure)); + } + + /** Calls `listener` with the new figure's id whenever it changes. */ + onChange(listener: (figure: string) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} diff --git a/client/src/dolls/phaser.fixture.ts b/client/src/dolls/phaser.fixture.ts new file mode 100644 index 00000000..894b34e1 --- /dev/null +++ b/client/src/dolls/phaser.fixture.ts @@ -0,0 +1,113 @@ +// What a SpriteFigure uses of Phaser, for tests in Node: vi.mock('phaser', () => import('./phaser.fixture')). +import type Phaser from 'phaser'; + +export class FakeImage { + key: string; + frame?: string; + flipX = false; + tint = -1; + + constructor(_scene: unknown, _x: number, _y: number, key: string, frame?: string) { + this.key = key; + this.frame = frame; + } + + setTexture(key: string, frame?: string): this { + this.key = key; + this.frame = frame; + return this; + } + + setFlipX(flip: boolean): this { + this.flipX = flip; + return this; + } + + setTint(tint: number): this { + this.tint = tint; + return this; + } + + setOrigin(): this { + return this; + } + + setPosition(): this { + return this; + } + + setAngle(): this { + return this; + } + + setScale(): this { + return this; + } +} + +class FakeContainer { + scene?: unknown; + readonly list: FakeImage[] = []; + scale = 1; + private readonly destroyed: (() => void)[] = []; + + constructor(scene: unknown) { + this.scene = scene; + } + + add(child: FakeImage): this { + this.list.push(child); + return this; + } + + setScale(scale: number): this { + this.scale = scale; + return this; + } + + setPosition(): this { + return this; + } + + once(_event: string, listener: () => void): this { + this.destroyed.push(listener); + return this; + } + + destroy(): void { + this.destroyed.forEach((listener) => listener()); + this.scene = undefined; + } +} + +export default { + GameObjects: { Container: FakeContainer, Image: FakeImage, Events: { DESTROY: 'destroy' } }, + Textures: { FilterMode: { LINEAR: 0 } }, +}; + +/** A scene whose textures are the atlases `loaded` and any added since. */ +export function fakeScene(loaded: string[] = []): Phaser.Scene { + const keys = new Set(loaded); + const textures = { + exists: (key: string) => keys.has(key), + addImage: (key: string) => (keys.add(key), { setFilter: () => undefined, add: () => undefined }), + }; + return { textures } as unknown as Phaser.Scene; +} + +/** The image a figure draws. */ +export const drawnImage = (figure: object): FakeImage => (figure as { list: FakeImage[] }).list[0]; + +/** Browser images whose loads the test finishes, in any order, by calling `onload`. */ +export function loadingImages(): { onload: () => void }[] { + const started: { onload: () => void }[] = []; + class LoadingImage { + onload = () => undefined; + onerror = () => undefined; + set src(_url: string) { + started.push(this); + } + } + globalThis.Image = LoadingImage as unknown as typeof Image; + return started; +} diff --git a/client/src/dolls/sprite-figure.test.ts b/client/src/dolls/sprite-figure.test.ts new file mode 100644 index 00000000..23f75310 --- /dev/null +++ b/client/src/dolls/sprite-figure.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; +import { dolls } from './assets'; +import { drawnImage, fakeScene, loadingImages } from './phaser.fixture'; +import { shown } from './sprite'; +import { SpriteFigure } from './sprite-figure'; + +vi.mock('phaser', () => import('./phaser.fixture')); + +const atlases = Object.keys(dolls).map((id) => `figure-${id}`); + +describe('wearing another figure', () => { + it('draws it the same size, in the pose it was in, once its atlas has loaded', async () => { + const figure = new SpriteFigure(fakeScene(atlases), 0, 0, dolls.player, 'figure-player', 245); + figure.setPose('walking', 'west', 0.3); + + await figure.wear(dolls.player_legs); + + expect(figure.figure).toBe(dolls.player_legs); + const image = drawnImage(figure); + expect(image.key).toBe('figure-player_legs'); + expect(image.frame).toBe(shown(dolls.player_legs, 'walking', 'west', 0.3).frame); + expect(image.flipX).toBe(true); + expect((figure as unknown as { scale: number }).scale).toBeCloseTo(245 / dolls.player_legs.height); + }); + + it('wears the last of several asked for while their atlases load, whichever loads last', async () => { + const loads = loadingImages(); + const figure = new SpriteFigure(fakeScene(['figure-player']), 0, 0, dolls.player, 'figure-player'); + + const legs = figure.wear(dolls.player_legs); + const eye = figure.wear(dolls.player_eye); + loads[1].onload(); + await eye; + loads[0].onload(); + await legs; + + expect(figure.figure).toBe(dolls.player_eye); + expect(drawnImage(figure).key).toBe('figure-player_eye'); + }); + + it('keeps its figure if it was destroyed while the atlas loaded', async () => { + const loads = loadingImages(); + const scene = fakeScene(['figure-player']); + const figure = new SpriteFigure(scene, 0, 0, dolls.player, 'figure-player'); + + const legs = figure.wear(dolls.player_legs); + figure.destroy(); + loads[0].onload(); + await legs; + + expect(figure.figure).toBe(dolls.player); + }); +}); diff --git a/client/src/dolls/sprite-figure.ts b/client/src/dolls/sprite-figure.ts index a4c57507..b98cef67 100644 --- a/client/src/dolls/sprite-figure.ts +++ b/client/src/dolls/sprite-figure.ts @@ -27,12 +27,13 @@ export function addFigureAtlas(scene: Phaser.Scene, key: string, figure: FigureM /** A resident painted whole, its origin on the ground under the middle of its head. `setPose` * is `PaperDoll`'s: each pose shows its painted frames by phase, mirrored to face west. */ export class SpriteFigure extends Phaser.GameObjects.Container { - readonly figure: FigureManifest; + private worn: FigureManifest; private readonly image: Phaser.GameObjects.Image; private key: string; private size = 1; private posedAs?: Parameters; private drawn?: Shown; + private asked?: FigureManifest; constructor( scene: Phaser.Scene, @@ -43,7 +44,7 @@ export class SpriteFigure extends Phaser.GameObjects.Container { height = figure.height, ) { super(scene, x, y); - this.figure = figure; + this.worn = figure; this.key = key; this.image = new Phaser.GameObjects.Image(scene, 0, 0, key, figure.poses.stand[0]); this.add(this.image); @@ -51,6 +52,10 @@ export class SpriteFigure extends Phaser.GameObjects.Container { this.setPose('standing', 'east'); } + get figure(): FigureManifest { + return this.worn; + } + /** Scales the figure so it stands `height` pixels tall. */ setDollHeight(height: number): this { this.size = height / this.figure.height; @@ -66,6 +71,17 @@ export class SpriteFigure extends Phaser.GameObjects.Container { return this; } + /** Draws another figure the same size once its atlas has loaded; the last one asked for wins. */ + async wear(figure: FigureManifest, key = `figure-${figure.id}`): Promise { + this.asked = figure; + await addFigureAtlas(this.scene, key, figure); + if (this.asked !== figure || !this.scene) return; + const height = this.size * this.worn.height; + this.worn = figure; + this.setDollHeight(height); + this.paint(key); + } + setPose(pose: DollPose, facing: Facing, phase = 0, options: PoseOptions = {}): this { this.posedAs = [pose, facing, phase, options]; return this.draw(shown(this.figure, pose, facing, phase, options)); diff --git a/client/src/side/poses.ts b/client/src/side/poses.ts index 986d4257..49038f98 100644 --- a/client/src/side/poses.ts +++ b/client/src/side/poses.ts @@ -25,7 +25,7 @@ const AS: Record = { /** Allocates nothing once each step of a pose has been drawn. */ export class PoseCache { - private readonly figure: FigureManifest; + readonly figure: FigureManifest; private readonly shown: (Shown | undefined)[] = []; private readonly options: PoseOptions = {}; diff --git a/client/src/side/side-doll.test.ts b/client/src/side/side-doll.test.ts new file mode 100644 index 00000000..aefc2507 --- /dev/null +++ b/client/src/side/side-doll.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { drawnImage, fakeScene } from '../dolls/phaser.fixture'; +import type { Body } from '../net'; +import { dolls, playerBody } from '../world/assets'; +import { SideDoll } from './side-doll'; + +vi.mock('phaser', () => import('../dolls/phaser.fixture')); + +const whole: Body = { + legs: 'present', + vertebra: 'present', + left_eye: 'present', + right_eye: 'present', + left_kidney: 'present', + right_kidney: 'present', +}; +const scene = () => fakeScene(Object.keys(dolls).map((id) => `figure-${id}`)); + +afterEach(() => playerBody.set(whole)); + +describe('the side view’s player', () => { + it('wears the figure for each bionic fitted', async () => { + const doll = new SideDoll(scene(), dolls.player, 'figure-player'); + + playerBody.set({ ...whole, legs: 'hydraulic_legs' }); + await vi.waitFor(() => expect(doll.figure).toBe(dolls.player_legs)); + playerBody.set({ ...whole, legs: 'hydraulic_legs', left_eye: 'infrared_eye' }); + + await vi.waitFor(() => expect(doll.figure).toBe(dolls.player_legs_eye)); + expect(drawnImage(doll).key).toBe('figure-player_legs_eye'); + }); + + it('catches up with a bionic fitted while its first atlas loaded', async () => { + const picked = dolls.player; + playerBody.set({ ...whole, vertebra: 'datajack' }); + + const doll = new SideDoll(scene(), picked, 'figure-player'); + + await vi.waitFor(() => expect(doll.figure).toBe(dolls.player_datajack)); + }); + + it('stops following once it’s gone', async () => { + const doll = new SideDoll(scene(), dolls.player, 'figure-player'); + doll.destroy(); + + playerBody.set({ ...whole, legs: 'hydraulic_legs' }); + await Promise.resolve(); + + expect(doll.figure).toBe(dolls.player); + }); + + it('leaves a resident’s figure alone', async () => { + const doll = new SideDoll(scene(), dolls.kofi, 'figure-kofi'); + + playerBody.set({ ...whole, legs: 'hydraulic_legs' }); + await Promise.resolve(); + + expect(doll.figure).toBe(dolls.kofi); + }); +}); diff --git a/client/src/side/side-doll.ts b/client/src/side/side-doll.ts index 60273f78..b8fb0f01 100644 --- a/client/src/side/side-doll.ts +++ b/client/src/side/side-doll.ts @@ -1,26 +1,38 @@ -import type Phaser from 'phaser'; +import Phaser from 'phaser'; +import { isPlayerFigure, PLAYER_FIGURE } from '../dolls/bionics'; import type { FigureManifest } from '../dolls/manifest'; import type { Shown } from '../dolls/sprite'; import { SpriteFigure } from '../dolls/sprite-figure'; +import { dollFor, playerBody } from '../world/assets'; import { DOLL_M, PPM } from './layout'; import type { Drawn } from './motion'; import { PoseCache } from './poses'; /** A resident's painted figure, posed from a cache and tinted by the light; its origin is on the - * ground under the middle of its head. */ + * ground under the middle of its head. The player's changes as bionics are fitted. */ export class SideDoll extends SpriteFigure { - private readonly cache: PoseCache; + private cache: PoseCache; private tint = -1; private onSeat?: { from: Shown; seat: number; shown: Shown }; constructor(scene: Phaser.Scene, figure: FigureManifest, key: string, height = DOLL_M * PPM) { super(scene, 0, 0, figure, key, height); this.cache = new PoseCache(figure); + if (isPlayerFigure(figure.id)) { + this.once(Phaser.GameObjects.Events.DESTROY, playerBody.onChange(() => this.follow())); + if (dollFor(PLAYER_FIGURE) !== figure) this.follow(); + } + } + + /** Wears the player's figure for what's fitted now. */ + private follow(): void { + this.wear(dollFor(PLAYER_FIGURE)).catch((error: unknown) => console.warn(error)); } /** Does nothing unless its frame, facing, seat or tint changed. `seat` is how many metres up * the seat it sits on is, if it's sitting. */ show({ pose, facing, phase, seated, on, run }: Drawn, tint: number, seat = 0): this { + if (this.cache.figure !== this.figure) this.cache = new PoseCache(this.figure); const shown = this.cache.get(pose, facing, phase, seated, on, run); this.draw(shown.pose === 'sit' && seat > 0 ? this.seated(shown, seat) : shown); if (tint !== this.tint) { diff --git a/client/src/world/README.md b/client/src/world/README.md index 6b92e624..4ea99e62 100644 --- a/client/src/world/README.md +++ b/client/src/world/README.md @@ -12,7 +12,8 @@ Run it with `make dev` and open http://localhost:5173. The residents go about th `assets.ts` bundles every street and floor map in [`content/street/`](../../../content/street/README.md): `maps` has them all by id, and `mapIds` their ids. `maps` is one object that's never replaced, since `ui/side-view.ts` tells side views by it. The dolls are part 4's (`dolls`, from `dolls/assets.ts`), and -someone without one of their own gets a resident's, the same one every time (`dollFor`). +someone without one of their own gets a resident's, the same one every time (`dollFor`). The +player's is the one for the bionics `player_state` last said were fitted (`playerBody`). ## Maps diff --git a/client/src/world/assets.test.ts b/client/src/world/assets.test.ts index ec85b49d..7f93a739 100644 --- a/client/src/world/assets.test.ts +++ b/client/src/world/assets.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { dollFor, dolls, mapIds, maps } from './assets'; +import { PlayerBody } from '../dolls/bionics'; +import { server, type Body } from '../net'; +import { dollFor, dolls, mapIds, maps, playerBody } from './assets'; + +const whole: Body = { + legs: 'present', + vertebra: 'present', + left_eye: 'present', + right_eye: 'present', + left_kidney: 'present', + right_kidney: 'present', +}; describe('maps', () => { it('has every street and floor, bundled', () => { @@ -19,7 +30,28 @@ describe('dollFor', () => { expect(dollFor('player')).toBe(dolls.player); const stranger = dollFor('wren_abernethy'); expect(dollFor('wren_abernethy')).toBe(stranger); - expect(stranger).not.toBe(dolls.player); + expect(stranger.id).not.toMatch(/^player/); expect(Object.values(dolls)).toContain(stranger); }); + + it('is the player’s with whatever bionics are fitted', () => { + const body = new PlayerBody(); + expect(dollFor('player', body)).toBe(dolls.player); + + body.set({ ...whole, legs: 'hydraulic_legs', right_eye: 'infrared_eye' }); + + expect(dollFor('player', body)).toBe(dolls.player_legs_eye); + expect(dollFor('kofi', body)).toBe(dolls.kofi); + }); + + it('hears what’s fitted from player_state', () => { + const player = { money: 0, items: [], carry_limit: 10, hunger: 0, body: { ...whole, vertebra: 'datajack' } }; + + server.dispatch(JSON.stringify({ type: 'player_state', player })); + + expect(playerBody.figure).toBe('player_datajack'); + expect(dollFor('player')).toBe(dolls.player_datajack); + server.dispatch(JSON.stringify({ type: 'player_state', player: { ...player, body: whole } })); + expect(dollFor('player')).toBe(dolls.player); + }); }); diff --git a/client/src/world/assets.ts b/client/src/world/assets.ts index dcc81fed..08ffa208 100644 --- a/client/src/world/assets.ts +++ b/client/src/world/assets.ts @@ -1,5 +1,7 @@ import { dolls } from '../dolls/assets'; +import { isPlayerFigure, PLAYER_FIGURE, PlayerBody } from '../dolls/bionics'; import type { FigureManifest } from '../dolls/manifest'; +import { server } from '../net'; import type { TiledMap } from './map'; const files = import.meta.glob( @@ -17,18 +19,22 @@ export const mapIds: readonly string[] = Object.keys(maps); export { dolls }; -const PLAYER = 'player'; const residentDolls = Object.keys(dolls) - .filter((id) => id !== PLAYER) + .filter((id) => !isPlayerFigure(id)) .sort(); +/** What the player has fitted, from `player_state`. */ +export const playerBody = new PlayerBody(); +server.on('player_state', ({ player }) => playerBody.set(player.body)); + function fnv1a(text: string): number { let hash = 0x811c9dc5; for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193); return hash >>> 0; } -/** Its own doll, or a resident's picked by its id, the same one every time. */ -export function dollFor(id: string): FigureManifest { +/** Its own doll, the player's for `body`, or a resident's picked by its id, the same every time. */ +export function dollFor(id: string, body: PlayerBody = playerBody): FigureManifest { + if (id === PLAYER_FIGURE) return dolls[body.figure] ?? dolls[PLAYER_FIGURE]; return dolls[id] ?? dolls[residentDolls[fnv1a(id) % residentDolls.length]]; } diff --git a/content/dolls/README.md b/content/dolls/README.md index e7657490..0c08c805 100644 --- a/content/dolls/README.md +++ b/content/dolls/README.md @@ -9,14 +9,18 @@ The client swaps, flips, bobs and sways the painted frames (`client/src/dolls/`) uv sync --extra images uv run simile dolls paint # every figure whose Appearance or pipeline changed uv run simile dolls paint kofi --force --paintings /tmp/dolls # one again, saving its paintings +uv run simile dolls paint player # the player, and with each combination of bionics uv run simile dolls seats # measure every seat again from the atlases, painting nothing ``` -Don't edit them by hand. CI never paints; the tests check what's committed. +Don't edit them by hand. CI never paints; the tests check what's committed. Painting waits +while another image model, a `simile papercraft` or `simile dolls` run, or an Ollama model is +using the GPU. ## Files -For each resident and `player`: +For each resident, `player`, and the player with each combination of bionics (`player_legs`, +`player_datajack_eye`, `player_legs_datajack_eye` and so on, below): | File | What it is | |---|---| @@ -113,6 +117,44 @@ Each painting is cached in `server/data/dolls/paintings/` by everything it was p cutting and matching can be retuned without painting again. A painting that comes out off-model gets a reroll in `build.REROLLS` (`"/": n`), which repaints that frame with another seed. +## The player with bionics + +Seven more figures, one for each combination of the hydraulic legs, the datajack and the +infrared eye (`server/simile/dolls/bionics.py`), with the player's frames and poses. Their +manifests say what's `fitted`, and their `hash` is the whole player's with each part's prompts +and rerolls, and `bionics.VERSION`. + +1. **Each part is painted once on every frame that shows it,** as an edit of the whole player's + same frame: 20 paintings. The eye isn't seen from behind, so no climb has it. + - The legs are steel hydraulic shins below shorts, in the same sneakers. + - The datajack is a chrome port with a green light. FLUX paints it on a band round the neck + however it's asked not to, so that's how it looks, the same in every frame. + - The eye is a red lens in a chrome rim. +2. **Each part is checked as the residents' poses are,** against the whole player's standing + figure, and painted again if it doesn't show (`bionics.missing`): + - steel over 12% of the legs, which the grey shading of the whole player's sneakers stays + under (about 6%), while hydraulic legs are 13 to 23%; + - the green light; + - one red lens, not two, no wider than 17% of the head, as the walk's lenses are (about + 15%), so it doesn't grow when the player stops. + + The face check spares the lens and its rim (`bionics.spared`). A frame may show the parts' + steel, red and green (`bionics.PALETTE`) without their counting as strays. With the legs, the + standing colour check looks for the bottom colour in the shorts (`bionics.SHORTS`). +3. **Laid over the whole player** (`bionics.changed`, `bionics.laid`): each part is what its edit + changed of the whole player's frame, near the part (anything else FLUX redrew, a backpack a + shade lighter, is left out), grown by its outline and feathered. Each combination is the whole + player's frames with its parts laid over them, so the player is the same in every combination + but for the parts, and each part is the same in every combination it's in. +4. **Scaled as the whole player is** (`sprites.frames`' `reference`), so fitting a part doesn't + change the player's size. + +The eye is painted where it shows, nearer the ear, whichever socket the sim fits it in. Both eyes +show in three-quarter view, and the client mirrors a figure to face west, so the eye swaps sockets +as the player turns, as every figure's hair parting and leading leg do. Painting the far socket +too would take another painting of the eye on every frame, and would show it as a red speck by the +nose whenever the player faced away from it. + ## The flat-doll route `POST /api/dolls/flat` with `{"appearance": {...}, "character": "new_face"}` (`character` diff --git a/content/dolls/player_datajack.json b/content/dolls/player_datajack.json new file mode 100644 index 00000000..c0448690 --- /dev/null +++ b/content/dolls/player_datajack.json @@ -0,0 +1,136 @@ +{ + "id": "player_datajack", + "hash": "df534cc00470fcdf", + "appearance": { + "skin": "#e4a672", + "hair_style": "short", + "hair_colour": "#3e2731", + "top_colour": "#0099db", + "bottom_colour": "#3a4466", + "shoe_colour": "#ffffff", + "build": "average", + "accessories": [ + "backpack" + ] + }, + "fitted": [ + "datajack" + ], + "version": 3, + "ppm": 80, + "height": 163.2, + "painted": "player_datajack.webp", + "frames": { + "climb": { + "frame": [ + 85, + 0, + 85, + 166 + ], + "feet": [ + 44.7, + 164.0 + ] + }, + "pass": { + "frame": [ + 172, + 0, + 65, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "run": { + "frame": [ + 489, + 0, + 101, + 136 + ], + "feet": [ + 58.0, + 166.0 + ] + }, + "run_pass": { + "frame": [ + 0, + 0, + 83, + 167 + ], + "feet": [ + 44.0, + 165.0 + ] + }, + "sit": { + "frame": [ + 394, + 0, + 93, + 150 + ], + "feet": [ + 49.5, + 148.0 + ], + "seat": [ + 49.5, + 119.0 + ] + }, + "stand": { + "frame": [ + 239, + 0, + 67, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "stride": { + "frame": [ + 308, + 0, + 84, + 165 + ], + "feet": [ + 39.0, + 163.0 + ] + } + }, + "poses": { + "stand": [ + "stand" + ], + "walk": [ + "stride", + "pass", + "stride", + "pass" + ], + "run": [ + "run", + "run_pass", + "run", + "run_pass" + ], + "sit": [ + "sit" + ], + "climb": [ + "climb" + ] + } +} diff --git a/content/dolls/player_datajack.webp b/content/dolls/player_datajack.webp new file mode 100644 index 00000000..9c561f88 Binary files /dev/null and b/content/dolls/player_datajack.webp differ diff --git a/content/dolls/player_datajack_eye.json b/content/dolls/player_datajack_eye.json new file mode 100644 index 00000000..d7e7d03e --- /dev/null +++ b/content/dolls/player_datajack_eye.json @@ -0,0 +1,137 @@ +{ + "id": "player_datajack_eye", + "hash": "112ad47c7c281268", + "appearance": { + "skin": "#e4a672", + "hair_style": "short", + "hair_colour": "#3e2731", + "top_colour": "#0099db", + "bottom_colour": "#3a4466", + "shoe_colour": "#ffffff", + "build": "average", + "accessories": [ + "backpack" + ] + }, + "fitted": [ + "datajack", + "infrared_eye" + ], + "version": 3, + "ppm": 80, + "height": 163.2, + "painted": "player_datajack_eye.webp", + "frames": { + "climb": { + "frame": [ + 85, + 0, + 85, + 166 + ], + "feet": [ + 44.7, + 164.0 + ] + }, + "pass": { + "frame": [ + 172, + 0, + 65, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "run": { + "frame": [ + 489, + 0, + 101, + 136 + ], + "feet": [ + 58.0, + 166.0 + ] + }, + "run_pass": { + "frame": [ + 0, + 0, + 83, + 167 + ], + "feet": [ + 44.0, + 165.0 + ] + }, + "sit": { + "frame": [ + 394, + 0, + 93, + 150 + ], + "feet": [ + 49.5, + 148.0 + ], + "seat": [ + 49.5, + 119.0 + ] + }, + "stand": { + "frame": [ + 239, + 0, + 67, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "stride": { + "frame": [ + 308, + 0, + 84, + 165 + ], + "feet": [ + 39.0, + 163.0 + ] + } + }, + "poses": { + "stand": [ + "stand" + ], + "walk": [ + "stride", + "pass", + "stride", + "pass" + ], + "run": [ + "run", + "run_pass", + "run", + "run_pass" + ], + "sit": [ + "sit" + ], + "climb": [ + "climb" + ] + } +} diff --git a/content/dolls/player_datajack_eye.webp b/content/dolls/player_datajack_eye.webp new file mode 100644 index 00000000..766519a7 Binary files /dev/null and b/content/dolls/player_datajack_eye.webp differ diff --git a/content/dolls/player_eye.json b/content/dolls/player_eye.json new file mode 100644 index 00000000..1af19178 --- /dev/null +++ b/content/dolls/player_eye.json @@ -0,0 +1,136 @@ +{ + "id": "player_eye", + "hash": "d154a483516fafac", + "appearance": { + "skin": "#e4a672", + "hair_style": "short", + "hair_colour": "#3e2731", + "top_colour": "#0099db", + "bottom_colour": "#3a4466", + "shoe_colour": "#ffffff", + "build": "average", + "accessories": [ + "backpack" + ] + }, + "fitted": [ + "infrared_eye" + ], + "version": 3, + "ppm": 80, + "height": 163.2, + "painted": "player_eye.webp", + "frames": { + "climb": { + "frame": [ + 85, + 0, + 85, + 166 + ], + "feet": [ + 44.7, + 164.0 + ] + }, + "pass": { + "frame": [ + 172, + 0, + 65, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "run": { + "frame": [ + 489, + 0, + 101, + 136 + ], + "feet": [ + 58.0, + 166.0 + ] + }, + "run_pass": { + "frame": [ + 0, + 0, + 83, + 167 + ], + "feet": [ + 44.0, + 165.0 + ] + }, + "sit": { + "frame": [ + 394, + 0, + 93, + 150 + ], + "feet": [ + 49.5, + 148.0 + ], + "seat": [ + 49.5, + 119.0 + ] + }, + "stand": { + "frame": [ + 239, + 0, + 67, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "stride": { + "frame": [ + 308, + 0, + 84, + 165 + ], + "feet": [ + 39.0, + 163.0 + ] + } + }, + "poses": { + "stand": [ + "stand" + ], + "walk": [ + "stride", + "pass", + "stride", + "pass" + ], + "run": [ + "run", + "run_pass", + "run", + "run_pass" + ], + "sit": [ + "sit" + ], + "climb": [ + "climb" + ] + } +} diff --git a/content/dolls/player_eye.webp b/content/dolls/player_eye.webp new file mode 100644 index 00000000..61b84c95 Binary files /dev/null and b/content/dolls/player_eye.webp differ diff --git a/content/dolls/player_legs.json b/content/dolls/player_legs.json new file mode 100644 index 00000000..885385cc --- /dev/null +++ b/content/dolls/player_legs.json @@ -0,0 +1,136 @@ +{ + "id": "player_legs", + "hash": "e94fe7a11c542307", + "appearance": { + "skin": "#e4a672", + "hair_style": "short", + "hair_colour": "#3e2731", + "top_colour": "#0099db", + "bottom_colour": "#3a4466", + "shoe_colour": "#ffffff", + "build": "average", + "accessories": [ + "backpack" + ] + }, + "fitted": [ + "hydraulic_legs" + ], + "version": 3, + "ppm": 80, + "height": 163.2, + "painted": "player_legs.webp", + "frames": { + "climb": { + "frame": [ + 85, + 0, + 85, + 166 + ], + "feet": [ + 44.5, + 164.0 + ] + }, + "pass": { + "frame": [ + 172, + 0, + 65, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "run": { + "frame": [ + 489, + 0, + 101, + 136 + ], + "feet": [ + 58.5, + 165.0 + ] + }, + "run_pass": { + "frame": [ + 0, + 0, + 83, + 167 + ], + "feet": [ + 44.0, + 164.0 + ] + }, + "sit": { + "frame": [ + 394, + 0, + 93, + 150 + ], + "feet": [ + 49.5, + 148.0 + ], + "seat": [ + 49.5, + 119.0 + ] + }, + "stand": { + "frame": [ + 239, + 0, + 67, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "stride": { + "frame": [ + 308, + 0, + 84, + 165 + ], + "feet": [ + 39.0, + 163.0 + ] + } + }, + "poses": { + "stand": [ + "stand" + ], + "walk": [ + "stride", + "pass", + "stride", + "pass" + ], + "run": [ + "run", + "run_pass", + "run", + "run_pass" + ], + "sit": [ + "sit" + ], + "climb": [ + "climb" + ] + } +} diff --git a/content/dolls/player_legs.webp b/content/dolls/player_legs.webp new file mode 100644 index 00000000..6335d26b Binary files /dev/null and b/content/dolls/player_legs.webp differ diff --git a/content/dolls/player_legs_datajack.json b/content/dolls/player_legs_datajack.json new file mode 100644 index 00000000..2c799726 --- /dev/null +++ b/content/dolls/player_legs_datajack.json @@ -0,0 +1,137 @@ +{ + "id": "player_legs_datajack", + "hash": "51ed17ca6635a1a5", + "appearance": { + "skin": "#e4a672", + "hair_style": "short", + "hair_colour": "#3e2731", + "top_colour": "#0099db", + "bottom_colour": "#3a4466", + "shoe_colour": "#ffffff", + "build": "average", + "accessories": [ + "backpack" + ] + }, + "fitted": [ + "hydraulic_legs", + "datajack" + ], + "version": 3, + "ppm": 80, + "height": 163.2, + "painted": "player_legs_datajack.webp", + "frames": { + "climb": { + "frame": [ + 85, + 0, + 85, + 166 + ], + "feet": [ + 44.5, + 164.0 + ] + }, + "pass": { + "frame": [ + 172, + 0, + 65, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "run": { + "frame": [ + 489, + 0, + 101, + 136 + ], + "feet": [ + 58.5, + 165.0 + ] + }, + "run_pass": { + "frame": [ + 0, + 0, + 83, + 167 + ], + "feet": [ + 44.0, + 164.0 + ] + }, + "sit": { + "frame": [ + 394, + 0, + 93, + 150 + ], + "feet": [ + 49.5, + 148.0 + ], + "seat": [ + 49.5, + 119.0 + ] + }, + "stand": { + "frame": [ + 239, + 0, + 67, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "stride": { + "frame": [ + 308, + 0, + 84, + 165 + ], + "feet": [ + 39.0, + 163.0 + ] + } + }, + "poses": { + "stand": [ + "stand" + ], + "walk": [ + "stride", + "pass", + "stride", + "pass" + ], + "run": [ + "run", + "run_pass", + "run", + "run_pass" + ], + "sit": [ + "sit" + ], + "climb": [ + "climb" + ] + } +} diff --git a/content/dolls/player_legs_datajack.webp b/content/dolls/player_legs_datajack.webp new file mode 100644 index 00000000..e5b25061 Binary files /dev/null and b/content/dolls/player_legs_datajack.webp differ diff --git a/content/dolls/player_legs_datajack_eye.json b/content/dolls/player_legs_datajack_eye.json new file mode 100644 index 00000000..a95827b1 --- /dev/null +++ b/content/dolls/player_legs_datajack_eye.json @@ -0,0 +1,138 @@ +{ + "id": "player_legs_datajack_eye", + "hash": "28a3784cd148cffe", + "appearance": { + "skin": "#e4a672", + "hair_style": "short", + "hair_colour": "#3e2731", + "top_colour": "#0099db", + "bottom_colour": "#3a4466", + "shoe_colour": "#ffffff", + "build": "average", + "accessories": [ + "backpack" + ] + }, + "fitted": [ + "hydraulic_legs", + "datajack", + "infrared_eye" + ], + "version": 3, + "ppm": 80, + "height": 163.2, + "painted": "player_legs_datajack_eye.webp", + "frames": { + "climb": { + "frame": [ + 85, + 0, + 85, + 166 + ], + "feet": [ + 44.5, + 164.0 + ] + }, + "pass": { + "frame": [ + 172, + 0, + 65, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "run": { + "frame": [ + 489, + 0, + 101, + 136 + ], + "feet": [ + 58.5, + 165.0 + ] + }, + "run_pass": { + "frame": [ + 0, + 0, + 83, + 167 + ], + "feet": [ + 44.0, + 164.0 + ] + }, + "sit": { + "frame": [ + 394, + 0, + 93, + 150 + ], + "feet": [ + 49.5, + 148.0 + ], + "seat": [ + 49.5, + 119.0 + ] + }, + "stand": { + "frame": [ + 239, + 0, + 67, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "stride": { + "frame": [ + 308, + 0, + 84, + 165 + ], + "feet": [ + 39.0, + 163.0 + ] + } + }, + "poses": { + "stand": [ + "stand" + ], + "walk": [ + "stride", + "pass", + "stride", + "pass" + ], + "run": [ + "run", + "run_pass", + "run", + "run_pass" + ], + "sit": [ + "sit" + ], + "climb": [ + "climb" + ] + } +} diff --git a/content/dolls/player_legs_datajack_eye.webp b/content/dolls/player_legs_datajack_eye.webp new file mode 100644 index 00000000..dd25c88d Binary files /dev/null and b/content/dolls/player_legs_datajack_eye.webp differ diff --git a/content/dolls/player_legs_eye.json b/content/dolls/player_legs_eye.json new file mode 100644 index 00000000..a7e6116f --- /dev/null +++ b/content/dolls/player_legs_eye.json @@ -0,0 +1,137 @@ +{ + "id": "player_legs_eye", + "hash": "a0443e91a18a1cc6", + "appearance": { + "skin": "#e4a672", + "hair_style": "short", + "hair_colour": "#3e2731", + "top_colour": "#0099db", + "bottom_colour": "#3a4466", + "shoe_colour": "#ffffff", + "build": "average", + "accessories": [ + "backpack" + ] + }, + "fitted": [ + "hydraulic_legs", + "infrared_eye" + ], + "version": 3, + "ppm": 80, + "height": 163.2, + "painted": "player_legs_eye.webp", + "frames": { + "climb": { + "frame": [ + 85, + 0, + 85, + 166 + ], + "feet": [ + 44.5, + 164.0 + ] + }, + "pass": { + "frame": [ + 172, + 0, + 65, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "run": { + "frame": [ + 489, + 0, + 101, + 136 + ], + "feet": [ + 58.5, + 165.0 + ] + }, + "run_pass": { + "frame": [ + 0, + 0, + 83, + 167 + ], + "feet": [ + 44.0, + 164.0 + ] + }, + "sit": { + "frame": [ + 394, + 0, + 93, + 150 + ], + "feet": [ + 49.5, + 148.0 + ], + "seat": [ + 49.5, + 119.0 + ] + }, + "stand": { + "frame": [ + 239, + 0, + 67, + 166 + ], + "feet": [ + 38.0, + 164.0 + ] + }, + "stride": { + "frame": [ + 308, + 0, + 84, + 165 + ], + "feet": [ + 39.0, + 163.0 + ] + } + }, + "poses": { + "stand": [ + "stand" + ], + "walk": [ + "stride", + "pass", + "stride", + "pass" + ], + "run": [ + "run", + "run_pass", + "run", + "run_pass" + ], + "sit": [ + "sit" + ], + "climb": [ + "climb" + ] + } +} diff --git a/content/dolls/player_legs_eye.webp b/content/dolls/player_legs_eye.webp new file mode 100644 index 00000000..becd7fc9 Binary files /dev/null and b/content/dolls/player_legs_eye.webp differ diff --git a/server/simile/dolls/bionics.py b/server/simile/dolls/bionics.py new file mode 100644 index 00000000..f0c7dd2a --- /dev/null +++ b/server/simile/dolls/bionics.py @@ -0,0 +1,222 @@ +"""The player with each combination of bionics: each part painted once over the player's frames.""" + +from collections.abc import Iterable +from itertools import combinations + +import numpy as np +from numpy.typing import NDArray +from PIL import Image, ImageFilter + +from simile.dolls.colour import cielab, delta_e_lab +from simile.dolls.figure import EDITS, FACE, styled, touching +from simile.dolls.sprites import head +from simile.dolls.words import described +from simile.schema import PLAYER_ID, Appearance +from simile.schema.player import Bionic + +VERSION = 1 +"""How the parts are laid over the whole player: a new one lays them again.""" +FITTED = (Bionic.HYDRAULIC_LEGS, Bionic.DATAJACK, Bionic.INFRARED_EYE) +EYE = Bionic.INFRARED_EYE +NAMES = {Bionic.HYDRAULIC_LEGS: "legs", Bionic.DATAJACK: "datajack", Bionic.INFRARED_EYE: "eye"} +type Combination = tuple[Bionic, ...] +COMBINATIONS: tuple[Combination, ...] = tuple( + c for n in range(1, len(FITTED) + 1) for c in combinations(FITTED, n) +) +"""Every combination of bionics but none, fewest first, each in `FITTED`'s order.""" +WORDS = { + Bionic.HYDRAULIC_LEGS: ( + "both legs replaced by bionic hydraulic legs: the trousers now end above the knees as " + "shorts, and below them each lower leg is bare steel-grey machinery with a shiny chrome " + "hydraulic piston down the shin and a round metal knee joint, ending in the same white " + "sneakers" + ), + Bionic.DATAJACK: ( + "a datajack port set into the skin at the back of the neck, just under the hair at the " + "nape: a chunky round chrome socket with a bright green light in its middle, and no " + "collar, band or ring around the neck" + ), + Bionic.INFRARED_EYE: ( + "the eye nearer the ear replaced by a small bionic infrared eye, no bigger than the other " + "eye: a bright red lens with a thin chrome rim. The other eye, beside the nose, stays a " + "plain dark dot eye, and the skin around both eyes is clean and unchanged" + ), +} +EYE_FACE = ( + "The face is clean, with a small smile and no smudges on it: one bright red bionic eye nearer " + "the ear and one plain dark dot eye beside the nose." +) +METAL = (12.0, 40.0, 88.0) +"""Steel and chrome, CIELAB: chroma below the first, lightness between the others.""" +LEGS = 0.6 +"""Where the legs start, down the figure.""" +SHORTS = (0.62, 0.7) +"""The bottom colour's `measure.CORES` with the hydraulic legs, whose shins are steel.""" +LENS = (40.0, 15.0) +"""The red lens: a* above the first and lightness above the second, in the top half.""" +LIGHT = (-30.0, 15.0) +"""The datajack's green light: a* below the first, b* above the second.""" +LEAST = {Bionic.HYDRAULIC_LEGS: 0.12, Bionic.DATAJACK: 0.001, Bionic.INFRARED_EYE: 0.002} +"""The share of a frame, or of its legs for the legs, a part must cover to be there.""" +LENS_WIDE = 0.17 +"""The widest the lens may be, as a share of the head's width: the walk's is about 0.15.""" +OTHER_EYE = 0.25 +"""A second red patch this big against the first is the other eye painted red.""" +RIM = 0.012 +"""How far round the lens, in the figure's heights, its rim reaches: the face check spares it.""" +PALETTE = np.array( + [[lightness, 0.0, 0.0] for lightness in range(35, 95, 10)] + + [[48.0, 70.0, 55.0], [80.0, -70.0, 65.0]] +) +"""The parts' colours in CIELAB, which a frame may show although the whole player doesn't.""" +CHANGED = 10.0 +"""How far a pixel of a part's frame is from the whole player's, CIEDE2000, to be changed.""" +SHORTS_FROM = 0.5 +"""How far down the figure the legs' changes start: the shorts' hem is above the knees.""" +REACH = {Bionic.DATAJACK: 0.06, Bionic.INFRARED_EYE: 0.03} +"""How far from its light or lens, in the figure's heights, a part's changes are its own.""" +GROW = 6 +"""Pixels round what changed laid with it, for its outline.""" +FEATHER = 2.0 +"""The blur, in pixels, over the edge of what's laid.""" + + +def figure_id(fitted: Iterable[Bionic]) -> str: + """The figure for a body with `fitted`: `player` with none, `player_legs_eye` and so on.""" + have = set(fitted) + return "_".join([PLAYER_ID, *(NAMES[b] for b in FITTED if b in have)]) + + +def faces(frame: str) -> bool: + return frame == "stand" or EDITS[frame].face + + +def shows(bionic: Bionic, frame: str) -> bool: + """Whether `frame` shows the part: the eye isn't seen from behind.""" + return bionic != Bionic.INFRARED_EYE or faces(frame) + + +def edit_prompt(frame: str, bionic: Bionic, appearance: Appearance) -> str: + """Adds `bionic` to the whole player's `frame`.""" + words = described(appearance, PLAYER_ID) + if bionic == Bionic.HYDRAULIC_LEGS: + words = words.replace(" trousers ", " shorts ") + face = "" + if faces(frame): + face = f" {EYE_FACE if bionic == Bionic.INFRARED_EYE else FACE}" + return styled( + "Redraw image 1 with the same character, unchanged in every other detail and the same " + f"size, in exactly the same pose and view, now with {WORDS[bionic]}. {words}{face} " + "Keep the background plain light grey." + ) + + +def regions(figure: Image.Image) -> tuple[NDArray[np.bool_], NDArray[np.float64]]: + """The opaque figure, and how far down it each row is, 0 at the crown and 1 at the soles.""" + solid = np.asarray(figure.getchannel("A")) > 200 + rows = np.flatnonzero(solid.any(axis=1)) + top, bottom = (rows[0], rows[-1]) if len(rows) else (0, solid.shape[0] - 1) + down = (np.arange(solid.shape[0], dtype=np.float64) - top) / max(1, bottom - top) + return solid, np.broadcast_to(down[:, None], solid.shape) + + +def height(solid: NDArray[np.bool_]) -> int: + rows = np.flatnonzero(solid.any(axis=1)) + return int(rows[-1] - rows[0]) if len(rows) else 0 + + +def parts(figure: Image.Image) -> dict[Bionic, NDArray[np.bool_]]: + """Where each part's colours are: steel in the legs, the green light and the red lens.""" + solid, down = regions(figure) + lab = cielab(np.asarray(figure.convert("RGB"), dtype=np.float64)) + light, a, b = lab[..., 0], lab[..., 1], lab[..., 2] + metal = (np.hypot(a, b) < METAL[0]) & (light > METAL[1]) & (light < METAL[2]) + return { + Bionic.HYDRAULIC_LEGS: solid & metal & (down >= LEGS), + Bionic.DATAJACK: solid & (a < LIGHT[0]) & (b > LIGHT[1]), + Bionic.INFRARED_EYE: solid & (a > LENS[0]) & (light > LENS[1]) & (down < 0.5), + } + + +def patches(mask: NDArray[np.bool_]) -> list[NDArray[np.bool_]]: + """`mask`'s patches, corners joining them, biggest first.""" + left = mask.copy() + found = [] + while left.any(): + y, x = np.argwhere(left)[0] + found.append(touching(left, int(x), int(y))) + left &= ~found[-1] + return sorted(found, key=lambda patch: -int(patch.sum())) + + +def missing( + figure: Image.Image, combination: Combination, frame: str, least: float = 1.0 +) -> list[str]: + """What's wrong with the parts `frame` shows: one missing, two eyes, or an outsize eye.""" + found = parts(figure) + solid, down = regions(figure) + out = [] + for bionic in combination: + if not shows(bionic, frame): + continue + whole = solid & (down >= LEGS) if bionic == Bionic.HYDRAULIC_LEGS else solid + if found[bionic].sum() < least * LEAST[bionic] * whole.sum(): + out.append(NAMES[bionic]) + elif bionic == Bionic.INFRARED_EYE: + lenses = patches(found[bionic]) + if len(lenses) > 1 and lenses[1].sum() >= OTHER_EYE * lenses[0].sum(): + out.append("two eyes") + if lens_width(figure, lenses[0]) > LENS_WIDE / least: + out.append("eye too big") + return out + + +def lens_width(figure: Image.Image, lens: NDArray[np.bool_]) -> float: + """How wide `lens` is, as a share of the head's width.""" + columns = np.flatnonzero(lens.any(axis=0)) + return (columns[-1] - columns[0] + 1) / max(head(figure)[1], 1) + + +def grown(mask: NDArray[np.bool_], by: int) -> NDArray[np.bool_]: + image = Image.fromarray(mask.astype(np.uint8) * 255) + return np.asarray(image.filter(ImageFilter.MaxFilter(2 * by + 1))) > 0 + + +def spared(figure: Image.Image) -> NDArray[np.bool_]: + """The bionic eye and its rim, which the face check doesn't count as painted over.""" + solid, _ = regions(figure) + reach = max(1, round(RIM * height(solid))) + return grown(parts(figure)[Bionic.INFRARED_EYE], reach) + + +def changed(whole: Image.Image, edit: Image.Image, bionic: Bionic) -> NDArray[np.bool_]: + """Where the `edit` of the `whole` player's frame painted `bionic`, with its outline.""" + a, b = np.asarray(whole.convert("RGBA")), np.asarray(edit.convert("RGBA")) + solid, down = regions(edit) + both = (a[..., 3] > 128) & (b[..., 3] > 128) + far = delta_e_lab(cielab(a[..., :3].astype(np.float64)), cielab(b[..., :3].astype(np.float64))) + diff = (np.abs(a[..., 3].astype(int) - b[..., 3].astype(int)) > 64) | (both & (far > CHANGED)) + seeds = parts(edit)[bionic] + if bionic == Bionic.HYDRAULIC_LEGS: + diff &= down >= SHORTS_FROM + elif seeds.any(): + seeds = patches(seeds)[0] + diff &= grown(seeds, round(REACH[bionic] * height(solid))) + joined = grown(diff | seeds, 2) + keep = np.zeros_like(joined) + for y, x in np.argwhere(seeds)[:: max(1, int(seeds.sum()) // 50)]: + if not keep[y, x]: + keep |= touching(joined, int(x), int(y)) + return grown(keep, GROW) + + +def laid(whole: Image.Image, layers: list[tuple[Image.Image, NDArray[np.bool_]]]) -> Image.Image: + """The `whole` player's frame with each layer's frame where it says, feathered.""" + out = np.asarray(whole.convert("RGBA")).astype(np.float64) + for edit, where in layers: + soft = Image.fromarray(where.astype(np.uint8) * 255).filter( + ImageFilter.GaussianBlur(FEATHER) + ) + weight = (np.asarray(soft) / 255.0)[..., None] + out = out * (1 - weight) + np.asarray(edit.convert("RGBA")).astype(np.float64) * weight + return Image.fromarray(np.round(out).astype(np.uint8), "RGBA") diff --git a/server/simile/dolls/build.py b/server/simile/dolls/build.py index 313227ea..acbfdc41 100644 --- a/server/simile/dolls/build.py +++ b/server/simile/dolls/build.py @@ -14,8 +14,11 @@ import numpy as np from PIL import Image +from simile.dolls import bionics +from simile.dolls.bionics import COMBINATIONS, Combination, figure_id from simile.dolls.figure import ( EDITS, + FRAMES, POSES, SIZE, STANDING, @@ -44,13 +47,14 @@ from simile.dolls.sprites import VERSION, atlas, cut_out, frames, manifest, scale, seat from simile.dolls.words import gender_in, persona from simile.schema import PLAYER_ID, Appearance +from simile.schema.player import Bionic REPO = Path(__file__).resolve().parents[3] CONTENT = REPO / "content" / "dolls" PAINTINGS = REPO / "server" / "data" / "dolls" / "paintings" """Every painting as FLUX returned it, by what it was painted from, so cutting and matching can be retuned without painting again. Ignored by git.""" -TRIES = {"stand": 3, "edit": 2} +TRIES = {"stand": 3, "edit": 2, "fitted": 3} """How many times a painting is made, at most, while its colours can't be matched.""" SCALE = 1.25 """How much bigger or smaller than the standing figure FLUX may draw another pose before it's @@ -58,7 +62,12 @@ WRONG = 20.0 """A colour painted further than this from the `Appearance`, CIEDE2000, is another colour, not a shade of it: rather than pulled back, the painting is made again.""" -REROLLS: dict[str, int] = {} +REROLLS: dict[str, int] = { + "player_datajack/stride": 1, + "player_eye/stand": 3, + "player_eye/pass": 2, + "player_eye/sit": 8, +} """`/` to a reroll number, for a painting that came out off-model by eye.""" MISSES: dict[tuple[str, str, str], str] = { ("henrietta", "climb", "shoe"): "ΔE 7.8: near-black shoes, seen from behind, among the outline", @@ -99,6 +108,18 @@ def fingerprint(who: str, appearance: Appearance, said: str = "") -> str: return hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()[:16] +def fitted_fingerprint(combination: Combination, player: str, appearance: Appearance) -> str: + """What the player with bionics is made from: the player's, its parts' prompts and rerolls.""" + ids = {figure_id((b,)) for b in combination} + inputs = { + "player": player, + "version": bionics.VERSION, + "prompts": [bionics.edit_prompt(f, b, appearance) for b in combination for f in FRAMES], + "rerolls": {k: v for k, v in REROLLS.items() if k.split("/")[0] in ids}, + } + return hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()[:16] + + def seed(who: str, frame: str, reroll: int = 0) -> int: return zlib.crc32(f"{who}/{frame}/{reroll}".encode()) % 2**31 @@ -120,6 +141,8 @@ class Try: followed: bool = False """Matched as the standing figure was painted, so a colour painted far off is the standing figure's, already accepted, rather than another colour.""" + missing: list[str] = field(default_factory=list) + """Bionics it should show but doesn't: `bionics.missing`.""" @dataclass @@ -177,6 +200,8 @@ def attempt( cut: Cut, pose: Pose, standing: Painted | None, + fitted: Combination = (), + frame: str = "stand", ) -> tuple[Try, Image.Image, Image.Image]: """One painting cut out and its colours pulled back to the `Appearance`, following how the `standing` figure was painted, if this isn't it. With how far off they were before and after, @@ -196,9 +221,11 @@ def attempt( tried.lost = sorted(set(standing.colours) - set(found)) stand = standing.figures["stand"] tried.scale = round(scale(fixed, stand, pose.bands == "climb"), 3) - tried.strays = strays(fixed, stand) + tried.strays = strays(fixed, stand, bionics.PALETTE if fitted else None) if pose.face: - tried.marks = round(face_marks(fixed, appearance), 3) + eye = bionics.spared(fixed) if Bionic.INFRARED_EYE in fitted else None + tried.marks = round(face_marks(fixed, appearance, eye), 3) + tried.missing = bionics.missing(fixed, fitted, frame) return tried, painting, fixed @@ -211,9 +238,9 @@ def misses(tried: Try, appearance: Appearance) -> tuple[bool, float, float, floa off_scale = abs(np.log(tried.scale)) > np.log(SCALE) unlike = bool(tried.lost) or tried.strays > STRAYS wrong = before > WRONG and not tried.followed - bad = after > CLOSE or wrong or tried.marks > MARKS or off_scale or unlike - odd = tried.marks + abs(np.log(tried.scale)) + tried.strays + len(tried.lost) - return bad, odd, after, before + bad = after > CLOSE or wrong or tried.marks > MARKS or off_scale or unlike or tried.missing + odd = tried.marks + abs(np.log(tried.scale)) + tried.strays + len(tried.lost + tried.missing) + return bool(bad), odd, after, before def paint_frame( @@ -226,14 +253,17 @@ def paint_frame( cut: Cut, pose: Pose, tries: int, + standing: Painted | None = None, + fitted: Combination = (), ) -> None: """Paints `frame` until it matches, at most `tries` times, keeping the best.""" first = REROLLS.get(f"{painted.who}/{frame}", 0) - standing = painted if painted.figures.get("stand") else None + standing = standing or (painted if painted.figures.get("stand") else None) made = [] for n in range(first, first + tries): number = seed(painted.who, frame, n) - made.append(attempt(painter, prompt, number, images, appearance, cut, pose, standing)) + args = (painter, prompt, number, images, appearance, cut, pose, standing, fitted, frame) + made.append(attempt(*args)) if not misses(made[-1][0], appearance)[0]: break best = min(made, key=lambda m: misses(m[0], appearance)) @@ -268,16 +298,61 @@ def paint( return painted +def paint_parts( + player: Painted, + appearance: Appearance, + painter: Cached, + cut: Cut = cut_out, + log: Callable[[str], None] = print, +) -> dict[Bionic, Painted]: + """Each bionic on every frame that shows it, as an edit of the `player`'s same frame.""" + found = {} + for bionic in bionics.FITTED: + painted = Painted(figure_id((bionic,)), colours=player.colours) + for frame in FRAMES: + if not bionics.shows(bionic, frame): + continue + pose = STANDING if frame == "stand" else EDITS[frame] + prompt = bionics.edit_prompt(frame, bionic, appearance) + images, tries = [player.paintings[frame]], TRIES["fitted"] + args = (painter, painted, frame, prompt, images, appearance, cut, pose, tries) + paint_frame(*args, standing=player, fitted=(bionic,)) + log(f"{painted.who} {frame}: {summary(painted.tries[frame][-1])}") + found[bionic] = painted + return found + + +def fit(player: Painted, parts: dict[Bionic, Painted]) -> dict[Combination, dict]: + """Each combination's frames: the `player`'s, with its `parts` laid over them.""" + layers = { + (bionic, frame): (figure, bionics.changed(player.figures[frame], figure, bionic)) + for bionic, painted in parts.items() + for frame, figure in painted.figures.items() + } + return { + combination: { + frame: bionics.laid( + whole, [layers[b, frame] for b in combination if (b, frame) in layers] + ) + for frame, whole in player.figures.items() + } + for combination in COMBINATIONS + } + + def summary(tried: Try) -> str: - return ", ".join(f"{k} {s.delta_e:g}" for k, s in tried.matched.items()) + missing = f", missing {', '.join(tried.missing)}" if tried.missing else "" + return ", ".join(f"{k} {s.delta_e:g}" for k, s in tried.matched.items()) + missing -def files(who: str, painted: Painted, **extra: Any) -> dict[str, bytes]: +def files( + who: str, painted: Painted, standing: Image.Image | None = None, **extra: Any +) -> dict[str, bytes]: """The manifest and the atlas, by file name.""" climbs = [f for f, pose in EDITS.items() if pose.bands == "climb"] lifted = {f: pose.lifted for f, pose in EDITS.items() if pose.lifted} seated = [f for f, pose in EDITS.items() if pose.seated] - made = frames(painted.figures, hands_up=climbs, lifted=lifted, seated=seated) + made = frames(painted.figures, "stand", climbs, lifted, seated, standing) data, places = atlas(made) name = f"{who}.webp" body = manifest(made, places, POSES, name, id=who, **extra) @@ -322,14 +397,50 @@ def current(directory: Path, who: str, hash: str) -> bool: def stale(directory: Path, keep: Iterable[str]) -> list[Path]: - """Files in `directory` that belong to no one in `keep`: #310's atlases, or a resident's - who's gone.""" + """Files in `directory` that belong to no one in `keep` nor the player with bionics: #310's + atlases, or a resident's who's gone.""" + keep = [*keep, *(figure_id(c) for c in COMBINATIONS)] wanted = {name for who in keep for name in (f"{who}.json", f"{who}.webp")} return [ p for p in directory.glob("*") if p.suffix in (".json", ".webp") and p.name not in wanted ] +def build_fitted( + model: Cached, + directory: Path, + appearance: Appearance, + player: str, + whole: Painted | None, + force: bool = False, + cut: Cut = cut_out, + log: Callable[[str], None] = print, +) -> list[Painted]: + """Paints the bionics over the `whole` player and lays them in every combination.""" + hashes = {figure_id(c): fitted_fingerprint(c, player, appearance) for c in COMBINATIONS} + if not force and all(current(directory, who, h) for who, h in hashes.items()): + log("the player with bionics: up to date") + return [] + whole = whole or paint(PLAYER_ID, appearance, model, cut=cut, log=lambda _: None) + parts = paint_parts(whole, appearance, model, cut, log) + dumped = appearance.model_dump(mode="json") + for combination, figures in fit(whole, parts).items(): + who = figure_id(combination) + for frame, figure in figures.items(): + if lost := bionics.missing(figure, combination, frame): + log(f"{who} {frame}: missing {', '.join(lost)}") + extra = { + "hash": hashes[who], + "appearance": dumped, + "fitted": [b.value for b in combination], + } + changed = write( + directory, files(who, Painted(who, figures=figures), whole.figures["stand"], **extra) + ) + log(f"{who}: wrote {len(changed)} files") + return list(parts.values()) + + def build( painter: Callable[[], AbstractContextManager[Painter]], directory: Path = CONTENT, @@ -340,7 +451,7 @@ def build( log: Callable[[str], None] = print, ) -> list[Painted]: """Paints each figure whose fingerprint changed, loading the painter only if a painting - isn't in `cache`.""" + isn't in `cache`. The player comes with every combination of bionics.""" everyone = people() said = personas() wanted = list(only) or list(everyone) @@ -360,6 +471,10 @@ def build( changed = write(directory, files(who, painted, hash=hashes[who], appearance=appearance)) log(f"{who}: painted in {painted.seconds:.0f} s of FLUX, wrote {len(changed)} files") done.append(painted) + if PLAYER_ID in wanted: + whole = next((p for p in done if p.who == PLAYER_ID), None) + player = (everyone[PLAYER_ID], hashes[PLAYER_ID], whole) + done += build_fitted(model, directory, *player, force=force, cut=cut, log=log) if not only: for path in stale(directory, everyone): path.unlink() diff --git a/server/simile/dolls/cli.py b/server/simile/dolls/cli.py index 13ae7ffc..eb747f3e 100644 --- a/server/simile/dolls/cli.py +++ b/server/simile/dolls/cli.py @@ -18,14 +18,16 @@ def paint( ] = None, ) -> None: """Paint the figures in content/dolls/ whose Appearance changed, with FLUX on Apple - Silicon (`uv sync --extra images`).""" + Silicon (`uv sync --extra images`), waiting while another image model runs.""" + from functools import partial + from simile.dolls.build import build - from simile.dolls.flux import Flux + from simile.dolls.flux import Flux, wait_for_gpu from simile.dolls.measure import CLOSE from simile.dolls.paint import NoPainter try: - done = build(Flux, only=who or (), force=force) + done = build(partial(Flux, wait_for_gpu), only=who or (), force=force) except (KeyError, NoPainter) as error: typer.echo(error.args[0], err=True) raise typer.Exit(1) from None diff --git a/server/simile/dolls/flux.py b/server/simile/dolls/flux.py index 2e4c6ba9..6340c573 100644 --- a/server/simile/dolls/flux.py +++ b/server/simile/dolls/flux.py @@ -2,7 +2,10 @@ alone or, given pictures, in edit mode. Never imports MLX until it loads.""" import gc +import os +import subprocess import tempfile +import time from collections.abc import Callable, Sequence from importlib.util import find_spec from pathlib import Path @@ -12,6 +15,45 @@ from simile.dolls.paint import NoPainter +OTHERS = ("mflux", "simile (papercraft|dolls)") +"""Command lines of other image models, or builds that run them.""" +SHELLS = {"sh", "bash", "zsh", "fish", "sleep", "pgrep", "grep"} +"""Programs whose command lines may name one without running it.""" +WAIT_S = 60 + + +def ours() -> set[str]: + """This process and those that started it, such as `uv run`.""" + found, pid = set(), os.getpid() + while pid > 1 and str(pid) not in found: + found.add(str(pid)) + parent = subprocess.run(["ps", "-o", "ppid=", "-p", str(pid)], capture_output=True) + pid = int(parent.stdout.strip() or 0) + return found + + +def busy() -> list[str]: + """Other image models running on this Mac, and Ollama's: they share its memory.""" + found, mine = [], ours() + for pattern in OTHERS: + run = subprocess.run(["pgrep", "-fl", pattern], capture_output=True, text=True) + for line in run.stdout.splitlines(): + pid, _, command = line.partition(" ") + if pid not in mine and Path(command.split(" ", 1)[0]).name not in SHELLS: + found.append(line) + try: + ollama = subprocess.run(["ollama", "ps"], capture_output=True, text=True).stdout + except FileNotFoundError: + ollama = "" + return found + [f"ollama: {line}" for line in ollama.splitlines()[1:] if line.strip()] + + +def wait_for_gpu(log: Callable[[str], None] = print) -> None: + """Waits until nothing else is using the model memory.""" + while others := busy(): + log(f"Waiting for {others}") + time.sleep(WAIT_S) + class Painter(Protocol): """Paints `prompt` at `size`, redrawing `images[0]` if there are any.""" @@ -98,7 +140,7 @@ def paint( class FakePainter: """Draws a figure in the colours the prompt names, without a model: for tests and dry runs. - An edit redraws `images[0]` a little darker.""" + An edit redraws the figure in `images[0]` a little darker, with any bionic it asks for.""" BACKGROUND = (214, 214, 216) @@ -119,10 +161,37 @@ def paint( self.calls.append((prompt, seed, len(images))) if images: base = np.asarray(images[0].convert("RGB").resize(size), dtype=np.float64) - return Image.fromarray(np.clip(base * 0.98, 0, 255).astype(np.uint8)) + figure_only = np.where(np.all(base == self.BACKGROUND, axis=2), 1.0, 0.98) + darker = np.clip(base * figure_only[..., None], 0, 255).astype(np.uint8) + return fitted(Image.fromarray(darker), prompt) return figure(size, named_colours(prompt)) +def fitted(painting: Image.Image, prompt: str) -> Image.Image: + """`painting` of `figure` with each bionic `prompt` asks for.""" + from PIL import ImageDraw + + from simile.dolls.bionics import WORDS + from simile.schema.player import Bionic + + w, h = painting.size + top, tall, x = 0.08 * h, 0.84 * h, w / 2 + + def box(left: float, upper: float, right: float, lower: float) -> tuple[float, ...]: + return (x + left * tall, top + upper * tall, x + right * tall, top + lower * tall) + + draw = ImageDraw.Draw(painting) + if WORDS[Bionic.HYDRAULIC_LEGS] in prompt: + for side in (-1, 1): + legs = sorted((side * 0.01, side * 0.1)) + draw.rectangle(box(legs[0], 0.72, legs[1], 0.93), (150, 152, 158), (40, 26, 20), 3) + if WORDS[Bionic.DATAJACK] in prompt: + draw.ellipse(box(-0.13, 0.3, -0.09, 0.34), (60, 220, 60)) + if WORDS[Bionic.INFRARED_EYE] in prompt: + draw.ellipse(box(0.04, 0.16, 0.08, 0.2), (222, 30, 30)) + return painting + + def named_colours(prompt: str) -> dict[str, str]: """The colours `words.described` names in `prompt`, as hex.""" import re diff --git a/server/simile/dolls/measure.py b/server/simile/dolls/measure.py index eda133f2..923ce578 100644 --- a/server/simile/dolls/measure.py +++ b/server/simile/dolls/measure.py @@ -151,11 +151,13 @@ def wanted(appearance: Appearance) -> dict[str, NDArray[np.float64]]: } -def core(figure: Image.Image, name: str) -> NDArray[np.bool_]: +def core( + figure: Image.Image, name: str, cores: dict[str, tuple[float, float]] = CORES +) -> NDArray[np.bool_]: solid = opaque(figure) rows = np.flatnonzero(solid.any(axis=1)) top, height = rows[0], rows[-1] - rows[0] + 1 - start, end = CORES[name] + start, end = cores[name] out = np.zeros_like(solid) out[top + int(start * height) : top + int(np.ceil(end * height))] = True return out & solid @@ -206,14 +208,20 @@ def follow( return out -def swatches(figure: Image.Image, appearance: Appearance, layout: str = "upright") -> dict: +def swatches( + figure: Image.Image, + appearance: Appearance, + layout: str = "upright", + cores: dict[str, tuple[float, float]] = CORES, +) -> dict: """Each colour as wanted, as painted, and how far apart they are, CIEDE2000: in a standing figure from the middle of its band, in another pose from all of it.""" lab = cielab(np.asarray(figure.convert("RGB"), dtype=np.float64)) rows = bands(figure, layout) out = {} for name, target in wanted(appearance).items(): - where = core(figure, name) if layout == "upright" else opaque(figure) & (rows[name] >= 1) + upright = layout == "upright" + where = core(figure, name, cores) if upright else opaque(figure) & (rows[name] >= 1) got, share = chosen(lab[where], target) want = hex_of(from_cielab(target)) if np.isnan(got).any(): @@ -251,7 +259,9 @@ def kept( return out -def strays(figure: Image.Image, standing: Image.Image) -> float: +def strays( + figure: Image.Image, standing: Image.Image, also: NDArray[np.float64] | None = None +) -> float: """The share of `figure` painted in main colours the standing figure hasn't got, each over `STRAY` from all of its: a garment or a limb that came out, or was pulled, another colour.""" mine = cielab(np.asarray(figure.convert("RGB"), dtype=np.float64)[opaque(figure)]) @@ -260,6 +270,8 @@ def strays(figure: Image.Image, standing: Image.Image) -> float: return 0.0 centres, shares = main_colours(mine, 2 * KINDS) known, _ = main_colours(theirs, 2 * KINDS) + if also is not None: + known = np.vstack([known, also]) far = np.array([delta_e_lab(known, c).min() for c in centres]) return round(float(shares[(far > STRAY) & (shares >= STRAY_LEAST)].sum()), 3) @@ -320,7 +332,9 @@ def pulled( return figure -def face_marks(figure: Image.Image, appearance: Appearance) -> float: +def face_marks( + figure: Image.Image, appearance: Appearance, spared: NDArray[np.bool_] | None = None +) -> float: """The share of the face painted in patches far from the skin that aren't ink: the face is the front of the thick of the skin in the top of the figure, and lines and specks thinner than a hundredth of its height don't count.""" @@ -343,6 +357,8 @@ def face_marks(figure: Image.Image, appearance: Appearance) -> float: x + int(FACE[0][0] * w) : x + int(FACE[0][1] * w), ] = True odd = box & solid & (far >= FACE_FAR) & (lab[..., 0] >= INK) + if spared is not None: + odd &= ~spared return float(opened(odd, max(1, round(0.01 * (bottom - top)))).sum() / max(box.sum(), 1)) diff --git a/server/simile/dolls/sprites.py b/server/simile/dolls/sprites.py index 8e8e4dff..4ed9c3d3 100644 --- a/server/simile/dolls/sprites.py +++ b/server/simile/dolls/sprites.py @@ -170,12 +170,13 @@ def frames( hands_up: Iterable[str] = (), lifted: Mapping[str, str] | None = None, seated: Iterable[str] = (), + reference: Image.Image | None = None, ) -> dict[str, Frame]: """Every cut-out figure at `PPM`: the standing one `HEIGHT_M` tall, and each other one by the same factor over its `scale`, since FLUX may redraw a pose a little bigger or smaller. A frame `lifted` to another is in the air: its feet go below its lowest row, so its head is as high as the other's. A `seated` frame says where it sits.""" - stand = trimmed(figures[standing]) + stand = trimmed(figures[standing] if reference is None else reference) factor = HEIGHT_M * PPM / height(stand) depth = HEAD * height(stand) out = {} diff --git a/server/tests/dolls/test_bionics.py b/server/tests/dolls/test_bionics.py new file mode 100644 index 00000000..cd829436 --- /dev/null +++ b/server/tests/dolls/test_bionics.py @@ -0,0 +1,160 @@ +import numpy as np +import pytest +from PIL import Image, ImageDraw + +from simile.characters.palette import PLAYER +from simile.dolls.bionics import ( + COMBINATIONS, + FITTED, + changed, + edit_prompt, + figure_id, + laid, + missing, + patches, + shows, + spared, +) +from simile.dolls.figure import FRAMES, stand_prompt +from simile.dolls.flux import FakePainter, figure, fitted, named_colours +from simile.dolls.measure import face_marks +from simile.schema.player import Bionic, Body + +from .keyed import keyed + +LEGS, DATAJACK, EYE = FITTED +SIZE = (300, 400) + + +def whole() -> Image.Image: + return figure(SIZE, named_colours(stand_prompt(PLAYER, "player"))) + + +def with_parts(*parts: Bionic) -> Image.Image: + painting = whole() + for part in parts: + painting = fitted(painting, edit_prompt("stand", part, PLAYER)) + return keyed(painting) + + +def test_every_combination_once_fewest_first() -> None: + assert len(COMBINATIONS) == 7 == len(set(COMBINATIONS)) + assert [len(c) for c in COMBINATIONS] == [1, 1, 1, 2, 2, 2, 3] + assert all(list(c) == sorted(c, key=FITTED.index) for c in COMBINATIONS) + + +@pytest.mark.parametrize( + ("fitted_", "id_"), + [ + ((), "player"), + ((LEGS,), "player_legs"), + ((EYE, LEGS), "player_legs_eye"), + ((EYE, DATAJACK, LEGS), "player_legs_datajack_eye"), + ], +) +def test_names_the_figure_for_whatever_is_fitted(fitted_: tuple, id_: str) -> None: + assert figure_id(fitted_) == id_ + + +def test_a_body_names_its_figure_by_its_bionics_whichever_eye() -> None: + left = Body(left_eye=EYE, vertebra=DATAJACK) + right = Body(right_eye=EYE, vertebra=DATAJACK, left_kidney="sold") + + assert figure_id(left.bionics) == figure_id(right.bionics) == "player_datajack_eye" + + +def test_the_eye_isnt_seen_from_behind() -> None: + assert [f for f in FRAMES if not shows(EYE, f)] == ["climb"] + assert all(shows(LEGS, f) and shows(DATAJACK, f) for f in FRAMES) + + +def test_each_part_is_asked_for_alone_over_the_whole_player() -> None: + legs, eye = edit_prompt("run", LEGS, PLAYER), edit_prompt("run", EYE, PLAYER) + + assert "now with both legs replaced" in legs and "eye nearer the ear" not in legs + assert " shorts and " in legs and " trousers and " not in legs + assert "now with the eye nearer the ear" in eye and " trousers and " in eye + assert "one bright red bionic eye" in eye and "simple dot eyes" in legs + + +def test_climbing_asks_for_no_face() -> None: + climb = edit_prompt("climb", DATAJACK, PLAYER) + + assert "datajack" in climb and "eye" not in climb + + +def test_finds_each_part_painted() -> None: + assert missing(with_parts(), FITTED, "stand") == ["legs", "datajack", "eye"] + assert missing(with_parts(LEGS, DATAJACK, EYE), FITTED, "stand") == [] + assert missing(with_parts(DATAJACK), FITTED, "climb") == ["legs"] + + +def test_both_eyes_painted_red_is_one_too_many() -> None: + painting = with_parts(EYE) + w, h = SIZE + ImageDraw.Draw(painting).ellipse((0.47 * w, 0.21 * h, 0.5 * w, 0.24 * h), (222, 30, 30)) + + assert missing(painting, (EYE,), "stand") == ["two eyes"] + + +def test_an_eye_wider_than_the_walks_is_too_big() -> None: + painting = with_parts(EYE) + w, h = SIZE + ImageDraw.Draw(painting).ellipse((0.5 * w, 0.15 * h, 0.62 * w, 0.24 * h), (222, 30, 30)) + + assert missing(with_parts(EYE), (EYE,), "stand") == [] + assert missing(painting, (EYE,), "stand") == ["eye too big"] + + +def test_the_face_check_spares_the_eye_and_nothing_else() -> None: + eyed = with_parts(EYE) + w, h = SIZE + marked = eyed.copy() + ImageDraw.Draw(marked).ellipse((0.53 * w, 0.27 * h, 0.6 * w, 0.32 * h), (139, 155, 180)) + + assert face_marks(eyed, PLAYER, spared(eyed)) == 0 + assert face_marks(marked, PLAYER, spared(marked)) > 0 + + +def test_counts_patches_biggest_first() -> None: + mask = np.zeros((10, 10), dtype=bool) + mask[1:3, 1:3] = True + mask[5:9, 5:9] = True + + assert [int(p.sum()) for p in patches(mask)] == [16, 4] + + +def test_a_part_is_where_painting_it_changed_the_frame_near_it() -> None: + player = with_parts() + eye = with_parts(EYE) + w, h = SIZE + ImageDraw.Draw(eye).rectangle((0.45 * w, 0.8 * h, 0.55 * w, 0.85 * h), (139, 155, 180)) + + where = changed(player, eye, EYE) + + lens = np.argwhere(np.asarray(eye.convert("RGB")) == (222, 30, 30)) + assert where[lens[:, 0], lens[:, 1]].all() + assert not where[int(0.82 * h), int(0.5 * w)] + assert where.sum() < 0.05 * where.size + + +def test_parts_are_laid_over_the_player_and_nothing_else_changes() -> None: + player = with_parts() + legs, eye = with_parts(LEGS), with_parts(EYE) + w, h = SIZE + + both = laid(player, [(legs, changed(player, legs, LEGS)), (eye, changed(player, eye, EYE))]) + + assert missing(both, (LEGS, EYE), "stand") == [] + before, after = np.asarray(player), np.asarray(both) + assert (before[: int(0.12 * h)] == after[: int(0.12 * h)]).all() + assert (before[:, : int(0.2 * w)] == after[:, : int(0.2 * w)]).all() + + +def test_the_fake_painter_paints_what_an_edit_asks_for() -> None: + painter = FakePainter() + base = painter.paint(stand_prompt(PLAYER, "player"), SIZE, 1) + + edited = painter.paint(edit_prompt("stand", EYE, PLAYER), SIZE, 1, [base]) + + assert missing(keyed(edited), FITTED, "stand") == ["legs", "datajack"] diff --git a/server/tests/dolls/test_build.py b/server/tests/dolls/test_build.py index 8a6c2cdc..4f99a19c 100644 --- a/server/tests/dolls/test_build.py +++ b/server/tests/dolls/test_build.py @@ -6,10 +6,12 @@ import pytest from PIL import Image, ImageDraw -from simile.dolls import build +from simile.dolls import bionics, build +from simile.dolls.bionics import COMBINATIONS, WORDS, figure_id from simile.dolls.figure import FRAMES, POSES from simile.dolls.flux import FakePainter, figure from simile.dolls.measure import MARKS +from simile.schema.player import Bionic from .keyed import keyed @@ -54,6 +56,15 @@ def paint( return crop.resize(size) +class OneEyed(FakePainter): + """Never paints the bionic eye.""" + + def paint( + self, prompt: str, size: tuple[int, int], seed: int, images: Sequence[Image.Image] = () + ) -> Image.Image: + return super().paint(prompt.replace(WORDS[Bionic.INFRARED_EYE], ""), size, seed, images) + + class FacePaint(FakePainter): """Paints a blue-grey patch on the cheek of every pose.""" @@ -74,8 +85,12 @@ def stamps(directory: Path) -> dict[str, int]: return {path.name: path.stat().st_mtime_ns for path in directory.iterdir()} +LEGS, DATAJACK, EYE = bionics.FITTED + + @pytest.fixture -def two(monkeypatch: pytest.MonkeyPatch) -> dict: +def every(monkeypatch: pytest.MonkeyPatch) -> dict: + """Odalys and the player, with every combination of bionics.""" monkeypatch.setattr(build, "SIZE", (192, 256)) everyone = build.people() chosen = {who: everyone[who] for who in ("odalys", "player")} @@ -83,6 +98,20 @@ def two(monkeypatch: pytest.MonkeyPatch) -> dict: return chosen +@pytest.fixture +def fitted(every: dict, monkeypatch: pytest.MonkeyPatch) -> dict: + """Odalys and the player, with the datajack, and with the eye too.""" + monkeypatch.setattr(build, "COMBINATIONS", ((DATAJACK,), (DATAJACK, EYE))) + return every + + +@pytest.fixture +def two(every: dict, monkeypatch: pytest.MonkeyPatch) -> dict: + """Odalys and the player, without bionics.""" + monkeypatch.setattr(build, "COMBINATIONS", ()) + return every + + def test_paints_each_figure_and_a_rerun_rewrites_nothing(tmp_path: Path, two: dict) -> None: painters = Painters() @@ -232,3 +261,76 @@ def test_seats_are_measured_again_from_the_atlas_without_painting( assert sorted(p.name for p in rewritten) == ["odalys.json", "player.json"] assert {p.name: p.read_bytes() for p in tmp_path.iterdir()} == built assert build.reseat(tmp_path) == [] + + +def test_paints_the_player_with_each_combination_of_bionics(tmp_path: Path, every: dict) -> None: + painters = Painters() + + done = made(painters, tmp_path, only=["player"], cache=None) + + assert [p.who for p in done] == ["player", "player_legs", "player_datajack", "player_eye"] + parts = sum(bionics.shows(b, frame) for b in bionics.FITTED for frame in FRAMES) + assert len(painters.fake.calls) == len(FRAMES) + parts + player = build.fingerprint("player", every["player"]) + for combination in COMBINATIONS: + who = figure_id(combination) + manifest = json.loads((tmp_path / f"{who}.json").read_text()) + assert manifest["fitted"] == [b.value for b in combination] + assert manifest["appearance"] == every["player"].model_dump(mode="json") + assert manifest["poses"] == POSES and set(manifest["frames"]) == set(FRAMES) + assert manifest["painted"] == f"{who}.webp" + assert manifest["hash"] == build.fitted_fingerprint(combination, player, every["player"]) + for painted in done[1:]: + assert all(not tries[-1].missing for tries in painted.tries.values()), painted.who + + +def test_each_part_is_painted_once_over_the_player_and_laid_over_it( + tmp_path: Path, fitted: dict +) -> None: + painters = Painters() + + made(painters, tmp_path, only=["player"], cache=None) + + edits = [prompt for prompt, _, images in painters.fake.calls if images] + eye = [prompt for prompt in edits if WORDS[EYE] in prompt] + assert len(eye) == len(FRAMES) - 1 + assert not any(WORDS[DATAJACK] in prompt for prompt in eye) + both = json.loads((tmp_path / "player_datajack_eye.json").read_text()) + datajack = json.loads((tmp_path / "player_datajack.json").read_text()) + climb, without_eye = (m["frames"]["climb"] for m in (both, datajack)) + assert climb["feet"] == without_eye["feet"] + assert climb["frame"][2:] == without_eye["frame"][2:] + + +def test_the_player_with_bionics_is_painted_again_only_when_it_changes( + tmp_path: Path, fitted: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + painters = Painters() + made(painters, tmp_path, only=["player"], cache=None) + before, calls = stamps(tmp_path), len(painters.fake.calls) + + made(painters, tmp_path, only=["player"], cache=None) + assert len(painters.fake.calls) == calls and stamps(tmp_path) == before + + monkeypatch.setitem(build.REROLLS, "player_datajack/stand", 1) + made(painters, tmp_path, only=["player"], cache=None) + + changed = {name for name, stamp in stamps(tmp_path).items() if stamp != before[name]} + assert {"player_datajack.json", "player_datajack_eye.json"} <= changed + assert not changed & {"player.json", "player.webp", "odalys.json"} + + +def test_a_bionic_that_isnt_painted_is_painted_again(tmp_path: Path, fitted: dict) -> None: + done = made(Painters(OneEyed()), tmp_path, only=["player"], cache=None) + + eye = next(p for p in done if p.who == "player_eye") + assert len(eye.tries["stand"]) == build.TRIES["fitted"] + assert eye.tries["stand"][-1].missing == ["eye"] + assert "climb" not in eye.tries + + +def test_the_player_with_bionics_is_kept_with_the_player(tmp_path: Path, fitted: dict) -> None: + made(Painters(), tmp_path, cache=None) + + names = {p.name for p in tmp_path.iterdir()} + assert {f"{figure_id(c)}.webp" for c in build.COMBINATIONS} <= names diff --git a/server/tests/dolls/test_content.py b/server/tests/dolls/test_content.py index 02fb3340..e2b19e31 100644 --- a/server/tests/dolls/test_content.py +++ b/server/tests/dolls/test_content.py @@ -5,9 +5,18 @@ import pytest from PIL import Image -from simile.dolls.build import CONTENT, MISSES, fingerprint, people, personas +from simile.dolls import bionics +from simile.dolls.bionics import COMBINATIONS, SHORTS, figure_id, missing, spared +from simile.dolls.build import ( + CONTENT, + MISSES, + fingerprint, + fitted_fingerprint, + people, + personas, +) from simile.dolls.figure import EDITS, FRAMES, POSES -from simile.dolls.measure import CLOSE, MARKS, face_marks, hidden, swatches +from simile.dolls.measure import CLOSE, CORES, MARKS, face_marks, hidden, swatches from simile.dolls.sprites import ( HEAD, HEIGHT_M, @@ -19,9 +28,20 @@ scale, seat, ) +from simile.schema import PLAYER_ID +from simile.schema.player import Bionic BUDGET = 3 * 1024 * 1024 EVERYONE = people() +FITTED = {figure_id(c): c for c in COMBINATIONS} +FIGURES = EVERYONE | dict.fromkeys(FITTED, EVERYONE[PLAYER_ID]) +"""Every resident, and the player with each combination of bionics.""" +SAME_SIZE = 0.03 +"""How much bigger or smaller the player's head may be drawn with bionics, once scaled.""" +SHOWN = 0.7 +"""The share of `bionics.LEAST` a part must cover once the atlas has shrunk it, lossily.""" +EYE_SIZES = 2 +"""How many atlas pixels wider the eye's lens may be in one frame than another.""" FEET_PX = 2.5 """How far a frame's feet may be from under the middle of its head and its lowest row, after the atlas's lossy WebP.""" @@ -65,7 +85,7 @@ def test_every_resident_and_the_player_has_a_current_figure(who: str) -> None: assert found["ppm"] == PPM and found["height"] == pytest.approx(HEIGHT_M * PPM, abs=0.1) -@pytest.mark.parametrize("who", EVERYONE) +@pytest.mark.parametrize("who", FIGURES) def test_each_frame_stands_under_the_middle_of_its_head(who: str) -> None: depth = HEAD * HEIGHT_M * PPM hands_up = [f for f, pose in EDITS.items() if pose.bands == "climb"] @@ -85,17 +105,19 @@ def test_each_frame_stands_under_the_middle_of_its_head(who: str) -> None: assert feet_x == pytest.approx(middle, abs=FEET_PX), name -@pytest.mark.parametrize("who", EVERYONE) +@pytest.mark.parametrize("who", FIGURES) def test_standing_figures_keep_the_appearances_colours(who: str) -> None: - found = swatches(frame(who, "stand"), EVERYONE[who]) + legs = Bionic.HYDRAULIC_LEGS in FITTED.get(who, ()) + cores = CORES | {"bottom": SHORTS} if legs else CORES + found = swatches(frame(who, "stand"), FIGURES[who], cores=cores) for name, swatch in found.items(): - if name in hidden(EVERYONE[who]) or (who, "stand", name) in MISSES: + if name in hidden(FIGURES[who]) or (who, "stand", name) in MISSES: continue assert swatch.delta_e < FROM_APPEARANCE, f"{name}: {swatch}" -@pytest.mark.parametrize("who", EVERYONE) +@pytest.mark.parametrize("who", FIGURES) def test_every_frame_is_drawn_to_the_standing_figures_scale(who: str) -> None: stand = frame(who, "stand") for name, pose in EDITS.items(): @@ -103,13 +125,59 @@ def test_every_frame_is_drawn_to_the_standing_figures_scale(who: str) -> None: assert drawn == pytest.approx(1, abs=SCALE_OFF), name -@pytest.mark.parametrize("who", EVERYONE) +@pytest.mark.parametrize("who", FIGURES) def test_no_face_is_painted_over(who: str) -> None: + eye = Bionic.INFRARED_EYE in FITTED.get(who, ()) for name in ["stand", *(name for name, pose in EDITS.items() if pose.face)]: - assert face_marks(frame(who, name), EVERYONE[who]) <= MARKS, name + picture = frame(who, name) + assert face_marks(picture, FIGURES[who], spared(picture) if eye else None) <= MARKS, name -@pytest.mark.parametrize("who", EVERYONE) +@pytest.mark.parametrize("who", FITTED) +def test_the_player_has_a_current_figure_for_each_combination_of_bionics(who: str) -> None: + found = manifest(who) + + player = fingerprint(PLAYER_ID, EVERYONE[PLAYER_ID]) + assert found["hash"] == fitted_fingerprint(FITTED[who], player, EVERYONE[PLAYER_ID]) + assert found["fitted"] == [b.value for b in FITTED[who]] + assert found["appearance"] == EVERYONE[PLAYER_ID].model_dump(mode="json") + assert found["poses"] == POSES + assert set(found["frames"]) == set(FRAMES) + assert found["ppm"] == PPM and found["height"] == pytest.approx(HEIGHT_M * PPM, abs=0.1) + + +@pytest.mark.parametrize("who", FITTED) +def test_every_frame_shows_the_bionics_fitted(who: str) -> None: + for name in FRAMES: + assert missing(frame(who, name), FITTED[who], name, SHOWN) == [], name + + +def test_the_whole_player_shows_no_bionics() -> None: + for name in FRAMES: + found = missing(frame(PLAYER_ID, name), bionics.FITTED, name, SHOWN) + + assert found == [bionics.NAMES[b] for b in bionics.FITTED if bionics.shows(b, name)], name + + +@pytest.mark.parametrize("who", [who for who, parts in FITTED.items() if bionics.EYE in parts]) +def test_the_eye_is_one_size_in_every_frame(who: str) -> None: + def width(name: str) -> int: + lens = bionics.patches(bionics.parts(frame(who, name))[bionics.EYE])[0] + return int(np.ptp(np.flatnonzero(lens.any(axis=0)))) + 1 + + widths = {name: width(name) for name in FRAMES if bionics.shows(bionics.EYE, name)} + + assert max(widths.values()) - min(widths.values()) <= EYE_SIZES, widths + + +@pytest.mark.parametrize("who", FITTED) +def test_the_player_is_the_same_size_whatever_is_fitted(who: str) -> None: + drawn = scale(frame(who, "stand"), frame(PLAYER_ID, "stand")) + + assert drawn == pytest.approx(1, abs=SAME_SIZE) + + +@pytest.mark.parametrize("who", FIGURES) def test_the_head_stays_level_through_the_run(who: str) -> None: def crown(name: str) -> float: solid = np.asarray(frame(who, name).getchannel("A")) >= 128 @@ -120,7 +188,7 @@ def crown(name: str) -> float: assert crown(name) >= crown(pose.lifted) - FEET_PX, name -@pytest.mark.parametrize("who", EVERYONE) +@pytest.mark.parametrize("who", FIGURES) def test_sitting_sits_on_its_thighs_with_its_feet_below(who: str) -> None: for name, pose in EDITS.items(): if pose.seated: @@ -146,7 +214,7 @@ def thigh_line(solid: np.ndarray) -> int: return int(edge[best[0] : best[1]].max() + 1) -@pytest.mark.parametrize("who", EVERYONE) +@pytest.mark.parametrize("who", FIGURES) def test_sitting_sits_on_the_bottom_of_its_thighs_as_painted(who: str) -> None: for name, pose in EDITS.items(): if pose.seated: @@ -158,7 +226,7 @@ def test_sitting_sits_on_the_bottom_of_its_thighs_as_painted(who: str) -> None: assert abs(entry["seat"][1] - thigh_line(solid)) / PPM <= ON_SEAT, name -@pytest.mark.parametrize("who", EVERYONE) +@pytest.mark.parametrize("who", FIGURES) def test_figures_are_opaque_inside_their_outline(who: str) -> None: alpha = np.asarray(atlas(who).getchannel("A")).astype(int) clear = alpha < 8 diff --git a/server/tests/dolls/test_flux.py b/server/tests/dolls/test_flux.py index 542f1550..5c0a6362 100644 --- a/server/tests/dolls/test_flux.py +++ b/server/tests/dolls/test_flux.py @@ -1,6 +1,9 @@ +import os + import pytest from simile.characters.palette import PLAYER +from simile.dolls import flux from simile.dolls.figure import edit_prompt, stand_prompt from simile.dolls.flux import Flux @@ -20,3 +23,33 @@ def test_paints_from_text_then_edits_the_painting() -> None: assert stand.size == sit.size == (256, 352) assert stand.tobytes() != sit.tobytes() + + +class Ran: + def __init__(self, stdout: str) -> None: + self.stdout = stdout + + +def test_waits_for_other_image_models_but_not_for_itself(monkeypatch: pytest.MonkeyPatch) -> None: + def run(command: list[str], **kwargs: object) -> Ran: + if command[0] == "ollama": + return Ran("NAME ID SIZE\n") + if "mflux" in command: + return Ran("41 /bin/zsh -c pgrep mflux\n") + return Ran("7 uv run simile dolls paint\n8 python simile papercraft\n") + + monkeypatch.setattr(flux.subprocess, "run", run) + monkeypatch.setattr(flux, "ours", lambda: {"7"}) + naps: list[float] = [] + monkeypatch.setattr(flux.time, "sleep", naps.append) + + assert flux.busy() == ["8 python simile papercraft"] + + calls = iter([["8 python simile papercraft"], []]) + monkeypatch.setattr(flux, "busy", lambda: next(calls)) + flux.wait_for_gpu(log=lambda _: None) + assert naps == [flux.WAIT_S] + + +def test_this_process_and_what_started_it_are_its_own() -> None: + assert {str(os.getpid()), str(os.getppid())} <= flux.ours() diff --git a/server/tests/dolls/test_sprites.py b/server/tests/dolls/test_sprites.py index 915a8a37..8b2d25cd 100644 --- a/server/tests/dolls/test_sprites.py +++ b/server/tests/dolls/test_sprites.py @@ -85,6 +85,17 @@ def test_frames_are_scaled_to_the_street_and_keep_the_head_one_size() -> None: assert frame.feet[0] == pytest.approx(head(frame.image, 0.25 * HEIGHT_M * PPM)[0]) +def test_frames_scaled_against_another_figure_keep_its_head_size() -> None: + whole = frames({"stand": person()}) + made = frames({"stand": person(scale=1.2), "stride": person(lean=0.15)}, reference=person()) + + depth = 0.25 * HEIGHT_M * PPM + for frame in made.values(): + assert head(frame.image, depth)[1] == pytest.approx( + head(whole["stand"].image, depth)[1], abs=2 + ) + + def test_packs_every_frame_apart_into_one_atlas() -> None: images = { f"f{i}": Image.new("RGBA", (90 + 7 * i, 160 - 9 * i), (9, 9, 9, 255)) for i in range(12)