Give texts, tilemaps, particles and containers the turn knob (#242)
The knob on its stem turned and scaled only sprites and colliders. Every other type with Scale and Rotation had to be typed into the inspector. - Text, tilemap and particle source get the knob above their box. Edge and corner grips stay with sprite and collider, the only types with a width and a height of their own. - A container gets a thin frame round all of its content, turned and scaled with it, and the knob sits above that frame. It turns and scales round the container's origin, so the content swings with it. A container with no content gets the knob above its symbol. - contentFrame in sizeGizmo.ts is the pure rule for that frame. sizeGizmoEditor.ts wires it in. - One pull is one undo step, as for sprites. Not clicked in the browser: scaling (as opposed to turning) of a container by the knob. Only the shared code path covers it. Tests: - ./check.sh: 178 files, 2573 tests, CHECK EXIT 0 - 2 new unit tests for contentFrame - e2e 29k, 4 checks: knob on text, tilemap and particles, frame round the container's content, a real pull turns the container by 20.9 degrees round its origin with the content on the computed pixel, one undo takes it back - 4 counter-probes over #242 and #243 bite (2 unit, 2 browser) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
98b9b300d9
commit
074bda2247
4 files changed
+196
-14
No files matched your search
@@ -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];
|
||||
|
||||
@@ -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<DrawItem['type']>(['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<Record<'width' | 'height' | 'radius' | 'rotation' | 'scale', number>>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(`
|
||||
|
||||
Reference in new issue
Block a user