Files
feuerwerk/src/viewport/vectorArtPicture.ts
T
adminandClaude Opus 5.5 056a622524
game-maker / gate (push) Successful in 2m57s
game-maker / suite (push) Successful in 8m55s
Refactoring round 6: shared sheet canvas, fewer copies (#85)
Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).

- SheetCanvas is the new base of the texture, pixel art and vector art
  canvases. It holds background, mask, hint, panning, wheel zoom, fit,
  view save and restore and the screen mapping. Pixel art keeps its
  whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
  animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
  contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
  commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
  GeneratedPictures.has and SnapToggle.snapping. Tests now check the
  same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
  The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
  wiki pages.

Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
  has not been called since #128. The rectangle tool therefore records
  a no-op undo step when it paints the same tile over itself. Left as
  is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
  and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
  could become 10 without a red test. tests/core/raycast.test.ts now
  covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
  #164. The sum is now 1310 (1308 + 2), computed.

Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
  both new pan checks; unclamped mix fails 1 test, no blue channel 3;
  coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
  bent ray length 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 06:57:43 +02:00

224 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The drawn half of vector art (#129, stage 2): the shapes of an asset painted onto a texture of
* its canvas size, handed to the picture path as the generated picture of #185. From there on a
* sprite, a tile, a tilemap, a particle source and a shader wear it exactly as they wear an
* uploaded image – none of them learns that it was drawn.
*
* Two things are worth knowing here. The texture is made at `SUPERSAMPLE` times the canvas
* resolution, so zooming into a drawing does not turn to mush right away, while `texture.width`
* stays the canvas size – which is what the sprite is measured by (`resolvePicture`). And the
* canvas is the frame: whatever a shape reaches past it is not in the texture, so the size a sprite
* shows is the size one sees.
*
* The render runs inside the ask for the picture, which is safe because every caller asks while it
* redraws its own widgets and the frame itself is rendered afterwards, on the ticker (`ui/render`).
*/
import {
FillGradient,
type FillStyle,
Graphics,
GraphicsPath,
Matrix,
RenderTexture,
type Renderer,
type Texture,
} from 'pixi.js';
import type { Asset } from '../core/model/Asset';
import { registerPictureSource } from '../core/model/generated';
import type { Project } from '../core/model/Project';
import { vectorArtPicture } from '../core/model/vectorart';
import { type DrawPaint, type PathOp, type ShapeDrawing, vectorArtCanvas } from '../core/model/vectorPath';
import { registerPictureGenerator } from './generatedPictures';
import { textureLibrary } from './TextureLibrary';
/**
* How much finer than the canvas the picture is rasterised (the user's answer: 2x). A drawing on a
* 256 canvas therefore lies on the GPU as 512 pixels, and the editor's own view is at most twice
* the canvas before it shows pixels instead of curves.
*/
const SUPERSAMPLE = 2;
/** Registers vector art in both halves of the picture path; the returned function takes it out. */
export function installVectorArtPictures(renderer: Renderer): () => void {
const removeSource = registerPictureSource('vectorart', vectorArtPicture);
const removeGenerator = registerPictureGenerator('vectorart', (asset, project) =>
rasteriseVectorArt(asset, renderer, project),
);
return () => {
removeSource();
removeGenerator();
};
}
/**
* The asset as a texture, or null when it is not vector art at all. An asset without a single
* drawable shape still gets its canvas: an empty picture is what an empty drawing looks like, and a
* sprite wearing it keeps its size while the first shape is being drawn.
*
* Null too while a picture it is filled with is still loading (#186). Nothing is remembered for a
* null, so the next ask – the redraw that follows the picture landing – rasterises it complete,
* instead of a drawing with a hole in it staying on screen under a key that never changes.
*/
function rasteriseVectorArt(asset: Asset, renderer: Renderer, project?: Project): Texture | null {
const canvas = vectorArtCanvas(asset, asset.props, project);
if (!canvas) return null;
const graphics = new Graphics();
const paints = new PaintCache();
let complete = true;
for (const drawing of canvas.drawings) complete = paintDrawing(graphics, drawing, paints) && complete;
if (!complete) {
release(graphics, paints);
return null;
}
const texture = RenderTexture.create({
width: canvas.width,
height: canvas.height,
resolution: SUPERSAMPLE,
antialias: true,
});
renderer.render({ container: graphics, target: texture, clear: true });
// The picture is on the GPU from here on; the scene graph that drew it is of no more use.
release(graphics, paints);
return texture;
}
/**
* Lets the drawing go, but not the pictures it filled with. `destroy(true)` would destroy every
* texture the context's fills name – a picture fill samples the library's own cut of a texture
* (#186), and a destroyed cut left the next redraw reading a texture without a source.
*/
function release(graphics: Graphics, paints: PaintCache): void {
graphics.destroy();
paints.destroy();
}
/**
* The gradients one painter keeps (#186). Each `FillGradient` builds a texture of its own, so a
* canvas that repaints on every pointer move would pile them up; one asked for again with the same
* stops is reused, and one not asked for between two sweeps is let go.
*
* Let go, not destroyed: the frame drawn last still has the gradient's texture bound, and destroying
* it there makes Pixi warn "destroyed while still bound to a shader" once per gradient. Handed to the
* texture garbage collector instead, it is unloaded once it has not been drawn for a while.
*/
export class PaintCache {
private readonly held = new Map<string, FillGradient>();
private readonly used = new Set<string>();
gradient(paint: Extract<DrawPaint, { stops: unknown }>): FillGradient {
const key = JSON.stringify(paint);
this.used.add(key);
let gradient = this.held.get(key);
if (!gradient) {
const colorStops = paint.stops.map((stop) => ({
offset: stop.offset,
color: [
((stop.color >> 16) & 0xff) / 255,
((stop.color >> 8) & 0xff) / 255,
(stop.color & 0xff) / 255,
stop.alpha,
],
}));
gradient =
paint.type === 'linear'
? new FillGradient({
type: 'linear',
start: paint.from,
end: paint.to,
colorStops,
textureSpace: 'global',
})
: new FillGradient({
type: 'radial',
center: paint.from,
innerRadius: 0,
outerCenter: paint.from,
outerRadius: Math.hypot(paint.to.x - paint.from.x, paint.to.y - paint.from.y),
colorStops,
textureSpace: 'global',
});
this.held.set(key, gradient);
}
return gradient;
}
/** Lets go of what was not asked for since the last sweep; call it after a full repaint. */
sweep(): void {
for (const [key, gradient] of this.held)
if (!this.used.has(key)) {
// Built lazily on first use: a gradient never drawn has no texture to hand over.
const source = (gradient.texture as Texture | undefined)?.source;
if (source) source.autoGarbageCollect = true;
this.held.delete(key);
}
this.used.clear();
}
destroy(): void {
this.used.clear();
this.sweep();
}
}
/**
* One shape: its fill first, its outline over it, so a stroke is never half swallowed by the fill.
* Exported because the Vector art tab paints the very same drawing onto its own `Graphics` (#129,
* stage 3) – what one edits there has to be what a sprite ends up wearing.
*/
export function paintDrawing(graphics: Graphics, drawing: ShapeDrawing, paints: PaintCache): boolean {
let complete = true;
if (drawing.fill.length > 0) {
const fill = fillOf(drawing.paint, drawing.color, paints);
complete = fill !== null;
// `checkForHoles` is what makes a ring inside another cut it out instead of painting over it.
graphics
.path(pathOf(drawing.fill, true))
.fill({ ...(fill ?? { color: drawing.color }), alpha: drawing.alpha });
}
if (drawing.stroke.length > 0) {
const line = fillOf(drawing.strokePaint, drawing.strokeColor, paints) ?? { color: drawing.strokeColor };
graphics
.path(pathOf(drawing.stroke, false))
.stroke({ ...line, width: drawing.strokeWidth, alpha: drawing.alpha });
}
return complete;
}
/**
* How a paint fills, or null while its picture is still loading. A picture fill samples its own cut
* of the texture with `repeat`: the library's sheets are `mirror-repeat` (see `TextureLibrary`), and
* a `FillPattern` would switch the address mode on the shared source under every sprite wearing it.
* The matrix takes texture pixels to canvas pixels, so one tile spans `from` to `to`.
*/
function fillOf(paint: DrawPaint | null, color: number, paints: PaintCache): FillStyle | null {
if (!paint) return { color };
if (paint.type !== 'texture') return { fill: paints.gradient(paint) };
const texture = textureLibrary.get(paint.ref, 'repeat');
if (!texture) return null;
const matrix = new Matrix()
.scale((paint.to.x - paint.from.x) / texture.width, (paint.to.y - paint.from.y) / texture.height)
.translate(paint.from.x, paint.from.y);
return { texture, matrix, textureSpace: 'global' };
}
function pathOf(ops: readonly PathOp[], checkForHoles: boolean): GraphicsPath {
const path = new GraphicsPath();
path.checkForHoles = checkForHoles;
for (const op of ops)
switch (op.op) {
case 'move':
path.moveTo(op.x, op.y);
break;
case 'line':
path.lineTo(op.x, op.y);
break;
case 'curve':
path.bezierCurveTo(op.c1x, op.c1y, op.c2x, op.c2y, op.x, op.y);
break;
case 'close':
path.closePath();
break;
}
return path;
}