Round 12 of the clean-up. Duplicate code had grown from 33 clones (249 lines) after round 11 to 140 clones (1263 lines), almost all of it in the panels and faces of the sound FX devices (#379 to #401). It is down to 57 clones (416 lines) now, and src is 816 lines shorter. - deviceParts.ts holds what the device panels built by hand: the head, rows of knobs, the screen style and the row layout for groups of knobs. Twelve panels use it. panel.knobs stays because the debug hooks read it. - DeviceFace is the base of the twelve faces. It finds the node and does wired, preview, commit, set, hide, reload and audition. compressorWired lives there as paramWired. - TextPool replaces ten copies of "make texts as needed, hide the rest" in the wave and video tabs, the rulers, the piano roll and the song and composition timelines. clipStrip.ts is the clip label strip the wave and video tabs share. - core/util/bytes.ts has the concat that ogg, matroska and avcColour each wrote out. - Dead code is gone: clear on the video players (never called since #402), posterFileOf and videoTimeAt (only tests called them) and easeOut (the same as glide(t, 3)). Values only used in their own file lose their export. - Comments over 125 columns are wrapped, and comments that told the history ("as before", "used to") say what is, in src and in the e2e modules. - Left alone on purpose: AnimationEditor and CompositionEditor share only the play button, and the curve panels are built differently. - Chip now places its boxes through put, which rounds. They were whole numbers already. The three row layouts are byte-identical before and after. - README lists the video and composition assets and the Videos and Compositions pages. Tests: - Gate (melektaus-atx): CHECK EXIT 0, 313 files, 4835 tests. - e2e 25, 34, 64, 65, 66, 69 (melektaus-atx): ALL PASSED, 330 checks. - TextPool without keeping its texts and framesToRoom without making room both go red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
6.5 KiB
TypeScript
181 lines
6.5 KiB
TypeScript
/**
|
||
* Footprints in world units, shared by the viewport, by pointer hit tests in the running game and by
|
||
* "Add collider", so all three agree on how big a thing is – and, through the object's transform, on
|
||
* where its corners are.
|
||
*/
|
||
import { type AssetType, hasSceneIcon } from '../model/AssetTypes';
|
||
import type { FrameOutline } from '../model/frameOutline';
|
||
import {
|
||
apply,
|
||
boxCorners,
|
||
type Extent,
|
||
extentOfPoints,
|
||
invert,
|
||
type Mat,
|
||
scaleOf,
|
||
translation,
|
||
} from '../model/matrix';
|
||
import { type PropertyBag, textOf, type Vec2 } from '../model/Properties';
|
||
import { tilemapSize } from '../model/tilemap';
|
||
import { cameraFrame } from './camera';
|
||
import { colliderSize } from './collider';
|
||
import { defaultsFor } from './PropertySchema';
|
||
|
||
const TEXT_DEFAULTS = defaultsFor('text');
|
||
|
||
/** Symbol sizes in world units. */
|
||
const BEHAVIOR_SYMBOL = 28;
|
||
const CONTAINER_SYMBOL = 24;
|
||
const PARTICLES_SYMBOL = 24;
|
||
const RIGIDBODY_SYMBOL = 24;
|
||
const JOINT_SYMBOL = 24;
|
||
|
||
/** Rough width of one character relative to the font size, for a plain sans. */
|
||
const CHAR_WIDTH = 0.55;
|
||
const LINE_HEIGHT = 1.25;
|
||
|
||
/**
|
||
* How big a text is drawn, given its props and the file of its font – or null where nothing can be
|
||
* measured. The viewport sets it, since only a canvas can measure; tests in node estimate instead.
|
||
*/
|
||
export type TextMeasure = (props: PropertyBag, font: string) => { w: number; h: number } | null;
|
||
|
||
let textMeasure: TextMeasure | null = null;
|
||
|
||
export function setTextMeasure(measure: TextMeasure | null): void {
|
||
textMeasure = measure;
|
||
}
|
||
|
||
/** The text as drawn where it can be measured, `font` being its font's file; estimated otherwise. */
|
||
export function textSize(props: PropertyBag, font = ''): { w: number; h: number } {
|
||
return textMeasure?.(props, font) ?? estimatedTextSize(props);
|
||
}
|
||
|
||
/** Estimated from the content and font size, without measuring the text. */
|
||
function estimatedTextSize(props: PropertyBag): { w: number; h: number } {
|
||
const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize));
|
||
const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n');
|
||
const longest = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
||
const spacing = Number(props.letterSpacing ?? TEXT_DEFAULTS.letterSpacing) || 0;
|
||
return {
|
||
w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH + Math.max(0, longest - 1) * spacing),
|
||
h: lines.length * size * LINE_HEIGHT,
|
||
};
|
||
}
|
||
|
||
/** 0 = left edge on the position, 0.5 = centred, 1 = right. */
|
||
export function textAnchor(props: PropertyBag): number {
|
||
const align = textOf(props.align ?? TEXT_DEFAULTS.align);
|
||
return align === 'left' ? 0 : align === 'right' ? 1 : 0.5;
|
||
}
|
||
|
||
/**
|
||
* Centred on the object's position; text is anchored instead (see `textAnchor`). A collider that
|
||
* wears a frame (#193) is that frame's size, and a text is measured in its font's file – both of
|
||
* which only the caller can look up.
|
||
*/
|
||
export function footprintOf(
|
||
type: AssetType,
|
||
props: PropertyBag,
|
||
frame?: FrameOutline | null,
|
||
font = '',
|
||
): { w: number; h: number } {
|
||
switch (type) {
|
||
case 'sprite':
|
||
case 'clip':
|
||
return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) };
|
||
case 'text':
|
||
return textSize(props, font);
|
||
case 'collider':
|
||
return colliderSize(props, frame);
|
||
case 'behavior':
|
||
return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
|
||
case 'container':
|
||
case 'composition':
|
||
return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
|
||
case 'rigidbody':
|
||
return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL };
|
||
case 'joint':
|
||
return { w: JOINT_SYMBOL, h: JOINT_SYMBOL };
|
||
// The particles themselves are drawn wherever the graph puts them; what can be grabbed and
|
||
// framed is the source, which is a point with a handle around it.
|
||
case 'particles':
|
||
return { w: PARTICLES_SYMBOL, h: PARTICLES_SYMBOL };
|
||
// The whole grid, centred on the object's position like every other footprint – so its cells
|
||
// are the same squares for drawing, for picking and for the outline around it.
|
||
case 'tilemap':
|
||
return tilemapSize(props);
|
||
default:
|
||
return cameraFrame(props);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* How an item of this type is drawn: with its whole transform, or – for the icons of behaviors,
|
||
* containers, particle sources and the camera frame – upright and unscaled at its position, since an
|
||
* icon that turns with its parent would only be harder to read. A source's own frame still goes into
|
||
* every particle; what stays upright is the square that can be grabbed, so it covers its icon.
|
||
* Colliders are symbols too but follow their body.
|
||
*/
|
||
export function placementOf(type: AssetType, m: Mat): Mat {
|
||
return hasSceneIcon(type) && type !== 'collider' ? translation(m.tx, m.ty) : m;
|
||
}
|
||
|
||
function anchorOffset(type: AssetType, props: PropertyBag, w: number): number {
|
||
return type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
|
||
}
|
||
|
||
/** The footprint's corners in world units, grown by `pad` world units on every side. */
|
||
export function footprintCorners(
|
||
type: AssetType,
|
||
props: PropertyBag,
|
||
m: Mat,
|
||
pad = 0,
|
||
frame?: FrameOutline | null,
|
||
font = '',
|
||
): Vec2[] {
|
||
const placed = placementOf(type, m);
|
||
const { w, h } = footprintOf(type, props, frame, font);
|
||
const local = pad / (scaleOf(placed) || 1);
|
||
return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0);
|
||
}
|
||
|
||
/**
|
||
* The upright square of a symbol that is only its icon, centred on where the icon stands and grown
|
||
* with the project's symbol scale like the icon, plus `pad` world units on every side.
|
||
*/
|
||
export function iconCorners(type: AssetType, props: PropertyBag, at: Vec2, scale: number, pad = 0): Vec2[] {
|
||
const { w, h } = footprintOf(type, props);
|
||
const hw = (w * scale) / 2 + pad;
|
||
const hh = (h * scale) / 2 + pad;
|
||
return [
|
||
{ x: at.x - hw, y: at.y - hh },
|
||
{ x: at.x + hw, y: at.y - hh },
|
||
{ x: at.x + hw, y: at.y + hh },
|
||
{ x: at.x - hw, y: at.y + hh },
|
||
];
|
||
}
|
||
|
||
/** The world rectangle around the drawn footprint – what culling, framing and labels need. */
|
||
export function footprintExtent(
|
||
type: AssetType,
|
||
props: PropertyBag,
|
||
m: Mat,
|
||
frame?: FrameOutline | null,
|
||
font = '',
|
||
): Extent {
|
||
return extentOfPoints(footprintCorners(type, props, m, 0, frame, font));
|
||
}
|
||
|
||
export function footprintContains(
|
||
type: AssetType,
|
||
props: PropertyBag,
|
||
m: Mat,
|
||
point: Vec2,
|
||
font = '',
|
||
): boolean {
|
||
const { w, h } = footprintOf(type, props, null, font);
|
||
const local = apply(invert(placementOf(type, m)), point);
|
||
return Math.abs(local.x - anchorOffset(type, props, w)) <= w / 2 && Math.abs(local.y) <= h / 2;
|
||
}
|