M2: cutting — honest 2D polygon split, materials, splat

- sim/geom.ts: pure convex-polygon split by a line + pieceCV/cvWord ladder.
- sim/food.ts: convex polygon foods (bread/tomato/cheese/katsu/egg) with
  toastsim material params (skin.resistance, flesh.moisture, wobble).
- sim/cutting.ts: blade pass-through detection -> split food into 2 real Matter
  bodies along the chord; slow press on a wet food squashes into a splat decal
  instead. Cut quality clean/ragged/crime by peak speed x chord fraction.
- Two traps fixed: (1) cut line through a vertex made lopsided halves -> split
  now shares on-line verts (regression in geom.check.ts); (2) Matter setVelocity
  is px/STEP not px/s -> piece launch was 60x too fast, flung halves to y=26000.
- Exit-bar: chop -> 2 clean pieces CV 0 (<0.1); push -> 0 pieces + splat 3.28;
  wild swing -> piece offstage. Verified in-browser + npm run check (headless).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 20:38:27 +10:00
parent da840b531a
commit ea2c8c5089
9 changed files with 502 additions and 4 deletions

View File

@ -17,8 +17,20 @@
- Assets: all 20 2D props rendered on MODELBEAST → `public/assets/img/` (samurai×5, - Assets: all 20 2D props rendered on MODELBEAST → `public/assets/img/` (samurai×5,
bg_washi, floor_boards, title, tickets, slash, 5 food faces, splat). Not wired in bg_washi, floor_boards, title, tickets, slash, 5 food faces, splat). Not wired in
yet — procedural silhouettes carry gameplay; art is backdrop/faces/juice (M4/M6). yet — procedural silhouettes carry gameplay; art is backdrop/faces/juice (M4/M6).
- **Next: M2** cutting (`sim/food.ts` + the cleaver + `cutStats`) — the clean chop. - **M2 ✅** cutting. `sim/geom.ts` (pure convex-split + pieceCV/cvWord), `sim/food.ts`
- Run: `npm run dev` (:5173). Harness: `__s.dangle() __s.walk() __s.pickupTest()`. (convex polygon foods: bread/tomato/cheese/katsu/egg, material params
resistance/moisture/wobble), `sim/cutting.ts` (blade pass-through → split food
into 2 real bodies along the chord, or squash→splat on a slow wet press).
Honest geometry, no canned slice anim. Traps hit + fixed: (a) cut line through a
vertex made lopsided halves — split now treats on-line verts as shared (regression
guarded in `geom.check.ts`); (b) **Matter `setVelocity` is px/STEP not px/s**
piece launch was 60× too fast and flung halves offstage. Exit-bar measured:
**chop** 2 clean pieces CV 0 (<0.1 req); **push** 0 pieces + splat 3.28 (wet crime);
**wild** swing flings a piece offstage. Verified in-browser (two clean halves rest
on the board) + `npm run check` (headless geom asserts).
- **Next: M3** stacking (`sim/stack.ts`) — the sando: layers, lean°, COM drift, nudge test.
- Run: `npm run dev` (:5173). Harness: `__s.dangle() __s.walk() __s.pickupTest()
__s.chop() __s.push() __s.wildChop()`. Headless: `npm run check`.

View File

