Recurring cleanup round over the code since round 6 (056a622), 105
commits. Behaviour is unchanged. Duplicated code in src went from 29
clones with 229 lines (0.32 %) to 14 clones with 109 lines (0.15 %),
measured with jscpd and the same settings as round 6.
- BehaviorSystem.ts shrinks from 941 to 589 lines. Collecting the
pulses (keys, timers, collisions, animations) moved to
behaviorPulses.ts. The NodeContext the nodes receive moved to
behaviorContext.ts. Imports from outside stay the same, TimeScaled
is still exported from BehaviorSystem.ts.
- PointKindButton and drawPointGrip replace the "Point:" button with
its menu and the point and handle drawing that the curve and the
vector art tab each had on their own.
- lerpVec, cubicAt and splitCubic in src/core/util/vec.ts replace four
copies of the cubic curve and two de Casteljau splits.
- mapNested in Properties.ts is the one walk over lists and structs
that liveObjects and objectRefs did separately. withFieldRenamed
and withoutField replace three copies of renaming a struct field
and two of removing one.
- jsonBody and staleRevision build the JSON body and turn a 409 into
StaleRevisionError for all stores. The auth store turns a 401 into
its error in one place.
- Smaller helpers: structAssetFrom for the two "Make struct type"
commands, shownObject in the inspector, selectedItem for the size
gizmo and the polygon corners, solidTiles in the tilemap collision,
requireVariablesAsset and requireBehaviorVariable.
- Left as is on purpose: the plain forwarding from the collision and
physics systems to Solids, the pointer wiring of the canvases and
the identical last lines of the two make commands.
- Removed unused code: tilemapTileIds and tilemapsUsing were never
called since #128 and #131. EditorState.runtimeProject became unused
with GameRun in #266. The assetId getter of the make commands was
read only by a test.
- 60 values used only in their own file lost their export. The shader
and particle node catalogues stay exported as in round 6.
- Five comments that told history now describe the current state. The
doc comment of BehaviorSystem sits on the class again.
- README lists the struct, clip area and curve assets and mentions the
game link from #266.
Findings:
- Deleting a tile that is painted on a tilemap leaves its cells in
place, and they draw nothing. The delete dialog does not mention the
tilemaps. That was probably the job of the unused tilemapsUsing.
Left as is, because fixing it changes behaviour.
- The solid filter for tile surfaces was untested. A bent filter
stayed green. A new test in tests/core/surface.test.ts now fails
against it.
Tests:
- ./check.sh: 195 files, 2815 tests, exit 0 (one test removed with
tilemapsUsing, one added for tile surfaces).
- Full e2e run: ALL PASSED, 1580 checks.
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks, all passed.
The example project files were restored byte for byte afterwards.
- Counter-checks, 12, all fail as expected: 10 in unit tests (among
them mapNested, splitCubic, cubicAt, staleRevision, the stay pulse,
the late timer and the time scale), 2 in the browser (Point button
always enabled fails 47, selectedItem without a hit fails 29).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
8.4 KiB
TypeScript
211 lines
8.4 KiB
TypeScript
/**
|
||
* Keeping bezier curves through a boolean operation (#208). Clipping itself only knows straight
|
||
* pieces, so a curve has to be cut into them – and on its own the result would be corners only, even
|
||
* where nothing crossed the curve at all.
|
||
*
|
||
* The way back is to remember where every sampled point came from: which cubic, and at which `t` on
|
||
* it. A run of result points that walks one cubic in one direction is then that cubic again, cut at
|
||
* both ends with de Casteljau – the piece is exact, not fitted. A point the clipper made up (an
|
||
* intersection) has no memory, so its `t` is read off the chord it landed on; the point itself keeps
|
||
* the position the clipper gave it, which is why no gap can open at a cut.
|
||
*/
|
||
import type { VectorPoint } from './Asset';
|
||
import type { Vec2 } from './Properties';
|
||
import { cubicAt, splitCubic } from '../util/vec';
|
||
|
||
/** How far a sampled curve may stray from the true one, in canvas pixels. */
|
||
export const FLATTEN_TOLERANCE = 0.2;
|
||
|
||
/** One cubic of the input, with the samples it was cut into. */
|
||
interface CurveSegment {
|
||
p0: Vec2;
|
||
p1: Vec2;
|
||
p2: Vec2;
|
||
p3: Vec2;
|
||
samples: { t: number; x: number; y: number }[];
|
||
}
|
||
|
||
/** Where a result point sits on an input curve. */
|
||
interface PointOnCurve {
|
||
seg: number;
|
||
t: number;
|
||
}
|
||
|
||
/** One edge of the result, and the piece of a cubic it runs along. */
|
||
interface CurveEdge {
|
||
seg: number;
|
||
from: number;
|
||
to: number;
|
||
}
|
||
|
||
/** What the flattening remembered and the rebuilding reads back. */
|
||
export interface CurveTable {
|
||
segments: CurveSegment[];
|
||
at: Map<string, PointOnCurve[]>;
|
||
}
|
||
|
||
/**
|
||
* How far the clipper may have moved a point and it still counts as sitting on a chord. It rounds to
|
||
* a hundredth of a pixel, so half of that is the whole error of a point it kept; the rest is room
|
||
* for one it made up.
|
||
*/
|
||
const ON_CHORD = 0.02;
|
||
/** How far the curve between two result points may bulge out of their edge and they still neighbour. */
|
||
const CHORD_SLACK = 1.5 * FLATTEN_TOLERANCE;
|
||
|
||
export function newCurveTable(): CurveTable {
|
||
return { segments: [], at: new Map() };
|
||
}
|
||
|
||
/** Opens a cubic in the table and returns its index; its samples are added as the curve is walked. */
|
||
export function addCurve(table: CurveTable, p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2): number {
|
||
table.segments.push({ p0, p1, p2, p3, samples: [] });
|
||
return table.segments.length - 1;
|
||
}
|
||
|
||
/** Notes that this point is the curve `seg` at `t`. */
|
||
export function addSample(table: CurveTable, point: Vec2, seg: number, t: number): void {
|
||
const segment = table.segments[seg];
|
||
if (!segment) return;
|
||
segment.samples.push({ t, x: point.x, y: point.y });
|
||
const key = pointKey(point);
|
||
const list = table.at.get(key);
|
||
if (list) list.push({ seg, t });
|
||
else table.at.set(key, [{ seg, t }]);
|
||
}
|
||
|
||
/** The same rounding the clipper does, so a point it handed back finds its own sample again. */
|
||
function pointKey(point: Vec2): string {
|
||
return `${Math.round(point.x * 100) / 100},${Math.round(point.y * 100) / 100}`;
|
||
}
|
||
|
||
/**
|
||
* The ring with its curves back: every run of points that walks one input cubic becomes that cubic
|
||
* again, everything else stays a corner. Never returns fewer than three points – a run is left open
|
||
* at one more point rather than letting a ring shrink out of being drawable.
|
||
*/
|
||
export function restoreCurves(points: readonly Vec2[], table: CurveTable): VectorPoint[] {
|
||
const n = points.length;
|
||
if (n < 3 || table.segments.length === 0) return points.map((p) => ({ x: p.x, y: p.y }));
|
||
const places = points.map((point) => placesOf(point, table));
|
||
const edges = points.map((_, i) => edgeOn(places[i]!, places[(i + 1) % n]!, table));
|
||
return build(points, edges, keptPoints(edges), table);
|
||
}
|
||
|
||
/** Every input curve this result point could be a point of. */
|
||
function placesOf(point: Vec2, table: CurveTable): PointOnCurve[] {
|
||
return table.at.get(pointKey(point)) ?? projectAll(point, table);
|
||
}
|
||
|
||
/**
|
||
* Where a point the clipper made up sits: on the chord between two neighbouring samples, so its `t`
|
||
* is read off that chord. An intersection of two curves lies on both, and both are returned.
|
||
*/
|
||
function projectAll(point: Vec2, table: CurveTable): PointOnCurve[] {
|
||
const found: PointOnCurve[] = [];
|
||
for (const [seg, segment] of table.segments.entries()) {
|
||
let best: PointOnCurve | null = null;
|
||
let bestGap = ON_CHORD;
|
||
for (let i = 1; i < segment.samples.length; i++) {
|
||
const a = segment.samples[i - 1]!;
|
||
const b = segment.samples[i]!;
|
||
const along = onChord(a, b, point);
|
||
if (!along || along.gap > bestGap) continue;
|
||
bestGap = along.gap;
|
||
best = { seg, t: a.t + (b.t - a.t) * along.u };
|
||
}
|
||
if (best) found.push(best);
|
||
}
|
||
return found;
|
||
}
|
||
|
||
/** How far along `a`→`b` the point sits and how far off that line it is; null when it is not between. */
|
||
function onChord(a: Vec2, b: Vec2, point: Vec2): { u: number; gap: number } | null {
|
||
const dx = b.x - a.x;
|
||
const dy = b.y - a.y;
|
||
const length = dx * dx + dy * dy;
|
||
if (length === 0) return null;
|
||
const u = ((point.x - a.x) * dx + (point.y - a.y) * dy) / length;
|
||
if (u < 0 || u > 1) return null;
|
||
return { u, gap: Math.hypot(a.x + dx * u - point.x, a.y + dy * u - point.y) };
|
||
}
|
||
|
||
/**
|
||
* The piece of a cubic this edge runs along, or null when the edge is a straight one. Two points of
|
||
* the same cubic only neighbour on it when the curve between them stays within the sampling
|
||
* tolerance of the edge – otherwise it is a chord the clipper cut clean across, and no curve belongs
|
||
* there. Of several candidates the shortest piece wins.
|
||
*/
|
||
function edgeOn(from: PointOnCurve[], to: PointOnCurve[], table: CurveTable): CurveEdge | null {
|
||
let best: CurveEdge | null = null;
|
||
for (const a of from)
|
||
for (const b of to) {
|
||
if (a.seg !== b.seg || a.t === b.t) continue;
|
||
if (best && Math.abs(b.t - a.t) >= Math.abs(best.to - best.from)) continue;
|
||
if (!staysOnCurve(table.segments[a.seg]!, a.t, b.t)) continue;
|
||
best = { seg: a.seg, from: a.t, to: b.t };
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/** Whether the piece of `segment` from `t0` to `t1` hugs the straight line between its two ends. */
|
||
function staysOnCurve(segment: CurveSegment, t0: number, t1: number): boolean {
|
||
const at = (t: number) => cubicAt(segment.p0, segment.p1, segment.p2, segment.p3, t);
|
||
const a = at(t0);
|
||
const b = at(t1);
|
||
for (let step = 1; step < 4; step++) {
|
||
const point = at(t0 + (t1 - t0) * (step / 4));
|
||
const along = onChord(a, b, point);
|
||
if (!along || along.gap > CHORD_SLACK) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Which points stay a point of the ring. One falls away when the edge into it and the edge out of it
|
||
* walk the same cubic the same way without a break – then it is a sample in the middle of a run.
|
||
*/
|
||
function keptPoints(edges: readonly (CurveEdge | null)[]): boolean[] {
|
||
const n = edges.length;
|
||
const keep = edges.map((_, i) => !continues(edges[(i - 1 + n) % n] ?? null, edges[i] ?? null));
|
||
for (let i = 0; keep.filter(Boolean).length < 3 && i < n; i++) keep[i] = true;
|
||
return keep;
|
||
}
|
||
|
||
function continues(into: CurveEdge | null, out: CurveEdge | null): boolean {
|
||
if (!into || !out || into.seg !== out.seg || into.to !== out.from) return false;
|
||
return into.to > into.from === out.to > out.from;
|
||
}
|
||
|
||
/** The ring as points again: one point per kept index, with the handles of the piece between two. */
|
||
function build(
|
||
points: readonly Vec2[],
|
||
edges: readonly (CurveEdge | null)[],
|
||
keep: readonly boolean[],
|
||
table: CurveTable,
|
||
): VectorPoint[] {
|
||
const n = points.length;
|
||
const kept = keep.flatMap((yes, i) => (yes ? [i] : []));
|
||
const out: VectorPoint[] = kept.map((i) => ({ x: points[i]!.x, y: points[i]!.y }));
|
||
for (const [j, start] of kept.entries()) {
|
||
const edge = edges[start];
|
||
if (!edge) continue;
|
||
const end = kept[(j + 1) % kept.length]!;
|
||
const last = edges[(end - 1 + n) % n]!;
|
||
const piece = subCurve(table.segments[edge.seg]!, edge.from, last.to);
|
||
const head = out[j]!;
|
||
const tail = out[(j + 1) % kept.length]!;
|
||
head.out = { x: piece[1]!.x - head.x, y: piece[1]!.y - head.y };
|
||
tail.in = { x: piece[2]!.x - tail.x, y: piece[2]!.y - tail.y };
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** The control points of the piece from `t0` to `t1`, walked in that direction. */
|
||
function subCurve(segment: CurveSegment, t0: number, t1: number): Vec2[] {
|
||
if (t0 > t1) return subCurve(segment, t1, t0).reverse();
|
||
const whole = [segment.p0, segment.p1, segment.p2, segment.p3];
|
||
const left = t1 >= 1 ? whole : splitCubic(whole, t1).left;
|
||
return t1 <= 0 ? left : splitCubic(left, t0 / t1).right;
|
||
}
|