473 lines
19 KiB
TypeScript
473 lines
19 KiB
TypeScript
/**
|
||
* Collider geometry in world units, shared by the editor and the runtime so both agree on what
|
||
* "overlaps" means. A box is oriented: it turns and grows with the body it sits on. A capsule and a
|
||
* polygon (#151) carry their points in the world already, and a polygon its convex pieces.
|
||
*/
|
||
import { angleOf, apply, type Mat, scaleOf } from '../model/matrix';
|
||
import { convexPieces, pointsOf, signedArea } from '../model/polygon';
|
||
import { type PropertyBag, type PropertyValue, textOf, type Vec2 } from '../model/Properties';
|
||
import {
|
||
roundedContact,
|
||
roundedContains,
|
||
roundedExit,
|
||
roundedExtent,
|
||
type Contact,
|
||
preferredOnTie,
|
||
type Rounded,
|
||
} from './convex';
|
||
import { defaultsFor } from './PropertySchema';
|
||
|
||
const DEFAULTS = defaultsFor('collider');
|
||
|
||
/** Overlaps this shallow count as touching, so two rotated boxes at rest do not jitter. */
|
||
const EPSILON = 1e-9;
|
||
|
||
export type ColliderShape =
|
||
| { kind: 'box'; x: number; y: number; w: number; h: number; angle: number }
|
||
| { kind: 'circle'; x: number; y: number; r: number }
|
||
/** The segment `a`–`b` grown by `r`. */
|
||
| { kind: 'capsule'; x: number; y: number; angle: number; a: Vec2; b: Vec2; r: number }
|
||
/** `outline` as drawn; `pieces` what collides – none when the outline is no polygon (see `polygonProblem`). */
|
||
| { kind: 'polygon'; x: number; y: number; angle: number; outline: Vec2[]; pieces: Vec2[][] };
|
||
|
||
type Box = Extract<ColliderShape, { kind: 'box' }>;
|
||
type Circle = Extract<ColliderShape, { kind: 'circle' }>;
|
||
type Classic = Box | Circle;
|
||
|
||
/** Where a polygon collider keeps its corners (#151). */
|
||
export const COLLIDER_POINTS_KEY = 'points';
|
||
|
||
/** A polygon's pieces in its own units, cut once per stored list rather than on every test. */
|
||
const piecesCache = new WeakMap<object, Vec2[][]>();
|
||
|
||
/** The corners of a polygon collider in its own units – the stored ones, or the default triangle. */
|
||
export function colliderPoints(props: PropertyBag): Vec2[] {
|
||
return pointsOf(props[COLLIDER_POINTS_KEY] ?? DEFAULTS[COLLIDER_POINTS_KEY]);
|
||
}
|
||
|
||
function localPieces(value: PropertyValue | undefined): Vec2[][] {
|
||
const key = typeof value === 'object' ? value : null;
|
||
const known = key && piecesCache.get(key);
|
||
if (known) return known;
|
||
const pieces = convexPieces(pointsOf(value));
|
||
if (key) piecesCache.set(key, pieces);
|
||
return pieces;
|
||
}
|
||
|
||
/** The capsule in a `w` × `h` box: rounded along the longer side, the radius half the shorter one. */
|
||
function capsuleCore(w: number, h: number): { a: Vec2; b: Vec2; r: number } {
|
||
if (h >= w) return { a: { x: 0, y: -(h - w) / 2 }, b: { x: 0, y: (h - w) / 2 }, r: w / 2 };
|
||
return { a: { x: -(w - h) / 2, y: 0 }, b: { x: (w - h) / 2, y: 0 }, r: h / 2 };
|
||
}
|
||
|
||
/** The shape as it sits in the world: sized by the transform's scale, turned (and mirrored) with it. */
|
||
export function colliderShape(props: PropertyBag, m: Mat): ColliderShape {
|
||
const scale = scaleOf(m);
|
||
if (props.shape === 'circle')
|
||
return {
|
||
kind: 'circle',
|
||
x: m.tx,
|
||
y: m.ty,
|
||
r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * scale,
|
||
};
|
||
const w = Math.max(0, Number(props.width ?? DEFAULTS.width));
|
||
const h = Math.max(0, Number(props.height ?? DEFAULTS.height));
|
||
if (props.shape === 'capsule') {
|
||
const core = capsuleCore(w, h);
|
||
return {
|
||
kind: 'capsule',
|
||
x: m.tx,
|
||
y: m.ty,
|
||
angle: angleOf(m),
|
||
a: apply(m, core.a),
|
||
b: apply(m, core.b),
|
||
r: core.r * scale,
|
||
};
|
||
}
|
||
if (props.shape === 'polygon') {
|
||
const value = props[COLLIDER_POINTS_KEY] ?? DEFAULTS[COLLIDER_POINTS_KEY];
|
||
return {
|
||
kind: 'polygon',
|
||
x: m.tx,
|
||
y: m.ty,
|
||
angle: angleOf(m),
|
||
outline: pointsOf(value).map((p) => apply(m, p)),
|
||
pieces: localPieces(value).map((piece) => piece.map((p) => apply(m, p))),
|
||
};
|
||
}
|
||
return { kind: 'box', x: m.tx, y: m.ty, w: w * scale, h: h * scale, angle: angleOf(m) };
|
||
}
|
||
|
||
/** Whether the collider only reports overlaps (true) or also blocks movement (false). */
|
||
export function isTrigger(props: PropertyBag): boolean {
|
||
return Boolean(props.isTrigger ?? DEFAULTS.isTrigger);
|
||
}
|
||
|
||
/** The side a one-way collider blocks from, in its own axes (y points down). */
|
||
const ONE_WAY_SIDES: Record<string, Vec2> = {
|
||
up: { x: 0, y: -1 },
|
||
down: { x: 0, y: 1 },
|
||
left: { x: -1, y: 0 },
|
||
right: { x: 1, y: 0 },
|
||
};
|
||
|
||
/**
|
||
* A contact normal at most this far from a one-way collider's side (45°) is a landing on it; any
|
||
* other contact passes through (#155).
|
||
*/
|
||
const ONE_WAY_CONE = Math.cos(Math.PI / 4) - 1e-9;
|
||
|
||
/**
|
||
* The world direction a one-way collider blocks from – its top face for the default "up", turned
|
||
* and mirrored with it – or null for a collider that blocks from everywhere.
|
||
*/
|
||
export function oneWayFacing(props: PropertyBag, m: Mat): Vec2 | null {
|
||
if (!(props.oneWay ?? DEFAULTS.oneWay)) return null;
|
||
const side = ONE_WAY_SIDES[textOf(props.oneWayDirection ?? DEFAULTS.oneWayDirection)] ?? ONE_WAY_SIDES.up!;
|
||
const x = m.a * side.x + m.c * side.y;
|
||
const y = m.b * side.x + m.d * side.y;
|
||
const length = Math.hypot(x, y);
|
||
return length > 0 ? { x: x / length, y: y / length } : null;
|
||
}
|
||
|
||
/** Whether a contact with that normal (towards the mover) lands on the side a one-way shape blocks from. */
|
||
export function landsOn(normal: Vec2, facing: Vec2): boolean {
|
||
return dot(normal, facing) >= ONE_WAY_CONE;
|
||
}
|
||
|
||
/** The shape with every point moved by `move` and turned by `turn` more – a body carrying it moved rigidly. */
|
||
export function movedShape(shape: ColliderShape, move: (p: Vec2) => Vec2, turn: number): ColliderShape {
|
||
const centre = move(shape);
|
||
switch (shape.kind) {
|
||
case 'circle':
|
||
return { ...shape, ...centre };
|
||
case 'box':
|
||
return { ...shape, ...centre, angle: shape.angle + turn };
|
||
case 'capsule':
|
||
return { ...shape, ...centre, angle: shape.angle + turn, a: move(shape.a), b: move(shape.b) };
|
||
case 'polygon':
|
||
return {
|
||
...shape,
|
||
...centre,
|
||
angle: shape.angle + turn,
|
||
outline: shape.outline.map(move),
|
||
pieces: shape.pieces.map((piece) => piece.map(move)),
|
||
};
|
||
}
|
||
}
|
||
|
||
/** The size in the collider's own units, before any transform. */
|
||
export function colliderSize(props: PropertyBag): { w: number; h: number } {
|
||
// Centred like every footprint, so it reaches as far as the farthest corner on each axis.
|
||
if (props.shape === 'polygon') {
|
||
const points = colliderPoints(props);
|
||
return {
|
||
w: 2 * points.reduce((max, p) => Math.max(max, Math.abs(p.x)), 0),
|
||
h: 2 * points.reduce((max, p) => Math.max(max, Math.abs(p.y)), 0),
|
||
};
|
||
}
|
||
if (props.shape === 'circle') {
|
||
const d = Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * 2;
|
||
return { w: d, h: d };
|
||
}
|
||
return {
|
||
w: Math.max(0, Number(props.width ?? DEFAULTS.width)),
|
||
h: Math.max(0, Number(props.height ?? DEFAULTS.height)),
|
||
};
|
||
}
|
||
|
||
function axesOf(box: Box): [Vec2, Vec2] {
|
||
const cos = Math.cos(box.angle);
|
||
const sin = Math.sin(box.angle);
|
||
return [
|
||
{ x: cos, y: sin },
|
||
{ x: -sin, y: cos },
|
||
];
|
||
}
|
||
|
||
function dot(a: Vec2, b: Vec2): number {
|
||
return a.x * b.x + a.y * b.y;
|
||
}
|
||
|
||
/** A world point in the box's own frame: origin at its centre, axes along its sides. */
|
||
function toBoxFrame(box: Box, p: Vec2): Vec2 {
|
||
const [ux, uy] = axesOf(box);
|
||
const d = { x: p.x - box.x, y: p.y - box.y };
|
||
return { x: dot(d, ux), y: dot(d, uy) };
|
||
}
|
||
|
||
/** Half of the box's projection onto the unit direction `n`. */
|
||
function reach(box: Box, n: Vec2): number {
|
||
const [ux, uy] = axesOf(box);
|
||
return (box.w / 2) * Math.abs(dot(ux, n)) + (box.h / 2) * Math.abs(dot(uy, n));
|
||
}
|
||
|
||
/**
|
||
* How wide the shape is along the unit direction `n` – a turned box measures its own slant. A
|
||
* polygon measures its thinnest piece: that is what a step along `n` must not jump over.
|
||
*/
|
||
export function shapeExtent(shape: ColliderShape, n: Vec2): number {
|
||
if (shape.kind === 'circle') return shape.r * 2;
|
||
if (shape.kind === 'box') return reach(shape, n) * 2;
|
||
const pieces = piecesOf(shape);
|
||
return pieces.length ? Math.min(...pieces.map((piece) => roundedExtent(piece, n))) : 0;
|
||
}
|
||
|
||
/**
|
||
* The shape as rounded convex pieces (see `convex.ts`): a box its corners, a circle its centre and
|
||
* a capsule its segment, each grown by its radius, a polygon its pieces.
|
||
*/
|
||
export function piecesOf(shape: ColliderShape): Rounded[] {
|
||
switch (shape.kind) {
|
||
case 'box': {
|
||
const [ux, uy] = axesOf(shape);
|
||
const corner = (sx: number, sy: number) => ({
|
||
x: shape.x + (sx * shape.w * ux.x + sy * shape.h * uy.x) / 2,
|
||
y: shape.y + (sx * shape.w * ux.y + sy * shape.h * uy.y) / 2,
|
||
});
|
||
return [{ points: [corner(-1, -1), corner(1, -1), corner(1, 1), corner(-1, 1)], r: 0 }];
|
||
}
|
||
case 'circle':
|
||
return [{ points: [{ x: shape.x, y: shape.y }], r: shape.r }];
|
||
case 'capsule':
|
||
return [{ points: [shape.a, shape.b], r: shape.r }];
|
||
case 'polygon':
|
||
return shape.pieces.map((points) => ({ points, r: 0 }));
|
||
}
|
||
}
|
||
|
||
/** The area the shape covers, in world units squared. */
|
||
export function shapeArea(shape: ColliderShape): number {
|
||
switch (shape.kind) {
|
||
case 'box':
|
||
return shape.w * shape.h;
|
||
case 'circle':
|
||
return Math.PI * shape.r * shape.r;
|
||
case 'capsule':
|
||
return (
|
||
Math.PI * shape.r * shape.r + 2 * shape.r * Math.hypot(shape.b.x - shape.a.x, shape.b.y - shape.a.y)
|
||
);
|
||
case 'polygon':
|
||
return shape.pieces.reduce((sum, piece) => sum + Math.abs(signedArea(piece)), 0);
|
||
}
|
||
}
|
||
|
||
/** Box and circle keep their own exact tests; everything else goes through the rounded pieces. */
|
||
function isClassic(shape: ColliderShape): shape is Classic {
|
||
return shape.kind === 'box' || shape.kind === 'circle';
|
||
}
|
||
|
||
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
|
||
if (!isClassic(shape)) return piecesOf(shape).some((piece) => roundedContains(piece, point));
|
||
if (shape.kind === 'circle')
|
||
return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
|
||
const local = toBoxFrame(shape, point);
|
||
return Math.abs(local.x) * 2 <= shape.w && Math.abs(local.y) * 2 <= shape.h;
|
||
}
|
||
|
||
/** Distance from a point to an axis-aligned box centred at the origin; 0 inside. */
|
||
function distanceToBox(p: Vec2, hw: number, hh: number): number {
|
||
const dx = Math.max(Math.abs(p.x) - hw, 0);
|
||
const dy = Math.max(Math.abs(p.y) - hh, 0);
|
||
return Math.hypot(dx, dy);
|
||
}
|
||
|
||
/** Shapes that merely touch along an edge do not count as overlapping. */
|
||
export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
|
||
if (!isClassic(a) || !isClassic(b)) {
|
||
const others = piecesOf(b);
|
||
return piecesOf(a).some((p) =>
|
||
others.some((q) => roundedContact(p, q, { x: 0, y: 0 }).separation < -EPSILON),
|
||
);
|
||
}
|
||
if (a.kind === 'box' && b.kind === 'box') {
|
||
// Separating axes: the sides of both boxes. Apart along any one of them is apart.
|
||
const centres = { x: a.x - b.x, y: a.y - b.y };
|
||
for (const n of [...axesOf(a), ...axesOf(b)])
|
||
if (reach(a, n) + reach(b, n) - Math.abs(dot(centres, n)) <= EPSILON) return false;
|
||
return true;
|
||
}
|
||
if (a.kind === 'circle' && b.kind === 'circle') {
|
||
const r = a.r + b.r;
|
||
return (a.x - b.x) ** 2 + (a.y - b.y) ** 2 < r * r;
|
||
}
|
||
const { box, circle } = boxAndCircle(a, b);
|
||
return distanceToBox(toBoxFrame(box, circle), box.w / 2, box.h / 2) < circle.r - EPSILON;
|
||
}
|
||
|
||
/**
|
||
* How two shapes meet: `separation` is the gap between them (negative when they overlap, then its
|
||
* size is the shortest way out) and `normal` the unit direction from `obstacle` towards `mover` that
|
||
* gap is measured along – the face that was hit, or the direction from a hit corner. Where the
|
||
* shapes give no direction (centres on top of each other) it runs against `travel`.
|
||
*/
|
||
export function contactBetween(
|
||
mover: ColliderShape,
|
||
obstacle: ColliderShape,
|
||
travel: Vec2,
|
||
): { separation: number; normal: Vec2 } {
|
||
if (!isClassic(mover) || !isClassic(obstacle)) return piecesContact(mover, obstacle, travel);
|
||
const against = (n: Vec2, offset: number): Vec2 => {
|
||
const sign = offset !== 0 ? Math.sign(offset) : dot(n, travel) > 0 ? -1 : 1;
|
||
return { x: sign * n.x + 0, y: sign * n.y + 0 };
|
||
};
|
||
if (mover.kind === 'box' && obstacle.kind === 'box') {
|
||
const centres = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
|
||
let best = { separation: -Infinity, normal: { x: 0, y: 0 } };
|
||
for (const n of [...axesOf(mover), ...axesOf(obstacle)]) {
|
||
const offset = dot(centres, n);
|
||
const separation = Math.abs(offset) - reach(mover, n) - reach(obstacle, n);
|
||
const normal = against(n, offset);
|
||
// On a tie (a corner on a corner) the face the travel runs into more squarely wins.
|
||
if (
|
||
separation > best.separation + EPSILON ||
|
||
(separation > best.separation - EPSILON && dot(normal, travel) < dot(best.normal, travel))
|
||
)
|
||
best = { separation: Math.max(separation, best.separation), normal };
|
||
}
|
||
return best;
|
||
}
|
||
if (mover.kind === 'circle' && obstacle.kind === 'circle') {
|
||
const d = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
|
||
const length = Math.hypot(d.x, d.y);
|
||
const normal = length > 0 ? { x: d.x / length, y: d.y / length } : against(unit(travel), 0);
|
||
return { separation: length - mover.r - obstacle.r, normal };
|
||
}
|
||
const { box, circle } = boxAndCircle(mover, obstacle);
|
||
const [ux, uy] = axesOf(box);
|
||
const p = toBoxFrame(box, circle);
|
||
const hw = box.w / 2;
|
||
const hh = box.h / 2;
|
||
// Local direction from the box towards the circle, and the gap along it.
|
||
let dir: Vec2;
|
||
let separation: number;
|
||
if (Math.abs(p.x) > hw || Math.abs(p.y) > hh) {
|
||
const d = { x: p.x - clamp(p.x, hw), y: p.y - clamp(p.y, hh) };
|
||
const length = Math.hypot(d.x, d.y);
|
||
dir = { x: d.x / length, y: d.y / length };
|
||
separation = length - circle.r;
|
||
} else {
|
||
// The centre is inside: out through the nearer face.
|
||
const sign = mover.kind === 'circle' ? 1 : -1;
|
||
const local = { x: sign * dot(travel, ux), y: sign * dot(travel, uy) };
|
||
const byX = hw - Math.abs(p.x) <= hh - Math.abs(p.y);
|
||
dir = byX
|
||
? { x: p.x !== 0 ? Math.sign(p.x) : -Math.sign(local.x) || 1, y: 0 }
|
||
: { x: 0, y: p.y !== 0 ? Math.sign(p.y) : -Math.sign(local.y) || 1 };
|
||
separation = -(byX ? hw - Math.abs(p.x) : hh - Math.abs(p.y)) - circle.r;
|
||
}
|
||
const world = { x: dir.x * ux.x + dir.y * uy.x, y: dir.x * ux.y + dir.y * uy.y };
|
||
// `world` runs from the box to the circle; the normal runs from the obstacle to the mover.
|
||
const sign = mover.kind === 'circle' ? 1 : -1;
|
||
return { separation, normal: { x: sign * world.x + 0, y: sign * world.y + 0 } };
|
||
}
|
||
|
||
/** How deep two shapes overlap by the measure `shapesOverlap` uses: 0 when they only touch. */
|
||
export function overlapDepth(separation: number): number {
|
||
return separation < -EPSILON ? -separation : 0;
|
||
}
|
||
|
||
function clamp(v: number, half: number): number {
|
||
return Math.min(half, Math.max(-half, v));
|
||
}
|
||
|
||
function unit(v: Vec2): Vec2 {
|
||
const length = Math.hypot(v.x, v.y);
|
||
return length > 0 ? { x: v.x / length, y: v.y / length } : { x: 1, y: 0 };
|
||
}
|
||
|
||
/**
|
||
* How far `mover` has to travel along `direction` until it just clears `obstacle`; 0 when they are
|
||
* apart already. This is what stops a moving object at a wall: it is pushed back against its
|
||
* travel by exactly this much.
|
||
*/
|
||
export function exitDistance(mover: ColliderShape, obstacle: ColliderShape, direction: Vec2): number {
|
||
const length = Math.hypot(direction.x, direction.y);
|
||
if (length === 0 || !shapesOverlap(mover, obstacle)) return 0;
|
||
const d = { x: direction.x / length, y: direction.y / length };
|
||
if (!isClassic(mover) || !isClassic(obstacle)) {
|
||
// Every overlapping pair of pieces has to come clear; once out, a pair stays out along the line.
|
||
const others = piecesOf(obstacle);
|
||
let exit = 0;
|
||
for (const p of piecesOf(mover)) for (const q of others) exit = Math.max(exit, roundedExit(p, q, d));
|
||
return exit;
|
||
}
|
||
if (mover.kind === 'box' && obstacle.kind === 'box') return boxesExit(mover, obstacle, d);
|
||
if (mover.kind === 'circle' && obstacle.kind === 'circle') {
|
||
const sep = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
|
||
const r = mover.r + obstacle.r;
|
||
const along = dot(sep, d);
|
||
return -along + Math.sqrt(Math.max(0, along * along - dot(sep, sep) + r * r));
|
||
}
|
||
const { box, circle } = boxAndCircle(mover, obstacle);
|
||
// Seen from the box: the circle's centre travels along `d` (or against it when the box moves).
|
||
const [ux, uy] = axesOf(box);
|
||
const sign = mover.kind === 'circle' ? 1 : -1;
|
||
const v = { x: sign * dot(d, ux), y: sign * dot(d, uy) };
|
||
const start = toBoxFrame(box, circle);
|
||
return circleExit(start, v, box.w / 2, box.h / 2, circle.r);
|
||
}
|
||
|
||
/**
|
||
* The travel along `d` at which the first separating axis opens – for two boxes that is the exact
|
||
* moment they stop overlapping. Along an axis the boxes may be moving apart or, when the mover is
|
||
* already past the obstacle's centre, deeper in and out the far side.
|
||
*/
|
||
function boxesExit(mover: Box, obstacle: Box, d: Vec2): number {
|
||
const centres = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
|
||
let best = Infinity;
|
||
for (const n of [...axesOf(mover), ...axesOf(obstacle)]) {
|
||
const speed = dot(d, n);
|
||
if (Math.abs(speed) < 1e-12) continue;
|
||
const span = reach(mover, n) + reach(obstacle, n);
|
||
const offset = dot(centres, n);
|
||
const apart = offset === 0 || Math.sign(speed) === Math.sign(offset);
|
||
const t = (apart ? span - Math.abs(offset) : span + Math.abs(offset)) / Math.abs(speed);
|
||
if (t < best) best = t;
|
||
}
|
||
return Number.isFinite(best) ? best : 0;
|
||
}
|
||
|
||
/**
|
||
* A circle's centre starting at `start` (inside the box grown by `r`) moving along `v` in the box's
|
||
* frame: the travel at which it is `r` away from the box. The distance to a box is convex along a
|
||
* line, so the crossing is found by bisection – exact for sides and corners alike.
|
||
*/
|
||
function circleExit(start: Vec2, v: Vec2, hw: number, hh: number, r: number): number {
|
||
const outside = (t: number) => distanceToBox({ x: start.x + v.x * t, y: start.y + v.y * t }, hw, hh) - r;
|
||
let hi = 1;
|
||
for (let i = 0; i < 64 && outside(hi) <= 0; i++) hi *= 2;
|
||
let lo = 0;
|
||
for (let i = 0; i < 60; i++) {
|
||
const mid = (lo + hi) / 2;
|
||
if (outside(mid) <= 0) lo = mid;
|
||
else hi = mid;
|
||
}
|
||
return hi;
|
||
}
|
||
|
||
/**
|
||
* The contact of two shapes made of pieces: the pair of pieces that overlaps deepest (or lies
|
||
* closest); on a tie see `preferredOnTie`. A shape without pieces – a
|
||
* polygon that is none – meets nothing.
|
||
*/
|
||
function piecesContact(mover: ColliderShape, obstacle: ColliderShape, travel: Vec2): Contact {
|
||
let best: Contact = { separation: Infinity, normal: { x: 0, y: 0 } };
|
||
const others = piecesOf(obstacle);
|
||
for (const p of piecesOf(mover))
|
||
for (const q of others) {
|
||
const contact = roundedContact(p, q, travel);
|
||
if (
|
||
contact.separation < best.separation - EPSILON ||
|
||
(contact.separation < best.separation + EPSILON &&
|
||
preferredOnTie(contact.normal, best.normal, travel))
|
||
)
|
||
best = { separation: Math.min(contact.separation, best.separation), normal: contact.normal };
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function boxAndCircle(a: Classic, b: Classic): { box: Box; circle: Circle } {
|
||
return a.kind === 'box' ? { box: a, circle: b as Circle } : { box: b as Box, circle: a };
|
||
}
|