@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"check": "node src/sim/geom.check.ts"
}, },
"dependencies": { "dependencies": {
"phaser": "^3.90.0" "phaser": "^3.90.0"

View File

@ -1,5 +1,6 @@
import type { Stage } from './scenes/stage'; import type { Stage } from './scenes/stage';
import { FLOOR_Y } from './scenes/stage'; import { FLOOR_Y } from './scenes/stage';
import { spawnFood } from './sim/food';
/** /**
* Dev harness toastsim's single most valuable organ, transplanted. * Dev harness toastsim's single most valuable organ, transplanted.
@ -148,6 +149,58 @@ export function installHarness(stage: Stage): void {
} }
return { ok, n, rate: `${ok}/${n}`, lifts }; return { ok, n, rate: `${ok}/${n}`, lifts };
}, },
// ---- M2: cutting -------------------------------------------------------
cutStats: () => stage.cutter.stats(),
/** Clear the board and drop a fresh food, settled on it. */
spawnFood(kind = 'tomato', x = 760) {
stage.auto = false;
for (const f of [...stage.cutter.foods]) stage.removeProp(f.body);
stage.cutter.foods = [];
stage.cutter.splats = [];
stage.cutter.last = null;
stage.cutter.add(spawnFood(stage, kind, x, 512));
for (let i = 0; i < 45; i++) stage.stepSim();
return stage.cutter.stats();
},
/** Drive the blade tip [x,y]→[x,y] over `secs`, then settle. Returns cutStats. */
swing(from: [number, number], to: [number, number], secs = 0.18, settleAfter = 30) {
stage.auto = false;
stage.cutter.resetBlade({ x: from[0], y: from[1] });
const frames = Math.max(2, Math.round(secs * 60));
for (let i = 1; i <= frames; i++) {
const t = i / frames;
stage.cutter.moveBlade({ x: from[0] + (to[0] - from[0]) * t, y: from[1] + (to[1] - from[1]) * t });
stage.stepSim();
}
for (let i = 0; i < settleAfter; i++) stage.stepSim();
return stage.cutter.stats();
},
/** M2 exit-bar A: a practiced fast chop through the centre → 2 clean pieces, CV < 0.1. */
chop(kind = 'tomato') {
this.spawnFood(kind);
const c = stage.cutter.foods[0].body.position;
return this.swing([c.x, c.y - 70], [c.x, c.y + 80], 0.18);
},
/** M2 exit-bar B: a slow press → no cut, a wet splat. */
push(kind = 'tomato') {
this.spawnFood(kind);
const c = stage.cutter.foods[0].body.position;
return this.swing([c.x, c.y - 70], [c.x, c.y + 80], 1.5);
},
/** M2 exit-bar C: a wild fast swing → a piece flung offstage (and that's CORRECT). */
wildChop(kind = 'tomato') {
this.spawnFood(kind);
const c = stage.cutter.foods[0].body.position;
const r = this.swing([c.x - 110, c.y], [c.x + 320, c.y], 0.08, 55);
const offstage = stage.cutter.foods.some((f) => f.body.position.x < 20 || f.body.position.x > 1260 || f.body.position.y > 705);
return { ...r, offstage, at: stage.cutter.foods.map((f) => ({ x: +f.body.position.x.toFixed(0), y: +f.body.position.y.toFixed(0) })) };
},
}; };
(window as { __s?: typeof s }).__s = s; (window as { __s?: typeof s }).__s = s;

View File

