import { Container, Graphics, Texture } from 'pixi.js'; import type { ParticlesAsset } from '../../core/model/Asset'; import { translation } from '../../core/model/matrix'; import type { PropertyBag } from '../../core/model/Properties'; import { resolveTexture } from '../../core/model/texture'; import { defaultsFor } from '../../core/properties/PropertySchema'; import { SHEET_EDGE } from '../../core/shader/nodes'; import { crisp, hairline, type Size, Theme, ui, Widget } from '../../ui'; import { ParticleField } from '../../viewport/particles'; import { particleLibrary } from '../../viewport/ParticleLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { wornShader } from '../../viewport/shaders'; import { textureLibrary } from '../../viewport/TextureLibrary'; import type { EditorState } from '../state'; const MIN_HEIGHT = 40; /** * The asset's own particles, emitted from the middle of the panel by a source that wears the asset's * own rows. It is the one place a formula graph can be judged: the numbers say nothing. What it is * not is an instance – a source placed in a scene may override every one of those rows, and the tab * has no way to know which of them is meant. */ export class ParticlePreview extends Widget { private readonly bg = crisp(new Graphics()); private readonly content = new Container(); private readonly clip = new Graphics(); private readonly field = new ParticleField(); private asset: ParticlesAsset | null = null; /** Null: the preview runs on the library clock. A number: it stands at that second. */ private held: number | null = null; constructor(private readonly state: EditorState) { super(); this.minHeight = MIN_HEIGHT; this.content.addChild(this.field.node); this.content.mask = this.clip; this.addChild(this.bg, this.content, this.clip); } override measure(): Size { return { w: Math.max(this.minWidth, 100), h: Math.max(this.minHeight, MIN_HEIGHT) }; } show(asset: ParticlesAsset | null): void { this.asset = asset; this.invalidate(); } /** Seconds the shown source has been emitting, whether held or running. */ get time(): number { return this.held ?? Math.max(0, this.field.elapsed(particleLibrary.now())); } get holding(): boolean { return this.held !== null; } hold(at: number | null): void { // Letting go has to move the origin, not just stop faking one: the library clock keeps counting // under a freeze, so the panel would otherwise resume at the second it would have reached. if (at === null && this.held !== null) this.field.rebase(particleLibrary.now(), this.held); this.held = at; this.invalidate(); } /** Back to second 0 – a held panel stands there too, rather than keeping the second it was on. */ restart(): void { this.field.restart(particleLibrary.now()); if (this.held !== null) this.held = 0; this.invalidate(); } /** How many particles the preview is drawing – what the checks read. */ get count(): number { return this.field.count; } override redraw(): void { const c = Theme.colors; // A ground of its own and a rule along the top: on the graph's own background the strip was // invisible as a thing, and a particle standing still read as a node that had lost its box. this.bg.clear().rect(0, 0, this.w, this.h).fill(c.panel); this.bg.rect(0, 0, this.w, hairline()).fill(c.border); this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff); this.field.node.visible = this.asset !== null; if (!this.asset) return; const build = particleLibrary.buildFor(this.asset); const rows = build.compiled?.params ?? []; const props = previewProps(this.asset, rows); const params = rows.map((row) => Number(props[row.key] ?? row.default)); const ref = resolveTexture(this.state.project, props); const shaders = shaderLibrary.source(this.state.project); const texture = ref ? textureLibrary.get(ref, wornShader(props, shaders)?.edge ?? SHEET_EDGE) : null; // The source sits in the middle of the panel; a fountain then has room above and below it. const m = translation(this.w / 2, this.h / 2); this.field.hold(this.held); this.field.wear(props, shaders, texture); this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, particleLibrary.now()); // One panel pixel per unit; the frame may reach past the panel, which its mask cuts off. const renderer = ui().app.renderer; this.field.shadeField(props, shaders, m, renderer.resolution, renderer, null); } override destroy(): void { this.field.destroy(); super.destroy(); } } /** * A source as the asset alone describes it: the rows the asset carries, over the type's defaults for * the ones it does not, with the graph's own parameters at their declared defaults underneath. */ function previewProps( asset: ParticlesAsset, params: readonly { key: string; default: number }[], ): PropertyBag { const props = defaultsFor('particles'); for (const row of params) props[row.key] = row.default; return { ...props, ...asset.props }; }