diff --git a/README.md b/README.md index 99c8b8d0..1911d9bb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/model/vectorPath.ts b/src/core/model/vectorPath.ts new file mode 100644 index 00000000..e51840e7 --- /dev/null +++ b/src/core/model/vectorPath.ts @@ -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) }; +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 918e9e4a..e2150a16 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -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 // 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 => { const user = await signedInUser(app, root, auth); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 60abb548..948fa4ae 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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 diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index 0f283c1d..031b71de 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -40,7 +40,19 @@ export class TextureLibrary extends FileLibrary { /** Installs who draws generated pictures; without one, a generated key stays empty. */ setPictureFactory(factory: PictureFactory | 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; + }), + ); } /** diff --git a/src/viewport/vectorArtPicture.ts b/src/viewport/vectorArtPicture.ts new file mode 100644 index 00000000..efe31fa3 --- /dev/null +++ b/src/viewport/vectorArtPicture.ts @@ -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; +} diff --git a/tests/core/vector-path.test.ts b/tests/core/vector-path.test.ts new file mode 100644 index 00000000..38f40192 --- /dev/null +++ b/tests/core/vector-path.test.ts @@ -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 { + 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(); + }); +}); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs new file mode 100644 index 00000000..86f57629 --- /dev/null +++ b/tools/e2e/47-vectorart.mjs @@ -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); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 7d40a6fa..784b7861 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 7169b1f8..fb73a35d 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -25,6 +25,7 @@ export const LANES = { shapes: -3900, queries: -4300, symbols: -4700, + vectorArt: -5100, }; /**