A vector art asset is a picture now, which is stage 2 of #129 and the first stage that shows something in the browser: the asset sits in the picture picker, a sprite carries it in its Texture row and what the sprite shows has been rasterized from the shapes, the generator of #185 being registered for the type vectorart - installVectorArtPictures(app.renderer) hooks the source (core) and the generator (viewport) in once per page from App.ts, by a call and not by a module side effect, so a test may install it or leave it out; the rule lives in core and the painting in viewport - the new core/model/vectorPath.ts turns a shape into plain drawing commands (move/line/curve/close), closed rings being filled while all rings are stroked, a segment staying a line as long as neither of its ends carries a handle - a single handle is enough for a curve, the missing control point sitting on its own end, so a corner may stand next to a round side - and the way back to the first point is only drawn when it is curved, close doing it otherwise; holes go through one door only, Pixi recognising them from a GraphicsPath with checkForHoles, which tests containment rather than even-odd depth, which is enough for a ring inside a ring while without the flag the inner ring simply paints over the outer one (measured, see the counter-checks); the oversampling is the 2x from the plan - RenderTexture.create({resolution: 2}) puts 512 px on the GPU for a 256 canvas while texture.width stays the canvas size, which is the measure the sprite hangs on, and the render texture is the clip itself, so what juts out over the canvas is not in it and needs no mask; drawn pictures get the same sampler state as uploaded ones, setPictureFactory wrapping the factory for it, mirroring keep(); two measuring traps cost me runs - __gm.objectRect(id) gives the centre of the object and not its corner, so the first pass read four pixels next to the sprite and reported background while the rasterizer was correct, and the grid lines of the scene cross exactly in the centre point, so the hole check read #363940 and failed for the right reason, measuring beside it now; renderer.render() runs inside the picture request, which is safe here because every caller asks during its own widget redraw and the frame is drawn on the ticker afterwards, a nested render inside a running render pass would not be - that warning stands in the module head and in the notes; new module 47 with 8 own checks in its own lane (the picture on the GPU at double resolution measuring the canvas, the sprite showing the fill, the inner ring being a hole with the scene shining through, the later shape on top, the stroke running along the ring, a changed drawing getting a new key with the old one freed, and the sprite showing the new colour), the shapes being put into the model by mutate because the tab that draws them is stage 3, and 21 new unit tests in tests/core/vector-path.test.ts; three counter-checks hold: oversampling back to 1 fails the GPU check, checkForHoles off fails the hole check, and not drawing the curved way back fails a unit test; the full suite ran locally instead of only in CI because the change sits in TextureLibrary and touches every picture: ALL PASSED - 1165 checks, measured and carried into README and the wiki (#129)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bdb3f066cf
commit
6371576d37
10 files changed
+623
-3
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1157 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1165 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* What a vector art asset draws, as plain commands (#129, stage 2). The rasteriser of the viewport
|
||||
* only walks this list onto a `Graphics`: everything that has to be decided – which ring is a fill
|
||||
* and which only a stroke, where a segment is a line and where a curve, what an absent handle
|
||||
* means – is decided here, in canvas pixels, so it can be measured without a GPU.
|
||||
*
|
||||
* Nothing is clipped here. The picture is rendered into a texture the size of the canvas, and what
|
||||
* lies outside it is simply not in that texture (answer 3).
|
||||
*/
|
||||
import type { Asset, VectorArtAsset, VectorPoint, VectorRing, VectorShape } from './Asset';
|
||||
import type { PropertyBag } from './Properties';
|
||||
import { isDrawableRing, vectorCanvasSize } from './vectorart';
|
||||
|
||||
/** One step along a path. `close` goes back to where the subpath began, in a straight line. */
|
||||
export type PathOp =
|
||||
| { op: 'move'; x: number; y: number }
|
||||
| { op: 'line'; x: number; y: number }
|
||||
| { op: 'curve'; c1x: number; c1y: number; c2x: number; c2y: number; x: number; y: number }
|
||||
| { op: 'close' };
|
||||
|
||||
/**
|
||||
* One shape ready to be drawn. The two paths are apart because they are not the same set of rings:
|
||||
* an open ring is a line and has nothing to enclose, so it is stroked and never filled, while a
|
||||
* shape whose rings are all open has no fill path at all.
|
||||
*/
|
||||
export interface ShapeDrawing {
|
||||
id: string;
|
||||
/** The closed rings, in the order they are drawn; a ring inside another cuts it out. */
|
||||
fill: PathOp[];
|
||||
/** Every ring, open ones included; empty when the shape has no outline. */
|
||||
stroke: PathOp[];
|
||||
color: number;
|
||||
alpha: number;
|
||||
strokeColor: number;
|
||||
/** In canvas pixels; 0 means no outline. */
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
/** What the asset draws, back to front – the last shape of the list lies on top. */
|
||||
export function vectorArtDrawings(asset: VectorArtAsset): ShapeDrawing[] {
|
||||
const drawings: ShapeDrawing[] = [];
|
||||
for (const shape of asset.shapes) {
|
||||
const drawing = shapeDrawing(shape);
|
||||
if (drawing) drawings.push(drawing);
|
||||
}
|
||||
return drawings;
|
||||
}
|
||||
|
||||
/** Null when the shape draws nothing: no drawable ring, or fully transparent and without outline. */
|
||||
export function shapeDrawing(shape: VectorShape): ShapeDrawing | null {
|
||||
const rings = shape.rings.filter(isDrawableRing);
|
||||
if (rings.length === 0) return null;
|
||||
const alpha = clamp01(shape.opacity);
|
||||
const strokeWidth = shape.stroke && shape.stroke.width > 0 ? shape.stroke.width : 0;
|
||||
if (alpha <= 0 && strokeWidth <= 0) return null;
|
||||
const fill = alpha > 0 ? rings.filter((ring) => ring.closed).flatMap(ringPath) : [];
|
||||
const stroke = strokeWidth > 0 ? rings.flatMap(ringPath) : [];
|
||||
if (fill.length === 0 && stroke.length === 0) return null;
|
||||
return {
|
||||
id: shape.id,
|
||||
fill,
|
||||
stroke,
|
||||
color: shape.fill,
|
||||
alpha,
|
||||
strokeColor: shape.stroke?.color ?? shape.fill,
|
||||
strokeWidth,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One ring as its own subpath. A segment is a straight line while neither of its two ends carries a
|
||||
* handle; one handle alone is enough for a curve, the missing control point then sitting on its own
|
||||
* end, which is what makes a corner next to a round side.
|
||||
*/
|
||||
export function ringPath(ring: VectorRing): PathOp[] {
|
||||
const points = ring.points;
|
||||
const first = points[0];
|
||||
if (!first) return [];
|
||||
const ops: PathOp[] = [{ op: 'move', x: first.x, y: first.y }];
|
||||
for (let i = 1; i < points.length; i++) ops.push(segment(points[i - 1]!, points[i]!));
|
||||
if (ring.closed) {
|
||||
const last = points[points.length - 1]!;
|
||||
// A straight way back is what `close` itself draws; a curved one has to be drawn before it.
|
||||
if (last.out || first.in) ops.push(segment(last, first));
|
||||
ops.push({ op: 'close' });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
function segment(from: VectorPoint, to: VectorPoint): PathOp {
|
||||
if (!from.out && !to.in) return { op: 'line', x: to.x, y: to.y };
|
||||
const c1 = from.out ? { x: from.x + from.out.x, y: from.y + from.out.y } : from;
|
||||
const c2 = to.in ? { x: to.x + to.in.x, y: to.y + to.in.y } : to;
|
||||
return { op: 'curve', c1x: c1.x, c1y: c1.y, c2x: c2.x, c2y: c2.y, x: to.x, y: to.y };
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 1;
|
||||
}
|
||||
|
||||
/** The canvas an asset is rasterised on, and whether anything at all is drawn on it. */
|
||||
export function vectorArtCanvas(
|
||||
asset: Asset,
|
||||
props: PropertyBag = asset.props,
|
||||
): { width: number; height: number; drawings: ShapeDrawing[] } | null {
|
||||
if (asset.type !== 'vectorart') return null;
|
||||
const { width, height } = vectorCanvasSize(props);
|
||||
return { width, height, drawings: vectorArtDrawings(asset) };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { fontLibrary } from '../viewport/FontLibrary';
|
||||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { projectPictureFactory } from '../viewport/generatedPictures';
|
||||
import { textureLibrary } from '../viewport/TextureLibrary';
|
||||
import { installVectorArtPictures } from '../viewport/vectorArtPicture';
|
||||
import { LoginScreen } from './auth/LoginScreen';
|
||||
import { installLoginHook } from './auth/loginHook';
|
||||
import { consoleLog } from './console/log';
|
||||
@@ -75,6 +76,9 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
||||
// for the page, not per session – the files they hold are shared by every account anyway.
|
||||
textureLibrary.onChange(() => requestRender());
|
||||
fontLibrary.onChange(() => requestRender());
|
||||
// Vector art draws its own picture and needs the renderer to do it (#129). Registered once for
|
||||
// the page like the libraries above: which project it is asked about comes from the factory below.
|
||||
installVectorArtPictures(app.renderer);
|
||||
|
||||
const openSession = async (): Promise<void> => {
|
||||
const user = await signedInUser(app, root, auth);
|
||||
|
||||
+20
-1
@@ -4,7 +4,7 @@ import { previewObjectId } from '../core/model/animationPreview';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { resolveObjects } from '../core/model/instance';
|
||||
import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project';
|
||||
import type { FrameRect } from '../core/model/texture';
|
||||
import { type FrameRect, resolvePicture } from '../core/model/texture';
|
||||
import { findLayer, layerZ } from '../core/model/tilemap';
|
||||
import { effectiveScale, gameClock } from '../core/time';
|
||||
import { physicsStatus } from '../runtime/physics/engine';
|
||||
@@ -672,6 +672,25 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
uploaded,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* A picture an asset draws itself (#129, #185): the key it is kept under, the canvas the model
|
||||
* says it is, and the size it actually lies on the GPU in – which is the only place the
|
||||
* supersampling of the rasteriser is visible. `kept` false means the key is not in memory,
|
||||
* which is what a check reads to see an old revision let go.
|
||||
*/
|
||||
drawnPicture: (assetId: string) => {
|
||||
const ref = resolvePicture(state.project, assetId);
|
||||
if (!ref) return null;
|
||||
const texture = textureLibrary.peek(ref.file);
|
||||
return {
|
||||
file: ref.file,
|
||||
width: ref.sourceWidth,
|
||||
height: ref.sourceHeight,
|
||||
pixelWidth: texture?.source.pixelWidth ?? 0,
|
||||
pixelHeight: texture?.source.pixelHeight ?? 0,
|
||||
kept: texture !== null,
|
||||
};
|
||||
},
|
||||
fps: () => editor.fps,
|
||||
/**
|
||||
* What the render loop did: frames actually drawn, and whether it is currently awake. The
|
||||
|
||||
@@ -40,7 +40,19 @@ export class TextureLibrary extends FileLibrary<Texture> {
|
||||
|
||||
/** Installs who draws generated pictures; without one, a generated key stays empty. */
|
||||
setPictureFactory(factory: PictureFactory<Texture> | null): void {
|
||||
this.generated.setFactory(factory);
|
||||
// A drawn picture gets the same sampler state as an uploaded one (see `keep`): it is worn by
|
||||
// the same meshes and fills, and the address mode belongs to the source either way.
|
||||
this.generated.setFactory(
|
||||
factory &&
|
||||
((assetId) => {
|
||||
const texture = factory(assetId);
|
||||
if (texture) {
|
||||
texture.source.style.addressMode = EDGE_MODE;
|
||||
texture.source.style.update();
|
||||
}
|
||||
return texture;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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 { Graphics, GraphicsPath, RenderTexture, type Renderer, type Texture } from 'pixi.js';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import { registerPictureSource } from '../core/model/generated';
|
||||
import { vectorArtPicture } from '../core/model/vectorart';
|
||||
import { type PathOp, type ShapeDrawing, vectorArtCanvas } from '../core/model/vectorPath';
|
||||
import { registerPictureGenerator } from './generatedPictures';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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) =>
|
||||
rasteriseVectorArt(asset, renderer),
|
||||
);
|
||||
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.
|
||||
*/
|
||||
export function rasteriseVectorArt(asset: Asset, renderer: Renderer): Texture | null {
|
||||
const canvas = vectorArtCanvas(asset);
|
||||
if (!canvas) return null;
|
||||
const texture = RenderTexture.create({
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
resolution: SUPERSAMPLE,
|
||||
antialias: true,
|
||||
});
|
||||
const graphics = new Graphics();
|
||||
for (const drawing of canvas.drawings) paint(graphics, drawing);
|
||||
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.
|
||||
graphics.destroy(true);
|
||||
return texture;
|
||||
}
|
||||
|
||||
/** One shape: its fill first, its outline over it, so a stroke is never half swallowed by the fill. */
|
||||
function paint(graphics: Graphics, drawing: ShapeDrawing): void {
|
||||
if (drawing.fill.length > 0) {
|
||||
// `checkForHoles` is what makes a ring inside another cut it out instead of painting over it.
|
||||
graphics.path(pathOf(drawing.fill, true)).fill({ color: drawing.color, alpha: drawing.alpha });
|
||||
}
|
||||
if (drawing.stroke.length > 0)
|
||||
graphics.path(pathOf(drawing.stroke, false)).stroke({
|
||||
color: drawing.strokeColor,
|
||||
width: drawing.strokeWidth,
|
||||
alpha: drawing.alpha,
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Asset, VectorArtAsset, VectorRing, VectorShape } from '../../src/core/model/Asset';
|
||||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||
import { ringPath, shapeDrawing, vectorArtCanvas, vectorArtDrawings } from '../../src/core/model/vectorPath';
|
||||
import { VECTOR_HEIGHT_KEY, VECTOR_WIDTH_KEY } from '../../src/core/model/vectorart';
|
||||
|
||||
function ring(points: VectorRing['points'], closed = true): VectorRing {
|
||||
return { points, closed };
|
||||
}
|
||||
|
||||
const SQUARE = ring([
|
||||
{ x: 10, y: 10 },
|
||||
{ x: 90, y: 10 },
|
||||
{ x: 90, y: 90 },
|
||||
{ x: 10, y: 90 },
|
||||
]);
|
||||
|
||||
const HOLE = ring([
|
||||
{ x: 30, y: 30 },
|
||||
{ x: 70, y: 30 },
|
||||
{ x: 70, y: 70 },
|
||||
{ x: 30, y: 70 },
|
||||
]);
|
||||
|
||||
function shape(over: Partial<VectorShape> = {}): VectorShape {
|
||||
return { id: 's1', name: 'Shape 1', rings: [SQUARE], fill: 0x4488ff, opacity: 1, ...over };
|
||||
}
|
||||
|
||||
function art(shapes: VectorShape[], props: PropertyBag = {}): VectorArtAsset {
|
||||
return { id: 'a1', type: 'vectorart', name: 'Logo', props, shapes };
|
||||
}
|
||||
|
||||
describe('the path of a ring', () => {
|
||||
it('is a line from point to point while no point carries a handle', () => {
|
||||
expect(ringPath(SQUARE)).toEqual([
|
||||
{ op: 'move', x: 10, y: 10 },
|
||||
{ op: 'line', x: 90, y: 10 },
|
||||
{ op: 'line', x: 90, y: 90 },
|
||||
{ op: 'line', x: 10, y: 90 },
|
||||
{ op: 'close' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves the way back to the first point to `close` while it is straight', () => {
|
||||
const ops = ringPath(SQUARE);
|
||||
expect(ops.filter((op) => op.op === 'close')).toHaveLength(1);
|
||||
expect(ops[ops.length - 1]).toEqual({ op: 'close' });
|
||||
});
|
||||
|
||||
it('draws the way back itself once one of its two ends is bent', () => {
|
||||
const bent = ring([
|
||||
{ x: 10, y: 10, in: { x: -5, y: 0 } },
|
||||
{ x: 90, y: 10 },
|
||||
{ x: 90, y: 90, out: { x: 0, y: 8 } },
|
||||
]);
|
||||
const ops = ringPath(bent);
|
||||
expect(ops[ops.length - 2]).toEqual({
|
||||
op: 'curve',
|
||||
c1x: 90,
|
||||
c1y: 98,
|
||||
c2x: 5,
|
||||
c2y: 10,
|
||||
x: 10,
|
||||
y: 10,
|
||||
});
|
||||
expect(ops[ops.length - 1]).toEqual({ op: 'close' });
|
||||
});
|
||||
|
||||
it('reads a handle as an offset from its own point', () => {
|
||||
const ops = ringPath(
|
||||
ring(
|
||||
[
|
||||
{ x: 10, y: 10, out: { x: 20, y: 0 } },
|
||||
{ x: 90, y: 10, in: { x: -20, y: 0 } },
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
expect(ops).toEqual([
|
||||
{ op: 'move', x: 10, y: 10 },
|
||||
{ op: 'curve', c1x: 30, c1y: 10, c2x: 70, c2y: 10, x: 90, y: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('puts the missing control point on its own end, so a corner may sit next to a round side', () => {
|
||||
const ops = ringPath(
|
||||
ring(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 40, y: 0, in: { x: -10, y: -10 } },
|
||||
],
|
||||
false,
|
||||
),
|
||||
);
|
||||
expect(ops[1]).toEqual({ op: 'curve', c1x: 0, c1y: 0, c2x: 30, c2y: -10, x: 40, y: 0 });
|
||||
});
|
||||
|
||||
it('is nothing for a ring without a point', () => {
|
||||
expect(ringPath(ring([]))).toEqual([]);
|
||||
});
|
||||
|
||||
it('stays open where the ring is open', () => {
|
||||
expect(
|
||||
ringPath(
|
||||
ring(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 10, y: 0 },
|
||||
],
|
||||
false,
|
||||
),
|
||||
),
|
||||
).toEqual([
|
||||
{ op: 'move', x: 0, y: 0 },
|
||||
{ op: 'line', x: 10, y: 0 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what one shape draws', () => {
|
||||
it('fills its closed rings and strokes nothing without an outline', () => {
|
||||
const drawing = shapeDrawing(shape());
|
||||
expect(drawing?.fill).toHaveLength(5);
|
||||
expect(drawing?.stroke).toEqual([]);
|
||||
expect(drawing?.color).toBe(0x4488ff);
|
||||
expect(drawing?.alpha).toBe(1);
|
||||
});
|
||||
|
||||
it('carries the hole in the same fill path, so it can cut the ring around it out', () => {
|
||||
const drawing = shapeDrawing(shape({ rings: [SQUARE, HOLE] }));
|
||||
expect(drawing?.fill.filter((op) => op.op === 'move')).toHaveLength(2);
|
||||
expect(drawing?.fill[5]).toEqual({ op: 'move', x: 30, y: 30 });
|
||||
});
|
||||
|
||||
it('never fills an open ring but strokes it', () => {
|
||||
const line = ring(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 50, y: 50 },
|
||||
],
|
||||
false,
|
||||
);
|
||||
const drawing = shapeDrawing(shape({ rings: [line], stroke: { color: 0xff0000, width: 3 } }));
|
||||
expect(drawing?.fill).toEqual([]);
|
||||
expect(drawing?.stroke).toHaveLength(2);
|
||||
expect(drawing?.strokeColor).toBe(0xff0000);
|
||||
expect(drawing?.strokeWidth).toBe(3);
|
||||
});
|
||||
|
||||
it('strokes every ring, the closed ones included', () => {
|
||||
const drawing = shapeDrawing(
|
||||
shape({
|
||||
rings: [
|
||||
SQUARE,
|
||||
ring(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 5, y: 5 },
|
||||
],
|
||||
false,
|
||||
),
|
||||
],
|
||||
stroke: { color: 1, width: 2 },
|
||||
}),
|
||||
);
|
||||
expect(drawing?.stroke.filter((op) => op.op === 'move')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('passes over a ring nobody has finished drawing', () => {
|
||||
expect(shapeDrawing(shape({ rings: [ring([{ x: 5, y: 5 }])] }))).toBeNull();
|
||||
const started = shapeDrawing(shape({ rings: [SQUARE, ring([{ x: 5, y: 5 }])] }));
|
||||
expect(started?.fill.filter((op) => op.op === 'move')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('draws nothing when it is fully transparent and has no outline', () => {
|
||||
expect(shapeDrawing(shape({ opacity: 0 }))).toBeNull();
|
||||
const outlined = shapeDrawing(shape({ opacity: 0, stroke: { color: 0xffffff, width: 1 } }));
|
||||
expect(outlined?.fill).toEqual([]);
|
||||
expect(outlined?.stroke).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('counts an outline of width 0 as no outline', () => {
|
||||
expect(shapeDrawing(shape({ stroke: { color: 0xffffff, width: 0 } }))?.stroke).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the opacity inside 0..1 and reads a broken one as opaque', () => {
|
||||
expect(shapeDrawing(shape({ opacity: 2 }))?.alpha).toBe(1);
|
||||
expect(shapeDrawing(shape({ opacity: Number.NaN }))?.alpha).toBe(1);
|
||||
expect(shapeDrawing(shape({ opacity: 0.25 }))?.alpha).toBe(0.25);
|
||||
});
|
||||
|
||||
it('strokes in the fill colour while no colour of its own is set', () => {
|
||||
expect(shapeDrawing(shape({ stroke: { color: 0x4488ff, width: 1 } }))?.strokeColor).toBe(0x4488ff);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the asset draws', () => {
|
||||
it('hands its shapes out in drawing order, the last one on top', () => {
|
||||
const drawings = vectorArtDrawings(art([shape(), shape({ id: 's2', rings: [HOLE], fill: 0xff0000 })]));
|
||||
expect(drawings.map((d) => d.id)).toEqual(['s1', 's2']);
|
||||
});
|
||||
|
||||
it('leaves out what draws nothing instead of handing out an empty shape', () => {
|
||||
const drawings = vectorArtDrawings(art([shape({ id: 'empty', rings: [] }), shape()]));
|
||||
expect(drawings.map((d) => d.id)).toEqual(['s1']);
|
||||
});
|
||||
|
||||
it('is drawn on the canvas of the asset, read from the property bag', () => {
|
||||
const canvas = vectorArtCanvas(art([shape()], { [VECTOR_WIDTH_KEY]: 64, [VECTOR_HEIGHT_KEY]: 32 }));
|
||||
expect(canvas).toMatchObject({ width: 64, height: 32 });
|
||||
expect(canvas?.drawings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('is an empty canvas while nothing is drawn on it, not nothing', () => {
|
||||
expect(vectorArtCanvas(art([]))).toMatchObject({ width: 256, height: 256, drawings: [] });
|
||||
});
|
||||
|
||||
it('is nothing for an asset of another type', () => {
|
||||
const texture = { id: 't1', type: 'texture', name: 'Sky', props: {} } as unknown as Asset;
|
||||
expect(vectorArtCanvas(texture)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { colourDistance } from '../lib/harness.mjs';
|
||||
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Vector art (#129), stage 2: the drawing is rasterised and travels the picture path of #185. What
|
||||
* only a running editor can show – the shapes are painted by the GPU, so what a check reads here is
|
||||
* the picture itself: the fill of a shape, the hole a ring inside it cuts, which of two shapes lies
|
||||
* on top, the outline along a ring, and that a changed drawing becomes a new picture while the old
|
||||
* one is let go.
|
||||
*
|
||||
* The shapes are put into the model rather than drawn with the mouse: the tab that draws them is
|
||||
* stage 3, and these checks are about the rasteriser, not about the tool.
|
||||
*/
|
||||
|
||||
const CANVAS = 256;
|
||||
const FIELD = '#2e8b57';
|
||||
const BADGE = '#dd4433';
|
||||
const OUTLINE = '#1133ee';
|
||||
const REDRAWN = '#e8c020';
|
||||
|
||||
/** The square field with a square hole in it, and a badge with an outline drawn over it. */
|
||||
const SHAPES = [
|
||||
{
|
||||
id: 'va-field',
|
||||
name: 'Field',
|
||||
fill: 0x2e8b57,
|
||||
opacity: 1,
|
||||
rings: [
|
||||
{
|
||||
closed: true,
|
||||
points: [
|
||||
{ x: 16, y: 16 },
|
||||
{ x: 240, y: 16 },
|
||||
{ x: 240, y: 240 },
|
||||
{ x: 16, y: 240 },
|
||||
],
|
||||
},
|
||||
{
|
||||
closed: true,
|
||||
points: [
|
||||
{ x: 96, y: 96 },
|
||||
{ x: 160, y: 96 },
|
||||
{ x: 160, y: 160 },
|
||||
{ x: 96, y: 160 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'va-badge',
|
||||
name: 'Badge',
|
||||
fill: 0xdd4433,
|
||||
opacity: 1,
|
||||
stroke: { color: 0x1133ee, width: 10 },
|
||||
rings: [
|
||||
{
|
||||
closed: true,
|
||||
points: [
|
||||
{ x: 176, y: 32 },
|
||||
{ x: 232, y: 32 },
|
||||
{ x: 232, y: 88 },
|
||||
{ x: 176, y: 88 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export async function vectorArt(t) {
|
||||
const { check, json, evaluate, mutate, settle, sleep, shot, showTab } = t;
|
||||
const Y = LANES.vectorArt;
|
||||
|
||||
// 47a. A drawing on a sprite: what the rasteriser put on the GPU, read off the screen.
|
||||
await mutate(`
|
||||
const project = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
${MAKE_OBJECT}
|
||||
project.assets.push({ id: 'va-art', type: 'vectorart', name: 'Emblem',
|
||||
props: { canvasWidth: ${CANVAS}, canvasHeight: ${CANVAS} }, shapes: ${JSON.stringify(SHAPES)} });
|
||||
const sprite = structuredClone(project.assets.find((a) => a.id === 'crate'));
|
||||
sprite.id = 'va-sprite';
|
||||
sprite.name = 'Emblem sprite';
|
||||
sprite.props = { ...sprite.props, width: ${CANVAS}, height: ${CANVAS}, color: 0xffffff,
|
||||
texture: 'va-art', shader: '' };
|
||||
project.assets.push(sprite);
|
||||
scene.objects.push(make('va-object', 'va-sprite', '', ${Y}, 'Emblem object'));
|
||||
__gm.state.select(null)`);
|
||||
await showTab('scene');
|
||||
await evaluate(`__gm.lookAt(0, ${Y})`);
|
||||
await settle();
|
||||
await sleep(300);
|
||||
|
||||
const picture = await json("__gm.drawnPicture('va-art')");
|
||||
check(
|
||||
'the drawing lies on the GPU at twice its canvas, and measures the canvas',
|
||||
Boolean(picture) &&
|
||||
picture.width === CANVAS &&
|
||||
picture.height === CANVAS &&
|
||||
picture.pixelWidth === CANVAS * 2 &&
|
||||
picture.pixelHeight === CANVAS * 2 &&
|
||||
picture.kept,
|
||||
JSON.stringify(picture),
|
||||
);
|
||||
|
||||
const box = await json("__gm.objectRect('va-object')");
|
||||
check('the sprite wearing the drawing is on screen', Boolean(box) && box.w > 32, JSON.stringify(box));
|
||||
if (!box) return;
|
||||
/**
|
||||
* A point of the canvas, in canvas pixels, as the colour on screen. `objectRect` answers with the
|
||||
* middle of the object, not its corner, so the canvas is measured out from there.
|
||||
*/
|
||||
const at = (cx, cy) =>
|
||||
json(
|
||||
`__gm.pixelAt(${Math.round(box.x + (cx / CANVAS - 0.5) * box.w)}, ${Math.round(box.y + (cy / CANVAS - 0.5) * box.h)})`,
|
||||
);
|
||||
|
||||
const field = await at(48, 200);
|
||||
check('the sprite shows the fill of the first shape', colourDistance(field, FIELD) < 14, field);
|
||||
|
||||
const outside = await json(`__gm.pixelAt(${Math.round(box.x - box.w)}, ${Math.round(box.y)})`);
|
||||
// Off the middle of the hole: the scene's grid lines cross exactly there, and a grid line is not
|
||||
// the background either – the check would then read the wrong colour for the right reason.
|
||||
const hole = await at(112, 148);
|
||||
check(
|
||||
'the ring inside the shape is a hole – the scene shows through it',
|
||||
colourDistance(hole, outside) < 14 && colourDistance(hole, FIELD) > 40,
|
||||
JSON.stringify({ hole, outside }),
|
||||
);
|
||||
|
||||
const badge = await at(204, 60);
|
||||
check('the shape drawn later lies over the one before it', colourDistance(badge, BADGE) < 14, badge);
|
||||
|
||||
const outline = await at(176, 60);
|
||||
check('the outline is drawn along the ring', colourDistance(outline, OUTLINE) < 24, outline);
|
||||
|
||||
await shot('47-vector-art');
|
||||
|
||||
// 47b. A changed drawing is a new picture (#185): the key moves on, the old one is let go.
|
||||
const before = picture?.file;
|
||||
await mutate(`
|
||||
const art = __gm.state.project.assets.find((a) => a.id === 'va-art');
|
||||
art.shapes[0].fill = 0x${REDRAWN.slice(1)}`);
|
||||
await settle();
|
||||
await sleep(300);
|
||||
const after = await json("__gm.drawnPicture('va-art')");
|
||||
const oldOne = before ? await json(`__gm.textureFilter(${JSON.stringify(before)})`) : null;
|
||||
check(
|
||||
'a changed drawing gets a key of its own and the old picture is freed',
|
||||
Boolean(after) && after.file !== before && after.kept && oldOne === null,
|
||||
JSON.stringify({ before, after: after?.file, oldOne }),
|
||||
);
|
||||
const repainted = await at(48, 200);
|
||||
check('the sprite shows the changed fill', colourDistance(repainted, REDRAWN) < 14, repainted);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { queries } from './43-queries.mjs';
|
||||
import { documents } from './44-documents.mjs';
|
||||
import { login } from './45-login.mjs';
|
||||
import { topbar } from './46-topbar.mjs';
|
||||
import { vectorArt } from './47-vectorart.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -97,4 +98,5 @@ export const STEPS = [
|
||||
documents,
|
||||
login,
|
||||
topbar,
|
||||
vectorArt,
|
||||
];
|
||||
@@ -25,6 +25,7 @@ export const LANES = {
|
||||
shapes: -3900,
|
||||
queries: -4300,
|
||||
symbols: -4700,
|
||||
vectorArt: -5100,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user