Scale, turn and mirror the shapes of a vector drawing (#291)
A shape could only be moved and reshaped point by point. Whoever
wanted it larger or turned pulled every point alone, and for several
shapes or a group there was no way at all. The answers in the issue
settle the rest: the change is baked into the points, nothing of it
is remembered, and the line width stays.
- With Select the selection stands in a frame of eight squares and a
knob, drawn like the one of the scene view. It holds one shape,
several shapes or a group, the same selection that is dragged as
one.
- A corner square scales from the opposite corner, a square on an
edge in its own direction. Shift keeps the proportions, Alt scales
about the middle.
- The knob turns round the middle of the frame, with Shift in steps
of 15 degrees. After the pull the frame stands upright again.
- The inspector has a band TRANSFORM with X, Y, Width, Height and
Rotate by, and the buttons Flip horizontal and Flip vertical.
- Points, handles, gradients and texture tiles are taken through the
same matrix. The rule is a pure function in vectorTransform.ts,
transformShapesCommand makes one undo step of any number of
shapes.
- A press goes to a point or a handle first, then to the frame, then
to a line. shapeFrame.ts holds where the handles are and what a
pull of each means.
Decided here:
- The frame stands 15 pixels off the shapes. The points of a shape
lie on its bounds, so a square on the bounds would cover them.
- The knob only turns. In the scene view it scales as well.
- A side cannot be pulled through the opposite one, the frame keeps
at least 1 pixel.
- Coordinates are rounded to a thousandth of a pixel, so a quarter
turn is exact.
- A texture tile stays upright under a turn and grows to the bounds
of the turned tile. The bands of a gradient stay square to its
axis under an uneven scale.
- The switch Scale stroke of the proposal is not built.
Found on the way:
- The inspector of a shape is now taller than the window. Two checks
of module 47 no longer found their row or button on the screen and
use the new hook inspectorButton, which scrolls there first.
- Not solved: with a shape at the top edge of the view the knob lies
outside the tab. The wiki names it under the limits.
Taken over from #217: the e2e check for two selected shapes without
a group, and unit tests for the snap of the common offset and for
the gradients of the shapes dragged along.
Tests:
- Gate on melektaus-atx: 209 files, 3176 tests (3121 + 55), exit 0
- e2e 47,48 on melektaus-atx: 112 checks, all pass
- No full run and no smokes. The sum of 1664 (1660 + 4) is reckoned,
1660 is what CI counted for 6d79cf5.
- 55 unit tests: 25 in tests/core/vector-transform.test.ts and 30 in
tests/editor/shape-frame.test.ts
- Four e2e checks in block 47k of module 47: pull a corner, turn
with Shift, type width and rotation, drag two shapes without a
group
- 47 unit counter-probes and four browser counter-probes all fail
their tests. Four of the unit ones did not at first: two showed
dead code, which is gone, two got tests.
- Not checked in the browser: the edge squares, Alt, the cursors,
the flip buttons and the rows X, Y and Height
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d79cf59a3
commit
345289982b
14 files changed
+2026
-28
No files matched your search
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Scaling, turning and mirroring vector art shapes (#291): what a ring, a paint and a whole
|
||||
* selection read after an affine map, how far a selection reaches, and the maps a frame handle makes.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildAsset } from '../../src/core/commands/assetCommands';
|
||||
import { transformShapesCommand } from '../../src/core/commands/vectorArtCommands';
|
||||
import type {
|
||||
VectorArtAsset,
|
||||
VectorGradient,
|
||||
VectorRing,
|
||||
VectorShape,
|
||||
VectorTextureFill,
|
||||
} from '../../src/core/model/Asset';
|
||||
import { translation } from '../../src/core/model/matrix';
|
||||
import { createEmptyProject } from '../../src/core/model/Project';
|
||||
import {
|
||||
frameFlipped,
|
||||
frameMovedTo,
|
||||
frameScale,
|
||||
frameSizedTo,
|
||||
geometryOf,
|
||||
geometryTransformed,
|
||||
outlineExtent,
|
||||
paintTransformed,
|
||||
ringTransformed,
|
||||
rotationAbout,
|
||||
scaleAbout,
|
||||
} from '../../src/core/model/vectorTransform';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
|
||||
const BOX = { x0: 10, y0: 20, x1: 50, y1: 40 };
|
||||
|
||||
function square(id: string, x: number, y: number, size = 20): VectorShape {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
fill: 0x336699,
|
||||
opacity: 1,
|
||||
rings: [
|
||||
{
|
||||
closed: true,
|
||||
points: [
|
||||
{ x, y },
|
||||
{ x: x + size, y },
|
||||
{ x: x + size, y: y + size },
|
||||
{ x, y: y + size },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A circle of radius `r` round `cx`, `cy`, drawn as four symmetric points. */
|
||||
function circle(cx: number, cy: number, r: number): VectorRing {
|
||||
// To a thousandth, which is what a map keeps: mirrored twice the circle must read the same.
|
||||
const k = Math.round(r * 552.2847498) / 1000;
|
||||
return {
|
||||
closed: true,
|
||||
points: [
|
||||
{ x: cx + r, y: cy, in: { x: 0, y: -k }, out: { x: 0, y: k }, mode: 'symmetric' },
|
||||
{ x: cx, y: cy + r, in: { x: k, y: 0 }, out: { x: -k, y: 0 }, mode: 'symmetric' },
|
||||
{ x: cx - r, y: cy, in: { x: 0, y: k }, out: { x: 0, y: -k }, mode: 'symmetric' },
|
||||
{ x: cx, y: cy - r, in: { x: -k, y: 0 }, out: { x: k, y: 0 }, mode: 'symmetric' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const LINEAR: VectorGradient = {
|
||||
type: 'linear',
|
||||
from: { x: 10, y: 30 },
|
||||
to: { x: 50, y: 30 },
|
||||
stops: [
|
||||
{ offset: 0, color: 0xff0000, alpha: 1 },
|
||||
{ offset: 1, color: 0x0000ff, alpha: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
const TILE: VectorTextureFill = {
|
||||
type: 'texture',
|
||||
from: { x: 10, y: 20 },
|
||||
to: { x: 50, y: 40 },
|
||||
texture: 't',
|
||||
};
|
||||
|
||||
describe('a ring under a map', () => {
|
||||
it('scales points about the anchor and handles by the factors alone', () => {
|
||||
const ring: VectorRing = {
|
||||
closed: false,
|
||||
points: [
|
||||
{ x: 10, y: 20, out: { x: 6, y: 4 } },
|
||||
{ x: 50, y: 40, in: { x: -6, y: -2 }, mode: 'smooth' },
|
||||
],
|
||||
};
|
||||
const scaled = ringTransformed(ring, scaleAbout({ x: 10, y: 20 }, 2, 0.5));
|
||||
expect(scaled.points).toEqual([
|
||||
{ x: 10, y: 20, out: { x: 12, y: 2 } },
|
||||
{ x: 90, y: 30, in: { x: -12, y: -1 }, mode: 'smooth' },
|
||||
]);
|
||||
// The ring that went in is not the one that came out.
|
||||
expect(ring.points[1]).toMatchObject({ x: 50, y: 40 });
|
||||
});
|
||||
|
||||
it('turns by a quarter exactly, points and handles, and four quarters are the ring it was', () => {
|
||||
const ring: VectorRing = {
|
||||
closed: true,
|
||||
points: [
|
||||
{ x: 10, y: 20, out: { x: 5, y: 0 } },
|
||||
{ x: 50, y: 20 },
|
||||
{ x: 50, y: 40, in: { x: 0, y: -3 }, mode: 'smooth', out: { x: 0, y: 3 } },
|
||||
],
|
||||
};
|
||||
const centre = { x: 30, y: 30 };
|
||||
const quarter = ringTransformed(ring, rotationAbout(centre, 90));
|
||||
// Clockwise on screen: the top left corner goes to the top right.
|
||||
expect(quarter.points[0]).toEqual({ x: 40, y: 10, out: { x: 0, y: 5 } });
|
||||
expect(quarter.points[1]).toEqual({ x: 40, y: 50 });
|
||||
expect(quarter.points[2]).toEqual({
|
||||
x: 20,
|
||||
y: 50,
|
||||
in: { x: 3, y: 0 },
|
||||
out: { x: -3, y: 0 },
|
||||
mode: 'smooth',
|
||||
});
|
||||
let round = ring;
|
||||
for (let i = 0; i < 4; i++) round = ringTransformed(round, rotationAbout(centre, 90));
|
||||
expect(round).toEqual(ring);
|
||||
expect(ringTransformed(ring, rotationAbout(centre, -270))).toEqual(quarter);
|
||||
});
|
||||
|
||||
it('keeps a turn by any other angle to a thousandth of a pixel', () => {
|
||||
const ring: VectorRing = {
|
||||
closed: false,
|
||||
points: [
|
||||
{ x: 10, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
],
|
||||
};
|
||||
const turned = ringTransformed(ring, rotationAbout({ x: 0, y: 0 }, 30));
|
||||
expect(turned.points[0]).toEqual({ x: 8.66, y: 5 });
|
||||
expect(Object.is(turned.points[1]!.x, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('mirrors about the middle and keeps a symmetric point symmetric', () => {
|
||||
const ring = circle(30, 30, 10);
|
||||
const flipped = ringTransformed(
|
||||
ring,
|
||||
frameFlipped(outlineExtent([{ ...square('c', 0, 0), rings: [ring] }])!, 'x'),
|
||||
);
|
||||
expect(flipped.points[0]).toMatchObject({ x: 20, y: 30, mode: 'symmetric' });
|
||||
const { in: a, out: b } = flipped.points[1]!;
|
||||
expect(a!.x).toBeCloseTo(-b!.x, 9);
|
||||
expect(a!.y).toBeCloseTo(-b!.y, 9);
|
||||
// Mirrored twice it is the ring it was.
|
||||
const box = outlineExtent([{ ...square('c', 0, 0), rings: [flipped] }])!;
|
||||
expect(ringTransformed(flipped, frameFlipped(box, 'x'))).toEqual(ring);
|
||||
});
|
||||
|
||||
it('drops a handle a scale squeezes to nothing, and the mode with the last one', () => {
|
||||
const ring: VectorRing = {
|
||||
closed: false,
|
||||
points: [
|
||||
{ x: 0, y: 0, out: { x: 0, y: 8 }, mode: 'smooth' },
|
||||
{ x: 10, y: 0 },
|
||||
],
|
||||
};
|
||||
const flat = ringTransformed(ring, scaleAbout({ x: 0, y: 0 }, 1, 0));
|
||||
expect(flat.points[0]).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a paint under a map', () => {
|
||||
it('takes both ends of a gradient along and leaves the stops alone', () => {
|
||||
const moved = paintTransformed(LINEAR, rotationAbout({ x: 30, y: 30 }, 90));
|
||||
expect(moved.from).toEqual({ x: 30, y: 10 });
|
||||
expect(moved.to).toEqual({ x: 30, y: 50 });
|
||||
expect(moved.stops).toEqual(LINEAR.stops);
|
||||
expect(moved.stops).not.toBe(LINEAR.stops);
|
||||
expect(paintTransformed(LINEAR, translation(5, -5))).toMatchObject({
|
||||
from: { x: 15, y: 25 },
|
||||
to: { x: 55, y: 25 },
|
||||
});
|
||||
});
|
||||
|
||||
it('scales and mirrors a picture tile exactly', () => {
|
||||
expect(paintTransformed(TILE, scaleAbout({ x: 10, y: 20 }, 2, 3))).toMatchObject({
|
||||
from: { x: 10, y: 20 },
|
||||
to: { x: 90, y: 80 },
|
||||
texture: 't',
|
||||
});
|
||||
// Mirrored, the tile's ends swap sides: the picture is drawn the other way round.
|
||||
expect(paintTransformed(TILE, scaleAbout({ x: 30, y: 30 }, -1, 1))).toMatchObject({
|
||||
from: { x: 50, y: 20 },
|
||||
to: { x: 10, y: 40 },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a turned picture tile upright, over the room the turned tile reaches', () => {
|
||||
const quarter = paintTransformed(TILE, rotationAbout({ x: 30, y: 30 }, 90));
|
||||
expect(quarter).toMatchObject({ from: { x: 20, y: 10 }, to: { x: 40, y: 50 } });
|
||||
const eighth = paintTransformed(TILE, rotationAbout({ x: 30, y: 30 }, 45));
|
||||
const half = (20 + 10) * Math.SQRT1_2;
|
||||
expect(eighth.from.x).toBeCloseTo(30 - half, 2);
|
||||
expect(eighth.to.y).toBeCloseTo(30 + half, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('how far a selection reaches', () => {
|
||||
it('spans all the shapes handed in', () => {
|
||||
expect(outlineExtent([square('a', 0, 0), square('b', 40, 10)])).toEqual({ x0: 0, y0: 0, x1: 60, y1: 30 });
|
||||
});
|
||||
|
||||
it('touches a circle where it is drawn, not where its handles stand out', () => {
|
||||
const e = outlineExtent([{ ...square('c', 0, 0), rings: [circle(30, 30, 10)] }])!;
|
||||
expect(e.x0).toBeCloseTo(20, 6);
|
||||
expect(e.x1).toBeCloseTo(40, 6);
|
||||
expect(e.y0).toBeCloseTo(20, 6);
|
||||
expect(e.y1).toBeCloseTo(40, 6);
|
||||
});
|
||||
|
||||
it('follows a curve that bulges past its own two ends', () => {
|
||||
const ring: VectorRing = {
|
||||
closed: false,
|
||||
points: [
|
||||
{ x: 0, y: 0, out: { x: 0, y: -40 } },
|
||||
{ x: 30, y: 0, in: { x: 0, y: -40 } },
|
||||
],
|
||||
};
|
||||
const e = outlineExtent([{ ...square('c', 0, 0), rings: [ring] }])!;
|
||||
// Both control points stand 40 above; the curve between them reaches three quarters of that.
|
||||
expect(e.y0).toBeCloseTo(-30, 6);
|
||||
expect(e.y1).toBe(0);
|
||||
});
|
||||
|
||||
it('does not walk back from the last point of an open line', () => {
|
||||
const ring: VectorRing = {
|
||||
closed: false,
|
||||
points: [
|
||||
{ x: 0, y: 0, in: { x: 0, y: 50 } },
|
||||
{ x: 30, y: 0, out: { x: 0, y: 50 } },
|
||||
],
|
||||
};
|
||||
expect(outlineExtent([{ ...square('c', 0, 0), rings: [ring] }])).toEqual({ x0: 0, y0: 0, x1: 30, y1: 0 });
|
||||
});
|
||||
|
||||
it('has nothing to frame for no shape, an empty one and a single point', () => {
|
||||
expect(outlineExtent([])).toBeNull();
|
||||
expect(outlineExtent([{ ...square('a', 0, 0), rings: [] }])).toBeNull();
|
||||
expect(
|
||||
outlineExtent([{ ...square('a', 0, 0), rings: [{ closed: false, points: [{ x: 4, y: 4 }] }] }]),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the map of a handle', () => {
|
||||
const corner = { sx: 1, sy: 1 } as const;
|
||||
|
||||
it('scales from the opposite corner when a corner is pulled', () => {
|
||||
expect(frameScale(BOX, corner, { x: 90, y: 50 })).toEqual(scaleAbout({ x: 10, y: 20 }, 2, 1.5));
|
||||
expect(frameScale(BOX, { sx: -1, sy: -1 }, { x: -10, y: 10 })).toEqual(
|
||||
scaleAbout({ x: 50, y: 40 }, 1.5, 1.5),
|
||||
);
|
||||
});
|
||||
|
||||
it('scales one direction only from an edge', () => {
|
||||
expect(frameScale(BOX, { sx: 1, sy: 0 }, { x: 30, y: 999 })).toEqual(
|
||||
scaleAbout({ x: 10, y: 30 }, 0.5, 1),
|
||||
);
|
||||
expect(frameScale(BOX, { sx: 0, sy: -1 }, { x: 999, y: 0 })).toEqual(scaleAbout({ x: 30, y: 40 }, 1, 2));
|
||||
});
|
||||
|
||||
it('keeps the proportions with Shift: a corner follows its farther axis, an edge takes the other along', () => {
|
||||
expect(frameScale(BOX, corner, { x: 90, y: 50 }, { proportional: true })).toEqual(
|
||||
scaleAbout({ x: 10, y: 20 }, 2, 2),
|
||||
);
|
||||
// The edge pulled out to double width doubles the height about the middle line.
|
||||
expect(frameScale(BOX, { sx: 1, sy: 0 }, { x: 90, y: 0 }, { proportional: true })).toEqual(
|
||||
scaleAbout({ x: 10, y: 30 }, 2, 2),
|
||||
);
|
||||
});
|
||||
|
||||
it('scales about the middle with Alt', () => {
|
||||
expect(frameScale(BOX, corner, { x: 70, y: 40 }, { centred: true })).toEqual(
|
||||
scaleAbout({ x: 30, y: 30 }, 2, 1),
|
||||
);
|
||||
});
|
||||
|
||||
it('never pulls a side through its opposite: the frame stays a pixel wide', () => {
|
||||
const m = frameScale(BOX, corner, { x: -100, y: -100 });
|
||||
expect(m.a).toBeCloseTo(1 / 40, 9);
|
||||
expect(m.d).toBeCloseTo(1 / 20, 9);
|
||||
});
|
||||
|
||||
it('leaves a direction alone in which the frame has no extent', () => {
|
||||
const line = { x0: 0, y0: 5, x1: 40, y1: 5 };
|
||||
expect(frameScale(line, corner, { x: 80, y: 60 })).toEqual(scaleAbout({ x: 0, y: 5 }, 2, 1));
|
||||
});
|
||||
|
||||
it('moves, sizes and mirrors the frame by a number', () => {
|
||||
expect(frameMovedTo(BOX, 'x', 0)).toEqual(translation(-10, 0));
|
||||
expect(frameMovedTo(BOX, 'y', 25)).toEqual(translation(0, 5));
|
||||
expect(frameSizedTo(BOX, 'x', 80)).toEqual(scaleAbout({ x: 10, y: 20 }, 2, 1));
|
||||
expect(frameSizedTo(BOX, 'y', 0)).toEqual(scaleAbout({ x: 10, y: 20 }, 1, 1 / 20));
|
||||
expect(frameSizedTo({ ...BOX, x1: 10 }, 'x', 80)).toBeNull();
|
||||
expect(frameFlipped(BOX, 'y')).toEqual(scaleAbout({ x: 30, y: 30 }, 1, -1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the command', () => {
|
||||
function rig() {
|
||||
const project = createEmptyProject('p', 'P');
|
||||
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
|
||||
const a = square('a', 0, 0);
|
||||
const b: VectorShape = { ...square('b', 40, 0), paint: { ...LINEAR }, stroke: { color: 0, width: 3 } };
|
||||
const c = square('c', 80, 0);
|
||||
asset.layers[0]!.items.push(a, { id: 'g', name: 'Group', items: [b] }, c);
|
||||
asset.layers.push({ id: 'upper', name: 'Layer 2', items: [square('d', 0, 40)] });
|
||||
project.assets.push(asset);
|
||||
const corner = (id: string) => allShapes(asset).find((s) => s.id === id)!.rings[0]!.points[0];
|
||||
return { project, asset, corner };
|
||||
}
|
||||
|
||||
it('takes several shapes, one in a group and one on another layer, in one step and back', () => {
|
||||
const r = rig();
|
||||
const command = transformShapesCommand(
|
||||
r.asset,
|
||||
['a', 'b', 'd'],
|
||||
scaleAbout({ x: 0, y: 0 }, 2, 2),
|
||||
'Scale shapes',
|
||||
)!;
|
||||
command.execute(r.project);
|
||||
expect(r.corner('a')).toEqual({ x: 0, y: 0 });
|
||||
expect(allShapes(r.asset).find((s) => s.id === 'a')!.rings[0]!.points[2]).toEqual({ x: 40, y: 40 });
|
||||
expect(r.corner('b')).toEqual({ x: 80, y: 0 });
|
||||
expect(r.corner('d')).toEqual({ x: 0, y: 80 });
|
||||
expect(r.corner('c')).toEqual({ x: 80, y: 0 });
|
||||
command.undo(r.project);
|
||||
expect(r.corner('b')).toEqual({ x: 40, y: 0 });
|
||||
expect(r.corner('d')).toEqual({ x: 0, y: 40 });
|
||||
});
|
||||
|
||||
it('takes the fill paint along and leaves the line as wide as it was', () => {
|
||||
const r = rig();
|
||||
const b = allShapes(r.asset).find((s) => s.id === 'b')!;
|
||||
const command = transformShapesCommand(r.asset, ['b'], scaleAbout({ x: 0, y: 0 }, 3, 3), 'Scale shape')!;
|
||||
command.execute(r.project);
|
||||
expect(b.paint).toMatchObject({ from: { x: 30, y: 90 }, to: { x: 150, y: 90 } });
|
||||
expect(b.stroke).toEqual({ color: 0, width: 3 });
|
||||
command.undo(r.project);
|
||||
expect(b.paint).toMatchObject({ from: { x: 10, y: 30 }, to: { x: 50, y: 30 } });
|
||||
});
|
||||
|
||||
it('takes the gradient of the line along as well', () => {
|
||||
const r = rig();
|
||||
const b = allShapes(r.asset).find((s) => s.id === 'b')!;
|
||||
b.stroke = { color: 0, width: 3, paint: { ...LINEAR } };
|
||||
transformShapesCommand(r.asset, ['b'], translation(5, 7), 'Move shape')!.execute(r.project);
|
||||
expect(b.stroke).toMatchObject({ width: 3, paint: { from: { x: 15, y: 37 }, to: { x: 55, y: 37 } } });
|
||||
});
|
||||
|
||||
it('is no step at all when nothing would read differently', () => {
|
||||
const r = rig();
|
||||
expect(
|
||||
transformShapesCommand(r.asset, ['a'], rotationAbout({ x: 10, y: 10 }, 360), 'Rotate shape'),
|
||||
).toBeNull();
|
||||
expect(transformShapesCommand(r.asset, ['nobody'], translation(5, 5), 'Move shape')).toBeNull();
|
||||
});
|
||||
|
||||
it('hands the rings on untouched when a geometry is read, and copies the paints', () => {
|
||||
const r = rig();
|
||||
const b = allShapes(r.asset).find((s) => s.id === 'b')!;
|
||||
const geometry = geometryOf(b);
|
||||
expect(geometry.rings).toBe(b.rings);
|
||||
expect(geometry.paint).toEqual(b.paint);
|
||||
expect(geometry.paint).not.toBe(b.paint);
|
||||
expect(geometryTransformed(geometry, translation(1, 1)).rings).not.toBe(b.rings);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* The frame round a vector art selection (#291): where it stands and which handle a press finds,
|
||||
* what pulling a handle does in the tab, and the TRANSFORM rows of the inspector. The last block
|
||||
* covers what #217 left without a test of its own – shapes dragged together.
|
||||
*/
|
||||
import { CanvasTextMetrics, type FederatedPointerEvent } from 'pixi.js';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildAsset } from '../../src/core/commands/assetCommands';
|
||||
import { GroupVectorShapesCommand } from '../../src/core/commands/vectorArtCommands';
|
||||
import type { VectorArtAsset, VectorGradient, VectorShape } from '../../src/core/model/Asset';
|
||||
import { IDENTITY } from '../../src/core/model/matrix';
|
||||
import { createEmptyProject } from '../../src/core/model/Project';
|
||||
import type { Vec2 } from '../../src/core/model/Properties';
|
||||
import { rotationAbout } from '../../src/core/model/vectorTransform';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
import type { InspectorHost } from '../../src/editor/inspector/host';
|
||||
import {
|
||||
buildShapeSection,
|
||||
refreshShapeSection,
|
||||
shapeSignature,
|
||||
} from '../../src/editor/inspector/shapeSection';
|
||||
import { flipSelection } from '../../src/editor/inspector/shapeTransformRows';
|
||||
import { EditorState, shapeSelection } from '../../src/editor/state';
|
||||
import {
|
||||
FRAME_PAD,
|
||||
FRAME_STEM,
|
||||
frameBox,
|
||||
frameCursor,
|
||||
frameGripAt,
|
||||
frameSizing,
|
||||
frameTurning,
|
||||
sidePoint,
|
||||
} from '../../src/editor/vectorart/shapeFrame';
|
||||
import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas';
|
||||
import { Button, NumberField, VStack, type Widget } from '../../src/ui';
|
||||
import { resetRenderLoop } from '../../src/ui/render';
|
||||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||||
|
||||
beforeAll(() => {
|
||||
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
|
||||
PaintCache.prototype.gradient = (() => null) as never;
|
||||
});
|
||||
beforeEach(() => resetRenderLoop());
|
||||
|
||||
const BOX = { x0: 10, y0: 20, x1: 50, y1: 40 };
|
||||
/** A view of two screen pixels per canvas pixel, moved by 100 and 50. */
|
||||
const toLocal = (x: number, y: number): Vec2 => ({ x: 100 + x * 2, y: 50 + y * 2 });
|
||||
|
||||
describe('where the frame stands', () => {
|
||||
it('stands a fixed number of screen pixels off the extent, at any zoom', () => {
|
||||
const box = frameBox(BOX, IDENTITY, toLocal);
|
||||
expect(box.corners).toEqual([
|
||||
{ x: 120 - FRAME_PAD, y: 90 - FRAME_PAD },
|
||||
{ x: 200 + FRAME_PAD, y: 90 - FRAME_PAD },
|
||||
{ x: 200 + FRAME_PAD, y: 130 + FRAME_PAD },
|
||||
{ x: 120 - FRAME_PAD, y: 130 + FRAME_PAD },
|
||||
]);
|
||||
expect(box.edges[0]).toEqual({ x: 160, y: 90 - FRAME_PAD });
|
||||
expect(box.edges[1]).toEqual({ x: 200 + FRAME_PAD, y: 110 });
|
||||
expect(box.knob).toEqual({ x: 160, y: 90 - FRAME_PAD - FRAME_STEM });
|
||||
});
|
||||
|
||||
it('turns with the map of a drag, the knob going round with it', () => {
|
||||
const box = frameBox(BOX, rotationAbout({ x: 30, y: 30 }, 90), toLocal);
|
||||
// The top edge is now the right one, so the knob stands out to the right of the middle.
|
||||
expect(box.knob.x).toBeCloseTo(160 + 20 + FRAME_PAD + FRAME_STEM, 6);
|
||||
expect(box.knob.y).toBeCloseTo(110, 6);
|
||||
expect(box.corners[0]!.x).toBeCloseTo(180 + FRAME_PAD, 6);
|
||||
expect(box.corners[0]!.y).toBeCloseTo(70 - FRAME_PAD, 6);
|
||||
});
|
||||
|
||||
it('finds the knob, a corner and an edge, and nothing in the middle', () => {
|
||||
const box = frameBox(BOX, IDENTITY, toLocal);
|
||||
expect(frameGripAt(box, box.knob)).toEqual({ kind: 'knob' });
|
||||
expect(frameGripAt(box, { x: box.corners[2]!.x + 3, y: box.corners[2]!.y + 3 })).toEqual({
|
||||
kind: 'size',
|
||||
side: { sx: 1, sy: 1 },
|
||||
});
|
||||
expect(frameGripAt(box, { x: 140, y: box.edges[2]!.y })).toEqual({
|
||||
kind: 'size',
|
||||
side: { sx: 0, sy: 1 },
|
||||
});
|
||||
expect(frameGripAt(box, { x: 160, y: 110 })).toBeNull();
|
||||
// A point of the framed shape lies on the extent: out of every handle's reach.
|
||||
expect(frameGripAt(box, { x: 200, y: 130 })).toBeNull();
|
||||
expect(frameGripAt(box, { x: 200, y: 110 })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the arrow along the pull of each handle', () => {
|
||||
expect(frameCursor({ kind: 'knob' })).toBe('grab');
|
||||
expect(frameCursor({ kind: 'size', side: { sx: 0, sy: -1 } })).toBe('ns-resize');
|
||||
expect(frameCursor({ kind: 'size', side: { sx: 1, sy: 0 } })).toBe('ew-resize');
|
||||
expect(frameCursor({ kind: 'size', side: { sx: -1, sy: -1 } })).toBe('nwse-resize');
|
||||
expect(frameCursor({ kind: 'size', side: { sx: 1, sy: -1 } })).toBe('nesw-resize');
|
||||
});
|
||||
|
||||
it('moves a handle as far as the pointer went, wherever on the handle the press was', () => {
|
||||
const side = { sx: 1, sy: 1 } as const;
|
||||
expect(sidePoint(BOX, side)).toEqual({ x: 50, y: 40 });
|
||||
expect(sidePoint(BOX, { sx: -1, sy: 0 })).toEqual({ x: 10, y: 30 });
|
||||
const m = frameSizing(
|
||||
BOX,
|
||||
side,
|
||||
{ x: 57, y: 47 },
|
||||
{ x: 97, y: 57 },
|
||||
{ proportional: false, centred: false },
|
||||
);
|
||||
expect(m).toMatchObject({ a: 2, d: 1.5 });
|
||||
const snapped = frameSizing(
|
||||
BOX,
|
||||
side,
|
||||
{ x: 57, y: 47 },
|
||||
{ x: 95, y: 47 },
|
||||
{
|
||||
proportional: false,
|
||||
centred: false,
|
||||
snap: (p) => ({ x: Math.round(p.x / 8) * 8, y: Math.round(p.y / 8) * 8 }),
|
||||
},
|
||||
);
|
||||
// 50 + 38 = 88 is on the grid already; the height stays, 40 being on it too.
|
||||
expect(snapped).toMatchObject({ a: 78 / 40, d: 1 });
|
||||
});
|
||||
|
||||
it('turns to a tenth of a degree, and in steps of fifteen with Shift', () => {
|
||||
expect(frameTurning(BOX, 37.26, false)).toEqual(rotationAbout({ x: 30, y: 30 }, 37.3));
|
||||
expect(frameTurning(BOX, 37.26, true)).toEqual(rotationAbout({ x: 30, y: 30 }, 30));
|
||||
expect(frameTurning(BOX, 38, true)).toEqual(rotationAbout({ x: 30, y: 30 }, 45));
|
||||
});
|
||||
});
|
||||
|
||||
/** A square of 20 pixels with its corner at `x`, `y`. */
|
||||
function square(id: string, x: number, y = 0): VectorShape {
|
||||
return {
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
fill: 0x336699,
|
||||
opacity: 1,
|
||||
rings: [
|
||||
{
|
||||
closed: true,
|
||||
points: [
|
||||
{ x, y },
|
||||
{ x: x + 20, y },
|
||||
{ x: x + 20, y: y + 20 },
|
||||
{ x, y: y + 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const GRADIENT: VectorGradient = {
|
||||
type: 'linear',
|
||||
from: { x: 80, y: 50 },
|
||||
to: { x: 100, y: 50 },
|
||||
stops: [
|
||||
{ offset: 0, color: 0xff0000, alpha: 1 },
|
||||
{ offset: 1, color: 0x0000ff, alpha: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
/** A drawing with the squares a, b and c side by side at y 40, and a host to build rows into. */
|
||||
function rig() {
|
||||
const state = new EditorState();
|
||||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
|
||||
asset.layers[0]!.items.push(square('a', 40, 40), square('b', 80, 40), square('c', 120, 40));
|
||||
state.project.assets.push(asset);
|
||||
const host: InspectorHost = {
|
||||
state,
|
||||
body: new VStack(),
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
project: () => state.project,
|
||||
liveVariables: () => null,
|
||||
};
|
||||
const build = (): void => {
|
||||
host.body.removeChildren();
|
||||
host.fields.clear();
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'shape') buildShapeSection(host, asset.id, s.shapeId, s.shapeIds);
|
||||
};
|
||||
const widgets = <T extends Widget>(type: new (...args: never[]) => T): T[] => {
|
||||
const found: T[] = [];
|
||||
const walk = (widget: Widget): void => {
|
||||
if (widget instanceof type) found.push(widget);
|
||||
for (const child of widget.children) walk(child as Widget);
|
||||
};
|
||||
walk(host.body);
|
||||
return found;
|
||||
};
|
||||
const shape = (id: string) => allShapes(asset).find((s) => s.id === id)!;
|
||||
const points = (id: string) => shape(id).rings[0]!.points.map((p) => [p.x, p.y]);
|
||||
return { state, asset, host, build, widgets, shape, points };
|
||||
}
|
||||
|
||||
let clock = 0;
|
||||
|
||||
function pressAt(global: Vec2, held: 'shift' | 'alt' | null = null) {
|
||||
clock += 1000;
|
||||
return {
|
||||
timeStamp: clock,
|
||||
button: 0,
|
||||
global,
|
||||
altKey: held === 'alt',
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: held === 'shift',
|
||||
stopPropagation: () => undefined,
|
||||
} as unknown as FederatedPointerEvent;
|
||||
}
|
||||
|
||||
interface Pressable {
|
||||
shiftPresses(asset: VectorArtAsset, local: Vec2): boolean;
|
||||
press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void;
|
||||
move(local: Vec2, keys?: { shiftKey: boolean; altKey: boolean }): void;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
function tab() {
|
||||
const r = rig();
|
||||
const canvas = new VectorArtCanvas(r.state);
|
||||
canvas.show(r.asset.id);
|
||||
canvas.resize(512, 512);
|
||||
canvas.redraw();
|
||||
const target = canvas as unknown as Pressable;
|
||||
const at = (x: number, y: number) => canvas.imageToScreen(x, y);
|
||||
/** Screen pixels per canvas pixel. */
|
||||
const zoom = at(1, 0).x - at(0, 0).x;
|
||||
const select = (...ids: string[]) => r.state.select(shapeSelection(r.asset.id, ids));
|
||||
const frame = () => canvas.frameInfo!;
|
||||
/** Presses at `from` and lets go `dx`, `dy` canvas pixels further, with a key held all the way. */
|
||||
const pull = (from: Vec2, dx: number, dy: number, held: 'shift' | 'alt' | null = null) => {
|
||||
const to = { x: from.x + dx * zoom, y: from.y + dy * zoom };
|
||||
target.press(r.asset, pressAt(from, held), from);
|
||||
target.move(to, { shiftKey: held === 'shift', altKey: held === 'alt' });
|
||||
target.release();
|
||||
};
|
||||
return { ...r, canvas, target, at, zoom, select, frame, pull };
|
||||
}
|
||||
|
||||
describe('the frame in the tab', () => {
|
||||
it('frames the selection off its bounds, and nothing with another tool or no selection', () => {
|
||||
const t = tab();
|
||||
expect(t.canvas.frameInfo).toBeNull();
|
||||
t.select('a');
|
||||
const corner = t.at(40, 40);
|
||||
expect(t.frame().corners[0]).toEqual({ x: corner.x - FRAME_PAD, y: corner.y - FRAME_PAD });
|
||||
t.select('a', 'c');
|
||||
const far = t.at(140, 60);
|
||||
expect(t.frame().corners[2]).toEqual({ x: far.x + FRAME_PAD, y: far.y + FRAME_PAD });
|
||||
t.canvas.setTool('pen');
|
||||
expect(t.canvas.frameInfo).toBeNull();
|
||||
t.canvas.setTool('paint');
|
||||
expect(t.canvas.frameInfo).toBeNull();
|
||||
});
|
||||
|
||||
it('scales a shape from the opposite corner when a corner is pulled, as one undo step', () => {
|
||||
const t = tab();
|
||||
t.select('a');
|
||||
t.pull(t.frame().corners[2]!, 20, 10);
|
||||
expect(t.points('a')).toEqual([
|
||||
[40, 40],
|
||||
[80, 40],
|
||||
[80, 70],
|
||||
[40, 70],
|
||||
]);
|
||||
t.state.undo();
|
||||
expect(t.points('a')[2]).toEqual([60, 60]);
|
||||
// The step before was none of the gesture's: the drawing has no further one to take back.
|
||||
t.state.undo();
|
||||
expect(t.points('a')[2]).toEqual([60, 60]);
|
||||
});
|
||||
|
||||
it('shows the pull while it runs, and its undo step leads back to the very rings that were there', () => {
|
||||
const t = tab();
|
||||
t.select('a', 'c');
|
||||
const before = t.shape('c').rings;
|
||||
const handle = t.frame().corners[2]!;
|
||||
t.target.press(t.asset, pressAt(handle), handle);
|
||||
t.target.move({ x: handle.x + 100 * t.zoom, y: handle.y });
|
||||
// The preview waits for the frame; a test has none, so it is run by hand.
|
||||
(t.canvas as unknown as { writePreview(): void }).writePreview();
|
||||
expect(t.points('c')[1]).toEqual([240, 40]);
|
||||
expect(t.points('a')[1]).toEqual([80, 40]);
|
||||
t.target.release();
|
||||
expect(t.points('c')[1]).toEqual([240, 40]);
|
||||
t.state.undo();
|
||||
expect(t.shape('c').rings).toBe(before);
|
||||
});
|
||||
|
||||
it('scales in one direction from an edge', () => {
|
||||
const t = tab();
|
||||
t.select('a');
|
||||
t.pull(t.frame().edges[3]!, -20, 33);
|
||||
expect(t.points('a')).toEqual([
|
||||
[20, 40],
|
||||
[60, 40],
|
||||
[60, 60],
|
||||
[20, 60],
|
||||
]);
|
||||
});
|
||||
|
||||
it('scales every selected shape as one, grouped or not, and one undo puts all back', () => {
|
||||
const t = tab();
|
||||
t.select('a', 'c');
|
||||
// The frame is 100 wide; pulled to 200 everything doubles from the left top corner.
|
||||
t.pull(t.frame().corners[2]!, 100, 20);
|
||||
expect(t.points('a')[2]).toEqual([80, 80]);
|
||||
expect(t.points('c')[0]).toEqual([200, 40]);
|
||||
expect(t.points('c')[2]).toEqual([240, 80]);
|
||||
expect(t.points('b')[0]).toEqual([80, 40]);
|
||||
t.state.undo();
|
||||
expect(t.points('a')[2]).toEqual([60, 60]);
|
||||
expect(t.points('c')[0]).toEqual([120, 40]);
|
||||
});
|
||||
|
||||
it('frames a whole group after a press on one of its shapes and scales it together', () => {
|
||||
const t = tab();
|
||||
t.state.execute(new GroupVectorShapesCommand(t.asset.id, ['a', 'b']));
|
||||
const on = t.at(50, 50);
|
||||
t.target.press(t.asset, pressAt(on), on);
|
||||
t.target.release();
|
||||
expect(t.state.selectedShapeIds).toEqual(['a', 'b']);
|
||||
t.pull(t.frame().edges[1]!, 60, 0);
|
||||
expect(t.points('a')[1]).toEqual([80, 40]);
|
||||
expect(t.points('b')[0]).toEqual([120, 40]);
|
||||
expect(t.points('b')[1]).toEqual([160, 40]);
|
||||
});
|
||||
|
||||
it('keeps the proportions with Shift and scales about the middle with Alt', () => {
|
||||
const t = tab();
|
||||
t.select('a');
|
||||
t.pull(t.frame().corners[2]!, 20, 4, 'shift');
|
||||
expect(t.points('a')[2]).toEqual([80, 80]);
|
||||
t.state.undo();
|
||||
t.pull(t.frame().corners[2]!, 10, 10, 'alt');
|
||||
expect(t.points('a')).toEqual([
|
||||
[30, 30],
|
||||
[70, 30],
|
||||
[70, 70],
|
||||
[30, 70],
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes a Shift press on a handle itself instead of leaving it to the pan or the selection', () => {
|
||||
const t = tab();
|
||||
t.select('a', 'c');
|
||||
const handle = t.frame().corners[2]!;
|
||||
expect(t.target.shiftPresses(t.asset, handle)).toBe(true);
|
||||
// The frame is 100 wide: pulled to 200 with Shift the height doubles along.
|
||||
t.pull(handle, 100, 0, 'shift');
|
||||
expect(t.state.selectedShapeIds).toEqual(['a', 'c']);
|
||||
expect(t.points('c')[2]).toEqual([240, 80]);
|
||||
// Beside the frame and the shapes Shift is still the pan's.
|
||||
expect(t.target.shiftPresses(t.asset, t.at(200, 200))).toBe(false);
|
||||
});
|
||||
|
||||
it('turns the selection round the middle of its frame with the knob', () => {
|
||||
const t = tab();
|
||||
t.select('a');
|
||||
const knob = t.frame().knob;
|
||||
const centre = t.at(50, 50);
|
||||
const reach = centre.y - knob.y;
|
||||
// From straight above the middle to straight right of it: a quarter turn clockwise.
|
||||
t.target.press(t.asset, pressAt(knob), knob);
|
||||
t.target.move({ x: centre.x + reach, y: centre.y });
|
||||
expect(t.frame().knob.x).toBeCloseTo(centre.x + reach, 6);
|
||||
expect(t.frame().knob.y).toBeCloseTo(centre.y, 6);
|
||||
t.target.release();
|
||||
expect(t.points('a')).toEqual([
|
||||
[60, 40],
|
||||
[60, 60],
|
||||
[40, 60],
|
||||
[40, 40],
|
||||
]);
|
||||
// Baked in: the frame is upright again and the knob back on top.
|
||||
expect(t.frame().knob.x).toBeCloseTo(knob.x, 6);
|
||||
expect(t.frame().knob.y).toBeCloseTo(knob.y, 6);
|
||||
t.state.undo();
|
||||
expect(t.points('a')[0]).toEqual([40, 40]);
|
||||
});
|
||||
|
||||
it('snaps a turn to steps of fifteen degrees with Shift', () => {
|
||||
const t = tab();
|
||||
t.select('a');
|
||||
const knob = t.frame().knob;
|
||||
const centre = t.at(50, 50);
|
||||
const reach = centre.y - knob.y;
|
||||
const toward = (degrees: number): Vec2 => {
|
||||
const r = ((degrees - 90) * Math.PI) / 180;
|
||||
return { x: centre.x + reach * Math.cos(r), y: centre.y + reach * Math.sin(r) };
|
||||
};
|
||||
t.target.press(t.asset, pressAt(knob), knob);
|
||||
t.target.move(toward(41), { shiftKey: true, altKey: false });
|
||||
t.target.release();
|
||||
const turned = rotationAbout({ x: 50, y: 50 }, 45);
|
||||
expect(t.points('a')[0]![0]).toBeCloseTo(turned.a * 40 + turned.c * 40 + turned.tx, 3);
|
||||
expect(t.points('a')[0]![1]).toBeCloseTo(turned.b * 40 + turned.d * 40 + turned.ty, 3);
|
||||
});
|
||||
|
||||
it('leaves a point of the shape to itself: the frame never lies under it', () => {
|
||||
const t = tab();
|
||||
t.select('a');
|
||||
const corner = t.at(60, 60);
|
||||
t.target.press(t.asset, pressAt(corner), corner);
|
||||
t.target.move({ x: corner.x + 10 * t.zoom, y: corner.y });
|
||||
t.target.release();
|
||||
expect(t.points('a')).toEqual([
|
||||
[40, 40],
|
||||
[60, 40],
|
||||
[70, 60],
|
||||
[40, 60],
|
||||
]);
|
||||
expect(t.canvas.pickedAt).toEqual({ ring: 0, index: 2 });
|
||||
});
|
||||
|
||||
it('puts a pulled handle on the grid while snapping is on', () => {
|
||||
const t = tab();
|
||||
t.select('a');
|
||||
t.canvas.setSnapping(true);
|
||||
t.pull(t.frame().corners[2]!, 13, 2);
|
||||
expect(t.points('a')[2]).toEqual([72, 64]);
|
||||
});
|
||||
|
||||
it('makes no undo step of a press on a handle that pulls nothing', () => {
|
||||
const t = tab();
|
||||
t.select('a');
|
||||
t.pull(t.frame().corners[2]!, 20, 20);
|
||||
const handle = t.frame().corners[0]!;
|
||||
t.target.press(t.asset, pressAt(handle), handle);
|
||||
t.target.release();
|
||||
t.state.undo();
|
||||
expect(t.points('a')[2]).toEqual([60, 60]);
|
||||
});
|
||||
|
||||
it('takes the fill paint along and leaves the line as wide as it was', () => {
|
||||
const t = tab();
|
||||
const b = t.shape('b');
|
||||
b.paint = { ...GRADIENT };
|
||||
b.stroke = { color: 0, width: 3 };
|
||||
t.select('b');
|
||||
t.pull(t.frame().corners[2]!, 20, 20);
|
||||
expect(b.paint).toMatchObject({ from: { x: 80, y: 60 }, to: { x: 120, y: 60 } });
|
||||
expect(b.stroke.width).toBe(3);
|
||||
t.state.undo();
|
||||
expect(b.paint).toMatchObject({ from: { x: 80, y: 50 }, to: { x: 100, y: 50 } });
|
||||
});
|
||||
|
||||
it('shows the arrow of the handle under the pointer', () => {
|
||||
const t = tab();
|
||||
t.select('a');
|
||||
t.target.move(t.frame().corners[2]!);
|
||||
expect(t.canvas.cursor).toBe('nwse-resize');
|
||||
t.target.move(t.frame().knob);
|
||||
expect(t.canvas.cursor).toBe('grab');
|
||||
t.target.move(t.at(50, 50));
|
||||
expect(t.canvas.cursor).toBe('move');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the TRANSFORM rows', () => {
|
||||
const KEYS = ['frameX', 'frameY', 'frameWidth', 'frameHeight', 'frameTurn'];
|
||||
const built = (...ids: string[]) => {
|
||||
const r = rig();
|
||||
r.state.select(shapeSelection(r.asset.id, ids));
|
||||
r.build();
|
||||
const row = (key: string) => r.widgets(NumberField)[KEYS.indexOf(key)]!;
|
||||
const button = (label: string) => r.widgets(Button).find((b) => b.text === label)!;
|
||||
const refresh = () => refreshShapeSection(r.host, r.asset.id, ids[0]!);
|
||||
return { ...r, row, button, refresh };
|
||||
};
|
||||
|
||||
it('show where the frame of the selection stands and how big it is', () => {
|
||||
const r = built('a', 'c');
|
||||
expect(KEYS.map((key) => Number(r.row(key).value))).toEqual([40, 40, 100, 20, 0]);
|
||||
expect([...r.host.fields.keys()].slice(0, 5)).toEqual(KEYS);
|
||||
});
|
||||
|
||||
it('move the whole selection to a typed place', () => {
|
||||
const r = built('a', 'c');
|
||||
r.row('frameX').onChange!('10');
|
||||
r.row('frameY').onChange!('0');
|
||||
expect(r.points('a')[0]).toEqual([10, 0]);
|
||||
expect(r.points('c')[0]).toEqual([90, 0]);
|
||||
expect(r.points('b')[0]).toEqual([80, 40]);
|
||||
});
|
||||
|
||||
it('stretch the selection to a typed size from its left top corner', () => {
|
||||
const r = built('a', 'c');
|
||||
r.row('frameWidth').onChange!('200');
|
||||
expect(r.points('c')[1]).toEqual([240, 40]);
|
||||
r.row('frameHeight').onChange!('10');
|
||||
expect(r.points('a')[2]).toEqual([80, 50]);
|
||||
r.refresh();
|
||||
expect(Number(r.row('frameWidth').value)).toBe(200);
|
||||
expect(Number(r.row('frameHeight').value)).toBe(10);
|
||||
});
|
||||
|
||||
it('turn by the typed degrees, and read 0 again once they are baked in', () => {
|
||||
const r = built('a');
|
||||
r.row('frameTurn').onChange!('90');
|
||||
expect(r.points('a')[0]).toEqual([60, 40]);
|
||||
r.refresh();
|
||||
expect(Number(r.row('frameTurn').value)).toBe(0);
|
||||
r.state.undo();
|
||||
expect(r.points('a')[0]).toEqual([40, 40]);
|
||||
});
|
||||
|
||||
it('preview a dragged number from the shapes as they were, and commit one undo step', () => {
|
||||
const r = built('a');
|
||||
const before = r.shape('a').rings;
|
||||
const turn = r.row('frameTurn');
|
||||
turn.onInput!('30');
|
||||
turn.onInput!('60');
|
||||
turn.onInput!('90');
|
||||
// Each preview starts from the square as it was: three steps of thirty would have added rounding.
|
||||
expect(r.points('a')[0]).toEqual([60, 40]);
|
||||
r.refresh();
|
||||
expect(Number(turn.value)).toBe(90);
|
||||
turn.onChange!('180');
|
||||
expect(r.points('a')[0]).toEqual([60, 60]);
|
||||
r.refresh();
|
||||
expect(Number(turn.value)).toBe(0);
|
||||
r.state.undo();
|
||||
expect(r.shape('a').rings).toBe(before);
|
||||
});
|
||||
|
||||
it('mirror the selection about the middle of its frame', () => {
|
||||
const r = built('a', 'c');
|
||||
r.button('Flip horizontal').onClick!();
|
||||
expect(r.points('a')[0]).toEqual([140, 40]);
|
||||
expect(r.points('c')[0]).toEqual([60, 40]);
|
||||
r.button('Flip vertical').onClick!();
|
||||
expect(r.points('a')[0]).toEqual([140, 60]);
|
||||
r.state.undo();
|
||||
r.state.undo();
|
||||
expect(r.points('a')[0]).toEqual([40, 40]);
|
||||
expect(flipSelection(r.state, r.asset.id, ['nobody'], 'x')).toBe(false);
|
||||
});
|
||||
|
||||
it('are not there for a shape that has no size yet, and come with its second point', () => {
|
||||
const r = rig();
|
||||
r.shape('a').rings = [{ closed: false, points: [{ x: 5, y: 5 }] }];
|
||||
r.state.select(shapeSelection(r.asset.id, ['a']));
|
||||
r.build();
|
||||
expect(r.host.fields.has('frameX')).toBe(false);
|
||||
const bare = shapeSignature(r.state.project, r.asset.id, 'a', ['a']);
|
||||
r.shape('a').rings = [
|
||||
{
|
||||
closed: false,
|
||||
points: [
|
||||
{ x: 5, y: 5 },
|
||||
{ x: 9, y: 5 },
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(shapeSignature(r.state.project, r.asset.id, 'a', ['a'])).not.toBe(bare);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapes dragged together (#217)', () => {
|
||||
it('snaps the offset all of them share, so each keeps its place beside the others', () => {
|
||||
const t = tab();
|
||||
t.shape('a').rings = square('a', 43, 41).rings;
|
||||
t.select('a', 'c');
|
||||
t.canvas.setSnapping(true);
|
||||
const on = t.at(130, 50);
|
||||
t.target.press(t.asset, pressAt(on), on);
|
||||
t.target.move({ x: on.x + 13 * t.zoom, y: on.y + 5 * t.zoom });
|
||||
t.target.release();
|
||||
// 13 and 5 go to the grid's 16 and 8 once, for both: a stays three pixels off the grid.
|
||||
expect(t.points('c')[0]).toEqual([136, 48]);
|
||||
expect(t.points('a')[0]).toEqual([59, 49]);
|
||||
});
|
||||
|
||||
it('takes the paints of every dragged shape along, and one undo puts them back', () => {
|
||||
const t = tab();
|
||||
t.shape('b').paint = { ...GRADIENT };
|
||||
const c = t.shape('c');
|
||||
c.stroke = { color: 0, width: 2, paint: { ...GRADIENT, from: { x: 120, y: 50 }, to: { x: 140, y: 50 } } };
|
||||
t.select('b', 'c');
|
||||
// On the grid, so the offset is a whole 8 and 16 and not what a screen pixel divides to.
|
||||
t.canvas.setSnapping(true);
|
||||
const on = t.at(130, 50);
|
||||
t.target.press(t.asset, pressAt(on), on);
|
||||
t.target.move({ x: on.x + 8 * t.zoom, y: on.y + 16 * t.zoom });
|
||||
t.target.release();
|
||||
expect(t.shape('b').paint).toMatchObject({ from: { x: 88, y: 66 }, to: { x: 108, y: 66 } });
|
||||
expect(c.stroke.paint).toMatchObject({ from: { x: 128, y: 66 }, to: { x: 148, y: 66 } });
|
||||
t.state.undo();
|
||||
expect(t.shape('b').paint).toMatchObject({ from: { x: 80, y: 50 }, to: { x: 100, y: 50 } });
|
||||
expect(c.stroke.paint).toMatchObject({ from: { x: 120, y: 50 }, to: { x: 140, y: 50 } });
|
||||
});
|
||||
});
|
||||
@@ -98,13 +98,25 @@ function widgets<T extends Widget>(host: InspectorHost, type: new (...args: neve
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The TRANSFORM rows (#291) stand over the shape's own, five number fields ahead of every other. */
|
||||
const FRAME_KEYS = ['frameX', 'frameY', 'frameWidth', 'frameHeight', 'frameTurn'];
|
||||
const numberFields = (host: InspectorHost): NumberField[] =>
|
||||
widgets(host, NumberField).slice(FRAME_KEYS.length);
|
||||
|
||||
const texts = (host: InspectorHost): string[] => widgets(host, Label).map((label) => label.text);
|
||||
|
||||
describe('shape rows', () => {
|
||||
it('shows fill, opacity and an outline switch, and no line rows without an outline', () => {
|
||||
const { host, assetId, ids } = withShapes(1);
|
||||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||
expect([...host.fields.keys()]).toEqual(['name', 'fillType', 'fill', 'opacity', 'outline']);
|
||||
expect([...host.fields.keys()]).toEqual([
|
||||
...FRAME_KEYS,
|
||||
'name',
|
||||
'fillType',
|
||||
'fill',
|
||||
'opacity',
|
||||
'outline',
|
||||
]);
|
||||
expect(texts(host)).toContain('Without an outline only the fill is drawn.');
|
||||
});
|
||||
|
||||
@@ -125,7 +137,7 @@ describe('shape rows', () => {
|
||||
const { host, assetId, ids } = withShapes(1);
|
||||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||
// A NumberField reads text: its own `onChange` is the one a typed value goes through.
|
||||
const opacity = widgets(host, NumberField)[0]!;
|
||||
const opacity = numberFields(host)[0]!;
|
||||
opacity.onChange!('0.4');
|
||||
expect(shapeOf(host, assetId).opacity).toBeCloseTo(0.4);
|
||||
opacity.onChange!('5');
|
||||
@@ -146,6 +158,7 @@ describe('shape rows', () => {
|
||||
shapeOf(host, assetId).stroke = { color: 0x112233, width: 3 };
|
||||
buildShapeSection(host, assetId, ids[0]!, [ids[0]!]);
|
||||
expect([...host.fields.keys()]).toEqual([
|
||||
...FRAME_KEYS,
|
||||
'name',
|
||||
'fillType',
|
||||
'fill',
|
||||
@@ -155,7 +168,7 @@ describe('shape rows', () => {
|
||||
'strokeColor',
|
||||
'strokeWidth',
|
||||
]);
|
||||
const width = widgets(host, NumberField)[1]!;
|
||||
const width = numberFields(host)[1]!;
|
||||
// The command refuses a line of no width, so the row never sends one.
|
||||
width.onChange!('0');
|
||||
expect(shapeOf(host, assetId).stroke?.width).toBe(0.1);
|
||||
@@ -189,7 +202,7 @@ describe('shape rows', () => {
|
||||
shape.name = 'Cloud';
|
||||
refreshShapeSection(host, assetId, ids[0]!);
|
||||
// The name field first, then the hex text inside the fill row – a name is a field, not a label.
|
||||
const fields = widgets(host, TextField);
|
||||
const fields = widgets(host, TextField).slice(FRAME_KEYS.length);
|
||||
expect(fields[0]!.value).toBe('Cloud');
|
||||
expect(fields[1]!.value).toBe(toHex(0x123456));
|
||||
});
|
||||
@@ -283,6 +296,7 @@ describe('fill and line types (#186)', () => {
|
||||
expect(signature).toContain('linear/none');
|
||||
rebuild(host, assetId, ids[0]!);
|
||||
expect([...host.fields.keys()]).toEqual([
|
||||
...FRAME_KEYS,
|
||||
'name',
|
||||
'fillType',
|
||||
'fillStopColor',
|
||||
@@ -302,7 +316,7 @@ describe('fill and line types (#186)', () => {
|
||||
rebuild(host, assetId, ids[0]!);
|
||||
// The stop's colour is the only colour row left; opacity of the stop is the first number row.
|
||||
widgets(host, ColorField)[0]!.onChange!(0xff0000);
|
||||
widgets(host, NumberField)[0]!.onChange!('0.5');
|
||||
numberFields(host)[0]!.onChange!('0.5');
|
||||
const paint = shapeOf(host, assetId).paint as VectorGradient;
|
||||
expect(paint.stops[0]).toEqual({ offset: 0, color: 0xff0000, alpha: 0.5 });
|
||||
expect(button(host, 'Remove stop').eventMode).toBe('none');
|
||||
|
||||
Reference in new issue
Block a user