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:
adminandClaude Opus 5.5 committed 2026-09-27 12:12:40 +02:00
1 parent 98b9b300d9
commit 074bda2247
4 files changed
+196 -14

No files matched your search

+12
View File
@@ -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];
+62 -13
View File
@@ -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;
}