@ -1,6 +1,8 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { installHarness } from '../dev'; import { installHarness } from '../dev';
import { Marionette } from '../sim/marionette'; import { Marionette } from '../sim/marionette';
import { Cutter } from '../sim/cutting';
import { spawnFood } from '../sim/food';
export const STAGE_W = 1280; export const STAGE_W = 1280;
export const STAGE_H = 720; export const STAGE_H = 720;
@ -23,7 +25,9 @@ export class Stage extends Phaser.Scene {
auto = true; auto = true;
props: Prop[] = []; props: Prop[] = [];
puppet!: Marionette; puppet!: Marionette;
cutter!: Cutter;
crate!: MatterJS.BodyType; crate!: MatterJS.BodyType;
board!: MatterJS.BodyType;
private space!: Phaser.Input.Keyboard.Key; private space!: Phaser.Input.Keyboard.Key;
constructor() { constructor() {
@ -45,6 +49,12 @@ export class Stage extends Phaser.Scene {
this.addBody(this.crate, 48, 48, 0x3a2d20); this.addBody(this.crate, 48, 48, 0x3a2d20);
this.puppet.gripTargets.push(this.crate); this.puppet.gripTargets.push(this.crate);
// cutting board (a static counter) + a food resting on it for M2.
this.board = this.matter.add.rectangle(760, 560, 240, 24, { isStatic: true });
this.addBody(this.board, 240, 24, 0x2a211a);
this.cutter = new Cutter(this);
this.cutter.add(spawnFood(this, 'tomato', 760, 512));
this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE); this.space = this.input.keyboard!.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
installHarness(this); installHarness(this);
} }
@ -63,6 +73,30 @@ export class Stage extends Phaser.Scene {
return body; return body;
} }
/** Register a body with a polygon silhouette (food, food pieces).
* localVerts are centred on the body origin. */
addPoly(body: MatterJS.BodyType, localVerts: { x: number; y: number }[], color: number): MatterJS.BodyType {
const view = this.add.polygon(body.position.x, body.position.y, localVerts, color);
this.props.push({ body, view });
return body;
}
/** A wet-food splat decal — a dark blob left on the stage (no body). */
splat(x: number, y: number, r: number, color: number): void {
const blob = this.add.ellipse(x, y, r * 2.2, r * 1.3, color, 0.85);
blob.setDepth(-1);
}
/** Destroy a body's silhouette and remove it from the world. */
removeProp(body: MatterJS.BodyType): void {
const i = this.props.findIndex((p) => p.body === body);
if (i >= 0) {
this.props[i].view.destroy();
this.props.splice(i, 1);
}
this.matter.world.remove(body);
}
stepSim(dt = 1000 / 60): void { stepSim(dt = 1000 / 60): void {
this.matter.world.step(dt); this.matter.world.step(dt);
this.puppet?.update(); this.puppet?.update();

163
src/sim/cutting.ts Normal file
View File

@ -0,0 +1,163 @@
import type { Stage } from '../scenes/stage';
import { Food, makeFood } from './food';
import {
add, centroid, cvWord, len, norm, perp, pieceCV, pointInPoly, polyArea, polyDiameter,
scale, segPolyCrossings, splitConvexByLine, sub, type V,
} from './geom';
export type CutQuality = 'clean' | 'ragged' | 'crime';
// cut thresholds, px/frame at 60fps (the calibration surface — tuned by harness)
const CUT = {
clean: 11, // peak blade speed for a clean pass
squash: 4.5, // below this a wet food splats instead of cutting
minChordFrac: 0.45, // the pass must span this much of the food to count as a cut
cleanChordFrac: 0.6, // and this much to be graded clean
bladeTransfer: 0.42, // fraction of blade speed the pieces carry off (px/step)
separate: 1.4, // px/step the halves spring apart (Matter velocity is per-step, not per-sec)
gap: 4, // px each half is nudged off the cut line so they don't spawn overlapping
};
const worldVerts = (b: MatterJS.BodyType): V[] => (b.vertices ?? []).map((v) => ({ x: v.x, y: v.y }));
interface Track {
entry: V;
pathLen: number;
frames: number;
peakSpeed: number;
}
export interface CutResult {
quality: CutQuality;
pieces: number;
cv: number;
speed: number;
chordFrac: number;
}
/**
* The cut. A blade tip is driven across the board (by the harness now, by the
* gripped hand at M4). When the tip passes THROUGH a food fast enough the food
* splits along the tip's chord into two real bodies; a slow press on a wet food
* squashes into a splat instead. Honest geometry no canned "sliced" anim.
*/
export class Cutter {
foods: Food[] = [];
splats: { mass: number }[] = [];
last: CutResult | null = null;
private tip: V | null = null;
private track = new Map<Food, Track>();
constructor(private stage: Stage) {}
add(food: Food): void {
this.foods.push(food);
}
/** Start a fresh stroke from `p` (clears any in-progress pass). */
resetBlade(p: V): void {
this.tip = p;
this.track.clear();
}
/** Advance the blade tip to `p` for one frame and run cut detection. */
moveBlade(p: V): void {
const prev = this.tip ?? p;
this.tip = p;
const stepLen = len(sub(p, prev));
for (const food of [...this.foods]) {
const verts = worldVerts(food.body);
const inNow = pointInPoly(p, verts);
const st = this.track.get(food);
if (!st) {
const crossings = segPolyCrossings(prev, p, verts);
if (inNow || crossings.length > 0) {
this.track.set(food, { entry: crossings[0] ?? prev, pathLen: stepLen, frames: 1, peakSpeed: stepLen });
}
} else {
st.pathLen += stepLen;
st.frames++;
st.peakSpeed = Math.max(st.peakSpeed, stepLen);
if (!inNow) {
const crossings = segPolyCrossings(prev, p, verts);
const exit = crossings.length ? crossings[crossings.length - 1] : prev;
this.commit(food, st.entry, exit, st.peakSpeed);
this.track.delete(food);
}
}
}
}
private commit(food: Food, entry: V, exit: V, peakSpeed: number): void {
const verts = worldVerts(food.body);
const diam = polyDiameter(verts) || 1;
const chordFrac = len(sub(exit, entry)) / diam;
const mat = food.material;
const power = peakSpeed * (1 - 0.5 * mat.resistance); // tougher skin, harder to cut
// too slow or too shallow → not a clean split
if (power < CUT.squash || chordFrac < CUT.minChordFrac) {
if (mat.moisture > 0.4 && power < CUT.squash) {
this.splat(food, peakSpeed);
} else {
this.last = { quality: 'crime', pieces: 1, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: +chordFrac.toFixed(2) };
}
return;
}
const parts = splitConvexByLine(verts, entry, exit);
if (!parts) return;
const quality: CutQuality = power >= CUT.clean && chordFrac >= CUT.cleanChordFrac ? 'clean' : 'ragged';
this.stage.removeProp(food.body);
this.foods = this.foods.filter((f) => f !== food);
const dir = norm(sub(exit, entry));
const n = perp(dir); // separation normal (splitConvexByLine returns [left, right] of dir)
const bladeVel = scale(dir, peakSpeed * CUT.bladeTransfer); // peakSpeed is px/step already
parts.forEach((pv, i) => {
const c = centroid(pv);
const side = i === 0 ? 1 : -1;
// spawn each half nudged off the cut line so the two bodies never overlap
// at birth — coincident bodies make Matter eject them across the county.
const pos = add(c, scale(n, side * CUT.gap));
const piece = makeFood(this.stage, pv, pos.x, pos.y, mat, food.color, food.face, food.kind + 'p');
this.stage.matter.body.setVelocity(piece.body, add(bladeVel, scale(n, side * CUT.separate)));
this.foods.push(piece);
});
const areas = parts.map(polyArea);
this.last = {
quality,
pieces: 2,
cv: +pieceCV(areas).toFixed(3),
speed: +peakSpeed.toFixed(2),
chordFrac: +chordFrac.toFixed(2),
};
}
private splat(food: Food, peakSpeed: number): void {
const verts = worldVerts(food.body);
const c = centroid(verts);
const mass = +((polyArea(verts) / 900) * food.material.moisture).toFixed(2);
this.splats.push({ mass });
this.stage.removeProp(food.body);
this.foods = this.foods.filter((f) => f !== food);
this.stage.splat(c.x, c.y, Math.sqrt(polyArea(verts) / Math.PI) * 1.3, food.color);
this.last = { quality: 'crime', pieces: 0, cv: 0, speed: +peakSpeed.toFixed(2), chordFrac: 0 };
// ponytail: slippery-floor patch deferred — no one carries over it until M4.
}
/** Everything the judge and the harness read off a cut. */
stats() {
const areas = this.foods.map((f) => polyArea(worldVerts(f.body)));
const cv = pieceCV(areas);
return {
pieces: this.foods.length,
cv: +cv.toFixed(3),
cvWord: cvWord(cv),
splatMass: +this.splats.reduce((a, b) => a + b.mass, 0).toFixed(2),
last: this.last,
};
}
}

75
src/sim/food.ts Normal file
View File

@ -0,0 +1,75 @@
import type { Stage } from '../scenes/stage';
import { centroid, type V } from './geom';
/** toastsim-style material params. resistance raises the cut threshold; moisture
* drives the splat; wobble is jelly-class jiggle (soft, deferred to when a
* jelly food actually ships). */
export interface Material {
resistance: number; // 0..1 skin toughness
moisture: number; // 0..1 wet food splats
wobble: number; // 0..1 jiggle
}
export interface FoodDef {
verts: V[]; // convex, centred on origin
material: Material;
color: number;
face: string; // generated face sprite id (rendered later; gameplay is the silhouette)
}
const circle = (r: number, n: number, squashY = 1): V[] =>
Array.from({ length: n }, (_, i) => {
const a = (i / n) * Math.PI * 2;
return { x: Math.cos(a) * r, y: Math.sin(a) * r * squashY };
});
const box = (w: number, h: number): V[] => {
const x = w / 2, y = h / 2, c = Math.min(x, y) * 0.4; // chamfered corners → convex octagon
return [
{ x: -x + c, y: -y }, { x: x - c, y: -y }, { x, y: -y + c }, { x, y: y - c },
{ x: x - c, y }, { x: -x + c, y }, { x: -x, y: y - c }, { x: -x, y: -y + c },
];
};
export const FOODS: Record<string, FoodDef> = {
tomato: { verts: circle(34, 12), material: { resistance: 0.25, moisture: 0.85, wobble: 0.15 }, color: 0x151515, face: 'face_tomato' },
bread: { verts: box(78, 52), material: { resistance: 0.35, moisture: 0.05, wobble: 0.0 }, color: 0x1c150e, face: 'face_bread' },
cheese: { verts: box(60, 44), material: { resistance: 0.45, moisture: 0.15, wobble: 0.0 }, color: 0x191510, face: 'face_cheese' },
katsu: { verts: box(86, 46), material: { resistance: 0.55, moisture: 0.1, wobble: 0.0 }, color: 0x171008, face: 'face_katsu' },
egg: { verts: circle(28, 10, 1.15), material: { resistance: 0.3, moisture: 0.4, wobble: 0.1 }, color: 0x121212, face: 'face_egg' },
};
let NEXT = 1;
/** A food body + its material and origin colour. Pieces are Foods too. */
export class Food {
readonly id: string;
constructor(
readonly body: MatterJS.BodyType,
readonly material: Material,
readonly color: number,
readonly face: string,
readonly kind: string,
) {
this.id = `${kind}${NEXT++}`;
}
}
/** Build a food body from centred local verts placed at world (x,y). Registers
* the silhouette on the stage. Shared by spawn and by the cutter's pieces. */
export function makeFood(stage: Stage, local: V[], x: number, y: number, mat: Material, color: number, face: string, kind: string): Food {
const body = stage.matter.add.fromVertices(x, y, local, {
friction: 0.6, frictionAir: 0.01, density: 0.0009, restitution: 0.05,
}) as MatterJS.BodyType;
// fromVertices recentres COM; render verts relative to that centre.
const c = centroid(local);
const rel = local.map((v) => ({ x: v.x - c.x, y: v.y - c.y }));
stage.addPoly(body, rel, color);
return new Food(body, mat, color, face, kind);
}
/** Spawn a whole food of `kind` at x, resting just above the floor. */
export function spawnFood(stage: Stage, kind: string, x: number, y: number): Food {
const def = FOODS[kind] ?? FOODS.tomato;
return makeFood(stage, def.verts, x, y, def.material, def.color, def.face, kind);
}

35
src/sim/geom.check.ts Normal file
View File

@ -0,0 +1,35 @@
/** Runnable self-check for the cut geometry the honest core, and the part
* that hid a degenerate-vertex bug (a cut line through two verts made lopsided
* halves). Run: `node src/sim/geom.check.ts` (Node strips the types). */
import { polyArea, pieceCV, cvWord, splitConvexByLine, type V } from './geom.ts';
import assert from 'node:assert/strict';
const circle = (r: number, n: number): V[] =>
Array.from({ length: n }, (_, i) => ({ x: Math.cos((i / n) * 2 * Math.PI) * r, y: Math.sin((i / n) * 2 * Math.PI) * r }));
// 1. centred vertical cut of a symmetric polygon → equal halves
{
const c = circle(34, 12);
const [L, R] = splitConvexByLine(c, { x: 0, y: -50 }, { x: 0, y: 50 })!;
assert(Math.abs(polyArea(L) - polyArea(R)) < 1e-6, 'off-vertex split should be symmetric');
}
// 2. THE REGRESSION: cut line passing exactly through two vertices (top+bottom
// of a 12-gon at (0,±r)) must still give equal halves, not lopsided ones.
{
const c = circle(34, 12);
const [L, R] = splitConvexByLine(c, { x: 0, y: -34 }, { x: 0, y: 34 })!;
assert(Math.abs(polyArea(L) - polyArea(R)) < 1e-6, 'through-vertex split must be symmetric');
}
// 3. a line that misses the polygon returns null
{
assert(splitConvexByLine(circle(10, 8), { x: 100, y: -10 }, { x: 100, y: 10 }) === null, 'miss → null');
}
// 4. pieceCV: equal pieces → 0; the ladder words line up
assert(pieceCV([100, 100]) === 0, 'equal areas → CV 0');
assert(pieceCV([50, 150]) > 0.4, 'lopsided pieces → high CV');
assert(cvWord(0) === 'even' && cvWord(0.5) === 'a lottery', 'cvWord ladder');
console.log('geom.check: all assertions passed');

124
src/sim/geom.ts Normal file
View File

@ -0,0 +1,124 @@
/** 2D polygon helpers for the cut. Pure math, no engine the honest core of
* the cutting sim (a convex food polygon split by the blade's line). */
export interface V {
x: number;
y: number;
}
export const sub = (a: V, b: V): V => ({ x: a.x - b.x, y: a.y - b.y });
export const add = (a: V, b: V): V => ({ x: a.x + b.x, y: a.y + b.y });
export const cross = (a: V, b: V): number => a.x * b.y - a.y * b.x;
export const len = (a: V): number => Math.hypot(a.x, a.y);
export const scale = (a: V, s: number): V => ({ x: a.x * s, y: a.y * s });
export const norm = (a: V): V => {
const l = len(a) || 1;
return { x: a.x / l, y: a.y / l };
};
export const perp = (a: V): V => ({ x: -a.y, y: a.x });
/** Shoelace area (always positive). */
export function polyArea(vs: V[]): number {
let a = 0;
for (let i = 0; i < vs.length; i++) {
const j = (i + 1) % vs.length;
a += cross(vs[i], vs[j]);
}
return Math.abs(a) / 2;
}
/** Area-weighted centroid. */
export function centroid(vs: V[]): V {
let a = 0, cx = 0, cy = 0;
for (let i = 0; i < vs.length; i++) {
const j = (i + 1) % vs.length;
const c = cross(vs[i], vs[j]);
a += c;
cx += (vs[i].x + vs[j].x) * c;
cy += (vs[i].y + vs[j].y) * c;
}
a *= 0.5;
if (Math.abs(a) < 1e-6) return { ...vs[0] };
return { x: cx / (6 * a), y: cy / (6 * a) };
}
/** Coefficient of variation of piece areas toastsim's pieceCV, straight port.
* 0 = identical pieces, higher = a lottery. */
export function pieceCV(areas: number[]): number {
if (areas.length < 2) return 0;
const mean = areas.reduce((a, b) => a + b, 0) / areas.length;
if (mean <= 0) return 0;
const v = areas.reduce((a, b) => a + (b - mean) ** 2, 0) / areas.length;
return Math.sqrt(v) / mean;
}
export function cvWord(cv: number): string {
return cv < 0.1 ? 'even' : cv < 0.22 ? 'respectable' : cv < 0.4 ? 'uneven' : 'a lottery';
}
export function pointInPoly(p: V, vs: V[]): boolean {
let inside = false;
for (let i = 0, j = vs.length - 1; i < vs.length; j = i++) {
const a = vs[i], b = vs[j];
if ((a.y > p.y) !== (b.y > p.y) && p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y) + a.x) {
inside = !inside;
}
}
return inside;
}
/** Longest span across the polygon (diameter). */
export function polyDiameter(vs: V[]): number {
let d = 0;
for (let i = 0; i < vs.length; i++)
for (let j = i + 1; j < vs.length; j++) d = Math.max(d, len(sub(vs[i], vs[j])));
return d;
}
/** Where segment a→b crosses the polygon boundary, sorted along a→b. */
export function segPolyCrossings(a: V, b: V, vs: V[]): V[] {
const dir = sub(b, a);
const hits: { p: V; t: number }[] = [];
for (let i = 0; i < vs.length; i++) {
const c = vs[i], d = vs[(i + 1) % vs.length];
const e = sub(d, c);
const denom = cross(dir, e);
if (Math.abs(denom) < 1e-9) continue;
const t = cross(sub(c, a), e) / denom; // along a→b
const u = cross(sub(c, a), dir) / denom; // along c→d
if (t >= -1e-6 && t <= 1 + 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) {
hits.push({ p: { x: a.x + dir.x * t, y: a.y + dir.y * t }, t });
}
}
hits.sort((x, y) => x.t - y.t);
return hits.map((h) => h.p);
}
/**
* Split a convex polygon by the infinite line through A and B. Returns the two
* halves (each 3 verts), or null if the line misses the polygon.
*/
export function splitConvexByLine(vs: V[], A: V, B: V): [V[], V[]] | null {
const dir = sub(B, A);
const EPS = 1e-6;
const sgn = (p: V): number => {
const s = cross(dir, sub(p, A));
return s > EPS ? 1 : s < -EPS ? -1 : 0; // 0 = on the cut line → shared by both
};
const left: V[] = [], right: V[] = [];
for (let i = 0; i < vs.length; i++) {
const cur = vs[i], nxt = vs[(i + 1) % vs.length];
const sc = sgn(cur), sn = sgn(nxt);
if (sc >= 0) left.push(cur);
if (sc <= 0) right.push(cur);
if (sc * sn < 0) {
// strictly opposite sides: split the edge at the crossing, add to both
const e = sub(nxt, cur);
const t = cross(sub(A, cur), dir) / cross(e, dir);
const p = { x: cur.x + e.x * t, y: cur.y + e.y * t };
left.push(p);
right.push(p);
}
}
if (left.length < 3 || right.length < 3) return null;
return [left, right];
}

View File

@ -12,5 +12,6 @@
"types": [], "types": [],
"lib": ["ES2022", "DOM", "DOM.Iterable"] "lib": ["ES2022", "DOM", "DOM.Iterable"]
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.check.ts"]
} }