/** * The TRANSFORM rows of a vector art selection (#291): where its frame stands and how big it is, a * row that turns it, and the two buttons that mirror it. Nothing of this is stored – the numbers are * read off the shapes each time, and a turn is baked into the points, so the row that turns reads 0 * again as soon as it has turned. * * The rows work on all selected shapes at once, a group included, as the frame in the tab does. */ import { transformShapesCommand } from '../../core/commands/vectorArtCommands'; import type { VectorArtAsset, VectorShape } from '../../core/model/Asset'; import type { Mat } from '../../core/model/matrix'; import { findAssetOf } from '../../core/model/Project'; import type { Extent } from '../../core/model/vectorPaint'; import { extentCentre, frameFlipped, frameMovedTo, frameSizedTo, geometryOf, geometryTransformed, MIN_FRAME_SIZE, outlineExtent, rotationAbout, type ShapeGeometry, writeGeometry, } from '../../core/model/vectorTransform'; import { allShapes } from '../../core/model/vectorTree'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Button, HStack, Theme } from '../../ui'; import { FramePreview } from '../framePreview'; import type { EditorState } from '../state'; import { createFieldRow } from './fieldFactory'; import { addTitle, type InspectorHost } from './host'; /** One row: what it shows of the frame, and the map that makes the frame show `value` instead. */ interface TransformRow { def: PropertyDef; read: (extent: Extent) => number; map: (extent: Extent, value: number) => Mat | null; label: string; } const number = (key: string, label: string, extra: Partial = {}): PropertyDef => ({ key, label, kind: 'number', default: 0, step: 1, decimals: 2, ...extra }) as PropertyDef; const ROWS: readonly TransformRow[] = [ { def: number('frameX', 'X'), read: (e) => e.x0, map: (e, value) => frameMovedTo(e, 'x', value), label: 'Move', }, { def: number('frameY', 'Y'), read: (e) => e.y0, map: (e, value) => frameMovedTo(e, 'y', value), label: 'Move', }, { def: number('frameWidth', 'Width', { min: MIN_FRAME_SIZE }), read: (e) => e.x1 - e.x0, map: (e, value) => frameSizedTo(e, 'x', value), label: 'Scale', }, { def: number('frameHeight', 'Height', { min: MIN_FRAME_SIZE }), read: (e) => e.y1 - e.y0, map: (e, value) => frameSizedTo(e, 'y', value), label: 'Scale', }, { def: number('frameTurn', 'Rotate by', { decimals: 1 }), read: () => 0, map: (e, value) => (Number.isFinite(value) ? rotationAbout(extentCentre(e), value) : null), label: 'Rotate', }, ]; const TURN_KEY = 'frameTurn'; /** The degrees the turning row is being dragged at; null while it rests, and then it reads 0. */ let turning: number | null = null; function selected(state: EditorState, assetId: string, shapeIds: readonly string[]) { const asset = findAssetOf(state.project, assetId, 'vectorart'); const shapes = asset ? allShapes(asset).filter((shape) => shapeIds.includes(shape.id)) : []; return { asset, shapes, extent: outlineExtent(shapes) }; } /** Whether the selection has a frame – a shape without a size has no rows to show. */ export function hasShapeFrame(asset: VectorArtAsset, shapeIds: readonly string[]): boolean { return outlineExtent(allShapes(asset).filter((shape) => shapeIds.includes(shape.id))) !== null; } function stepLabel(verb: string, count: number): string { return `${verb} ${count > 1 ? 'shapes' : 'shape'}`; } /** * Takes the selected shapes through the map `toMap` makes of their frame, as one undo step. False * when there is no frame or the map changes nothing. Exported for the buttons and the tests. */ function transformSelection( state: EditorState, assetId: string, shapeIds: readonly string[], toMap: (extent: Extent) => Mat | null, verb: string, ): boolean { const { asset, shapes, extent } = selected(state, assetId, shapeIds); const m = extent && toMap(extent); const command = asset && m ? transformShapesCommand(asset, shapeIds, m, stepLabel(verb, shapes.length)) : null; return command ? state.execute(command) : false; } /** Mirrors the selection about the middle of its frame; `x` swaps left and right. */ export function flipSelection( state: EditorState, assetId: string, shapeIds: readonly string[], axis: 'x' | 'y', ): boolean { return transformSelection(state, assetId, shapeIds, (extent) => frameFlipped(extent, axis), 'Flip'); } export function addTransformRows(host: InspectorHost, assetId: string, shapeIds: readonly string[]): void { const { extent } = selected(host.state, assetId, shapeIds); if (!extent) return; addTitle(host, 'TRANSFORM'); for (const row of ROWS) addRow(host, assetId, shapeIds, row, extent); const flips = new HStack({ gap: Theme.gap }); for (const [axis, label] of [ ['x', 'Flip horizontal'], ['y', 'Flip vertical'], ] as const) flips.add( new Button(label, { variant: 'ghost', onClick: () => void flipSelection(host.state, assetId, shapeIds, axis), }), 1, ); host.body.add(flips); } /** * A row previews from the shapes as its first input found them, not from the preview before: a * dragged number passes through many values, and each mapped onto the last would add up their * rounding. On commit the shapes are put back, so the command records what was really there. */ function addRow( host: InspectorHost, assetId: string, shapeIds: readonly string[], row: TransformRow, extent: Extent, ): void { const { state } = host; const perFrame = new FramePreview(state, 'shape-transform'); let held: { extent: Extent; parts: { shape: VectorShape; before: ShapeGeometry }[] } | null = null; const field = createFieldRow(host, row.def, row.read(extent), { onInput: (input) => { if (!held) { const now = selected(state, assetId, shapeIds); if (!now.extent) return; held = { extent: now.extent, parts: now.shapes.map((shape) => ({ shape, before: geometryOf(shape) })), }; } const m = row.map(held.extent, Number(input)); if (!m) return; if (row.def.key === TURN_KEY) turning = Number(input); for (const part of held.parts) writeGeometry(part.shape, geometryTransformed(part.before, m)); perFrame.request(); }, onChange: (input) => { perFrame.cancel(); for (const part of held?.parts ?? []) writeGeometry(part.shape, part.before); held = null; turning = null; const value = Number(input); if (!transformSelection(state, assetId, shapeIds, (e) => row.map(e, value), row.label)) state.preview(); }, }); host.fields.set(row.def.key, field); host.body.add(field.row); } /** Pushes the frame as it stands into the rows – after a drag in the tab, an undo, a typed number. */ export function refreshTransformRows( host: InspectorHost, assetId: string, shapeIds: readonly string[], ): void { const { extent } = selected(host.state, assetId, shapeIds); if (!extent) return; for (const row of ROWS) host.fields.get(row.def.key)?.setValue(row.def.key === TURN_KEY ? (turning ?? 0) : row.read(extent)); }