1127 lines
42 KiB
TypeScript
1127 lines
42 KiB
TypeScript
/**
|
||
* The editable view of a scene. It shows `state.project`, which in play mode is the runtime
|
||
* copy, so live positions are visible and stay editable. A container's template is the same
|
||
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
|
||
*/
|
||
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||
import {
|
||
MoveSceneObjectCommand,
|
||
PlaceAssetCommand,
|
||
SetOverrideCommand,
|
||
} from '../../core/commands/sceneObjectCommands';
|
||
import { type CellEdit, PaintTilesCommand } from '../../core/commands/tilemapCommands';
|
||
import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset';
|
||
import { hasSceneIcon } from '../../core/model/AssetTypes';
|
||
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
|
||
import { objectRef, resolvedHolder } from '../../core/model/instance';
|
||
import {
|
||
apply as applyPoint,
|
||
applyVector,
|
||
type Extent,
|
||
invert,
|
||
linearOf,
|
||
type Mat,
|
||
scaleOf,
|
||
} from '../../core/model/matrix';
|
||
import { placementRejection } from '../../core/model/placement';
|
||
import type { ListValue, Position, PropertyValue, Vec2 } from '../../core/model/Properties';
|
||
import { findAsset, findAssetOf, type ObjectHolder } from '../../core/model/Project';
|
||
import {
|
||
cellAt,
|
||
cellAtPoint,
|
||
cellKey,
|
||
cellOrigin,
|
||
cellsAlong,
|
||
cellsInRect,
|
||
findLayer,
|
||
floodFill,
|
||
inGrid,
|
||
tilemapColumns,
|
||
tilemapRows,
|
||
tilemapTileSize,
|
||
} from '../../core/model/tilemap';
|
||
import type { BoundaryLine } from '../../core/model/polygon';
|
||
import { type CellPolygon, type CellRect, solidBoundary } from '../../core/model/tilemapCollision';
|
||
import { footprintCorners } from '../../core/properties/bounds';
|
||
import { colliderPoints, COLLIDER_POINTS_KEY } from '../../core/properties/collider';
|
||
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
|
||
import { type EditorState, objectSelection, type TilemapTool } from '../state';
|
||
import { paintedLayerId, paintedTilemap, pickTile } from '../panels/TilemapPanel';
|
||
import {
|
||
activeDrag,
|
||
drawBand,
|
||
drawGrid,
|
||
endDrag,
|
||
onDragChange,
|
||
pressCount,
|
||
Theme,
|
||
textStyle,
|
||
} from '../../ui';
|
||
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
|
||
import { collectHolderItems, type DrawItem } from '../../viewport/items';
|
||
import type { LayerFocus } from '../../viewport/tilemap';
|
||
import { CONE_LENGTH, dashedLine, markBlockingSide, placeNode } from '../../viewport/symbols';
|
||
import { drawSymbolIcon } from '../../viewport/symbolIcons';
|
||
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
||
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
|
||
import { FreeViewport } from './FreeViewport';
|
||
import type { Frame } from './WorldViewport';
|
||
|
||
/** Screen pixels around a polygon's corner or edge that grab it. */
|
||
const CORNER_GRIP = 7;
|
||
/** Side of a corner handle in screen pixels. */
|
||
const CORNER_SIZE = 8;
|
||
|
||
/** Pixels of travel before a press turns into a drag. */
|
||
const MARQUEE_THRESHOLD = 3;
|
||
const DRAG_THRESHOLD = 2;
|
||
const GRID_STEP = 100;
|
||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||
const MIN_GRID_STEP = 12;
|
||
|
||
interface ObjectDrag {
|
||
holderId: string;
|
||
/** `toLocal` turns a world delta into one in the object's parent frame (the parent may be turned or sized). */
|
||
items: { objectId: string; start: Position; toLocal: Mat }[];
|
||
pointer: Vec2;
|
||
moved: boolean;
|
||
}
|
||
|
||
/**
|
||
* A corner of a polygon collider being dragged (#151) – one just put on an edge, too. Like a move
|
||
* drag it writes the model as a preview; `before` is the override that was there, put back on
|
||
* release so the command records the edit as one undo step.
|
||
*/
|
||
interface CornerDrag {
|
||
holderId: string;
|
||
objectId: string;
|
||
before: PropertyValue | undefined;
|
||
points: Vec2[];
|
||
index: number;
|
||
/** From the world into the collider's own units. */
|
||
toLocal: Mat;
|
||
pointer: Vec2;
|
||
inserted: boolean;
|
||
moved: boolean;
|
||
}
|
||
|
||
/** An asset hovering over the view on its way in: where it would land, and whether it may. */
|
||
interface DropPreview {
|
||
assetId: string;
|
||
at: Vec2;
|
||
rejection: string;
|
||
}
|
||
|
||
/**
|
||
* A stroke on a placed tilemap. `previous` is what the cells held before the live preview wrote
|
||
* into them – the same trick a move drag uses: the model shows the stroke at once, and on release
|
||
* it is put back so the command records it as one undo step.
|
||
*/
|
||
interface PaintStroke {
|
||
holderId: string;
|
||
objectId: string;
|
||
assetId: string;
|
||
layerId: string;
|
||
tool: TilemapTool;
|
||
tileId: string;
|
||
edits: CellEdit[];
|
||
previous: Map<string, string>;
|
||
from: Vec2;
|
||
last: Vec2 | null;
|
||
/** Where the pointer is now, in cells – what the rectangle preview is drawn from. */
|
||
to: Vec2;
|
||
}
|
||
|
||
/** A rubber-band selection in progress, in viewport-local pixels. */
|
||
interface Marquee {
|
||
start: Vec2;
|
||
current: Vec2;
|
||
active: boolean;
|
||
}
|
||
|
||
/** One selection outline that was drawn – `alpha` is the value the stroke really got. */
|
||
export interface GizmoInfo {
|
||
id: string;
|
||
primary: boolean;
|
||
alpha: number;
|
||
}
|
||
|
||
const OFF_GIZMO_ALPHA = 0.45;
|
||
|
||
/** Owner links in the colour of the icon they leave; anything else in the muted default. */
|
||
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
|
||
behavior: Theme.colors.behavior,
|
||
particles: Theme.colors.particles,
|
||
rigidbody: Theme.colors.collider,
|
||
};
|
||
|
||
/** Where a resolved tilemap blocks: what its outline in the scene view is worked out from. */
|
||
type TilemapSolids = Pick<NonNullable<DrawItem['tilemap']>, 'solids' | 'outlines' | 'oneWay'>;
|
||
|
||
function sameSolids(a: TilemapSolids, b: TilemapSolids): boolean {
|
||
const sameRects = (p: readonly CellRect[], q: readonly CellRect[]) =>
|
||
p.length === q.length &&
|
||
p.every((r, i) => r.x === q[i]!.x && r.y === q[i]!.y && r.w === q[i]!.w && r.h === q[i]!.h);
|
||
const samePolygons = (p: readonly CellPolygon[], q: readonly CellPolygon[]) =>
|
||
p.length === q.length &&
|
||
p.every(
|
||
(polygon, i) =>
|
||
polygon.outline.length === q[i]!.outline.length &&
|
||
polygon.outline.every((c, j) => c.x === q[i]!.outline[j]!.x && c.y === q[i]!.outline[j]!.y),
|
||
);
|
||
return (
|
||
sameRects(a.solids, b.solids) &&
|
||
samePolygons(a.outlines, b.outlines) &&
|
||
sameRects(a.oneWay.solids, b.oneWay.solids) &&
|
||
samePolygons(a.oneWay.outlines, b.oneWay.outlines)
|
||
);
|
||
}
|
||
|
||
export class SceneViewport extends FreeViewport {
|
||
private readonly grid = new Graphics();
|
||
private readonly links = new Graphics();
|
||
private readonly gizmos = new Graphics();
|
||
private readonly labels = new Map<string, Text>();
|
||
private readonly icons = new Map<string, Graphics>();
|
||
/** Where each placed tilemap blocks, outlined like a collider (#132). */
|
||
private readonly solids = new Map<string, Graphics>();
|
||
/**
|
||
* The outer edges of each tilemap's solids and what they were worked out from (#173). A map is
|
||
* resolved afresh on every draw, so they are kept by what blocks rather than by the resolved map.
|
||
*/
|
||
private readonly boundaries = new Map<
|
||
string,
|
||
{ from: TilemapSolids; solid: BoundaryLine[]; oneWay: BoundaryLine[] }
|
||
>();
|
||
private editorWorld: World | null = null;
|
||
private lastRays: CastRay[] = [];
|
||
private dragging: ObjectDrag | null = null;
|
||
private corner: CornerDrag | null = null;
|
||
/** The corners drawn for the edited polygon, in global pixels (test hook). */
|
||
private lastCorners: { objectId: string; corners: Vec2[] } | null = null;
|
||
private stroke: PaintStroke | null = null;
|
||
private marquee: Marquee | null = null;
|
||
private dropPreview: DropPreview | null = null;
|
||
private lastGizmos: GizmoInfo[] = [];
|
||
/** Ids that got an owner link this frame (test hook). */
|
||
private lastLinks: string[] = [];
|
||
|
||
constructor(state: EditorState) {
|
||
super(state);
|
||
// Grid behind the content, links below the symbols, gizmos above everything clipped.
|
||
this.addChildAt(this.grid, 1);
|
||
this.world.addChildAt(this.links, 1);
|
||
this.content.addChild(this.gizmos);
|
||
this.links.eventMode = 'none';
|
||
|
||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||
this.on('pointerup', () => this.onPointerUp());
|
||
this.on('pointerupoutside', () => this.onPointerUp());
|
||
|
||
state.on('load', () => {
|
||
this.resetView();
|
||
this.redraw();
|
||
});
|
||
// A drag can end anywhere – over another panel, off the canvas – and this view never hears
|
||
// about it, so the preview would stay on screen without this.
|
||
onDragChange(() => {
|
||
if (!activeDrag()) this.setDropPreview(null);
|
||
});
|
||
}
|
||
|
||
/** What this view shows and edits; the Container tab returns its container asset instead. */
|
||
protected holder(): ObjectHolder | undefined {
|
||
return this.state.activeScene;
|
||
}
|
||
|
||
/** The holder with the content of placed containers resolved in – what the view shows. */
|
||
private resolved(): ObjectHolder | undefined {
|
||
const holder = this.holder();
|
||
return holder && resolvedHolder(this.state.project, holder);
|
||
}
|
||
|
||
/** The pending drop: where a dragged asset would land and whether it may (test hook). */
|
||
dropInfo(): { assetId: string; x: number; y: number; rejection: string } | null {
|
||
const p = this.dropPreview;
|
||
return p ? { assetId: p.assetId, x: p.at.x, y: p.at.y, rejection: p.rejection } : null;
|
||
}
|
||
|
||
protected override collectFrame(): Frame {
|
||
return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport);
|
||
}
|
||
|
||
protected override get baseColor(): number {
|
||
return Theme.colors.viewport;
|
||
}
|
||
|
||
/** Without a scene there is nothing to frame: the neutral base colour stands alone. */
|
||
protected override paintsBackground(): boolean {
|
||
return Boolean(this.holder());
|
||
}
|
||
|
||
protected override get itemsInteractive(): boolean {
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* The painted tilemap shows its layers the way its panel asks (#138), whatever the tool: the
|
||
* painted layer is worth seeing alone before the brush is taken up, too. Every other map, and
|
||
* this one once it is deselected, is drawn whole.
|
||
*/
|
||
protected override tilemapFocus(item: DrawItem): LayerFocus | null {
|
||
const mode = this.state.tilemapPaint.focus;
|
||
const target = mode === 'all' ? null : paintedTilemap(this.state);
|
||
if (!target || target.objectId !== item.id) return null;
|
||
return { layerId: paintedLayerId(this.state, target.asset), mode };
|
||
}
|
||
|
||
private onPointerDown(e: FederatedPointerEvent): void {
|
||
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
|
||
this.startPan(e);
|
||
return;
|
||
}
|
||
if (e.button === 0 && this.startCornerEdit(e)) return;
|
||
if (e.button === 0 && e.target === this) {
|
||
// Empty space: a click deselects, a drag becomes a rubber band – decided on release.
|
||
const local = this.toLocal(e.global);
|
||
this.marquee = {
|
||
start: { x: local.x, y: local.y },
|
||
current: { x: local.x, y: local.y },
|
||
active: false,
|
||
};
|
||
e.stopPropagation();
|
||
}
|
||
}
|
||
|
||
private onPointerMove(e: FederatedPointerEvent): void {
|
||
const incoming = activeDrag();
|
||
if (incoming) {
|
||
this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e);
|
||
return;
|
||
}
|
||
if (this.panTo(e)) return;
|
||
if (this.corner) {
|
||
this.dragCorner(e);
|
||
return;
|
||
}
|
||
if (this.stroke) {
|
||
const item = this.lastItems.find((i) => i.id === this.stroke!.objectId);
|
||
if (!item) return;
|
||
const at = this.cellUnder(item, e);
|
||
if (this.stroke.last && at.x === this.stroke.last.x && at.y === this.stroke.last.y) return;
|
||
const from = this.stroke.last ?? this.stroke.from;
|
||
this.stroke.last = at;
|
||
this.stroke.to = at;
|
||
// A pointer jumps between events, so the cells in between are filled in as well.
|
||
if (this.stroke.tool !== 'rect') this.paintCells(item, cellsAlong(from, at));
|
||
this.redraw();
|
||
return;
|
||
}
|
||
if (this.dragging) {
|
||
const local = this.toLocal(e.global);
|
||
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
|
||
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
|
||
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||
this.dragging.moved = true;
|
||
const holder = this.holder();
|
||
if (!holder) return;
|
||
for (const item of this.dragging.items) {
|
||
const local = applyVector(item.toLocal, { x: dx, y: dy });
|
||
objectRef(this.state.project, holder, item.objectId)?.setPosition({
|
||
x: Math.round(item.start.x + local.x),
|
||
y: Math.round(item.start.y + local.y),
|
||
z: item.start.z,
|
||
});
|
||
}
|
||
this.state.preview();
|
||
} else if (this.marquee) {
|
||
const local = this.toLocal(e.global);
|
||
this.marquee.current = { x: local.x, y: local.y };
|
||
if (
|
||
!this.marquee.active &&
|
||
Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD
|
||
)
|
||
return;
|
||
this.marquee.active = true;
|
||
this.redraw();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Follows an asset dragged in from the panel. `assetId` is empty for payloads this view does
|
||
* not take; the preview is dropped whenever the pointer leaves it, so nothing lands blind.
|
||
*/
|
||
private trackIncomingAsset(assetId: string, e: FederatedPointerEvent): void {
|
||
const local = this.toLocal(e.global);
|
||
const holder = this.holder();
|
||
const asset = assetId ? findAsset(this.state.project, assetId) : undefined;
|
||
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
|
||
if (!asset || !holder || !inside) {
|
||
this.setDropPreview(null);
|
||
return;
|
||
}
|
||
const world = screenToWorld(this.currentView(), local.x, local.y);
|
||
this.setDropPreview({
|
||
assetId,
|
||
at: { x: Math.round(world.x), y: Math.round(world.y) },
|
||
rejection: placementRejection(this.state.project, holder, asset),
|
||
});
|
||
}
|
||
|
||
private setDropPreview(preview: DropPreview | null): void {
|
||
if (!preview && !this.dropPreview) return;
|
||
this.dropPreview = preview;
|
||
this.redraw();
|
||
}
|
||
|
||
private dropAsset(preview: DropPreview): void {
|
||
const holder = this.holder();
|
||
if (!holder) return;
|
||
if (preview.rejection) {
|
||
this.state.setNotice(preview.rejection);
|
||
return;
|
||
}
|
||
const command = new PlaceAssetCommand(holder.id, preview.assetId, preview.at);
|
||
if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId]));
|
||
}
|
||
|
||
private onPointerUp(): void {
|
||
const preview = this.dropPreview;
|
||
if (preview) {
|
||
this.setDropPreview(null);
|
||
endDrag();
|
||
this.dropAsset(preview);
|
||
return;
|
||
}
|
||
this.endPan();
|
||
if (this.corner) {
|
||
this.endCorner();
|
||
return;
|
||
}
|
||
if (this.stroke) {
|
||
this.endStroke();
|
||
return;
|
||
}
|
||
const marquee = this.marquee;
|
||
this.marquee = null;
|
||
if (marquee) {
|
||
if (marquee.active) this.selectInMarquee(marquee);
|
||
else this.state.select(null);
|
||
this.redraw();
|
||
}
|
||
const drag = this.dragging;
|
||
this.dragging = null;
|
||
if (!drag || !drag.moved) return;
|
||
const holder = this.holder();
|
||
if (!holder) return;
|
||
// The preview moved the objects already; put them back so the command records the start.
|
||
const moves: MoveSceneObjectCommand[] = [];
|
||
for (const item of drag.items) {
|
||
const ref = objectRef(this.state.project, holder, item.objectId);
|
||
if (!ref) continue;
|
||
const next = { ...ref.object.position };
|
||
ref.setPosition(item.start);
|
||
moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
|
||
}
|
||
if (moves.length > 0) this.state.execute(oneStep('Move objects', moves));
|
||
}
|
||
|
||
/**
|
||
* The tilemap a click paints on: the one selected (see `paintedTilemap`), with a painting tool
|
||
* chosen. Anything else – another object, the select tool, a modifier held – leaves the view
|
||
* doing what it always did.
|
||
*/
|
||
private paintTargetOf(objectId: string): { item: DrawItem; asset: TilemapAsset; layerId: string } | null {
|
||
if (this.state.tilemapPaint.tool === 'select') return null;
|
||
const target = paintedTilemap(this.state);
|
||
if (!target || target.objectId !== objectId) return null;
|
||
const item = this.lastItems.find((i) => i.id === objectId);
|
||
if (!item || item.type !== 'tilemap') return null;
|
||
const layerId = paintedLayerId(this.state, target.asset);
|
||
return layerId ? { item, asset: target.asset, layerId } : null;
|
||
}
|
||
|
||
/** The cell under a pointer position, in the tilemap's own grid. */
|
||
private cellUnder(item: DrawItem, e: FederatedPointerEvent): Vec2 {
|
||
const local = this.toLocal(e.global);
|
||
const world = screenToWorld(this.currentView(), local.x, local.y);
|
||
const inMap = applyPoint(invert(item.m), world);
|
||
return cellAtPoint(item.props, inMap.x, inMap.y);
|
||
}
|
||
|
||
/**
|
||
* What a press with a painting tool does. Fill and pick are done with the click – a drag adds
|
||
* nothing to either – so only the other three start a stroke. True when the press was used.
|
||
*/
|
||
private usePaintTool(
|
||
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
|
||
holderId: string,
|
||
e: FederatedPointerEvent,
|
||
): boolean {
|
||
const tool = this.state.tilemapPaint.tool;
|
||
if (tool !== 'fill' && tool !== 'pick') return this.startStroke(target, holderId, e);
|
||
const layer = findLayer(target.asset, target.layerId);
|
||
const at = this.cellUnder(target.item, e);
|
||
if (!layer || !inGrid(target.item.props, at.x, at.y)) return true;
|
||
if (tool === 'pick') this.pickCell(layer, at);
|
||
else this.fillFrom(target, layer, at);
|
||
return true;
|
||
}
|
||
|
||
/** Takes the tile of a cell on the painted layer – the one painted on next (#133). */
|
||
private pickCell(layer: TilemapLayer, at: Vec2): void {
|
||
const tileId = cellAt(layer, at.x, at.y);
|
||
if (!tileId) this.state.setNotice('This cell is empty on the active layer.');
|
||
else if (!findAssetOf(this.state.project, tileId, 'tile'))
|
||
this.state.setNotice('This cell names a tile that no longer exists.');
|
||
else pickTile(this.state, tileId);
|
||
}
|
||
|
||
/** Fills the connected cells of one tile in a single command, so one click is one undo step. */
|
||
private fillFrom(
|
||
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
|
||
layer: TilemapLayer,
|
||
at: Vec2,
|
||
): void {
|
||
const tileId = this.state.tilemapPaint.tileId;
|
||
if (!tileId) {
|
||
this.state.setNotice('Choose a tile to paint with.');
|
||
return;
|
||
}
|
||
const cells = floodFill(target.item.props, layer, at, tileId);
|
||
if (cells.length > 0)
|
||
this.state.execute(
|
||
new PaintTilesCommand(
|
||
target.asset.id,
|
||
target.layerId,
|
||
cells.map((cell) => ({ ...cell, tileId })),
|
||
'Fill tiles',
|
||
),
|
||
);
|
||
}
|
||
|
||
private startStroke(
|
||
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
|
||
holderId: string,
|
||
e: FederatedPointerEvent,
|
||
): boolean {
|
||
const paint = this.state.tilemapPaint;
|
||
const tileId = paint.tool === 'erase' ? '' : paint.tileId;
|
||
if (paint.tool !== 'erase' && !tileId) {
|
||
this.state.setNotice('Choose a tile to paint with.');
|
||
return false;
|
||
}
|
||
const at = this.cellUnder(target.item, e);
|
||
this.stroke = {
|
||
holderId,
|
||
objectId: target.item.id,
|
||
assetId: target.asset.id,
|
||
layerId: target.layerId,
|
||
tool: paint.tool,
|
||
tileId,
|
||
edits: [],
|
||
previous: new Map(),
|
||
from: at,
|
||
last: null,
|
||
to: at,
|
||
};
|
||
// A rectangle only shows where it would go until the button comes up; the other two draw at once.
|
||
if (paint.tool !== 'rect') this.paintCells(target.item, [at]);
|
||
this.redraw();
|
||
return true;
|
||
}
|
||
|
||
private strokeLayer(stroke: PaintStroke): TilemapLayer | undefined {
|
||
const asset = findAssetOf(this.state.project, stroke.assetId, 'tilemap');
|
||
return asset && findLayer(asset, stroke.layerId);
|
||
}
|
||
|
||
/** Writes cells into the model as a preview, remembering what they held. */
|
||
private paintCells(item: DrawItem, cells: readonly Vec2[]): void {
|
||
const stroke = this.stroke;
|
||
const layer = stroke && this.strokeLayer(stroke);
|
||
if (!stroke || !layer) return;
|
||
let changed = false;
|
||
for (const at of cells) {
|
||
if (!inGrid(item.props, at.x, at.y)) continue;
|
||
const key = cellKey(at.x, at.y);
|
||
if (!stroke.previous.has(key)) stroke.previous.set(key, layer.cells[key] ?? '');
|
||
if ((layer.cells[key] ?? '') === stroke.tileId) continue;
|
||
stroke.edits.push({ x: at.x, y: at.y, tileId: stroke.tileId });
|
||
if (stroke.tileId) layer.cells[key] = stroke.tileId;
|
||
else delete layer.cells[key];
|
||
changed = true;
|
||
}
|
||
if (changed) this.state.preview();
|
||
}
|
||
|
||
/** Puts the preview back and lets the command do the edit, so one stroke is one undo step. */
|
||
private endStroke(): void {
|
||
const stroke = this.stroke;
|
||
this.stroke = null;
|
||
if (!stroke) return;
|
||
const layer = this.strokeLayer(stroke);
|
||
if (layer)
|
||
for (const [key, value] of stroke.previous) {
|
||
if (value) layer.cells[key] = value;
|
||
else delete layer.cells[key];
|
||
}
|
||
const item = this.lastItems.find((i) => i.id === stroke.objectId);
|
||
const edits =
|
||
stroke.tool === 'rect' && item
|
||
? cellsInRect(item.props, stroke.from, stroke.to).map((at) => ({
|
||
...at,
|
||
tileId: stroke.tileId,
|
||
}))
|
||
: stroke.edits;
|
||
if (edits.length > 0)
|
||
this.state.execute(
|
||
new PaintTilesCommand(
|
||
stroke.assetId,
|
||
stroke.layerId,
|
||
edits,
|
||
stroke.tool === 'erase' ? 'Erase tiles' : 'Paint tiles',
|
||
),
|
||
);
|
||
else this.state.preview();
|
||
this.redraw();
|
||
}
|
||
|
||
/**
|
||
* The polygon collider whose corners can be edited: the primary selection, if it is one and
|
||
* belongs to this view's holder – a placed container's content is edited in its template.
|
||
*/
|
||
private editedPolygon(): DrawItem | null {
|
||
const s = this.state.selection;
|
||
const holder = this.holder();
|
||
if (s?.kind !== 'object' || !holder || !objectRef(this.state.project, holder, s.objectId)) return null;
|
||
const item = this.lastItems.find((i) => i.id === s.objectId);
|
||
return item?.type === 'collider' && item.props.shape === 'polygon' ? item : null;
|
||
}
|
||
|
||
/**
|
||
* A press on a corner or an edge of the edited polygon: a double-click takes the corner away,
|
||
* a press on an edge puts a corner there, and either can then be dragged. True when it was one.
|
||
*/
|
||
private startCornerEdit(e: FederatedPointerEvent): boolean {
|
||
const item = this.editedPolygon();
|
||
const holder = this.holder();
|
||
const ref = item && holder && objectRef(this.state.project, holder, item.id);
|
||
if (!item || !holder || !ref) return false;
|
||
const toLocal = invert(item.m);
|
||
const pointer = this.toLocal(e.global);
|
||
const at = applyPoint(toLocal, screenToWorld(this.currentView(), pointer.x, pointer.y));
|
||
const points = colliderPoints(item.props);
|
||
const tolerance = CORNER_GRIP / (this.currentView().scale * (scaleOf(item.m) || 1));
|
||
const grip = gripAt(points, at, tolerance);
|
||
if (!grip) return false;
|
||
e.stopPropagation();
|
||
if (grip.kind === 'corner' && pressCount(e, `corner ${item.id} ${grip.index}`) === 2) {
|
||
const next = withoutCorner(points, grip.index);
|
||
if (next)
|
||
this.state.execute(new SetOverrideCommand(holder.id, item.id, COLLIDER_POINTS_KEY, listOf(next)));
|
||
else this.state.setNotice('A polygon keeps at least three corners.');
|
||
return true;
|
||
}
|
||
const inserted = grip.kind === 'edge';
|
||
this.corner = {
|
||
holderId: holder.id,
|
||
objectId: item.id,
|
||
before: ref.object.overrides[COLLIDER_POINTS_KEY],
|
||
points: inserted ? withCornerAfter(points, grip.index, rounded(grip.at)) : points,
|
||
index: inserted ? grip.index + 1 : grip.index,
|
||
toLocal,
|
||
pointer: { x: pointer.x, y: pointer.y },
|
||
inserted,
|
||
moved: false,
|
||
};
|
||
if (inserted) this.previewCorners();
|
||
return true;
|
||
}
|
||
|
||
private dragCorner(e: FederatedPointerEvent): void {
|
||
const corner = this.corner!;
|
||
const pointer = this.toLocal(e.global);
|
||
if (
|
||
!corner.moved &&
|
||
Math.hypot(pointer.x - corner.pointer.x, pointer.y - corner.pointer.y) < DRAG_THRESHOLD
|
||
)
|
||
return;
|
||
corner.moved = true;
|
||
const at = applyPoint(corner.toLocal, screenToWorld(this.currentView(), pointer.x, pointer.y));
|
||
corner.points = withCornerMoved(corner.points, corner.index, rounded(at));
|
||
this.previewCorners();
|
||
}
|
||
|
||
private previewCorners(): void {
|
||
const corner = this.corner;
|
||
const holder = this.holder();
|
||
const ref = corner && holder && objectRef(this.state.project, holder, corner.objectId);
|
||
if (!corner || !ref) return;
|
||
ref.setOverride(COLLIDER_POINTS_KEY, listOf(corner.points));
|
||
this.state.preview();
|
||
}
|
||
|
||
/** Puts the preview back and lets the command do the edit, so one drag is one undo step. */
|
||
private endCorner(): void {
|
||
const corner = this.corner!;
|
||
this.corner = null;
|
||
const holder = this.holder();
|
||
const ref = holder && objectRef(this.state.project, holder, corner.objectId);
|
||
if (!ref) return;
|
||
ref.setOverride(COLLIDER_POINTS_KEY, corner.before);
|
||
if (corner.moved || corner.inserted)
|
||
this.state.execute(
|
||
new SetOverrideCommand(corner.holderId, corner.objectId, COLLIDER_POINTS_KEY, listOf(corner.points)),
|
||
);
|
||
else this.state.preview();
|
||
}
|
||
|
||
/** The corners of the polygon being edited, where they are drawn (test hook). */
|
||
cornerInfo(): { objectId: string; corners: Vec2[] } | null {
|
||
return this.lastCorners;
|
||
}
|
||
|
||
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||
if (e.button !== 0) return;
|
||
if (this.startCornerEdit(e)) return;
|
||
const holder = this.resolved();
|
||
const object = holder && findObject(holder, objectId);
|
||
if (!holder || !object) return;
|
||
e.stopPropagation();
|
||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||
this.state.toggleObject(holder.id, objectId);
|
||
return;
|
||
}
|
||
// A painting tool on the selected tilemap: the press starts a stroke instead of a move.
|
||
const target = this.paintTargetOf(objectId);
|
||
if (target && this.usePaintTool(target, holder.id, e)) return;
|
||
// Clicking a selected object keeps the selection so the whole group can be dragged.
|
||
if (!this.state.selectedObjectIds.includes(objectId))
|
||
this.state.select(objectSelection(holder.id, [objectId]));
|
||
// Only the tops of the selected subtrees move; descendants follow through the hierarchy.
|
||
const items = subtreeRoots(holder, this.state.selectedObjectIds)
|
||
.map((id) => findObject(holder, id))
|
||
.filter((o): o is NonNullable<typeof o> => Boolean(o))
|
||
.map((o) => ({
|
||
objectId: o.id,
|
||
start: { ...o.position },
|
||
toLocal: invert(linearOf(parentTransform(this.state.project, holder, o.parentId).m)),
|
||
}));
|
||
const local = this.toLocal(e.global);
|
||
this.dragging = { holderId: holder.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||
}
|
||
|
||
/** Selects every object whose position – not its bounds – lies inside the band. */
|
||
private selectInMarquee(marquee: Marquee): void {
|
||
const holder = this.holder();
|
||
if (!holder) return;
|
||
const view = this.currentView();
|
||
const a = screenToWorld(view, marquee.start.x, marquee.start.y);
|
||
const b = screenToWorld(view, marquee.current.x, marquee.current.y);
|
||
const minX = Math.min(a.x, b.x);
|
||
const maxX = Math.max(a.x, b.x);
|
||
const minY = Math.min(a.y, b.y);
|
||
const maxY = Math.max(a.y, b.y);
|
||
const ids = this.lastItems
|
||
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
|
||
.map((i) => i.id);
|
||
this.state.select(objectSelection(holder.id, ids));
|
||
}
|
||
|
||
protected override beforeItems(): void {
|
||
this.syncGrid();
|
||
this.gizmos.clear();
|
||
}
|
||
|
||
/** The selection outlines currently drawn (test hook). */
|
||
gizmoInfo(): GizmoInfo[] {
|
||
return this.lastGizmos;
|
||
}
|
||
|
||
protected override afterItems(items: DrawItem[], view: View): void {
|
||
this.drawParentLinks(items, view.scale);
|
||
this.drawGizmos(items, view);
|
||
this.drawRays(view);
|
||
}
|
||
|
||
/** The editor run's world, whose rays are drawn over the scene (#153); null when nothing runs. */
|
||
setEditorWorld(world: World | null): void {
|
||
this.editorWorld = world;
|
||
this.redraw();
|
||
}
|
||
|
||
/** The rays drawn right now, in world units (test hook). */
|
||
rayInfo(): { from: Vec2; to: Vec2; hit: boolean }[] {
|
||
return this.lastRays.map(({ from, to, hit }) => ({ from: { ...from }, to: { ...to }, hit }));
|
||
}
|
||
|
||
/** The rays the editor run cast in the last moment: to what they met, or as far as they reached. */
|
||
private drawRays(view: View): void {
|
||
const world = this.editorWorld;
|
||
this.lastRays = world ? world.rays.filter((ray) => ray.time >= world.time - RAY_SHOWN) : [];
|
||
for (const { from, to, hit } of this.lastRays) {
|
||
const color = hit ? Theme.colors.collider : Theme.colors.textMuted;
|
||
const a = worldToScreen(view, from.x, from.y);
|
||
const b = worldToScreen(view, to.x, to.y);
|
||
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y).stroke({ width: 1.5, color });
|
||
if (hit) this.gizmos.circle(b.x, b.y, 3).fill(color);
|
||
}
|
||
}
|
||
|
||
protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, extent: Extent): void {
|
||
this.syncIcon(item, onScreen);
|
||
this.syncSolids(item, onScreen, scale);
|
||
this.syncLabel(item, onScreen, scale, extent);
|
||
}
|
||
|
||
protected override disposeDecorations(id: string): void {
|
||
this.labels.get(id)?.destroy();
|
||
this.labels.delete(id);
|
||
this.icons.get(id)?.destroy();
|
||
this.icons.delete(id);
|
||
this.solids.get(id)?.destroy();
|
||
this.solids.delete(id);
|
||
this.boundaries.delete(id);
|
||
}
|
||
|
||
/** The blocking rectangles and outlined cells of a placed tilemap, and the outer edges drawn round them, in cells (test hook). */
|
||
solidInfo(id: string): {
|
||
visible: boolean;
|
||
rects: CellRect[];
|
||
outlines: Vec2[][];
|
||
oneWay: CellRect[];
|
||
oneWayOutlines: Vec2[][];
|
||
boundary: BoundaryLine[];
|
||
} | null {
|
||
const node = this.solids.get(id);
|
||
const item = this.lastItems.find((i) => i.id === id);
|
||
if (!node || !item?.tilemap) return null;
|
||
return {
|
||
visible: node.visible,
|
||
rects: item.tilemap.solids.map((rect) => ({ ...rect })),
|
||
outlines: item.tilemap.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
|
||
oneWay: item.tilemap.oneWay.solids.map((rect) => ({ ...rect })),
|
||
oneWayOutlines: item.tilemap.oneWay.outlines.map((polygon) => polygon.outline.map((p) => ({ ...p }))),
|
||
boundary: this.boundaryOf(item).solid.map((line) => ({
|
||
points: line.points.map((p) => ({ ...p })),
|
||
closed: line.closed,
|
||
})),
|
||
};
|
||
}
|
||
|
||
private boundaryOf(item: DrawItem): { solid: BoundaryLine[]; oneWay: BoundaryLine[] } {
|
||
const from: TilemapSolids = item.tilemap!;
|
||
let boundary = this.boundaries.get(item.id);
|
||
if (!boundary || !sameSolids(boundary.from, from)) {
|
||
boundary = {
|
||
from,
|
||
solid: solidBoundary(from.solids, from.outlines),
|
||
oneWay: solidBoundary(from.oneWay.solids, from.oneWay.outlines),
|
||
};
|
||
this.boundaries.set(item.id, boundary);
|
||
}
|
||
return boundary;
|
||
}
|
||
|
||
/**
|
||
* The solid cells of a tilemap, drawn the way a collider is: tinted and outlined, above the
|
||
* content so a painted wall cannot hide them. Editor-only for the same reason as the icon: the
|
||
* game view never syncs decorations.
|
||
*/
|
||
private syncSolids(item: DrawItem, show: boolean, scale: number): void {
|
||
let node = this.solids.get(item.id);
|
||
const rects = item.tilemap?.solids ?? [];
|
||
const outlines = item.tilemap?.outlines ?? [];
|
||
const oneWay = item.tilemap?.oneWay ?? { solids: [], outlines: [] };
|
||
if (!show || rects.length + outlines.length + oneWay.solids.length + oneWay.outlines.length === 0) {
|
||
if (node) node.visible = false;
|
||
return;
|
||
}
|
||
if (!node) {
|
||
node = new Graphics();
|
||
node.eventMode = 'none';
|
||
this.solids.set(item.id, node);
|
||
this.overlay.addChild(node);
|
||
}
|
||
node.visible = true;
|
||
placeNode(node, item.m);
|
||
const tile = tilemapTileSize(item.props);
|
||
const width = 2 / (scale * scaleOf(item.m));
|
||
node.clear();
|
||
const color = Theme.colors.collider;
|
||
const boundary = this.boundaryOf(item);
|
||
// Filled piece by piece, but stroked only along the outer edges, so no seam shows (#173).
|
||
const fill = (rects: readonly CellRect[], outlines: readonly CellPolygon[], alpha: number) => {
|
||
if (rects.length + outlines.length === 0) return;
|
||
for (const rect of rects) {
|
||
const corner = cellOrigin(item.props, rect.x, rect.y);
|
||
node.rect(corner.x, corner.y, rect.w * tile, rect.h * tile);
|
||
}
|
||
for (const polygon of outlines)
|
||
node.poly(
|
||
polygon.outline.map((p) => cellOrigin(item.props, p.x, p.y)),
|
||
true,
|
||
);
|
||
node.fill({ color, alpha });
|
||
};
|
||
const stroke = (lines: readonly BoundaryLine[], lineWidth: number) => {
|
||
if (lines.length === 0) return;
|
||
for (const line of lines)
|
||
node.poly(
|
||
line.points.map((p) => cellOrigin(item.props, p.x, p.y)),
|
||
line.closed,
|
||
);
|
||
node.stroke({ width: lineWidth, color });
|
||
};
|
||
fill(rects, outlines, 0.12);
|
||
stroke(boundary.solid, width);
|
||
fill(oneWay.solids, oneWay.outlines, 0.06);
|
||
stroke(boundary.oneWay, width / 2);
|
||
// A platform's top is what blocks, so that is what stands out, with the cone it lands within.
|
||
const length = CONE_LENGTH / (scale * scaleOf(item.m));
|
||
for (const rect of oneWay.solids) {
|
||
const a = cellOrigin(item.props, rect.x, rect.y);
|
||
const b = cellOrigin(item.props, rect.x + rect.w, rect.y + rect.h);
|
||
markBlockingSide(
|
||
node,
|
||
{ left: a.x, top: a.y, right: b.x, bottom: b.y },
|
||
{ x: 0, y: -1 },
|
||
width * 2,
|
||
color,
|
||
{ angle: rect.angle, length },
|
||
);
|
||
}
|
||
}
|
||
|
||
/** The icon of a particle source, the name beside it and whether it hangs off its owner (test hook). */
|
||
iconInfo(id: string): { layer: string; visible: boolean; label: string; link: boolean } | null {
|
||
const icon = this.icons.get(id);
|
||
if (!icon) return null;
|
||
const label = this.labels.get(id);
|
||
return {
|
||
layer: icon.parent === this.overlay ? 'overlay' : 'objects',
|
||
visible: icon.visible,
|
||
label: label?.visible ? label.text : '',
|
||
link: this.lastLinks.includes(id),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A source draws only its particles, so without a mark of its own an empty one is invisible and
|
||
* cannot be found (#89). The mark is a decoration rather than a symbol: `GameViewport` never syncs
|
||
* decorations, so it is editor-only without the source leaving the running game.
|
||
*/
|
||
private syncIcon(item: DrawItem, show: boolean): void {
|
||
let icon = this.icons.get(item.id);
|
||
if (!show || item.type !== 'particles') {
|
||
if (icon) icon.visible = false;
|
||
return;
|
||
}
|
||
if (!icon) {
|
||
icon = new Graphics();
|
||
icon.eventMode = 'none';
|
||
drawSymbolIcon(icon, 'particles');
|
||
this.icons.set(item.id, icon);
|
||
this.overlay.addChild(icon);
|
||
}
|
||
icon.visible = true;
|
||
icon.position.set(item.x, item.y);
|
||
}
|
||
|
||
private syncGrid(): void {
|
||
this.grid.clear();
|
||
const step = GRID_STEP * this.view.zoom;
|
||
if (step < MIN_GRID_STEP) return;
|
||
const origin = worldToScreen(this.currentView(), 0, 0);
|
||
drawGrid(this.grid, {
|
||
originX: origin.x,
|
||
originY: origin.y,
|
||
step,
|
||
w: this.w,
|
||
h: this.h,
|
||
alpha: 0.05,
|
||
axisAlpha: 0.15,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Dashed line from every parented symbol to its parent. Sprites show their nesting in the
|
||
* hierarchy panel instead, and colliders sit on their body, so neither gets a line.
|
||
*/
|
||
private drawParentLinks(items: DrawItem[], scale: number): void {
|
||
this.links.clear();
|
||
this.lastLinks = [];
|
||
const byId = new Map(items.map((i) => [i.id, i]));
|
||
for (const item of items) {
|
||
if (!hasSceneIcon(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
|
||
const parent = byId.get(item.parentId);
|
||
if (!parent || !parent.enabled) continue;
|
||
this.lastLinks.push(item.id);
|
||
const color = LINK_COLORS[item.type] ?? Theme.colors.textMuted;
|
||
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
|
||
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
|
||
this.links.circle(parent.x, parent.y, 3 / scale).fill(color);
|
||
}
|
||
}
|
||
|
||
private syncLabel(item: DrawItem, show: boolean, scale: number, extent: Extent): void {
|
||
let label = this.labels.get(item.id);
|
||
if (!show || !hasSceneIcon(item.type)) {
|
||
if (label) label.visible = false;
|
||
return;
|
||
}
|
||
if (!label) {
|
||
label = new Text({ text: '', style: textStyle('viewportLabelMuted') });
|
||
label.eventMode = 'none';
|
||
this.labels.set(item.id, label);
|
||
this.overlay.addChild(label);
|
||
}
|
||
label.visible = true;
|
||
label.text = item.name;
|
||
label.scale.set(1 / scale);
|
||
if (item.type === 'camera' || item.type === 'collider')
|
||
label.position.set(extent.left, extent.top - 16 / scale);
|
||
else label.position.set(extent.right + 4 / scale, item.y - 8 / scale);
|
||
}
|
||
|
||
/**
|
||
* Outline at the size the object will have – the footprint answers "does it fit here", which a
|
||
* cursor alone does not. Red means the drop would be refused; the crosshair is where it snaps.
|
||
*/
|
||
private drawDropPreview(view: View): void {
|
||
const preview = this.dropPreview;
|
||
const asset = preview && findAsset(this.state.project, preview.assetId);
|
||
if (!preview || !asset) return;
|
||
const color = preview.rejection ? Theme.colors.danger : Theme.colors.accent;
|
||
const m = localMatrixOf(preview.at, transformOf(asset.props));
|
||
const centre = worldToScreen(view, preview.at.x, preview.at.y);
|
||
this.gizmos
|
||
.poly(this.screenCorners(footprintCorners(asset.type, asset.props, m), view), true)
|
||
.fill({ color, alpha: 0.12 })
|
||
.stroke({ width: 2, color });
|
||
this.gizmos
|
||
.moveTo(centre.x - 6, centre.y)
|
||
.lineTo(centre.x + 6, centre.y)
|
||
.moveTo(centre.x, centre.y - 6)
|
||
.lineTo(centre.x, centre.y + 6)
|
||
.stroke({ width: 1, color });
|
||
}
|
||
|
||
/**
|
||
* While a painting tool is chosen, the selected tilemap shows its cells – without the grid one
|
||
* paints blind – and a rectangle drag shows the box it would fill.
|
||
*/
|
||
private drawPaintOverlay(items: DrawItem[], view: View): void {
|
||
const target = this.state.tilemapPaint.tool === 'select' ? null : paintedTilemap(this.state);
|
||
if (!target) return;
|
||
const item = items.find((i) => i.id === target.objectId && i.type === 'tilemap');
|
||
if (!item) return;
|
||
const tile = tilemapTileSize(item.props);
|
||
const columns = tilemapColumns(item.props);
|
||
const rows = tilemapRows(item.props);
|
||
const corner = (x: number, y: number): Vec2 => {
|
||
const at = cellOrigin(item.props, x, y);
|
||
return worldToScreen(
|
||
view,
|
||
item.m.tx + at.x * item.m.a + at.y * item.m.c,
|
||
item.m.ty + at.x * item.m.b + at.y * item.m.d,
|
||
);
|
||
};
|
||
// Below this the lines are noise, exactly as the scene grid is (see `syncGrid`).
|
||
if (tile * view.scale >= MIN_GRID_STEP) {
|
||
for (let x = 0; x <= columns; x++) {
|
||
const a = corner(x, 0);
|
||
const b = corner(x, rows);
|
||
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y);
|
||
}
|
||
for (let y = 0; y <= rows; y++) {
|
||
const a = corner(0, y);
|
||
const b = corner(columns, y);
|
||
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y);
|
||
}
|
||
this.gizmos.stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.25 });
|
||
}
|
||
const stroke = this.stroke;
|
||
if (!stroke || stroke.tool !== 'rect') return;
|
||
const x0 = Math.min(stroke.from.x, stroke.to.x);
|
||
const y0 = Math.min(stroke.from.y, stroke.to.y);
|
||
const x1 = Math.max(stroke.from.x, stroke.to.x) + 1;
|
||
const y1 = Math.max(stroke.from.y, stroke.to.y) + 1;
|
||
this.gizmos
|
||
.poly([corner(x0, y0), corner(x1, y0), corner(x1, y1), corner(x0, y1)], true)
|
||
.fill({ color: Theme.colors.accent, alpha: 0.2 })
|
||
.stroke({ width: 2, color: Theme.colors.accent });
|
||
}
|
||
|
||
/** A handle on every corner of the polygon collider being edited (#151). */
|
||
private drawCorners(view: View): void {
|
||
const item = this.editedPolygon();
|
||
this.lastCorners = null;
|
||
if (!item) return;
|
||
const half = CORNER_SIZE / 2;
|
||
const corners = colliderPoints(item.props).map((p) => {
|
||
const world = applyPoint(item.m, p);
|
||
return worldToScreen(view, world.x, world.y);
|
||
});
|
||
for (const c of corners)
|
||
this.gizmos
|
||
.rect(c.x - half, c.y - half, CORNER_SIZE, CORNER_SIZE)
|
||
.fill(Theme.colors.accentText)
|
||
.stroke({ width: 1, color: Theme.colors.accent });
|
||
this.lastCorners = {
|
||
objectId: item.id,
|
||
corners: corners.map((c) => {
|
||
const g = this.gizmos.toGlobal(c);
|
||
return { x: g.x, y: g.y };
|
||
}),
|
||
};
|
||
}
|
||
|
||
private screenCorners(corners: readonly Vec2[], view: View): Vec2[] {
|
||
return corners.map((c) => worldToScreen(view, c.x, c.y));
|
||
}
|
||
|
||
/** Selection outlines – only the primary object gets corner handles – and the rubber band. */
|
||
private drawGizmos(items: DrawItem[], view: View): void {
|
||
this.lastGizmos = [];
|
||
const s = this.state.selection;
|
||
if (s?.kind === 'object') {
|
||
for (const id of s.objectIds) {
|
||
const item = items.find((i) => i.id === id);
|
||
if (!item) continue;
|
||
// Two screen pixels outside the footprint, whichever way it is turned.
|
||
const corners = this.screenCorners(
|
||
footprintCorners(item.type, item.props, item.m, 2 / view.scale),
|
||
view,
|
||
);
|
||
// A switched-off object draws nothing, so its outline is the only way to see where it
|
||
// sits – dimmed, because the empty frame would otherwise read as a drawing that failed.
|
||
const alpha = item.enabled ? 1 : OFF_GIZMO_ALPHA;
|
||
const primary = id === s.objectId;
|
||
this.lastGizmos.push({ id, primary, alpha });
|
||
this.gizmos.poly(corners, true).stroke({ width: 2, color: Theme.colors.gizmo, alpha });
|
||
if (!primary) continue;
|
||
for (const c of corners)
|
||
this.gizmos.rect(c.x - 3, c.y - 3, 6, 6).fill({ color: Theme.colors.gizmo, alpha });
|
||
}
|
||
}
|
||
this.drawCorners(view);
|
||
this.drawDropPreview(view);
|
||
this.drawPaintOverlay(items, view);
|
||
const m = this.marquee;
|
||
if (m?.active) drawBand(this.gizmos, m.start, m.current);
|
||
}
|
||
}
|
||
|
||
/** A corner lands on whole units, as a dragged object does. */
|
||
function rounded(p: Vec2): Vec2 {
|
||
return { x: Math.round(p.x), y: Math.round(p.y) };
|
||
}
|
||
|
||
function listOf(points: readonly Vec2[]): ListValue {
|
||
return points.map((p) => ({ x: p.x, y: p.y }));
|
||
}
|