Physics stage 2: colliders gain the shapes capsule and polygon, a polygon keeps its corners in the property points, turns, scales and mirrors with its object and is edited in the scene view (drag a corner, click an edge to insert one, double-click to delete, at least three stay, each step one undo step), a concave polygon is split into convex parts, a self-intersecting one is drawn red and collides with nothing, Rapier gets a capsule and one convex collider per part on the same body, and Move and the editor run use our own f64 geometry for the new shapes, where on a tie between two normals the one that blocks the move least wins, so a box no longer stops at the foot of a slope (#151); first version of a collision outline per texture frame, edited in the texture tab with the new Collision toggle, which a plain tile blocks with instead of its rectangle (#141, to be reworked)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b5e386b60
commit
32057a96fc
25 files changed
+2341
-122
No files matched your search
@@ -349,11 +349,25 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
options: [
|
||||
{ value: 'box', label: 'Box' },
|
||||
{ value: 'circle', label: 'Circle' },
|
||||
{ value: 'capsule', label: 'Capsule' },
|
||||
{ value: 'polygon', label: 'Polygon' },
|
||||
],
|
||||
},
|
||||
// A box and a capsule fill width × height; the capsule is rounded along the longer side.
|
||||
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
|
||||
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
||||
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
|
||||
// A polygon's corners in the collider's own units, edited on the outline in the scene (#151).
|
||||
{
|
||||
key: 'points',
|
||||
label: 'Points',
|
||||
kind: 'list',
|
||||
default: [
|
||||
{ x: 0, y: -50 },
|
||||
{ x: 50, y: 50 },
|
||||
{ x: -50, y: 50 },
|
||||
],
|
||||
},
|
||||
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
|
||||
// The material, read by the physics engine where a rigidbody takes part (#146).
|
||||
{ key: 'friction', label: 'Friction', kind: 'number', default: 0.5, min: 0, step: 0.05 },
|
||||
|
||||
+170
-15
@@ -1,9 +1,20 @@
|
||||
/**
|
||||
* 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.
|
||||
* "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, type Mat, scaleOf } from '../model/matrix';
|
||||
import type { PropertyBag, Vec2 } from '../model/Properties';
|
||||
import { angleOf, apply, type Mat, scaleOf } from '../model/matrix';
|
||||
import { convexPieces, pointsOf, signedArea } from '../model/polygon';
|
||||
import type { PropertyBag, PropertyValue, Vec2 } from '../model/Properties';
|
||||
import {
|
||||
roundedContact,
|
||||
roundedContains,
|
||||
roundedExit,
|
||||
roundedExtent,
|
||||
type Contact,
|
||||
preferredOnTie,
|
||||
type Rounded,
|
||||
} from './convex';
|
||||
import { defaultsFor } from './PropertySchema';
|
||||
|
||||
const DEFAULTS = defaultsFor('collider');
|
||||
@@ -13,12 +24,43 @@ 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 };
|
||||
| { 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;
|
||||
|
||||
/** The shape as it sits in the world: sized by the transform's scale, a box turned by its angle. */
|
||||
/** 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')
|
||||
@@ -28,14 +70,32 @@ export function colliderShape(props: PropertyBag, m: Mat): ColliderShape {
|
||||
y: m.ty,
|
||||
r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * scale,
|
||||
};
|
||||
return {
|
||||
kind: 'box',
|
||||
x: m.tx,
|
||||
y: m.ty,
|
||||
w: Math.max(0, Number(props.width ?? DEFAULTS.width)) * scale,
|
||||
h: Math.max(0, Number(props.height ?? DEFAULTS.height)) * scale,
|
||||
angle: angleOf(m),
|
||||
};
|
||||
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). */
|
||||
@@ -45,6 +105,14 @@ export function isTrigger(props: PropertyBag): boolean {
|
||||
|
||||
/** 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 };
|
||||
@@ -82,11 +150,63 @@ function reach(box: Box, n: Vec2): number {
|
||||
}
|
||||
|
||||
/** How wide the shape is along the unit direction `n` – a turned box measures its own slant. */
|
||||
/**
|
||||
* 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 {
|
||||
return shape.kind === 'circle' ? shape.r * 2 : reach(shape, n) * 2;
|
||||
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);
|
||||
@@ -102,6 +222,12 @@ function distanceToBox(p: Vec2, hw: number, hh: number): number {
|
||||
|
||||
/** 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 };
|
||||
@@ -128,6 +254,7 @@ export function contactBetween(
|
||||
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 };
|
||||
@@ -206,6 +333,13 @@ export function exitDistance(mover: ColliderShape, obstacle: ColliderShape, dire
|
||||
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 };
|
||||
@@ -260,6 +394,27 @@ function circleExit(start: Vec2, v: Vec2, hw: number, hh: number, r: number): nu
|
||||
return hi;
|
||||
}
|
||||
|
||||
function boxAndCircle(a: ColliderShape, b: ColliderShape): { box: Box; circle: Circle } {
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Collision between rounded convex pieces (#151): a core – one point, a segment or a convex
|
||||
* polygon – grown by a radius. A circle is a point grown by its radius, a capsule a segment, a box
|
||||
* or a piece of a polygon a polygon grown by nothing. Every pair is worked out the same way: the
|
||||
* separating axes of the two cores decide whether the cores overlap and, if so, how deep; apart,
|
||||
* the real distance between them is taken. The radii are then taken off.
|
||||
*/
|
||||
import type { Vec2 } from '../model/Properties';
|
||||
|
||||
export interface Rounded {
|
||||
points: readonly Vec2[];
|
||||
r: number;
|
||||
}
|
||||
|
||||
/** A contact as `contactBetween` reports it: gap (negative = overlap) and the normal towards the mover. */
|
||||
export interface Contact {
|
||||
separation: number;
|
||||
normal: Vec2;
|
||||
}
|
||||
|
||||
/** Ties between axes closer than this are settled by the travel. */
|
||||
const TIE = 1e-9;
|
||||
|
||||
function dot(a: Vec2, b: Vec2): number {
|
||||
return a.x * b.x + a.y * b.y;
|
||||
}
|
||||
|
||||
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: 0, y: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of two normals a contact takes when both are as good (a corner on a corner, where any
|
||||
* direction between them is right): the one that stands least in the travel's way while still
|
||||
* standing in it – so a crate walking along the floor into the foot of a slope climbs it, and one
|
||||
* that meets a block exactly corner to corner goes on along its top. A normal that does not stand
|
||||
* against the travel at all only wins over another such one.
|
||||
*/
|
||||
export function preferredOnTie(a: Vec2, b: Vec2, travel: Vec2): boolean {
|
||||
const da = dot(a, travel);
|
||||
const db = dot(b, travel);
|
||||
return da < 0 && db < 0 ? da > db : da < db;
|
||||
}
|
||||
|
||||
/**
|
||||
* The axes a core can be told apart along: a polygon's edge normals, a segment's normal and
|
||||
* direction (so a point beyond its end is found apart too), nothing for a point.
|
||||
*/
|
||||
function axesOf(points: readonly Vec2[]): Vec2[] {
|
||||
if (points.length < 2) return [];
|
||||
const axes: Vec2[] = [];
|
||||
const edges = points.length === 2 ? 1 : points.length;
|
||||
for (let i = 0; i < edges; i++) {
|
||||
const a = points[i]!;
|
||||
const b = points[(i + 1) % points.length]!;
|
||||
const d = unit({ x: b.x - a.x, y: b.y - a.y });
|
||||
if (d.x === 0 && d.y === 0) continue;
|
||||
axes.push({ x: -d.y, y: d.x });
|
||||
if (points.length === 2) axes.push(d);
|
||||
}
|
||||
return axes;
|
||||
}
|
||||
|
||||
function span(points: readonly Vec2[], n: Vec2): [number, number] {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (const p of points) {
|
||||
const v = dot(p, n);
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
/** The closest point to `p` on the segment ab. */
|
||||
function closestOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 {
|
||||
const ab = { x: b.x - a.x, y: b.y - a.y };
|
||||
const length = dot(ab, ab);
|
||||
const t = length > 0 ? Math.min(1, Math.max(0, dot({ x: p.x - a.x, y: p.y - a.y }, ab) / length)) : 0;
|
||||
return { x: a.x + ab.x * t, y: a.y + ab.y * t };
|
||||
}
|
||||
|
||||
/** The edges of a core as segments; a point is one segment of no length. */
|
||||
function edgesOf(points: readonly Vec2[]): [Vec2, Vec2][] {
|
||||
if (points.length === 1) return [[points[0]!, points[0]!]];
|
||||
if (points.length === 2) return [[points[0]!, points[1]!]];
|
||||
return points.map((p, i) => [p, points[(i + 1) % points.length]!]);
|
||||
}
|
||||
|
||||
/** The closest points of two cores that lie apart: every corner of one against every edge of the other. */
|
||||
function closestPoints(a: readonly Vec2[], b: readonly Vec2[]): { onA: Vec2; onB: Vec2; distance: number } {
|
||||
let best = { onA: a[0]!, onB: b[0]!, distance: Infinity };
|
||||
for (const p of a)
|
||||
for (const [s, t] of edgesOf(b)) {
|
||||
const q = closestOnSegment(p, s, t);
|
||||
const distance = Math.hypot(p.x - q.x, p.y - q.y);
|
||||
if (distance < best.distance) best = { onA: p, onB: q, distance };
|
||||
}
|
||||
for (const q of b)
|
||||
for (const [s, t] of edgesOf(a)) {
|
||||
const p = closestOnSegment(q, s, t);
|
||||
const distance = Math.hypot(p.x - q.x, p.y - q.y);
|
||||
if (distance < best.distance) best = { onA: p, onB: q, distance };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* How `mover` meets `obstacle`: the gap between the rounded pieces (negative when they overlap, its
|
||||
* size then the shortest way out) and the unit normal from the obstacle towards the mover. Where
|
||||
* nothing gives a direction – two cores on the same spot – the normal runs against `travel`.
|
||||
*/
|
||||
export function roundedContact(mover: Rounded, obstacle: Rounded, travel: Vec2): Contact {
|
||||
const radii = mover.r + obstacle.r;
|
||||
const against = (n: Vec2) => (dot(n, travel) > 0 ? { x: -n.x, y: -n.y } : n);
|
||||
let best: Contact = { separation: -Infinity, normal: { x: 0, y: 0 } };
|
||||
for (const n of [...axesOf(mover.points), ...axesOf(obstacle.points)]) {
|
||||
const [a0, a1] = span(mover.points, n);
|
||||
const [b0, b1] = span(obstacle.points, n);
|
||||
// Pushed out along +n the mover needs to clear b1; along -n, b0.
|
||||
const plus = a0 - b1;
|
||||
const minus = b0 - a1;
|
||||
const separation = Math.max(plus, minus);
|
||||
const normal = Math.abs(plus - minus) <= TIE ? against(n) : plus > minus ? n : { x: -n.x, y: -n.y };
|
||||
if (
|
||||
separation > best.separation + TIE ||
|
||||
(separation > best.separation - TIE && preferredOnTie(normal, best.normal, travel))
|
||||
)
|
||||
best = { separation: Math.max(separation, best.separation), normal };
|
||||
}
|
||||
// Apart, or two points (no axis at all): the real distance between the cores.
|
||||
if (best.separation > 0 || best.separation === -Infinity) {
|
||||
const { onA, onB, distance } = closestPoints(mover.points, obstacle.points);
|
||||
if (distance > 0)
|
||||
return {
|
||||
separation: distance - radii,
|
||||
normal: { x: (onA.x - onB.x) / distance + 0, y: (onA.y - onB.y) / distance + 0 },
|
||||
};
|
||||
// Two points on the same spot: out against the travel.
|
||||
const back = unit({ x: -travel.x, y: -travel.y });
|
||||
return { separation: -radii, normal: back.x === 0 && back.y === 0 ? { x: 0, y: -1 } : back };
|
||||
}
|
||||
return { separation: best.separation - radii, normal: { x: best.normal.x + 0, y: best.normal.y + 0 } };
|
||||
}
|
||||
|
||||
/** Whether `p` lies on the rounded piece, its outline counting as on it. */
|
||||
export function roundedContains(piece: Rounded, p: Vec2): boolean {
|
||||
return roundedContact({ points: [p], r: 0 }, piece, { x: 0, y: 0 }).separation <= 0;
|
||||
}
|
||||
|
||||
/** How wide the piece is along the unit direction `n`. */
|
||||
export function roundedExtent(piece: Rounded, n: Vec2): number {
|
||||
const [min, max] = span(piece.points, n);
|
||||
return max - min + 2 * piece.r;
|
||||
}
|
||||
|
||||
export function translated(piece: Rounded, by: Vec2): Rounded {
|
||||
return { points: piece.points.map((p) => ({ x: p.x + by.x, y: p.y + by.y })), r: piece.r };
|
||||
}
|
||||
|
||||
/**
|
||||
* How far `mover` has to travel along the unit direction `d` until it clears `obstacle`; 0 when
|
||||
* they are apart. Along a line the overlap of two convex pieces is one stretch, so its end is
|
||||
* found by bisection.
|
||||
*/
|
||||
export function roundedExit(mover: Rounded, obstacle: Rounded, d: Vec2): number {
|
||||
const inside = (t: number) =>
|
||||
roundedContact(translated(mover, { x: d.x * t, y: d.y * t }), obstacle, d).separation < 0;
|
||||
if (!inside(0)) return 0;
|
||||
let hi = 1;
|
||||
for (let i = 0; i < 64 && inside(hi); i++) hi *= 2;
|
||||
let lo = 0;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (inside(mid)) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return hi;
|
||||
}
|
||||
Reference in new issue
Block a user