diff --git a/README.md b/README.md index 5ff95da8..c04aebe2 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, 1357 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1363 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/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index e1a143de..d6133dbc 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -56,6 +56,8 @@ export function sceneHooks({ state, editor }: HookContext) { gizmos: () => viewport.gizmoInfo(), /** The corner handles of the polygon collider being edited, in global pixels (#151). */ polygonCorners: () => viewport.cornerInfo(), + /** The size gizmo on the selected sprite or collider, in global pixels (#223). */ + sizeGizmo: () => viewport.sizeGizmoInfo(), /** The rays the editor run cast in the last moment, as the Scene tab draws them, in world units (#153). */ rays: () => viewport.rayInfo(), layerOf: (id: string) => viewport.layerOf(id), diff --git a/src/editor/sizeGizmo.ts b/src/editor/sizeGizmo.ts new file mode 100644 index 00000000..9724f70f --- /dev/null +++ b/src/editor/sizeGizmo.ts @@ -0,0 +1,171 @@ +/** + * The rules of the size gizmo (#223), without Pixi: which handle a press lands on, and what a drag + * of it writes. Edges and corners size a sprite or collider with the opposite side staying put. One + * knob outside the top edge turns or scales it – which of the two, the first stretch of the drag + * decides, and it stays that way until the release, so a drag never does both. + */ +import type { Vec2 } from '../core/model/Properties'; + +/** A side of the box in its own axes: -1, 0 or 1 on each, a corner has both. */ +export interface Side { + sx: -1 | 0 | 1; + sy: -1 | 0 | 1; +} + +export type SizeGrip = { kind: 'size'; side: Side } | { kind: 'knob' }; + +export type KnobMode = 'turn' | 'scale'; + +/** The four corners as `boxCorners` lists them: top left, top right, bottom right, bottom left. */ +const CORNER_SIDES: readonly Side[] = [ + { sx: -1, sy: -1 }, + { sx: 1, sy: -1 }, + { sx: 1, sy: 1 }, + { sx: -1, sy: 1 }, +]; + +/** The edge from corner `i` to corner `i + 1`: top, right, bottom, left. */ +const EDGE_SIDES: readonly Side[] = [ + { sx: 0, sy: -1 }, + { sx: 1, sy: 0 }, + { sx: 0, sy: 1 }, + { sx: -1, sy: 0 }, +]; + +/** Where the knob sits: `stem` pixels out from the middle of the top edge, away from the centre. */ +export function knobAt(corners: readonly Vec2[], stem: number): { base: Vec2; knob: Vec2 } { + const [a, b, c] = corners as [Vec2, Vec2, Vec2]; + const base = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + const centre = { x: (a.x + c.x) / 2, y: (a.y + c.y) / 2 }; + let out = { x: base.x - centre.x, y: base.y - centre.y }; + // A box without height has its top edge on the centre: out is then square to that edge, or up. + if (Math.hypot(out.x, out.y) < 1e-9) out = { x: b.y - a.y, y: a.x - b.x }; + if (Math.hypot(out.x, out.y) < 1e-9) out = { x: 0, y: -1 }; + const length = Math.hypot(out.x, out.y); + return { base, knob: { x: base.x + (out.x / length) * stem, y: base.y + (out.y / length) * stem } }; +} + +/** + * The handle within `tolerance` of `p`, all in screen pixels: the knob first, then a corner, then an + * edge. Inside the box a corner or edge reaches only half as far, so a small sprite can still be + * grabbed and moved away from its middle. `sizable` false leaves the knob alone (a polygon keeps its + * corners to itself), `corners` false the edges only (a circle has one size, a corner would ask for + * two). + */ +export function sizeGripAt( + corners: readonly Vec2[], + knob: Vec2, + p: Vec2, + tolerance: number, + options: { sizable: boolean; corners: boolean }, +): SizeGrip | null { + if (Math.hypot(knob.x - p.x, knob.y - p.y) <= tolerance) return { kind: 'knob' }; + if (!options.sizable) return null; + let best: SizeGrip | null = null; + let nearest = insideBox(corners, p) ? tolerance / 2 : tolerance; + if (options.corners) + for (const [i, corner] of corners.entries()) { + const distance = Math.hypot(corner.x - p.x, corner.y - p.y); + if (distance > nearest) continue; + best = { kind: 'size', side: CORNER_SIDES[i]! }; + nearest = distance; + } + if (best) return best; + for (const [i, a] of corners.entries()) { + const b = corners[(i + 1) % corners.length]!; + const distance = distanceToSegment(p, a, b); + if (distance > nearest) continue; + best = { kind: 'size', side: EDGE_SIDES[i]! }; + nearest = distance; + } + return best; +} + +/** Whether `p` lies inside the four corners, whichever way round they run. */ +function insideBox(corners: readonly Vec2[], p: Vec2): boolean { + const sides = corners.map((a, i) => { + const b = corners[(i + 1) % corners.length]!; + return Math.sign((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)); + }); + return sides.every((side) => side >= 0) || sides.every((side) => side <= 0); +} + +function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number { + const d = { x: b.x - a.x, y: b.y - a.y }; + const length = d.x * d.x + d.y * d.y; + const t = length > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * d.x + (p.y - a.y) * d.y) / length)) : 0; + return Math.hypot(a.x + d.x * t - p.x, a.y + d.y * t - p.y); +} + +/** + * The new size when the `side` of a `w` × `h` box, centred on its own origin, is dragged to `p` (in + * the box's own units, measured before the drag): whole units, at least one, the opposite side where + * it was. `shift` is how far the centre moves for that, in the same units. A circle stays round. + */ +export function resized( + size: { w: number; h: number }, + side: Side, + p: Vec2, + circle: boolean, +): { w: number; h: number; shift: Vec2 } { + const along = (s: number, v: number, extent: number) => + s === 0 ? extent : Math.max(1, Math.round(s * v + extent / 2)); + let w = along(side.sx, p.x, size.w); + let h = along(side.sy, p.y, size.h); + if (circle) { + const d = Math.max(2, 2 * Math.round((side.sx !== 0 ? w : h) / 2)); + w = d; + h = d; + } + return { + w, + h, + shift: { + x: side.sx === 0 ? 0 : (side.sx * (w - size.w)) / 2, + y: side.sy === 0 ? 0 : (side.sy * (h - size.h)) / 2, + }, + }; +} + +/** + * What a knob drag does, decided once the pointer has left the press by `threshold`: along the line + * from the centre through the press it scales, across it it turns. Null while it is still undecided. + */ +export function knobMode(centre: Vec2, press: Vec2, now: Vec2, threshold: number): KnobMode | null { + const move = { x: now.x - press.x, y: now.y - press.y }; + if (Math.hypot(move.x, move.y) < threshold) return null; + const out = { x: press.x - centre.x, y: press.y - centre.y }; + const length = Math.hypot(out.x, out.y) || 1; + const radial = (move.x * out.x + move.y * out.y) / length; + const across = (move.y * out.x - move.x * out.y) / length; + return Math.abs(radial) >= Math.abs(across) ? 'scale' : 'turn'; +} + +/** + * The angle in degrees the pointer swept round `centre` going from `from` to `now`, the short way. + * A drag adds these up move by move, so it can turn on past a half circle without jumping back. + */ +export function swept(centre: Vec2, from: Vec2, now: Vec2): number { + const a = Math.atan2(from.y - centre.y, from.x - centre.x); + const b = Math.atan2(now.y - centre.y, now.x - centre.x); + const delta = ((b - a) * 180) / Math.PI; + return ((((delta + 180) % 360) + 360) % 360) - 180; +} + +/** + * The rotation after sweeping `sweep` degrees, to one decimal. `sign` is -1 under a mirrored parent, + * where the object's own turn runs the other way; `snap` holds it to steps of that many degrees. + */ +export function turned(start: number, sweep: number, sign: 1 | -1, snap = 0): number { + const rotation = start + sign * sweep; + if (snap > 0) return Math.round(rotation / snap) * snap; + return Math.round(rotation * 10) / 10; +} + +/** The scale after pulling the pointer from `press` to `now`, measured from `centre`: to 0.01, never below. */ +export function scaled(start: number, centre: Vec2, press: Vec2, now: Vec2): number { + const from = Math.hypot(press.x - centre.x, press.y - centre.y); + const to = Math.hypot(now.x - centre.x, now.y - centre.y); + if (from < 1e-9) return start; + return Math.max(0.01, Math.round(start * (to / from) * 100) / 100); +} diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 633b0d84..67f0dfeb 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -39,6 +39,7 @@ import { screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World'; import { FreeViewport } from './FreeViewport'; import { PolygonCornerEditor } from './polygonCorners'; +import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor'; import { MIN_GRID_STEP, type SceneViewHost } from './sceneViewHost'; import { type PaintTarget, TilemapPainter } from './tilemapPainting'; import { TilemapSolidOutlines } from './tilemapSolids'; @@ -116,11 +117,17 @@ export class SceneViewport extends FreeViewport { const local = this.toLocal(global); return screenToWorld(this.currentView(), local.x, local.y); }, + local: (global) => { + const local = this.toLocal(global); + return { x: local.x, y: local.y }; + }, + view: () => this.currentView(), scale: () => this.currentView().scale, redraw: () => this.redraw(), }; private readonly painter = new TilemapPainter(this.host); private readonly corners = new PolygonCornerEditor(this.host); + private readonly sizer = new SizeGizmoEditor(this.host); private editorWorld: World | null = null; private lastRays: CastRay[] = []; private dragging: ObjectDrag | null = null; @@ -208,7 +215,7 @@ export class SceneViewport extends FreeViewport { this.startPan(e); return; } - if (e.button === 0 && this.corners.press(e)) return; + if (e.button === 0 && (this.corners.press(e) || this.sizer.press(e))) return; if (e.button === 0 && e.target === this) this.pressEmpty(e); } @@ -233,7 +240,13 @@ export class SceneViewport extends FreeViewport { this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e); return; } - if (this.panTo(e) || this.corners.move(e.global) || this.painter.move(e.global)) return; + if ( + this.panTo(e) || + this.corners.move(e.global) || + this.sizer.move(e.global, e.shiftKey) || + this.painter.move(e.global) + ) + return; if (this.dragging) { const local = this.toLocal(e.global); const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; @@ -312,7 +325,7 @@ export class SceneViewport extends FreeViewport { return; } this.endPan(); - if (this.corners.release() || this.painter.release()) return; + if (this.corners.release() || this.sizer.release() || this.painter.release()) return; const marquee = this.marquee; this.marquee = null; if (marquee) { @@ -341,6 +354,11 @@ export class SceneViewport extends FreeViewport { if (moves.length > 0) this.state.execute(oneStep('Move objects', moves)); } + /** The size gizmo's handles on the selection, where they are drawn (test hook). */ + sizeGizmoInfo(): SizeGizmoInfo | null { + return this.sizer.info(); + } + /** The corners of the polygon being edited, where they are drawn (test hook). */ cornerInfo(): { objectId: string; corners: Vec2[] } | null { return this.corners.info(); @@ -357,7 +375,7 @@ export class SceneViewport extends FreeViewport { return; } if (e.button !== 0) return; - if (this.corners.press(e)) return; + if (this.corners.press(e) || this.sizer.press(e)) return; const holder = this.resolved(); if (!holder) return; const stack = this.stackUnder(e); @@ -402,7 +420,15 @@ export class SceneViewport extends FreeViewport { private openMenuUnder(e: FederatedPointerEvent): void { const source = this.holder(); const holder = this.resolved(); - if (!source || !holder || this.dragging || this.painter.active || this.corners.active || this.marquee) + if ( + !source || + !holder || + this.dragging || + this.painter.active || + this.corners.active || + this.sizer.active || + this.marquee + ) return; const target = menuTargetOf(this.stackUnder(e), this.state.selectedObjectIds, this.primaryObjectId()); if (!target) return; @@ -743,11 +769,12 @@ export class SceneViewport extends FreeViewport { const primary = id === s.objectId; this.lastGizmos.push({ id, primary, alpha }); this.gizmos.poly(corners, true).stroke({ width: 2, color: Theme.colors.gizmo, alpha }); - if (!primary) continue; + if (!primary || this.sizer.handles(id)) continue; for (const c of corners) this.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha }); } } + this.sizer.draw(this.gizmos, view); this.corners.draw(this.gizmos, view); this.drawDropPreview(view); this.painter.draw(this.gizmos, items, view); diff --git a/src/editor/viewport/sceneViewHost.ts b/src/editor/viewport/sceneViewHost.ts index 84a9bbd4..8fddb650 100644 --- a/src/editor/viewport/sceneViewHost.ts +++ b/src/editor/viewport/sceneViewHost.ts @@ -1,6 +1,7 @@ import type { ObjectHolder } from '../../core/model/Project'; import type { Vec2 } from '../../core/model/Properties'; import type { DrawItem } from '../../viewport/items'; +import type { View } from '../../viewport/view'; import type { EditorState } from '../state'; /** Below this on-screen spacing a grid is noise rather than orientation, so it is dropped. */ @@ -15,6 +16,9 @@ export interface SceneViewHost { items(): readonly DrawItem[]; /** The world point under a global pointer position. */ worldAt(global: Vec2): Vec2; + /** A global pointer position in the view's own pixels, where `worldToScreen` puts things. */ + local(global: Vec2): Vec2; + view(): View; /** Screen pixels per world unit. */ scale(): number; redraw(): void; diff --git a/src/editor/viewport/sizeGizmoEditor.ts b/src/editor/viewport/sizeGizmoEditor.ts new file mode 100644 index 00000000..fcb3b49f --- /dev/null +++ b/src/editor/viewport/sizeGizmoEditor.ts @@ -0,0 +1,315 @@ +import type { FederatedPointerEvent, Graphics } from 'pixi.js'; +import { oneStep } from '../../core/commands/CompoundCommand'; +import type { Command } from '../../core/commands/Command'; +import { + ClearOverrideCommand, + MoveSceneObjectCommand, + SetOverrideCommand, +} from '../../core/commands/sceneObjectCommands'; +import { objectRef, resolvedHolder } from '../../core/model/instance'; +import { + apply as applyPoint, + applyVector, + determinant, + invert, + linearOf, + type Mat, +} from '../../core/model/matrix'; +import type { Position, PropertyValue, Vec2 } from '../../core/model/Properties'; +import { findAsset } from '../../core/model/Project'; +import { footprintCorners, footprintOf } from '../../core/properties/bounds'; +import { parentTransform } from '../../core/properties/transform'; +import { Theme } from '../../ui'; +import type { DrawItem } from '../../viewport/items'; +import { type View, worldToScreen } from '../../viewport/view'; +import { FramePreview } from '../framePreview'; +import { + knobAt, + type KnobMode, + knobMode, + resized, + scaled, + type Side, + type SizeGrip, + sizeGripAt, + swept, + turned, +} from '../sizeGizmo'; +import type { SceneViewHost } from './sceneViewHost'; + +/** Screen pixels around a handle that grab it. */ +const GRIP = 7; +/** Side of a size handle, and radius of the knob, in screen pixels. */ +const HANDLE = 6; +const KNOB_RADIUS = 5; +/** How far the knob stands out from the top edge, in screen pixels. */ +const STEM = 26; +/** Screen pixels a knob drag travels before it is decided whether it turns or scales. */ +const DECIDE = 5; +/** Pixels of travel before a press on an edge or corner turns into a drag. */ +const DRAG_THRESHOLD = 2; +/** Degrees a turn snaps to while Shift is held. */ +const TURN_SNAP = 15; + +/** The keys a drag writes; the preview puts the values straight on the object, the release a command. */ +type Writes = Partial>; + +interface GizmoDrag { + holderId: string; + objectId: string; + grip: SizeGrip; + /** Undecided until the knob has travelled `DECIDE` pixels, then fixed until the release. */ + mode: KnobMode | 'size' | null; + /** The object as the press found it, put back on release so one command records the edit. */ + before: { position: Position; overrides: Record }; + size: { w: number; h: number }; + circle: boolean; + rotation: number; + scale: number; + /** The item's world transform at the press, and its inverse. */ + m: Mat; + toOwn: Mat; + /** From a world delta to one in the parent's frame, where the position lives. */ + toParent: Mat; + mirrored: boolean; + press: Vec2; + moved: boolean; + /** Where the pointer was at the last move, and the degrees it swept round the centre since the press. */ + last: Vec2; + sweep: number; + writes: Writes; + position: Position; +} + +/** The handles drawn on the selection, in global pixels, and what the running drag does (test hook). */ +export interface SizeGizmoInfo { + objectId: string; + corners: Vec2[]; + edges: Vec2[]; + knob: Vec2; + mode: GizmoDrag['mode']; +} + +/** Handles on the selected sprite or collider (#223): edges and corners size it, the knob turns or scales it. */ +export class SizeGizmoEditor { + private drag: GizmoDrag | null = null; + private lastInfo: SizeGizmoInfo | null = null; + private readonly perFrame: FramePreview; + + constructor(private readonly host: SceneViewHost) { + this.perFrame = new FramePreview(host.state, 'size-gizmo'); + } + + get active(): boolean { + return this.drag !== null; + } + + /** The primary selection when it is a sprite or collider this view can edit. */ + private edited(): DrawItem | null { + const state = this.host.state; + const s = state.selection; + const holder = this.host.holder(); + if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null; + const item = this.host.items().find((i) => i.id === s.objectId); + return item && (item.type === 'sprite' || item.type === 'collider') ? item : null; + } + + /** Whether this item's corners are drawn by the gizmo, so the plain outline leaves them out. */ + handles(id: string): boolean { + return this.edited()?.id === id; + } + + private screenBox(item: DrawItem, view: View): { corners: Vec2[]; base: Vec2; knob: Vec2 } { + const corners = footprintCorners(item.type, item.props, item.m, 0, item.frame).map((c) => + worldToScreen(view, c.x, c.y), + ); + return { corners, ...knobAt(corners, STEM) }; + } + + /** A press on one of the handles starts its drag; true when it was one. */ + press(e: FederatedPointerEvent): boolean { + const state = this.host.state; + const item = this.edited(); + const holder = this.host.holder(); + const ref = item && holder && objectRef(state.project, holder, item.id); + if (!item || !holder || !ref) return false; + const view = this.host.view(); + const box = this.screenBox(item, view); + const shape = item.props.shape; + const circle = item.type === 'collider' && shape === 'circle'; + const sizable = !(item.type === 'collider' && (shape === 'polygon' || shape === 'frame')); + const grip = sizeGripAt(box.corners, box.knob, this.host.local(e.global), GRIP, { + sizable, + corners: !circle, + }); + if (!grip) return false; + e.stopPropagation(); + const tree = resolvedHolder(state.project, holder); + const parent = parentTransform(state.project, tree, ref.object.parentId); + const size = footprintOf(item.type, item.props, item.frame); + const overrides: Record = {}; + for (const key of ['width', 'height', 'radius', 'rotation', 'scale']) + overrides[key] = ref.object.overrides[key]; + this.drag = { + holderId: holder.id, + objectId: item.id, + grip, + mode: grip.kind === 'size' ? 'size' : null, + before: { position: { ...ref.object.position }, overrides }, + size, + circle, + rotation: Number(item.props.rotation ?? 0) || 0, + scale: Number(item.props.scale ?? 1) || 1, + m: item.m, + toOwn: invert(item.m), + toParent: invert(linearOf(parent.m)), + mirrored: determinant(parent.m) < 0, + press: this.host.worldAt(e.global), + moved: false, + last: this.host.worldAt(e.global), + sweep: 0, + writes: {}, + position: { ...ref.object.position }, + }; + return true; + } + + /** Follows the pointer with the handle under way; false when there is none. */ + move(global: Vec2, snap: boolean): boolean { + const drag = this.drag; + if (!drag) return false; + const now = this.host.worldAt(global); + const centre = { x: drag.m.tx, y: drag.m.ty }; + const travel = Math.hypot(now.x - drag.press.x, now.y - drag.press.y) * this.host.scale(); + if (drag.mode === null) drag.mode = knobMode(centre, drag.press, now, DECIDE / this.host.scale()); + if (drag.mode === null || (!drag.moved && travel < DRAG_THRESHOLD)) return true; + drag.moved = true; + drag.position = { ...drag.before.position }; + if (drag.mode === 'size' && drag.grip.kind === 'size') { + const next = resized(drag.size, drag.grip.side, applyPoint(drag.toOwn, now), drag.circle); + drag.writes = drag.circle ? { radius: next.w / 2 } : { width: next.w, height: next.h }; + const shift = applyVector(drag.toParent, applyVector(drag.m, next.shift)); + drag.position = { + x: drag.before.position.x + shift.x, + y: drag.before.position.y + shift.y, + z: drag.before.position.z, + }; + if (!sidesTouch(drag.grip.side, 'x')) delete drag.writes.width; + if (!sidesTouch(drag.grip.side, 'y')) delete drag.writes.height; + } else if (drag.mode === 'turn') { + drag.sweep += swept(centre, drag.last, now); + drag.last = now; + const sign = drag.mirrored ? -1 : 1; + drag.writes = { rotation: turned(drag.rotation, drag.sweep, sign, snap ? TURN_SNAP : 0) }; + } else if (drag.mode === 'scale') { + drag.writes = { scale: scaled(drag.scale, centre, drag.press, now) }; + } + this.preview(); + return true; + } + + private ref() { + const drag = this.drag; + const holder = this.host.holder(); + return drag && holder ? objectRef(this.host.state.project, holder, drag.objectId) : undefined; + } + + private preview(): void { + const drag = this.drag; + const ref = this.ref(); + if (!drag || !ref) return; + for (const [key, value] of Object.entries(drag.writes)) ref.setOverride(key, value); + ref.setPosition(drag.position); + this.host.state.heldObjectIds = new Set([drag.objectId]); + this.perFrame.request(); + } + + /** Puts the preview back and lets one command do the edit, so one drag is one undo step. */ + release(): boolean { + const drag = this.drag; + if (!drag) return false; + const ref = this.ref(); + this.drag = null; + this.perFrame.cancel(); + const state = this.host.state; + state.heldObjectIds = new Set(); + if (!ref) return true; + for (const [key, value] of Object.entries(drag.before.overrides)) ref.setOverride(key, value); + ref.setPosition(drag.before.position); + const asset = findAsset(state.project, ref.object.assetId); + const commands: Command[] = []; + for (const [key, value] of Object.entries(drag.writes)) { + // A value equal to the asset's own clears the override, as "Fit to sprite" does (#222). + const same = !ref.target && asset?.props[key] === value; + commands.push( + same + ? new ClearOverrideCommand(drag.holderId, drag.objectId, key) + : new SetOverrideCommand(drag.holderId, drag.objectId, key, value), + ); + } + const p = drag.position; + const b = drag.before.position; + if (p.x !== b.x || p.y !== b.y) + commands.push(new MoveSceneObjectCommand(drag.holderId, drag.objectId, p)); + if (commands.length > 0) state.execute(oneStep(labelOf(drag.mode), commands)); + else state.preview(); + return true; + } + + /** The handles drawn for the selection, in global pixels, and what a drag of the knob does (test hook). */ + info(): SizeGizmoInfo | null { + return this.lastInfo; + } + + /** Corner and edge handles (not on a polygon, no corners on a circle) and the knob on its stem. */ + draw(g: Graphics, view: View): void { + this.lastInfo = null; + const item = this.edited(); + if (!item) return; + const { corners, base, knob } = this.screenBox(item, view); + const shape = item.props.shape; + const circle = item.type === 'collider' && shape === 'circle'; + const sizable = !(item.type === 'collider' && (shape === 'polygon' || shape === 'frame')); + 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 }; + }); + const color = Theme.colors.gizmo; + g.moveTo(base.x, base.y).lineTo(knob.x, knob.y).stroke({ width: 1.5, color }); + const busy = this.drag?.objectId === item.id && this.drag.grip.kind === 'knob'; + g.circle(knob.x, knob.y, KNOB_RADIUS) + .fill(busy ? color : Theme.colors.accentText) + .stroke({ width: 1.5, color }); + const square = (p: Vec2) => + g + .rect(p.x - HANDLE / 2, p.y - HANDLE / 2, HANDLE, HANDLE) + .fill(Theme.colors.accentText) + .stroke({ + width: 1.5, + color, + }); + if (sizable) { + for (const p of edges) square(p); + if (!circle) for (const p of corners) square(p); + } + const global = (p: Vec2) => { + const q = g.toGlobal(p); + return { x: q.x, y: q.y }; + }; + this.lastInfo = { + objectId: item.id, + corners: sizable && !circle ? corners.map(global) : [], + edges: sizable ? edges.map(global) : [], + knob: global(knob), + mode: this.drag?.mode ?? null, + }; + } +} + +function sidesTouch(side: Side, axis: 'x' | 'y'): boolean { + return (axis === 'x' ? side.sx : side.sy) !== 0; +} + +function labelOf(mode: GizmoDrag['mode']): string { + return mode === 'turn' ? 'Rotate object' : mode === 'scale' ? 'Scale object' : 'Resize object'; +} diff --git a/tests/editor/size-gizmo.test.ts b/tests/editor/size-gizmo.test.ts new file mode 100644 index 00000000..160c8379 --- /dev/null +++ b/tests/editor/size-gizmo.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { knobAt, knobMode, resized, scaled, sizeGripAt, swept, turned } from '../../src/editor/sizeGizmo'; + +// A 100 × 40 box centred on (0, 0), corners as `boxCorners` lists them. +const BOX = [ + { x: -50, y: -20 }, + { x: 50, y: -20 }, + { x: 50, y: 20 }, + { x: -50, y: 20 }, +]; +const ALL = { sizable: true, corners: true }; + +describe('size gizmo (#223)', () => { + it('puts the knob out from the middle of the top edge, turned with the box', () => { + expect(knobAt(BOX, 26)).toEqual({ base: { x: 0, y: -20 }, knob: { x: 0, y: -46 } }); + // Turned a quarter clockwise: the top edge is now the right-hand one. + const turnedBox = [ + { x: 20, y: -50 }, + { x: 20, y: 50 }, + { x: -20, y: 50 }, + { x: -20, y: -50 }, + ]; + const { knob } = knobAt(turnedBox, 26); + expect(knob.x).toBeCloseTo(46); + expect(knob.y).toBeCloseTo(0); + }); + + it('finds the knob first, then a corner, then an edge', () => { + const knob = { x: 0, y: -46 }; + expect(sizeGripAt(BOX, knob, { x: 1, y: -45 }, 7, ALL)).toEqual({ kind: 'knob' }); + expect(sizeGripAt(BOX, knob, { x: 48, y: 18 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 1, sy: 1 } }); + expect(sizeGripAt(BOX, knob, { x: 10, y: 22 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } }); + expect(sizeGripAt(BOX, knob, { x: -53, y: 0 }, 7, ALL)).toEqual({ + kind: 'size', + side: { sx: -1, sy: 0 }, + }); + expect(sizeGripAt(BOX, knob, { x: 0, y: 0 }, 7, ALL)).toBeNull(); + }); + + it('reaches only half as far into the box, so a small sprite can still be grabbed off its middle', () => { + const knob = { x: 0, y: -46 }; + expect(sizeGripAt(BOX, knob, { x: 10, y: 26 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } }); + expect(sizeGripAt(BOX, knob, { x: 10, y: 17 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } }); + expect(sizeGripAt(BOX, knob, { x: 10, y: 14 }, 7, ALL)).toBeNull(); + expect(sizeGripAt(BOX, knob, { x: 45, y: 15 }, 7, ALL)).toBeNull(); + expect(sizeGripAt(BOX, knob, { x: 48, y: 18 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 1, sy: 1 } }); + }); + + it('leaves the corners to the edges on a circle, and a polygon only its knob', () => { + const knob = { x: 0, y: -46 }; + expect(sizeGripAt(BOX, knob, { x: 49, y: 17 }, 7, { sizable: true, corners: false })).toEqual({ + kind: 'size', + side: { sx: 1, sy: 0 }, + }); + expect(sizeGripAt(BOX, knob, { x: 50, y: 0 }, 7, { sizable: false, corners: true })).toBeNull(); + expect(sizeGripAt(BOX, knob, { x: 0, y: -46 }, 7, { sizable: false, corners: true })).toEqual({ + kind: 'knob', + }); + }); + + it('sizes from the dragged side and keeps the opposite one where it was', () => { + // Right edge from 50 out to 80: 30 wider, the centre 15 to the right, the left edge stays at -50. + expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: 80, y: 3 }, false)).toEqual({ + w: 130, + h: 40, + shift: { x: 15, y: 0 }, + }); + // Top left corner in by 10 and 5. + expect(resized({ w: 100, h: 40 }, { sx: -1, sy: -1 }, { x: -40, y: -15 }, false)).toEqual({ + w: 90, + h: 35, + shift: { x: 5, y: 2.5 }, + }); + }); + + it('rounds to whole units, never below one, and keeps a circle round', () => { + expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: 60.4, y: 0 }, false).w).toBe(110); + expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: -500, y: 0 }, false).w).toBe(1); + const circle = resized({ w: 40, h: 40 }, { sx: 0, sy: 1 }, { x: 0, y: 40 }, true); + expect(circle).toEqual({ w: 60, h: 60, shift: { x: 0, y: 10 } }); + }); + + it('decides once between turning and scaling by the way the knob leaves the press', () => { + const centre = { x: 0, y: 0 }; + const press = { x: 0, y: -46 }; + expect(knobMode(centre, press, { x: 2, y: -46 }, 5)).toBeNull(); + expect(knobMode(centre, press, { x: 12, y: -44 }, 5)).toBe('turn'); + expect(knobMode(centre, press, { x: 3, y: -60 }, 5)).toBe('scale'); + expect(knobMode(centre, press, { x: -2, y: -30 }, 5)).toBe('scale'); + }); + + it('turns by the angle swept round the centre, the other way under a mirror, snapped with Shift', () => { + const centre = { x: 0, y: 0 }; + const press = { x: 0, y: -46 }; + const quarter = swept(centre, press, { x: 46, y: 0 }); + expect(quarter).toBeCloseTo(90); + expect(turned(10, quarter, 1)).toBeCloseTo(100); + expect(turned(10, quarter, -1)).toBeCloseTo(-80); + expect(turned(0, swept(centre, press, { x: 46, y: -40 }), 1, 15)).toBe(45); + }); + + it('adds the sweeps of a drag up, so a turn past the half circle goes on instead of jumping back', () => { + const centre = { x: 0, y: 0 }; + const path = [ + { x: 0, y: -46 }, + { x: 46, y: 0 }, + { x: 0, y: 46 }, + { x: -46, y: 1 }, + ]; + let sweep = 0; + for (let i = 1; i < path.length; i++) sweep += swept(centre, path[i - 1]!, path[i]!); + expect(turned(0, sweep, 1)).toBeCloseTo(268.8, 1); + }); + + it('scales by how much farther from the centre the knob is pulled', () => { + const centre = { x: 0, y: 0 }; + expect(scaled(1, centre, { x: 0, y: -40 }, { x: 0, y: -80 })).toBe(2); + expect(scaled(1.5, centre, { x: 0, y: -40 }, { x: 0, y: -20 })).toBe(0.75); + expect(scaled(1, centre, { x: 0, y: -40 }, { x: 0, y: 0 })).toBe(0.01); + }); +}); diff --git a/tools/e2e/29-transform.mjs b/tools/e2e/29-transform.mjs index 5ef69547..d7d01151 100644 --- a/tools/e2e/29-transform.mjs +++ b/tools/e2e/29-transform.mjs @@ -236,6 +236,111 @@ export async function transform(t) { ); await remove(RIG); + // 29j. The size gizmo (#223) on an upright 100×40 sprite: an edge sizes it with the opposite edge + // staying put, the knob above the top edge turns it when pulled across and scales it when + // pulled out – and a drag that starts one way stays that way. + await mutate(` + const scene = __gm.state.activeScene; + const make = (id, assetId, parentId, position, overrides = {}) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides, parentId, instanceEdits: {} }); + scene.objects.push( + make('otf-size', ${JSON.stringify(SPRITE)}, '', { x: 150, y: ${LANE}, z: 960 }), + make('otf-sizec', 'collider', 'otf-size', { x: 0, y: 0, z: 1 }, { shape: 'circle', radius: 20 }), + )`); + await selectObject('otf-size'); + const gizmo = () => json('__gm.sizeGizmo()'); + // A press, moves through every point, a release: `drag` only goes in a straight line. + const path = async (points) => { + const [first, ...rest] = points; + await t.mouse('mouseMoved', first.x, first.y); + await t.mouse('mousePressed', first.x, first.y); + for (const p of rest) { + await t.mouse('mouseMoved', p.x, p.y, 'left', { buttons: 1 }); + await sleep(16); + } + const last = rest.at(-1) ?? first; + await t.mouse('mouseReleased', last.x, last.y); + await t.settle(); + }; + const line = (from, to, steps = 10) => + Array.from({ length: steps + 1 }, (_, i) => ({ + x: from.x + ((to.x - from.x) * i) / steps, + y: from.y + ((to.y - from.y) * i) / steps, + })); + const sized = await object('otf-size'); + const g0 = await gizmo(); + const r0 = await rect('otf-size'); + check( + 'a selected sprite gets the size gizmo: four corners, four edges, and the knob above the top edge', + g0?.objectId === 'otf-size' && + g0.corners.length === 4 && + g0.edges.length === 4 && + Math.abs(g0.knob.x - r0.x) < 2 && + g0.knob.y < r0.y - r0.h / 2 - 10, + JSON.stringify({ g0, r0 }), + ); + + const right = g0.edges[1]; + await path(line(right, { x: right.x + 30, y: right.y + 5 })); + const wide = { object: await object('otf-size'), rect: await rect('otf-size') }; + const grow = 30 / s; + check( + 'pulling the right edge 30 px out widens the sprite by 30 / scale and keeps its left edge where it was', + Math.abs(Number(wide.object.overrides.width) - Math.round(100 + grow)) < 0.01 && + wide.object.overrides.height === undefined && + Math.abs(wide.rect.x - wide.rect.w / 2 - (r0.x - r0.w / 2)) < 2 && + wide.object.position.y === sized.position.y, + JSON.stringify({ wide, r0, grow }), + ); + await undo(); + check( + 'one undo takes the whole resize back', + (await object('otf-size')).overrides.width === undefined && (await object('otf-size')).position.x === 150, + ); + + const k = (await gizmo()).knob; + const reach = k.y - r0.y; + await path(line(k, { x: k.x - reach, y: k.y })); + const spun = (await object('otf-size')).overrides; + check( + 'pulling the knob across turns the sprite by the angle swept round its centre (45°) and leaves its scale', + Math.abs(Number(spun.rotation) - 45) < 1.5 && spun.scale === undefined, + JSON.stringify({ spun, reach }), + ); + await undo(); + + const k2 = (await gizmo()).knob; + await path([ + ...line(k2, { x: k2.x, y: k2.y - 40 }), + ...line({ x: k2.x, y: k2.y - 40 }, { x: k2.x + 60, y: k2.y - 40 }), + ]); + const pulled = (await object('otf-size')).overrides; + const expected = Math.hypot(60, -reach + 40) / -reach; + check( + 'pulling the knob out scales from the centre, and turning sideways afterwards does not turn it', + Math.abs(Number(pulled.scale) - expected) < 0.03 && pulled.rotation === undefined, + JSON.stringify({ pulled, expected }), + ); + await undo(); + await shot('29-size-gizmo'); + + await selectObject('otf-sizec'); + const c0 = await gizmo(); + const cRect = await rect('otf-sizec'); + const cRight = c0.edges[1]; + await path(line(cRight, { x: cRight.x + 20 * s, y: cRight.y })); + const circle = { object: await object('otf-sizec'), rect: await rect('otf-sizec') }; + check( + 'a circle collider has edges but no corners, and an edge pulled 20 out makes its radius 30', + c0.corners.length === 0 && + c0.edges.length === 4 && + circle.object.overrides.radius === 30 && + Math.abs(circle.rect.x - circle.rect.w / 2 - (cRect.x - cRect.w / 2)) < 2, + JSON.stringify({ c0, cRect, circle }), + ); + await undo(); + await evaluate('__gm.state.select(null)'); + await remove(['otf-size', 'otf-sizec']); + // 29i. Cleanup, and the view back where the earlier steps left the scene. await remove([BOX, CHILD, CAMERA]); await mutate(`