diff --git a/src/editor/sizeGizmo.ts b/src/editor/sizeGizmo.ts index 9724f70f..2570fccc 100644 --- a/src/editor/sizeGizmo.ts +++ b/src/editor/sizeGizmo.ts @@ -4,6 +4,7 @@ * 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 { apply, boxCorners, determinant, extentOfPoints, invert, type Mat } from '../core/model/matrix'; 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. */ @@ -32,6 +33,17 @@ const EDGE_SIDES: readonly Side[] = [ { sx: -1, sy: 0 }, ]; +/** + * The frame round what a container holds (#242), in world units: the corners of its content boxed + * in the container's own axes, so the frame turns and grows with it. Null when it holds nothing. + */ +export function contentFrame(m: Mat, content: readonly Vec2[]): Vec2[] | null { + if (content.length === 0 || Math.abs(determinant(m)) < 1e-12) return null; + const toOwn = invert(m); + const e = extentOfPoints(content.map((p) => apply(toOwn, p))); + return boxCorners(m, e.right - e.left, e.bottom - e.top, (e.left + e.right) / 2, (e.top + e.bottom) / 2); +} + /** 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]; diff --git a/src/editor/viewport/sizeGizmoEditor.ts b/src/editor/viewport/sizeGizmoEditor.ts index fcb3b49f..7469f099 100644 --- a/src/editor/viewport/sizeGizmoEditor.ts +++ b/src/editor/viewport/sizeGizmoEditor.ts @@ -18,12 +18,14 @@ import { import type { Position, PropertyValue, Vec2 } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; import { footprintCorners, footprintOf } from '../../core/properties/bounds'; +import { hasTransformProps } from '../../core/properties/PropertySchema'; 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 { + contentFrame, knobAt, type KnobMode, knobMode, @@ -50,6 +52,10 @@ const DECIDE = 5; const DRAG_THRESHOLD = 2; /** Degrees a turn snaps to while Shift is held. */ const TURN_SNAP = 15; +/** How strongly the frame round a container's content is drawn. */ +const FRAME_ALPHA = 0.6; +/** Content that is only an icon or a view, and so takes no room in a container's frame. */ +const SHAPELESS = new Set(['behavior', 'rigidbody', 'camera', 'container']); /** The keys a drag writes; the preview puts the values straight on the object, the release a command. */ type Writes = Partial>; @@ -84,13 +90,18 @@ interface GizmoDrag { /** The handles drawn on the selection, in global pixels, and what the running drag does (test hook). */ export interface SizeGizmoInfo { objectId: string; + /** The box the knob stands on, handles or not. */ + box: Vec2[]; 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. */ +/** + * Handles on the selected object (#223): edges and corners size a sprite or collider, the knob turns + * or scales anything that has a turn and a scale (#242). + */ export class SizeGizmoEditor { private drag: GizmoDrag | null = null; private lastInfo: SizeGizmoInfo | null = null; @@ -104,25 +115,47 @@ export class SizeGizmoEditor { return this.drag !== null; } - /** The primary selection when it is a sprite or collider this view can edit. */ + /** The primary selection when it is an object with a turn and a scale 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; + return item && hasTransformProps(item.type) ? 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; + const item = this.edited(); + return item?.id === id && hasSizeHandles(item); + } + + /** A container is framed round its content (#242), everything else round its own footprint. */ + private worldBox(item: DrawItem): Vec2[] { + if (item.type === 'container') { + const frame = contentFrame(item.m, this.contentCorners(item.id)); + if (frame) return frame; + } + return footprintCorners(item.type, item.props, item.m, 0, item.frame); + } + + /** The corners of everything below a container that has a shape of its own, at any depth. */ + private contentCorners(containerId: string): Vec2[] { + const items = this.host.items(); + const byId = new Map(items.map((i) => [i.id, i])); + const below = (item: DrawItem): boolean => { + for (let parent = byId.get(item.parentId); parent; parent = byId.get(parent.parentId)) + if (parent.id === containerId) return true; + return false; + }; + return items + .filter((i) => !SHAPELESS.has(i.type) && below(i)) + .flatMap((i) => footprintCorners(i.type, i.props, i.m, 0, i.frame)); } 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), - ); + const corners = this.worldBox(item).map((c) => worldToScreen(view, c.x, c.y)); return { corners, ...knobAt(corners, STEM) }; } @@ -135,9 +168,8 @@ export class SizeGizmoEditor { 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 circle = isCircle(item); + const sizable = isSizable(item); const grip = sizeGripAt(box.corners, box.knob, this.host.local(e.global), GRIP, { sizable, corners: !circle, @@ -267,14 +299,15 @@ export class SizeGizmoEditor { 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 circle = isCircle(item); + const sizable = isSizable(item); 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; + // The selection outline sits round the container's icon, so the frame the knob belongs to is drawn here. + if (item.type === 'container') g.poly(corners, true).stroke({ width: 1, color, alpha: FRAME_ALPHA }); 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) @@ -298,6 +331,7 @@ export class SizeGizmoEditor { }; this.lastInfo = { objectId: item.id, + box: corners.map(global), corners: sizable && !circle ? corners.map(global) : [], edges: sizable ? edges.map(global) : [], knob: global(knob), @@ -306,6 +340,21 @@ export class SizeGizmoEditor { } } +/** Only sprites and colliders have a width and a height to pull at; everything else turns and scales. */ +function hasSizeHandles(item: DrawItem): boolean { + return item.type === 'sprite' || item.type === 'collider'; +} + +/** A polygon keeps its corners to itself, and a frame collider is the frame's size. */ +function isSizable(item: DrawItem): boolean { + const shape = item.props.shape; + return hasSizeHandles(item) && !(item.type === 'collider' && (shape === 'polygon' || shape === 'frame')); +} + +function isCircle(item: DrawItem): boolean { + return item.type === 'collider' && item.props.shape === 'circle'; +} + function sidesTouch(side: Side, axis: 'x' | 'y'): boolean { return (axis === 'x' ? side.sx : side.sy) !== 0; } diff --git a/tests/editor/size-gizmo.test.ts b/tests/editor/size-gizmo.test.ts index 160c8379..54daa6d8 100644 --- a/tests/editor/size-gizmo.test.ts +++ b/tests/editor/size-gizmo.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { knobAt, knobMode, resized, scaled, sizeGripAt, swept, turned } from '../../src/editor/sizeGizmo'; +import { apply, type Mat } from '../../src/core/model/matrix'; +import { + contentFrame, + 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 = [ @@ -119,3 +129,26 @@ describe('size gizmo (#223)', () => { expect(scaled(1, centre, { x: 0, y: -40 }, { x: 0, y: 0 })).toBe(0.01); }); }); + +describe('the frame round a container (#242)', () => { + // Turned a quarter to the right and twice the size, standing at (100, 50). + const m: Mat = { a: 0, b: 2, c: -2, d: 0, tx: 100, ty: 50 }; + const own = (x: number, y: number) => apply(m, { x, y }); + + it("boxes the content in the container's own axes, so it turns and grows with it", () => { + const content = [own(10, -20), own(30, -20), own(30, 0), own(10, 0), own(20, -10)]; + const frame = contentFrame(m, content)!; + const expected = [own(10, -20), own(30, -20), own(30, 0), own(10, 0)]; + frame.forEach((corner, i) => { + expect(corner.x).toBeCloseTo(expected[i]!.x); + expect(corner.y).toBeCloseTo(expected[i]!.y); + }); + // The top edge in its own axes, where the knob stands, is on the world's right side now. + expect(knobAt(frame, 26).knob.x).toBeGreaterThan(Math.max(...frame.map((c) => c.x))); + }); + + it('has none without content or at no size', () => { + expect(contentFrame(m, [])).toBeNull(); + expect(contentFrame({ ...m, b: 0, c: 0 }, [own(0, 0)])).toBeNull(); + }); +}); diff --git a/tools/e2e/29-transform.mjs b/tools/e2e/29-transform.mjs index d7d01151..b8d83123 100644 --- a/tools/e2e/29-transform.mjs +++ b/tools/e2e/29-transform.mjs @@ -341,6 +341,94 @@ export async function transform(t) { await evaluate('__gm.state.select(null)'); await remove(['otf-size', 'otf-sizec']); + // 29k. The knob on everything else with a turn and a scale (#242): a text, a tilemap, a particle + // source and a container get the knob, but no edges or corners. The container's knob stands + // over a frame round its content – a 100×40 sprite 150 to the right of its origin – and + // pulling it across turns the container round that origin, the content swinging with it. + const OTHERS = ['otf-text', 'otf-map', 'otf-pt', 'otf-box']; + const INSIDE = 'otf-box~tf-in'; + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + const make = (id, assetId, parentId, position) => ({ id, assetId, name: '', position, editorOffset: { x: 0, y: 0 }, overrides: {}, parentId, instanceEdits: {} }); + p.assets.push( + { id: 'tf-text', type: 'text', name: 'tf-text', props: { text: 'Knob', fontSize: 32 } }, + { id: 'tf-map', type: 'tilemap', name: 'tf-map', props: { columns: 3, rows: 2, tileSize: 32 }, layers: [{ id: 'tf-l', name: 'Ground', cells: {} }] }, + { id: 'tf-pt', type: 'particles', name: 'tf-pt', props: {}, graph: { nodes: [], edges: [] } }, + { id: 'tf-box', type: 'container', name: 'tf-box', props: { enabled: true }, objects: [make('tf-in', ${JSON.stringify(SPRITE)}, '', { x: 150, y: 0, z: 0 })] }, + ); + scene.objects.push( + make('otf-text', 'tf-text', '', { x: -250, y: ${LANE - 120}, z: 960 }), + make('otf-map', 'tf-map', '', { x: -250, y: ${LANE + 120}, z: 960 }), + make('otf-pt', 'tf-pt', '', { x: 250, y: ${LANE - 120}, z: 960 }), + make('otf-box', 'tf-box', '', { x: 0, y: ${LANE + 120}, z: 960 }), + )`); + const knobs = {}; + for (const id of ['otf-text', 'otf-map', 'otf-pt']) { + await selectObject(id); + const g = await gizmo(); + const r = await rect(id); + knobs[id] = { g, r }; + } + check( + 'a text, a tilemap and a particle source get the knob above their box, without edges or corners', + Object.entries(knobs).every( + ([id, { g, r }]) => + g?.objectId === id && + g.corners.length === 0 && + g.edges.length === 0 && + Math.abs(g.knob.x - r.x) < 2 && + g.knob.y < r.y - r.h / 2 - 10, + ), + JSON.stringify(knobs), + ); + + await selectObject('otf-box'); + const b0 = await gizmo(); + const in0 = await rect(INSIDE); + const xs = (b0?.box ?? []).map((c) => c.x); + const ys = (b0?.box ?? []).map((c) => c.y); + check( + "a container's knob stands over a frame round its content, not over its icon", + b0?.objectId === 'otf-box' && + b0.corners.length === 0 && + b0.edges.length === 0 && + Math.abs(Math.min(...xs) - (in0.x - in0.w / 2)) < 2 && + Math.abs(Math.max(...xs) - (in0.x + in0.w / 2)) < 2 && + Math.abs(Math.min(...ys) - (in0.y - in0.h / 2)) < 2 && + Math.abs(Math.max(...ys) - (in0.y + in0.h / 2)) < 2 && + Math.abs(b0.knob.x - in0.x) < 2 && + b0.knob.y < in0.y - in0.h / 2 - 10, + JSON.stringify({ b0, in0 }), + ); + + if (!b0) return; + const origin = { x: in0.x - 150 * s, y: in0.y }; + await path(line(b0.knob, { x: b0.knob.x + 60, y: b0.knob.y + 60 })); + const turnedBox = (await object('otf-box')).overrides; + const theta = (Number(turnedBox.rotation) * Math.PI) / 180; + const in1 = await rect(INSIDE); + const swung = { x: origin.x + 150 * s * Math.cos(theta), y: origin.y + 150 * s * Math.sin(theta) }; + check( + 'pulling the knob across turns the container round its origin, and its content swings with it', + Math.abs(Number(turnedBox.rotation)) > 5 && + turnedBox.scale === undefined && + Math.abs(in1.x - swung.x) < 3 && + Math.abs(in1.y - swung.y) < 3, + JSON.stringify({ turnedBox, in1, swung }), + ); + await shot('29-container-knob'); + await undo(); + check( + 'one undo takes the turn of the container back', + (await object('otf-box')).overrides.rotation === undefined, + ); + await evaluate('__gm.state.select(null)'); + await remove(OTHERS); + await mutate(` + const gone = new Set(['tf-text', 'tf-map', 'tf-pt', 'tf-box']); + __gm.state.project.assets = __gm.state.project.assets.filter((a) => !gone.has(a.id))`); + // 29i. Cleanup, and the view back where the earlier steps left the scene. await remove([BOX, CHILD, CAMERA]); await mutate(`