diff --git a/OPUS-BUILD-BRIEF.md b/OPUS-BUILD-BRIEF.md index 44431c8..d3a3754 100644 --- a/OPUS-BUILD-BRIEF.md +++ b/OPUS-BUILD-BRIEF.md @@ -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`. diff --git a/package.json b/package.json index 02e3d12..bfff145 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/dev.ts b/src/dev.ts index 670483f..d778dbe 100644 --- a/src/dev.ts +++ b/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; diff --git a/src/scenes/stage.ts b/src/scenes/stage.ts index 509b4c3..4c63f95 100644 --- a/src/scenes/stage.ts +++ b/src/scenes/stage.ts @@ -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(); diff --git a/src/sim/cutting.ts b/src/sim/cutting.ts new file mode 100644 index 0000000..a9b5955 --- /dev/null +++ b/src/sim/cutting.ts @@ -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(); + + 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, + }; + } +} diff --git a/src/sim/food.ts b/src/sim/food.ts new file mode 100644 index 0000000..65ca6d4 --- /dev/null +++ b/src/sim/food.ts @@ -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 = { + 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); +} diff --git a/src/sim/geom.check.ts b/src/sim/geom.check.ts new file mode 100644 index 0000000..8865a92 --- /dev/null +++ b/src/sim/geom.check.ts @@ -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'); diff --git a/src/sim/geom.ts b/src/sim/geom.ts new file mode 100644 index 0000000..805b4de --- /dev/null +++ b/src/sim/geom.ts @@ -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]; +} diff --git a/tsconfig.json b/tsconfig.json index a13e03c..1148ebc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,6 @@ "types": [], "lib": ["ES2022", "DOM", "DOM.Iterable"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.check.ts"] }