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>
121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
/**
|
||
* The frame round the selected shapes of a vector drawing (#291), without Pixi: where it is drawn,
|
||
* which of its handles a press lands on and what a pull on that handle means. The handles are the
|
||
* scene view's (`sizeGizmo.ts`) – eight squares that size and a knob on a stem that turns.
|
||
*
|
||
* Unlike a scene object's, the frame stands a few pixels off what it frames. A shape's own points
|
||
* lie on its bounds, a square's exactly on the corners, and a handle drawn there would hide the
|
||
* point under it; off the bounds neither is ever in the other's reach.
|
||
*/
|
||
import { applyVector, type Mat } from '../../core/model/matrix';
|
||
import type { Vec2 } from '../../core/model/Properties';
|
||
import type { Extent } from '../../core/model/vectorPaint';
|
||
import {
|
||
extentCentre,
|
||
extentCorners,
|
||
type FrameSide,
|
||
frameScale,
|
||
rotationAbout,
|
||
} from '../../core/model/vectorTransform';
|
||
import { knobAt, type SizeGrip, sizeGripAt } from '../sizeGizmo';
|
||
|
||
/** Screen pixels between what is framed and the frame. More than a point's reach plus a handle's. */
|
||
export const FRAME_PAD = 15;
|
||
/** Screen pixels around a handle that grab it. */
|
||
export const FRAME_GRIP = 6;
|
||
/** How far the knob stands out from the top edge, in screen pixels. */
|
||
export const FRAME_STEM = 26;
|
||
/** Degrees a turn snaps to while Shift is held. */
|
||
export const TURN_SNAP = 15;
|
||
|
||
export interface FrameBox {
|
||
/** Top left, top right, bottom right, bottom left – in the pixels `toLocal` hands out. */
|
||
corners: Vec2[];
|
||
/** The middle of each edge: top, right, bottom, left. */
|
||
edges: Vec2[];
|
||
base: Vec2;
|
||
knob: Vec2;
|
||
}
|
||
|
||
/**
|
||
* The frame as it is drawn: the corners of `extent` taken through `m` – the map of a drag under way,
|
||
* so the frame turns with what it holds – then onto the screen and `FRAME_PAD` pixels outwards.
|
||
*/
|
||
export function frameBox(extent: Extent, m: Mat, toLocal: (x: number, y: number) => Vec2): FrameBox {
|
||
const origin = toLocal(0, 0);
|
||
const direction = (v: Vec2): Vec2 => {
|
||
const moved = applyVector(m, v);
|
||
const tip = toLocal(moved.x, moved.y);
|
||
const d = { x: tip.x - origin.x, y: tip.y - origin.y };
|
||
const length = Math.hypot(d.x, d.y);
|
||
return length < 1e-9 ? v : { x: d.x / length, y: d.y / length };
|
||
};
|
||
const right = direction({ x: 1, y: 0 });
|
||
const down = direction({ x: 0, y: 1 });
|
||
const out: [number, number][] = [
|
||
[-1, -1],
|
||
[1, -1],
|
||
[1, 1],
|
||
[-1, 1],
|
||
];
|
||
const corners = extentCorners(extent, m).map((corner, i) => {
|
||
const at = toLocal(corner.x, corner.y);
|
||
const [sx, sy] = out[i]!;
|
||
return {
|
||
x: at.x + FRAME_PAD * (sx * right.x + sy * down.x),
|
||
y: at.y + FRAME_PAD * (sx * right.y + sy * down.y),
|
||
};
|
||
});
|
||
const edges = corners.map((a, i) => {
|
||
const b = corners[(i + 1) % corners.length]!;
|
||
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||
});
|
||
return { corners, edges, ...knobAt(corners, FRAME_STEM) };
|
||
}
|
||
|
||
/** The handle of the frame under `p`, the knob first; null beside all of them. */
|
||
export function frameGripAt(box: FrameBox, p: Vec2): SizeGrip | null {
|
||
return sizeGripAt(box.corners, box.knob, p, FRAME_GRIP, { sizable: true, corners: true });
|
||
}
|
||
|
||
/** The arrow a handle shows: along the direction it pulls in; the knob shows a hand. */
|
||
export function frameCursor(grip: SizeGrip): string {
|
||
if (grip.kind === 'knob') return 'grab';
|
||
const { sx, sy } = grip.side;
|
||
if (sx === 0) return 'ns-resize';
|
||
if (sy === 0) return 'ew-resize';
|
||
return sx === sy ? 'nwse-resize' : 'nesw-resize';
|
||
}
|
||
|
||
/** Where the handle on `side` sits on the framed extent itself, in canvas pixels. */
|
||
export function sidePoint(extent: Extent, side: FrameSide): Vec2 {
|
||
const centre = extentCentre(extent);
|
||
return {
|
||
x: side.sx === 0 ? centre.x : side.sx > 0 ? extent.x1 : extent.x0,
|
||
y: side.sy === 0 ? centre.y : side.sy > 0 ? extent.y1 : extent.y0,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The map a size handle has reached: the pointer went from `press` to `now` (canvas pixels), and the
|
||
* handle went the same way from where it sat – so a press beside the handle's middle does not jump.
|
||
* `snap` puts the place the handle is pulled to on the grid.
|
||
*/
|
||
export function frameSizing(
|
||
extent: Extent,
|
||
side: FrameSide,
|
||
press: Vec2,
|
||
now: Vec2,
|
||
options: { proportional: boolean; centred: boolean; snap?: (p: Vec2) => Vec2 },
|
||
): Mat {
|
||
const from = sidePoint(extent, side);
|
||
const to = { x: from.x + now.x - press.x, y: from.y + now.y - press.y };
|
||
return frameScale(extent, side, options.snap ? options.snap(to) : to, options);
|
||
}
|
||
|
||
/** The map of a turn by `sweep` degrees round the middle: to a tenth, or in steps of `TURN_SNAP`. */
|
||
export function frameTurning(extent: Extent, sweep: number, snap: boolean): Mat {
|
||
const degrees = snap ? Math.round(sweep / TURN_SNAP) * TURN_SNAP : Math.round(sweep * 10) / 10;
|
||
return rotationAbout(extentCentre(extent), degrees);
|
||
}
|