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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user