nordic-skating / commit
feat(ice): cracks that look like cracks, in the ice and from the skater
src/ice/cracks.ts | 154 ++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 4 +- src/render/ice.ts | 49 +++++++++----- src/render/marks.ts | 189 ++++++++++++++++++++++++---------------------------- test/cracks.test.ts | 78 ++++++++++++++++++++++ 5 files changed, 355 insertions(+), 119 deletions(-) diff --git a/src/ice/cracks.ts b/src/ice/cracks.ts new file mode 100644 index 0000000..2f469cb --- /dev/null +++ b/src/ice/cracks.ts @@ -0,0 +1,154 @@ +/** + * How ice cracks, as far as how it looks goes. + * + * A sheet of black ice is crossed by a few long cracks that run nearly straight for tens + * of metres. Later cracks start from the side of an earlier one, run off at a wide angle + * to it, and stop when they meet another: a crack cannot cross a crack, since the ice is + * already parted there. So the cracks make a network of long runs joined at T's, with + * shorter and finer ones filling in between, and never a tangle of lines crossing at + * random. + */ + +export type Point = [number, number] + +export interface Crack { + /** Where it runs, in metres. On a sheet that repeats, it may run off one edge: it comes in at the other. */ + points: Point[] + /** 0 for one of the long first cracks, 1 for one that runs between them, 2 for a hairline. */ + rank: 0 | 1 | 2 +} + +/** Metres between the points of a crack. */ +const STEP = 0.25 +/** How much a crack of each rank wanders from straight at each step, radians. */ +const WANDER = [0.035, 0.07, 0.12] as const + +/** Where along the step from `a` to `b` it meets the stretch from `c` to `d`, as a fraction of the step; nowhere if it does not. */ +function meets(a: Point, b: Point, c: Point, d: Point): number | undefined { + const rx = b[0] - a[0] + const ry = b[1] - a[1] + const sx = d[0] - c[0] + const sy = d[1] - c[1] + const cross = rx * sy - ry * sx + if (Math.abs(cross) < 1e-12) return undefined + const t = ((c[0] - a[0]) * sy - (c[1] - a[1]) * sx) / cross + const u = ((c[0] - a[0]) * ry - (c[1] - a[1]) * rx) / cross + return t >= 0 && t <= 1 && u >= 0 && u <= 1 ? t : undefined +} + +/** + * The cracks of a square sheet of ice `size` metres on a side that repeats in both + * directions: `first` long ones, then `between` that run from one crack to another, + * then `fine` hairlines. `random` gives numbers from 0 to 1, and the same numbers give + * the same cracks. + */ +export function crackNetwork(random: () => number, size: number, first = 5, between = 26, fine = 44): Crack[] { + const cracks: Crack[] = [] + // Every step of every crack so far, kept by the metre square its middle is in, so that + // a new step is tried only against the steps near it. + const cells = Math.ceil(size) + const near: [Point, Point][][] = Array.from({ length: cells * cells }, () => []) + const cell = (x: number, y: number): number => + (((Math.floor(x) % cells) + cells) % cells) + ((((Math.floor(y) % cells) + cells) % cells) * cells) + const keep = (a: Point, b: Point): void => void near[cell((a[0] + b[0]) / 2, (a[1] + b[1]) / 2)]!.push([a, b]) + + /** Where the step from `a` to `b` first meets a crack already there, as a fraction of it. */ + const stopped = (a: Point, b: Point): number | undefined => { + let first: number | undefined + const mx = (a[0] + b[0]) / 2 + const my = (a[1] + b[1]) / 2 + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + for (const [c, d] of near[cell(mx + dx, my + dy)]!) { + // The sheet repeats: the stretch is tried where its nearest copy to this step lies. + const ox = Math.round((mx - (c[0] + d[0]) / 2) / size) * size + const oy = Math.round((my - (c[1] + d[1]) / 2) / size) * size + const t = meets(a, b, [c[0] + ox, c[1] + oy], [d[0] + ox, d[1] + oy]) + // Not at the step's very start, which is where a crack leaves the one it began on. + if (t !== undefined && t > 1e-9 && (first === undefined || t < first)) first = t + } + } + } + return first + } + + /** Run a crack from `from` in direction `way` for up to `far` metres, or until it meets another. */ + const run = (from: Point, way: number, far: number, rank: 0 | 1 | 2): Point[] => { + const points: Point[] = [from] + for (let gone = 0; gone < far; gone += STEP) { + way += (random() - 0.5) * 2 * WANDER[rank] + // Now and then a sharper turn, where the ice gives along a different grain. + if (random() < 0.03) way += (random() - 0.5) * 0.5 + const a = points[points.length - 1]! + const b: Point = [a[0] + Math.cos(way) * STEP, a[1] + Math.sin(way) * STEP] + const t = stopped(a, b) + if (t !== undefined) { + points.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]) + break + } + points.push(b) + } + return points + } + + const add = (points: Point[], rank: 0 | 1 | 2): void => { + if (points.length < 3) return + for (let i = 1; i < points.length; i++) keep(points[i - 1]!, points[i]!) + cracks.push({ points, rank }) + } + + // The long first cracks: from somewhere, both ways, most of the way across the sheet. + for (let n = 0; n < first; n++) { + const from: Point = [random() * size, random() * size] + const way = random() * Math.PI * 2 + const one = run(from, way, size * (0.5 + 0.4 * random()), 0) + const other = run(from, way + Math.PI, size * (0.5 + 0.4 * random()), 0) + add([...other.reverse(), ...one.slice(1)], 0) + } + // The rest start from a point on a crack already there and leave it at a wide angle. + const branch = (far: () => number, rank: 1 | 2): void => { + const parent = cracks[Math.floor(random() * cracks.length)]! + const at = 1 + Math.floor(random() * (parent.points.length - 2)) + const [px, py] = parent.points[at]! + const [qx, qy] = parent.points[at - 1]! + const along = Math.atan2(py - qy, px - qx) + const off = (random() < 0.5 ? 1 : -1) * (Math.PI / 2 + (random() - 0.5) * 1.1) + add(run([px, py], along + off, far(), rank), rank) + } + for (let n = 0; n < between; n++) branch(() => 3 + 11 * random(), 1) + for (let n = 0; n < fine; n++) branch(() => 0.8 + 3.5 * random(), 2) + return cracks +} + +/** + * A crack opening now, under a skater or near one: a star of a few cracks running out + * from one point, about `far` metres each, each forking once or twice. Every stretch of + * it comes with how far along the crack from that point it is, in metres, for whoever + * wants to show it running outwards. + */ +export function crackStar(random: () => number, far: number): { from: Point; to: Point; along: number }[] { + const stretches: { from: Point; to: Point; along: number }[] = [] + const arm = (start: Point, way: number, length: number, gone: number, forks: number): void => { + let at = start + for (let run = 0; run < length; run += STEP) { + way += (random() - 0.5) * 0.16 + if (random() < 0.05) way += (random() - 0.5) * 0.6 + const to: Point = [at[0] + Math.cos(way) * STEP, at[1] + Math.sin(way) * STEP] + stretches.push({ from: at, to, along: gone + run }) + at = to + // A fork: a shorter crack off to one side, from here. + if (forks > 0 && random() < 0.5 * STEP) { + forks-- + arm(at, way + (random() < 0.5 ? 1 : -1) * (0.5 + 0.6 * random()), (length - run) * (0.3 + 0.4 * random()), gone + run, 0) + } + } + } + // Two arms nearly opposite, which is the crack itself, and often a third or fourth. + const way = random() * Math.PI * 2 + const arms = 2 + Math.floor(random() * 2.6) + for (let n = 0; n < arms; n++) { + const turn = n < 2 ? n * Math.PI + (random() - 0.5) * 0.4 : random() * Math.PI * 2 + arm([0, 0], way + turn, far * (n < 2 ? 0.6 + 0.4 * random() : 0.25 + 0.4 * random()), 0, 2) + } + return stretches +} diff --git a/src/main.ts b/src/main.ts index c154452..ce11826 100644 --- a/src/main.ts +++ b/src/main.ts @@ -95,6 +95,8 @@ function showWorld(): void { sun.intensity = look.sunStrength sun.position.set(...look.sunFrom) ice.light = look.iceLight + // The marks on the ice are seen by the same light, though never quite lost in the dark. + marks.light = 0.25 + 0.75 * look.iceLight } const asked = params.get('time') as TimeName | null const askedWeather = params.get('weather') as Weather | null @@ -395,7 +397,7 @@ renderer.setAnimationLoop(() => { const h = lake.thickness(skater.x, skater.s) // What the skates leave on the ice: their line, and a scratch at each push, off the // left skate and the right in turn. - marks.follow(skater.x, skater.s) + marks.follow(skater.x, skater.s, dt) if (dt > 0) marks.track(skater.x, skater.s) const pushed = Math.floor(skater.stride) !== strides if (pushed) marks.push(skater.x, skater.s, skater.heading, Math.floor(skater.stride) % 2 ? 1 : -1) diff --git a/src/render/ice.ts b/src/render/ice.ts index ca69085..b10974d 100644 --- a/src/render/ice.ts +++ b/src/render/ice.ts @@ -1,4 +1,5 @@ import * as THREE from 'three' +import { crackNetwork } from '../ice/cracks.js' import { BREAKING_THICKNESS } from '../ice/strength.js' import { type Lake, MAX_THICKNESS, MIN_THICKNESS, mulberry32 } from '../world/lake.js' @@ -48,8 +49,8 @@ function wrapped(g: CanvasRenderingContext2D, paint: () => void): void { /** * The marks that show the ice is moving under you: without them a sheet of black ice is - * one flat colour and the skater appears to stand still. Frost in soft patches, the white - * hairline cracks that black ice is laced with, and bubbles frozen into it. + * one flat colour and the skater appears to stand still. Frost in soft patches, the + * network of white cracks that black ice is laced with, and bubbles frozen into it. */ function paintIce(): HTMLCanvasElement { const canvas = document.createElement('canvas') @@ -75,23 +76,37 @@ function paintIce(): HTMLCanvasElement { g.lineCap = 'round' g.lineJoin = 'round' - for (let i = 0; i < 30; i++) { - // A crack runs roughly straight, kinking a little at each grain of the ice. - const points: [number, number][] = [[random() * PIXELS, random() * PIXELS]] - let direction = random() * Math.PI * 2 - const kinks = 5 + Math.floor(random() * 10) - for (let k = 0; k < kinks; k++) { - direction += (random() - 0.5) * 0.9 - const step = (1 + random() * 2.2) * PER_METRE - const [x, y] = points[points.length - 1]! - points.push([x + Math.cos(direction) * step, y + Math.sin(direction) * step]) - } - g.strokeStyle = `rgba(205, 228, 240, ${0.3 + random() * 0.5})` - g.lineWidth = 1.2 + random() * 1.6 - wrapped(g, () => { + // The cracks (ice/cracks.ts). A crack in clear ice is not a line on it but a thin + // white sheet standing in it, seen from above and a little to one side: a bright edge + // where it meets the surface, and its face going down into the ice beside that, fainter + // and wider. So each is drawn three times, widest and faintest first; and the long ones + // once more to one side, for the face. + const LOOK = [ + { core: [1.4, 0.9], body: [3.6, 0.17], haze: [10, 0.05] }, + { core: [1.0, 0.72], body: [2.2, 0.13], haze: [5.5, 0.04] }, + { core: [0.8, 0.42], body: [1.6, 0.07], haze: [0, 0] }, + ] as const + for (const { points, rank } of crackNetwork(random, TILE)) { + const trace = (dx: number, dy: number): void => { g.beginPath() - points.forEach(([x, y], n) => (n === 0 ? g.moveTo(x, y) : g.lineTo(x, y))) + points.forEach(([x, y], n) => (n === 0 ? g.moveTo(x * PER_METRE + dx, y * PER_METRE + dy) : g.lineTo(x * PER_METRE + dx, y * PER_METRE + dy))) g.stroke() + } + // Some are older than others, and have healed: milkier and less sharp. + const fresh = 0.7 + 0.3 * random() + const look = LOOK[rank] + wrapped(g, () => { + for (const [width, alpha] of [look.haze, look.body, look.core]) { + if (width === 0) continue + g.strokeStyle = `rgba(214, 234, 245, ${alpha * fresh})` + g.lineWidth = width + trace(0, 0) + } + if (rank === 0) { + g.strokeStyle = `rgba(214, 234, 245, ${0.16 * fresh})` + g.lineWidth = 1.2 + trace(3.2, 2.1) + } }) } diff --git a/src/render/marks.ts b/src/render/marks.ts index 9b1cc60..294229e 100644 --- a/src/render/marks.ts +++ b/src/render/marks.ts @@ -1,98 +1,115 @@ import * as THREE from 'three' +import { crackStar } from '../ice/cracks.js' -/** Side of the square of ice round the skater that keeps its marks, metres, and its picture's side in pixels. */ -const SIZE = 100 -const PIXELS = 1024 -const PER_METRE = PIXELS / SIZE -/** When the skater is this far from the middle of the square, metres, it is moved to be round them again. */ -const RECENTRE = 22 +/** How many stretches of line the lake remembers; when there are more, the oldest are forgotten. */ +const STRETCHES = 9000 /** The line of the skates is drawn on in stretches this long, metres. */ -const TRACK_STEP = 0.7 +const TRACK_STEP = 0.5 +/** How fast a crack runs out from where it starts, m/s: quick, and slow enough to see go. */ +const CRACK_SPEED = 28 +/** When the skater is this far from where the marks are measured from, metres, they are measured from nearer. */ +const RECENTRE = 400 + +type Colour = readonly [number, number, number, number] +/** What each kind of mark looks like: its colour, and how solid it is. */ +const TRACK: Colour = [0.82, 0.9, 0.95, 0.5] +const PUSH: Colour = [0.9, 0.95, 0.98, 0.75] +const CRACK: Colour = [0.93, 0.97, 1, 0.95] /** * What the skater leaves on the ice: the lines of their skates, the scratch of each push, * and, on ice thin enough to crack, the cracks. * - * They are drawn on a picture laid over the ice round the skater, a hundred metres square, - * which stays where it is on the ice while the skater moves about on it. So a skater who - * turns back, or circles an islet, comes on their own tracks, as in the film. When the - * skater nears its edge the picture is moved along, and what was at its far side is lost: - * the lake does not remember further than that. + * Every mark is a hairline: a line with no width of its own, drawn a pixel wide however + * near it is, which is what a scratch or a crack in ice is. A crack runs out from where + * it starts over a moment, not all at once. + * + * The marks stay where they were made, so a skater who turns back, or circles an islet, + * comes on their own tracks, as in the film. The lake remembers the last few thousand + * stretches of line and forgets the ones before. */ export class Marks { - readonly mesh: THREE.Mesh - private readonly canvas = document.createElement('canvas') - private readonly pen: CanvasRenderingContext2D - private readonly texture: THREE.CanvasTexture - /** Where the middle of the square is on the lake: across, and along. */ - private midX = 0 - private midS = 0 + readonly mesh: THREE.LineSegments + private readonly places = new Float32Array(STRETCHES * 6) + private readonly colours = new Float32Array(STRETCHES * 8) + private readonly geometry = new THREE.BufferGeometry() + /** How many stretches have ever been drawn; the next goes in the place this leaves over. */ + private drawn = 0 + /** Where on the lake the marks are measured from: across, and along. */ + private fromX = 0 + private fromS = 0 /** Where the line of the skates was last drawn to; nowhere when the pen is up. */ private last: [number, number] | null = null + /** The stretches of cracks still to appear, soonest first, and the time by this clock. */ + private coming: { at: number; ax: number; as: number; bx: number; bs: number }[] = [] + private clock = 0 constructor() { - this.canvas.width = this.canvas.height = PIXELS - this.pen = this.canvas.getContext('2d')! - this.pen.lineCap = 'round' - this.pen.lineJoin = 'round' - this.texture = new THREE.CanvasTexture(this.canvas) - this.texture.colorSpace = THREE.SRGBColorSpace - this.mesh = new THREE.Mesh( - new THREE.PlaneGeometry(SIZE, SIZE), - // Lit like the ice it lies on, so that the marks are pale by day and dim by night. - new THREE.MeshLambertMaterial({ map: this.texture, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2 }), + this.geometry.setAttribute('position', new THREE.BufferAttribute(this.places, 3)) + this.geometry.setAttribute('color', new THREE.BufferAttribute(this.colours, 4)) + this.geometry.setDrawRange(0, 0) + this.mesh = new THREE.LineSegments( + this.geometry, + new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, depthWrite: false }), ) - this.mesh.rotation.x = -Math.PI / 2 + // They are never far from the skater, and are moved with them; nothing is gained by asking. + this.mesh.frustumCulled = false this.mesh.renderOrder = 1 } - /** Where a place on the lake is in the picture, in pixels: its top is the far end. */ - private at(x: number, s: number): [number, number] { - return [(x - this.midX + SIZE / 2) * PER_METRE, (SIZE / 2 - (s - this.midS)) * PER_METRE] + /** How brightly the marks show: 1 by day, less by the light of a night. */ + set light(strength: number) { + ;(this.mesh.material as THREE.LineBasicMaterial).color.setScalar(strength) } /** Another lake, or a skater set down somewhere else: nothing has been marked here. */ clear(x: number, s: number): void { - this.pen.clearRect(0, 0, PIXELS, PIXELS) - this.midX = x - this.midS = s + this.drawn = 0 + this.fromX = x + this.fromS = s this.last = null - this.texture.needsUpdate = true + this.coming = [] + this.geometry.setDrawRange(0, 0) } - /** Keep the picture where it lies on the ice, for a skater at `x` across and `s` along. */ - follow(x: number, s: number): void { - if (Math.abs(x - this.midX) > RECENTRE || Math.abs(s - this.midS) > RECENTRE) { - // Moved by whole pixels, so that what is kept is not blurred by the move. - const dx = Math.round((x - this.midX) * PER_METRE) - const ds = Math.round((s - this.midS) * PER_METRE) - this.pen.save() - this.pen.globalCompositeOperation = 'copy' - this.pen.drawImage(this.canvas, -dx, ds) - this.pen.restore() - this.midX += dx / PER_METRE - this.midS += ds / PER_METRE - this.texture.needsUpdate = true + /** One stretch of line, from one place on the lake to another. */ + private stretch(ax: number, as: number, bx: number, bs: number, colour: Colour): void { + const n = this.drawn++ % STRETCHES + // A hair above the ice, so that it is the line that shows and not the ice through it. + this.places.set([ax - this.fromX, 0.02, -(as - this.fromS), bx - this.fromX, 0.02, -(bs - this.fromS)], n * 6) + this.colours.set([...colour, ...colour], n * 8) + this.geometry.getAttribute('position').needsUpdate = true + this.geometry.getAttribute('color').needsUpdate = true + this.geometry.setDrawRange(0, Math.min(this.drawn, STRETCHES) * 2) + } + + /** + * Keep the marks where they lie on the ice, for a skater at `x` across and `s` along, + * and let the cracks that are opening run on by `dt` seconds. + */ + follow(x: number, s: number, dt: number): void { + if (Math.abs(x - this.fromX) > RECENTRE || Math.abs(s - this.fromS) > RECENTRE) { + // Measured from where the skater now is, so that the numbers stay small. + for (let i = 0; i < this.places.length; i += 3) { + this.places[i] = this.places[i]! + this.fromX - x + this.places[i + 2] = this.places[i + 2]! - (this.fromS - s) + } + this.fromX = x + this.fromS = s + this.geometry.getAttribute('position').needsUpdate = true + } + this.mesh.position.set(this.fromX - x, 0, -(this.fromS - s)) + this.clock += dt + while (this.coming.length > 0 && this.coming[0]!.at <= this.clock) { + const { ax, as, bx, bs } = this.coming.shift()! + this.stretch(ax, as, bx, bs, CRACK) } - this.mesh.position.set(this.midX - x, 0, -(this.midS - s)) } /** The line of the skates, on to where the skater now is. */ track(x: number, s: number): void { - // A stretch at a time, not a frame at a time: every stretch drawn is the whole - // picture sent to the graphics card again. if (this.last && Math.hypot(x - this.last[0], s - this.last[1]) < TRACK_STEP) return - const to = this.at(x, s) - if (this.last) { - const from = this.at(...this.last) - this.pen.strokeStyle = 'rgba(214, 232, 244, 0.55)' - this.pen.lineWidth = 1.1 - this.pen.beginPath() - this.pen.moveTo(...from) - this.pen.lineTo(...to) - this.pen.stroke() - this.texture.needsUpdate = true - } + if (this.last) this.stretch(this.last[0], this.last[1], x, s, TRACK) this.last = [x, s] } @@ -106,48 +123,18 @@ export class Marks { const footX = x + foot * 0.25 * Math.cos(heading) const footS = s - foot * 0.25 * Math.sin(heading) const out = heading + foot * 1.75 - const [fromX, fromY] = this.at(footX, footS) - const [toX, toY] = this.at(footX + 1.3 * Math.sin(out), footS + 1.3 * Math.cos(out)) - this.pen.strokeStyle = 'rgba(232, 242, 250, 0.7)' - this.pen.lineWidth = 1.6 - this.pen.beginPath() - this.pen.moveTo(fromX, fromY) - this.pen.lineTo(toX, toY) - this.pen.stroke() - this.texture.needsUpdate = true + this.stretch(footX, footS, footX + 1.1 * Math.sin(out), footS + 1.1 * Math.cos(out), PUSH) } /** - * A crack opening in the ice at `x`, `s`: a jagged line about `length` metres long that - * forks once or twice, running whichever way it runs. + * A crack opening in the ice at `x`, `s`: a few arms running out from there, about + * `length` metres each, forking as they go (ice/cracks.ts), over the moment it takes + * a crack to run that far. */ crack(x: number, s: number, length: number): void { - const run = (fromX: number, fromS: number, way: number, far: number, wide: number): void => { - let px = fromX - let ps = fromS - this.pen.strokeStyle = 'rgba(236, 246, 252, 0.8)' - this.pen.lineWidth = wide - this.pen.beginPath() - this.pen.moveTo(...this.at(px, ps)) - for (let gone = 0; gone < far; ) { - const step = 0.3 + Math.random() * 0.7 - // Kinked at every step, but about the one way it runs: a crack does not curl. - const kinked = way + (Math.random() - 0.5) * 1.2 - px += Math.sin(kinked) * step - ps += Math.cos(kinked) * step - gone += step - this.pen.lineTo(...this.at(px, ps)) - // A fork: a shorter, finer crack off to one side, drawn when this one is done. - if (wide > 1 && Math.random() < 0.12) forks.push([px, ps, way + (Math.random() < 0.5 ? 0.9 : -0.9), (far - gone) * 0.6]) - } - this.pen.stroke() + for (const { from, to, along } of crackStar(Math.random, length)) { + this.coming.push({ at: this.clock + along / CRACK_SPEED, ax: x + from[0], as: s + from[1], bx: x + to[0], bs: s + to[1] }) } - const forks: [number, number, number, number][] = [] - const way = Math.random() * Math.PI * 2 - // Both ways from where it starts. - run(x, s, way, length / 2, 1.5) - run(x, s, way + Math.PI, length / 2, 1.5) - for (const [fx, fs, fway, far] of forks) run(fx, fs, fway, far, 0.9) - this.texture.needsUpdate = true + this.coming.sort((a, b) => a.at - b.at) } } diff --git a/test/cracks.test.ts b/test/cracks.test.ts new file mode 100644 index 0000000..6b9af19 --- /dev/null +++ b/test/cracks.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { type Point, crackNetwork, crackStar } from '../src/ice/cracks.js' +import { mulberry32 } from '../src/world/lake.js' + +const SIZE = 32 +const network = crackNetwork(mulberry32(2018), SIZE) + +/** Whether the stretch a-b crosses the stretch c-d somewhere inside both, not at an end of either. */ +function crosses(a: Point, b: Point, c: Point, d: Point): boolean { + const side = (p: Point, q: Point, r: Point): number => (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) + const eps = 1e-6 + return side(a, b, c) * side(a, b, d) < -eps && side(c, d, a) * side(c, d, b) < -eps +} + +describe('cracks', () => { + it('are the same cracks for the same numbers', () => { + expect(crackNetwork(mulberry32(2018), SIZE)).toEqual(network) + expect(crackNetwork(mulberry32(2019), SIZE)).not.toEqual(network) + }) + + it('are a few long ones, more between them, and many hairlines', () => { + const length = (points: Point[]): number => + points.slice(1).reduce((sum, p, i) => sum + Math.hypot(p[0] - points[i]![0], p[1] - points[i]![1]), 0) + const of = (rank: number): number[] => network.filter((c) => c.rank === rank).map((c) => length(c.points)) + expect(of(0).length).toBe(5) + expect(of(1).length).toBeGreaterThan(15) + expect(of(2).length).toBeGreaterThan(25) + const mean = (lengths: number[]): number => lengths.reduce((a, b) => a + b) / lengths.length + expect(mean(of(0))).toBeGreaterThan(15) + expect(mean(of(1))).toBeLessThan(mean(of(0))) + expect(mean(of(2))).toBeLessThan(mean(of(1))) + }) + + it('run nearly straight', () => { + for (const { points, rank } of network.filter((c) => c.rank < 2 && c.points.length > 20)) { + const end = points[points.length - 1]! + const straight = Math.hypot(end[0] - points[0]![0], end[1] - points[0]![1]) + const along = (points.length - 1) * 0.25 + // As the crow flies, most of the way it actually runs. + expect(straight / along).toBeGreaterThan(rank === 0 ? 0.8 : 0.6) + } + }) + + it('stop at one another and never cross', () => { + // Every stretch of every crack, with which crack it is of; and the sheet repeats, so + // each is tried against the others' copies in the sheets round it as well. + const stretches = network.flatMap((crack, which) => + crack.points.slice(1).map((p, i) => ({ a: crack.points[i]!, b: p, which }))) + let crossings = 0 + for (const one of stretches) { + for (const other of stretches) { + if (other.which <= one.which) continue + for (const ox of [-SIZE, 0, SIZE]) { + for (const oy of [-SIZE, 0, SIZE]) { + if (crosses(one.a, one.b, [other.a[0] + ox, other.a[1] + oy], [other.b[0] + ox, other.b[1] + oy])) crossings++ + } + } + } + } + // A later crack may be run through by nothing; an earlier one may, at the very start + // of a crack leaving it, be touched. Crossings proper there are none of, or next to. + expect(crossings).toBeLessThanOrEqual(3) + }) + + it('open as a star of arms from one point, in order outwards', () => { + const star = crackStar(mulberry32(7), 6) + expect(star.length).toBeGreaterThan(20) + expect(star.filter((s) => s.along === 0 && s.from[0] === 0 && s.from[1] === 0).length).toBeGreaterThanOrEqual(2) + let furthest = 0 + for (const { from, to, along } of star) { + expect(Math.hypot(to[0] - from[0], to[1] - from[1])).toBeCloseTo(0.25, 5) + expect(along).toBeGreaterThanOrEqual(0) + furthest = Math.max(furthest, Math.hypot(to[0], to[1])) + } + expect(furthest).toBeGreaterThan(2) + expect(furthest).toBeLessThan(7) + }) +})