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:
parent
da840b531a
commit
ea2c8c5089
@ -17,8 +17,20 @@
|
||||
- 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
|
||||
yet — procedural silhouettes carry gameplay; art is backdrop/faces/juice (M4/M6).
|
||||
- **Next: M2** cutting (`sim/food.ts` + the cleaver + `cutStats`) — the clean chop.
|
||||
- Run: `npm run dev` (:5173). Harness: `__s.dangle() __s.walk() __s.pickupTest()`.
|
||||
- **M2 ✅** cutting. `sim/geom.ts` (pure convex-split + pieceCV/cvWord), `sim/food.ts`
|
||||
(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`.
|
||||
|
||||
|
||||
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "node src/sim/geom.check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"phaser": "^3.90.0"
|
||||
|
||||
53
src/dev.ts
53
src/dev.ts
@ -1,5 +1,6 @@
|
||||
import type { Stage } from './scenes/stage';
|
||||
import { FLOOR_Y } from './scenes/stage';
|
||||
import { spawnFood } from './sim/food';
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
},
|
||||
|
||||
// ---- 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;
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import Phaser from 'phaser';
|
||||
import { installHarness } from '../dev';
|
||||
import { Marionette } from '../sim/marionette';
|
||||
import { Cutter } from '../sim/cutting';
|
||||
import { spawnFood } from '../sim/food';
|
||||
|
||||
export const STAGE_W = 1280;
|
||||
export const STAGE_H = 720;
|
||||
@ -23,7 +25,9 @@ export class Stage extends Phaser.Scene {
|
||||
auto = true;
|
||||
props: Prop[] = [];
|
||||
puppet!: Marionette;
|
||||
cutter!: Cutter;
|
||||
crate!: MatterJS.BodyType;
|
||||
board!: MatterJS.BodyType;
|
||||
private space!: Phaser.Input.Keyboard.Key;
|
||||
|
||||
constructor() {
|
||||
@ -45,6 +49,12 @@ export class Stage extends Phaser.Scene {
|
||||
this.addBody(this.crate, 48, 48, 0x3a2d20);
|
||||
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);
|
||||
installHarness(this);
|
||||
}
|
||||
@ -63,6 +73,30 @@ export class Stage extends Phaser.Scene {
|
||||
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 {
|
||||
this.matter.world.step(dt);
|
||||
this.puppet?.update();
|
||||
|
||||
163
src/sim/cutting.ts
Normal file
163
src/sim/cutting.ts
Normal 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
75
src/sim/food.ts
Normal 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
35
src/sim/geom.check.ts
Normal 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
124
src/sim/geom.ts
Normal 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];
|
||||
}
|
||||
@ -12,5 +12,6 @@
|
||||
"types": [],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.check.ts"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user