From c2403bb7e93a4897d7bdd0c53b56cdb4399d47be Mon Sep 17 00:00:00 2001 From: Taylor Baldwin Date: Sat, 23 Dec 2023 19:08:28 -0500 Subject: [PATCH] new sketches for ptpx --- .sketch-template.ts | 31 +++ package.json | 2 +- plots/README.md | 3 +- sketches/2023.11.30-21.32.21.ts | 414 ++++++++++++++++++++++++++++ sketches/2023.11.30-22.04.51.ts | 446 ++++++++++++++++++++++++++++++ sketches/2023.12.01-22.37.42.ts | 474 ++++++++++++++++++++++++++++++++ sketches/2023.12.01-23.19.47.ts | 458 ++++++++++++++++++++++++++++++ sketches/2023.12.03-13.27.57.ts | 115 ++++++++ sketches/2023.12.07-20.41.27.ts | 135 +++++++++ sketches/2023.12.09-14.14.13.ts | 446 ++++++++++++++++++++++++++++++ sketches/2023.12.11-20.09.04.ts | 464 +++++++++++++++++++++++++++++++ sketches/2023.12.13-23.14.17.ts | 466 +++++++++++++++++++++++++++++++ 12 files changed, 3452 insertions(+), 2 deletions(-) create mode 100644 .sketch-template.ts create mode 100644 sketches/2023.11.30-21.32.21.ts create mode 100644 sketches/2023.11.30-22.04.51.ts create mode 100644 sketches/2023.12.01-22.37.42.ts create mode 100644 sketches/2023.12.01-23.19.47.ts create mode 100644 sketches/2023.12.03-13.27.57.ts create mode 100644 sketches/2023.12.07-20.41.27.ts create mode 100644 sketches/2023.12.09-14.14.13.ts create mode 100644 sketches/2023.12.11-20.09.04.ts create mode 100644 sketches/2023.12.13-23.14.17.ts diff --git a/.sketch-template.ts b/.sketch-template.ts new file mode 100644 index 0000000..09d7d9e --- /dev/null +++ b/.sketch-template.ts @@ -0,0 +1,31 @@ +import * as canvasSketch from 'canvas-sketch' +import * as random from 'canvas-sketch-util/random' +import { GUI } from 'dat-gui' + +const WIDTH = 2048 +const HEIGHT = 2048 + +const settings = { + seed: 1 +} + +type SketchArgs = { context: CanvasRenderingContext2D, viewportWidth: number, viewportHeight: number } + +canvasSketch(({ render }) => { + const gui = new GUI() + gui.add(settings, 'seed', 0, 9999).step(1).onChange(render) + + return (args: SketchArgs) => { + const { context, viewportWidth, viewportHeight } = args + const width = viewportWidth + const height = viewportHeight + + const rand = random.createRandom(settings.seed) + + context.fillStyle = 'white' + context.fillRect(0, 0, width, height) + } +}, { + dimensions: [WIDTH, HEIGHT], + animate: true +}) diff --git a/package.json b/package.json index 3632ab7..509bdbb 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "build": "node scripts/build.js", "start": "echo \"open localhost:8080 in a browser\" && cd docs && python -m SimpleHTTPServer 8080", "lint": "standard", - "new": "canvas-sketch --new --output=tmp --template=./.sketch-template.js -- --p tsify", + "new": "filename=$(date +\"sketches/%Y.%m.%d-%H.%M.%S.ts\") && cp ./.sketch-template.ts $filename && npm run dev -- $filename", "dev": "run(){ canvas-sketch --output=tmp $1 -- -p tsify; }; run" }, "author": "Taylor Baldwin", diff --git a/plots/README.md b/plots/README.md index e83a396..ad10761 100644 --- a/plots/README.md +++ b/plots/README.md @@ -1,11 +1,12 @@ ## Useful commands for axidraw: axicli -m manual -d 30 -u 60 -M raise_pen +axicli -m manual -d 30 -u 60 -M lower_pen axicli SVGPLOT -d 30 -u 60 -a 18 -z 20 -b Ex: -axicli 2022.12.14-19.57.40-plot-hash-9215.svg -d 30 -u 60 -a 18 -z 20 -b +axicli 2023.12.11-20.09.04-plot-hash-867.svg -d 30 -u 60 -a 18 -z 20 -b ## Plot server: diff --git a/sketches/2023.11.30-21.32.21.ts b/sketches/2023.11.30-21.32.21.ts new file mode 100644 index 0000000..f5ee67c --- /dev/null +++ b/sketches/2023.11.30-21.32.21.ts @@ -0,0 +1,414 @@ +import * as canvasSketch from 'canvas-sketch' +import * as random from 'canvas-sketch-util/random' +import { GUI } from 'dat-gui' +import * as vec2 from 'gl-vec2' + +const PLOTNAME = '2023.11.30-21.32.21' + +const MM_PER_INCH = 25.4 +const PIXELS_PER_INCH = 200 +const WIDTH = 6 * PIXELS_PER_INCH +const HEIGHT = 4 * PIXELS_PER_INCH +const PIXELS_PER_MM = PIXELS_PER_INCH / MM_PER_INCH +const PIXELS_PER_CM = PIXELS_PER_MM * 10 + +const settings = { + seed: 50, // 9428 // 1409 + canvasMargin: 0.33, + minDist: 5, + lineWidthMM: 0.1 +} + +let shapes: Shape[] + +type SketchArgs = { context: CanvasRenderingContext2D, viewportWidth: number, viewportHeight: number } + +canvasSketch(({ render }) => { + const gui = new GUI() + gui.add(settings, 'seed', 0, 9999).step(1).onChange(render) + gui.add(settings, 'canvasMargin', 0, 0.5).step(0.01).onChange(render) + gui.add(settings, 'minDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'lineWidthMM', 0.05, 2).step(0.01).onChange(render) + + return (args: SketchArgs) => { + const { context, viewportWidth, viewportHeight } = args + const width = viewportWidth + const height = viewportHeight + + shapes = [] + const margin = settings.canvasMargin * height + + context.clearRect(0, 0, width, height) + context.fillStyle = 'white' + context.fillRect(0, 0, width, height) + + const rand = random.createRandom(settings.seed) + + // draw a rectangle in the center of the canvas + const rectWidth = width - margin * 2 + const rectHeight = height - margin * 2 + const mainShape: Shape = [ + [[margin, margin], [margin + rectWidth, margin]], + [[margin + rectWidth, margin], [margin + rectWidth, margin + rectHeight]], + [[margin + rectWidth, margin + rectHeight], [margin, margin + rectHeight]], + [[margin, margin + rectHeight], [margin, margin]] + ] + + // extend the rectangle out by n pixels + const n = 30 + shapes.push([ + [[margin - n, margin - n], [margin + rectWidth + n, margin - n]], + [[margin + rectWidth + n, margin - n], [margin + rectWidth + n, margin + rectHeight + n]], + [[margin + rectWidth + n, margin + rectHeight + n], [margin - n, margin + rectHeight + n]], + [[margin - n, margin + rectHeight + n], [margin - n, margin - n]] + ]) + + const dir = [width / 2, height / 2] + + let lineCount = 0 + while (true) { + vec2.add(dir, dir, [0, 20]) + const line: Line = [ + vec2.scale([], dir, -1000), + vec2.scale([], dir, 1000) + ] + + const intersections = shapeLineIntersections(line, mainShape) + lineCount += 1 + if (!intersections.length || lineCount > 100) break + shapes.push([ + [intersections[0], intersections[1]] + ]) + } + + for (const shape of shapes) { + drawShape(context, shape) + } + } +}, { + dimensions: [WIDTH, HEIGHT] +}) + +type Vec2 = [number, number] +type Point = Vec2 +type Dir = Vec2 +type Line = [Point, Point] +type Curve = [Point, Point, Point] // start, end, center +type Ray = [Point, Dir] +type Segment = Line | Curve +type Shape = Segment[] +type ArcVals = { + center: Point + startAngle: number + endAngle: number + radius: number +} + +function isPoint(obj: any): obj is Point { + return Array.isArray(obj) && obj.length === 2 && typeof obj[0] === 'number' && typeof obj[1] === 'number' +} + +function isLine(obj: any): obj is Line { + return Array.isArray(obj) && obj.length === 2 && obj.every(isPoint) +} + +function isCurve(obj: any): obj is Curve { + return Array.isArray(obj) && obj.length === 3 && obj.every(isPoint) +} + +// make sure line is well extended beyond bounds of the canvas +// because it just checks for segments +// function divideShapeWithLine (line: Line, shape: Shape): [Shape, Shape] | null { +// const shape1: Segment[] = [] +// const shape2: Segment[] = [] +// const intersections: Point[] = [] +// for (const segment of shape) { +// let pushTo = intersections.length === 1 ? shape2 : shape1 +// if (intersections.length === 2) { +// pushTo.push(segment) +// continue +// } +// const int = isLine(segment) ? segmentIntersection(segment, line) : getValidCurveIntersections(segment, line) +// if (int === null) { +// pushTo.push(segment) +// continue +// } +// const firstChunk: Segment = isLine(segment) ? [segment[0], int] : [segment[0], int, segment[2]] +// pushTo.push(firstChunk) +// if (intersections.length === 1) { +// shape2.push([int, intersections[0]]) +// shape1.push([intersections[0], int]) +// } +// intersections.push(int) +// pushTo = intersections.length === 1 ? shape2 : shape1 +// const secondChunk: Segment = isLine(segment) ? [int, segment[1]] : [int, segment[1], segment[2]] +// pushTo.push(secondChunk) +// } + +// if (intersections.length !== 2) return null +// return [shape1, shape2] +// } + +// make sure line is well extended beyond bounds of the canvas +// because it just checks for segments +function shapeLineIntersections (line: Line, shape: Shape): Point[] { + const intersections: Point[] = [] + for (const segment of shape) { + if (isLine(segment)) { + const int = segmentIntersection(segment, line) + if (int !== null) intersections.push(int) + continue + } + const ints = curveLineIntersection(segment, line) + if (ints !== null) { + for (const pt of ints) intersections.push(pt) + } + } + return intersections +} + +function distRayToShape (ray: Ray, shape: Shape): number | null { + const [pt, dir] = ray + const p2 = vec2.scaleAndAdd([], pt, dir, 1000000) + const intersections = shapeLineIntersections([pt, p2], shape) + let min = Infinity + for (const int of intersections) { + const dist = vec2.squaredDistance(pt, int) + min = Math.min(min, dist) + } + return Number.isFinite(min) ? Math.sqrt(min) : null +} + +function getArcValsForCurve (curve: Curve): ArcVals { + const [start, end, center] = curve + const radius = vec2.distance(start, center) + const dirStart: Vec2 = vec2.sub([], start, center) + const dirEnd: Vec2 = vec2.sub([], end, center) + const startAngle = Math.atan2(dirStart[1], dirStart[0]) + const endAngle = Math.atan2(dirEnd[1], dirEnd[0]) + return { center, radius, startAngle, endAngle } +} + +// special function that returns null if there's not exactly one intersection +// for a given curve +function getValidCurveIntersections(curve: Curve, line: Line): Point | null { + const intersections = curveLineIntersection(curve, line) + return intersections.length === 1 ? intersections[0] : null +} + +// special function that returns null if no intersection or if line is tangent to curve +function curveLineIntersection(curve: Curve, line: Line): Point[] { + const [start, end, center] = curve + const radius = vec2.distance(start, center) + const intersections = findCircleLineIntersections(radius, center, line[0], line[1]) + if (intersections.length < 2) return [] + const dirStart: Vec2 = vec2.sub([], start, center) + const dirEnd: Vec2 = vec2.sub([], end, center) + const startAngle = Math.atan2(dirStart[1], dirStart[0]) + let endAngle = Math.atan2(dirEnd[1], dirEnd[0]) + if (endAngle < startAngle) endAngle += Math.PI * 2 + const filteredIntersections = intersections.filter(pt => { + const dir: Vec2 = vec2.sub([], pt, center) + let angle = Math.atan2(dir[1], dir[0]) + if (angle < startAngle) angle += Math.PI * 2 + else if (angle > endAngle) angle -= Math.PI * 2 + return startAngle < angle && endAngle > angle + }) + return filteredIntersections +} + +function findCircleLineIntersections (r: number, center: Point, p1: Point, p2: Point): Point[] { + // circle: (x - h)^2 + (y - k)^2 = r^2 + // line: y = m * x + n + // r: circle radius + // center: circle center + // p1 & p2: two points on the intersecting line + + // m: slope + // n: y-intercept + const h = center[0] + const k = center[1] + const m = (p2[1] - p1[1]) / (p2[0] - p1[0]) + const n = p2[1] - m * p2[0] + + // get a, b, c values + const a = 1 + m * m + const b = -h * 2 + (m * (n - k)) * 2 + const c = h * h + (n - k) * (n - k) - r * r + + // insert into quadratic formula + const i1 = getPt((-b + Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a)) + const i2 = getPt((-b - Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a)) + + if (isEqual(i1[0], i2[0]) && isEqual(i1[1], i2[1])) { + return [i1] + } + + return [i1, i2].filter(pt => Number.isFinite(pt[0]) && Number.isFinite(pt[1])) + + function getPt (x: number): Point { + return [x, m * x + n] + } +} + +function isEqual (a: number, b: number) { + return Math.abs(a - b) < 1 +} + +const EPS = 0.0000001 +function between (a: number, b: number, c: number): boolean { + return a - EPS <= b && b <= c + EPS +} +function segmentIntersection (segment1: Line, segment2: Line): Point | null { + const [[x1, y1], [x2, y2]] = segment1 + const [[x3, y3], [x4, y4]] = segment2 + var x = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + var y = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + if (isNaN(x) || isNaN(y)) { + return null + } + if (x1 >= x2) { + if (!between(x2, x, x1)) return null + } else { + if (!between(x1, x, x2)) return null + } + if (y1 >= y2) { + if (!between(y2, y, y1)) return null + } else { + if (!between(y1, y, y2)) return null + } + if (x3 >= x4) { + if (!between(x4, x, x3)) return null + } else { + if (!between(x3, x, x4)) return null + } + if (y3 >= y4) { + if (!between(y4, y, y3)) return null + } else { + if (!between(y3, y, y4)) return null + } + return [x, y] +} + +// not really a centroid +function getCentroid (shape: Shape): Point { + const pts: Point[] = shape.map(segment => segment[0]) + const total: Point = [0, 0] + pts.forEach(pt => { + total[0] += pt[0] / pts.length + total[1] += pt[1] / pts.length + }) + return total +} + +function drawShape(context: CanvasRenderingContext2D, shape: Shape) { + context.beginPath() + context.moveTo(shape[0][0][0], shape[0][0][1]) + for (const seg of shape) { + if (isLine(seg)) context.lineTo(seg[1][0], seg[1][1]) + if (isCurve(seg)) { + const { center, radius, startAngle, endAngle } = getArcValsForCurve(seg) + context.arc(center[0], center[1], radius, startAngle, endAngle) + } + } + context.strokeStyle = 'rgba(0, 0, 0, 0.5)' + context.stroke() +} + +// stolen from penplot by mattdesl (couldn't require it because it uses import/export) +const TO_PX = 35.43307 +const DEFAULT_SVG_LINE_WIDTH = 0.03 + +const convert = (num: number) => Number((TO_PX * num).toFixed(5)) + +type Opts = { + dimensions: Vec2 + fillStyle?: string + strokeStyle?: string + lineWidth?: number +} +function shapesToSVG (shapes: Shape[], opt: Opts) { + const dimensions = opt?.dimensions + if (!dimensions) throw new TypeError('must specify dimensions currently') + + const commands: string[] = [] + shapes.forEach(shape => { + const start = shape[0][0] + commands.push(`M ${convert(start[0])},${convert(start[1])}`) + shape.forEach(segment => { + const x = convert(segment[1][0]) + const y = convert(segment[1][1]) + if (isLine(segment)) { + commands.push(`L ${x},${y}`) + } else { + // rx ry angle large-arc-flag sweep-flag x y + const [start, end, center] = segment + const r = convert(vec2.distance(start, center)) + const sDir = vec2.sub([], start, center) + const eDir = vec2.sub([], end, center) + const sAngle = Math.atan2(sDir[1], sDir[0]) / Math.PI / 2 * 360 + const eAngle = Math.atan2(eDir[1], eDir[0]) / Math.PI / 2 * 360 + const angle = (eAngle - sAngle).toFixed(2) + commands.push(`A ${r},${r} ${angle} 0,1 ${x},${y}`) + } + }) + }) + + const svgPath = commands.join(' ') + const viewWidth = convert(dimensions[0]) + const viewHeight = convert(dimensions[1]) + const fillStyle = opt.fillStyle || 'none' + const strokeStyle = opt.strokeStyle || 'black' + const lineWidth = opt.lineWidth || DEFAULT_SVG_LINE_WIDTH + + return ` + + + + + +` +} + +console.log('press shift-S to send a plot localhost:8080 to be written to disk') +window.addEventListener('keypress', (e) => { + if (e.code === 'KeyS' && e.shiftKey) { + e.preventDefault() + e.stopPropagation() + + const opts: Opts = { + dimensions: [WIDTH / PIXELS_PER_CM, HEIGHT / PIXELS_PER_CM], // in cm + lineWidth: settings.lineWidthMM / 10 // in cm + } + const halfPxDimensions = [WIDTH / 2, HEIGHT / 2] + const svg = shapesToSVG(shapes.map((shape: Shape) => + shape.map(seg => + seg.map((v: Vec2) => { + const pt = vec2.add([], v, halfPxDimensions) + vec2.scale(pt, pt, 1 / PIXELS_PER_CM) + return pt + }) as Segment + ) as Shape + ), opts) + + console.log('THE SVG:', svg) + + // TODO: hash the params + const hash = settings.seed + const filename = `${PLOTNAME}-plot-hash-${hash}.svg` + fetch('http://localhost:8080/save-plot', { + method: 'POST', + body: JSON.stringify({ filename, svg }) + }).then(res => { + if (res.status !== 200) { + console.error('Attempt to save plot failed') + } else { + console.log(`Saved plot: ${filename}`) + } + }) + } +}) diff --git a/sketches/2023.11.30-22.04.51.ts b/sketches/2023.11.30-22.04.51.ts new file mode 100644 index 0000000..887fac3 --- /dev/null +++ b/sketches/2023.11.30-22.04.51.ts @@ -0,0 +1,446 @@ +import * as canvasSketch from 'canvas-sketch' +import * as random from 'canvas-sketch-util/random' +import { GUI } from 'dat-gui' +import * as vec2 from 'gl-vec2' + +const PLOTNAME = '2023.11.30-21.32.21' + +const MM_PER_INCH = 25.4 +const PIXELS_PER_INCH = 200 +const WIDTH = 6 * PIXELS_PER_INCH +const HEIGHT = 4 * PIXELS_PER_INCH +const PIXELS_PER_MM = PIXELS_PER_INCH / MM_PER_INCH +const PIXELS_PER_CM = PIXELS_PER_MM * 10 + +const settings = { + seed: 2167, + canvasMargin: 0.33, + minDist: 5, + maxDist: 8, + loops: 2, + lineWidthMM: 0.1 +} + +let shapes: Shape[] + +type SketchArgs = { context: CanvasRenderingContext2D, viewportWidth: number, viewportHeight: number } + +canvasSketch(({ render }) => { + const gui = new GUI() + gui.add(settings, 'seed', 0, 9999).step(1).onChange(render) + gui.add(settings, 'canvasMargin', 0, 0.5).step(0.01).onChange(render) + gui.add(settings, 'minDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'maxDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'loops', 0, 20).step(1).onChange(render) + gui.add(settings, 'lineWidthMM', 0.05, 2).step(0.01).onChange(render) + + return (args: SketchArgs) => { + const { context, viewportWidth, viewportHeight } = args + const width = viewportWidth + const height = viewportHeight + + shapes = [] + const margin = settings.canvasMargin * height + + context.clearRect(0, 0, width, height) + context.fillStyle = 'white' + context.fillRect(0, 0, width, height) + + const rand = random.createRandom(settings.seed) + + // draw a rectangle in the center of the canvas + const rectWidth = width - margin * 2 + const rectHeight = height - margin * 2 + const mainShape: Shape = [ + [[margin, margin], [margin + rectWidth, margin]], + [[margin + rectWidth, margin], [margin + rectWidth, margin + rectHeight]], + [[margin + rectWidth, margin + rectHeight], [margin, margin + rectHeight]], + [[margin, margin + rectHeight], [margin, margin]] + ] + + // extend the rectangle out by n pixels + const n = 15 + shapes.push([ + [[margin - n, margin - n], [margin + rectWidth + n, margin - n]], + [[margin + rectWidth + n, margin - n], [margin + rectWidth + n, margin + rectHeight + n]], + [[margin + rectWidth + n, margin + rectHeight + n], [margin - n, margin + rectHeight + n]], + [[margin - n, margin + rectHeight + n], [margin - n, margin - n]] + ]) + + let loops = settings.loops + while (loops--) { + const angle = rand.range(Math.PI * 2) + const dir: Vec2 = [Math.cos(angle), Math.sin(angle)] + + const perpDir: Vec2 = [dir[1], -dir[0]] + + const initOffset: Vec2 = [width / 2, height / 2] + vec2.add(initOffset, initOffset, vec2.scale([], vec2.random([]), rand.range(50))) + + let offset = initOffset.slice() + let lineCount = 0 + while (true) { + const line: Line = [ + vec2.scaleAndAdd([], offset, dir, -1000), + vec2.scaleAndAdd([], offset, dir, 1000) + ] + vec2.add(offset, offset, vec2.scale([], perpDir, rand.range(settings.minDist, settings.maxDist))) + + const intersections = shapeLineIntersections(line, mainShape) + lineCount += 1 + if (!intersections.length || lineCount > 100000) break + shapes.push([ + [intersections[0], intersections[1]] + ]) + } + + offset = initOffset.slice() + lineCount = 0 + while (true) { + vec2.add(offset, offset, vec2.scale([], perpDir, rand.range(-settings.minDist, -settings.maxDist))) + const line: Line = [ + vec2.scaleAndAdd([], offset, dir, -1000), + vec2.scaleAndAdd([], offset, dir, 1000) + ] + + const intersections = shapeLineIntersections(line, mainShape) + lineCount += 1 + if (!intersections.length || lineCount > 100000) break + shapes.push([ + [intersections[0], intersections[1]] + ]) + } + } + + for (const shape of shapes) { + drawShape(context, shape) + } + } +}, { + dimensions: [WIDTH, HEIGHT] +}) + +type Vec2 = [number, number] +type Point = Vec2 +type Dir = Vec2 +type Line = [Point, Point] +type Curve = [Point, Point, Point] // start, end, center +type Ray = [Point, Dir] +type Segment = Line | Curve +type Shape = Segment[] +type ArcVals = { + center: Point + startAngle: number + endAngle: number + radius: number +} + +function isPoint(obj: any): obj is Point { + return Array.isArray(obj) && obj.length === 2 && typeof obj[0] === 'number' && typeof obj[1] === 'number' +} + +function isLine(obj: any): obj is Line { + return Array.isArray(obj) && obj.length === 2 && obj.every(isPoint) +} + +function isCurve(obj: any): obj is Curve { + return Array.isArray(obj) && obj.length === 3 && obj.every(isPoint) +} + +// make sure line is well extended beyond bounds of the canvas +// because it just checks for segments +// function divideShapeWithLine (line: Line, shape: Shape): [Shape, Shape] | null { +// const shape1: Segment[] = [] +// const shape2: Segment[] = [] +// const intersections: Point[] = [] +// for (const segment of shape) { +// let pushTo = intersections.length === 1 ? shape2 : shape1 +// if (intersections.length === 2) { +// pushTo.push(segment) +// continue +// } +// const int = isLine(segment) ? segmentIntersection(segment, line) : getValidCurveIntersections(segment, line) +// if (int === null) { +// pushTo.push(segment) +// continue +// } +// const firstChunk: Segment = isLine(segment) ? [segment[0], int] : [segment[0], int, segment[2]] +// pushTo.push(firstChunk) +// if (intersections.length === 1) { +// shape2.push([int, intersections[0]]) +// shape1.push([intersections[0], int]) +// } +// intersections.push(int) +// pushTo = intersections.length === 1 ? shape2 : shape1 +// const secondChunk: Segment = isLine(segment) ? [int, segment[1]] : [int, segment[1], segment[2]] +// pushTo.push(secondChunk) +// } + +// if (intersections.length !== 2) return null +// return [shape1, shape2] +// } + +// make sure line is well extended beyond bounds of the canvas +// because it just checks for segments + +function shapeLineIntersections (line: Line, shape: Shape): Point[] { + const intersections: Point[] = [] + for (const segment of shape) { + if (isLine(segment)) { + const int = segmentIntersection(segment, line) + if (int !== null) intersections.push(int) + continue + } + const ints = curveLineIntersection(segment, line) + if (ints !== null) { + for (const pt of ints) intersections.push(pt) + } + } + return intersections +} + +function distRayToShape (ray: Ray, shape: Shape): number | null { + const [pt, dir] = ray + const p2 = vec2.scaleAndAdd([], pt, dir, 1000000) + const intersections = shapeLineIntersections([pt, p2], shape) + let min = Infinity + for (const int of intersections) { + const dist = vec2.squaredDistance(pt, int) + min = Math.min(min, dist) + } + return Number.isFinite(min) ? Math.sqrt(min) : null +} + +function getArcValsForCurve (curve: Curve): ArcVals { + const [start, end, center] = curve + const radius = vec2.distance(start, center) + const dirStart: Vec2 = vec2.sub([], start, center) + const dirEnd: Vec2 = vec2.sub([], end, center) + const startAngle = Math.atan2(dirStart[1], dirStart[0]) + const endAngle = Math.atan2(dirEnd[1], dirEnd[0]) + return { center, radius, startAngle, endAngle } +} + +// special function that returns null if there's not exactly one intersection +// for a given curve +function getValidCurveIntersections(curve: Curve, line: Line): Point | null { + const intersections = curveLineIntersection(curve, line) + return intersections.length === 1 ? intersections[0] : null +} + +// special function that returns null if no intersection or if line is tangent to curve +function curveLineIntersection(curve: Curve, line: Line): Point[] { + const [start, end, center] = curve + const radius = vec2.distance(start, center) + const intersections = findCircleLineIntersections(radius, center, line[0], line[1]) + if (intersections.length < 2) return [] + const dirStart: Vec2 = vec2.sub([], start, center) + const dirEnd: Vec2 = vec2.sub([], end, center) + const startAngle = Math.atan2(dirStart[1], dirStart[0]) + let endAngle = Math.atan2(dirEnd[1], dirEnd[0]) + if (endAngle < startAngle) endAngle += Math.PI * 2 + const filteredIntersections = intersections.filter(pt => { + const dir: Vec2 = vec2.sub([], pt, center) + let angle = Math.atan2(dir[1], dir[0]) + if (angle < startAngle) angle += Math.PI * 2 + else if (angle > endAngle) angle -= Math.PI * 2 + return startAngle < angle && endAngle > angle + }) + return filteredIntersections +} + +function findCircleLineIntersections (r: number, center: Point, p1: Point, p2: Point): Point[] { + // circle: (x - h)^2 + (y - k)^2 = r^2 + // line: y = m * x + n + // r: circle radius + // center: circle center + // p1 & p2: two points on the intersecting line + + // m: slope + // n: y-intercept + const h = center[0] + const k = center[1] + const m = (p2[1] - p1[1]) / (p2[0] - p1[0]) + const n = p2[1] - m * p2[0] + + // get a, b, c values + const a = 1 + m * m + const b = -h * 2 + (m * (n - k)) * 2 + const c = h * h + (n - k) * (n - k) - r * r + + // insert into quadratic formula + const i1 = getPt((-b + Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a)) + const i2 = getPt((-b - Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a)) + + if (isEqual(i1[0], i2[0]) && isEqual(i1[1], i2[1])) { + return [i1] + } + + return [i1, i2].filter(pt => Number.isFinite(pt[0]) && Number.isFinite(pt[1])) + + function getPt (x: number): Point { + return [x, m * x + n] + } +} + +function isEqual (a: number, b: number) { + return Math.abs(a - b) < 1 +} + +const EPS = 0.0000001 +function between (a: number, b: number, c: number): boolean { + return a - EPS <= b && b <= c + EPS +} +function segmentIntersection (segment1: Line, segment2: Line): Point | null { + const [[x1, y1], [x2, y2]] = segment1 + const [[x3, y3], [x4, y4]] = segment2 + var x = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + var y = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + if (isNaN(x) || isNaN(y)) { + return null + } + if (x1 >= x2) { + if (!between(x2, x, x1)) return null + } else { + if (!between(x1, x, x2)) return null + } + if (y1 >= y2) { + if (!between(y2, y, y1)) return null + } else { + if (!between(y1, y, y2)) return null + } + if (x3 >= x4) { + if (!between(x4, x, x3)) return null + } else { + if (!between(x3, x, x4)) return null + } + if (y3 >= y4) { + if (!between(y4, y, y3)) return null + } else { + if (!between(y3, y, y4)) return null + } + return [x, y] +} + +// not really a centroid +function getCentroid (shape: Shape): Point { + const pts: Point[] = shape.map(segment => segment[0]) + const total: Point = [0, 0] + pts.forEach(pt => { + total[0] += pt[0] / pts.length + total[1] += pt[1] / pts.length + }) + return total +} + +function drawShape(context: CanvasRenderingContext2D, shape: Shape) { + context.beginPath() + context.moveTo(shape[0][0][0], shape[0][0][1]) + for (const seg of shape) { + if (isLine(seg)) context.lineTo(seg[1][0], seg[1][1]) + if (isCurve(seg)) { + const { center, radius, startAngle, endAngle } = getArcValsForCurve(seg) + context.arc(center[0], center[1], radius, startAngle, endAngle) + } + } + context.strokeStyle = 'rgba(0, 0, 0, 0.5)' + context.stroke() +} + +// stolen from penplot by mattdesl (couldn't require it because it uses import/export) +const TO_PX = 35.43307 +const DEFAULT_SVG_LINE_WIDTH = 0.03 + +const convert = (num: number) => Number((TO_PX * num).toFixed(5)) + +type Opts = { + dimensions: Vec2 + fillStyle?: string + strokeStyle?: string + lineWidth?: number +} +function shapesToSVG (shapes: Shape[], opt: Opts) { + const dimensions = opt?.dimensions + if (!dimensions) throw new TypeError('must specify dimensions currently') + + const commands: string[] = [] + shapes.forEach(shape => { + const start = shape[0][0] + commands.push(`M ${convert(start[0])},${convert(start[1])}`) + shape.forEach(segment => { + const x = convert(segment[1][0]) + const y = convert(segment[1][1]) + if (isLine(segment)) { + commands.push(`L ${x},${y}`) + } else { + // rx ry angle large-arc-flag sweep-flag x y + const [start, end, center] = segment + const r = convert(vec2.distance(start, center)) + const sDir = vec2.sub([], start, center) + const eDir = vec2.sub([], end, center) + const sAngle = Math.atan2(sDir[1], sDir[0]) / Math.PI / 2 * 360 + const eAngle = Math.atan2(eDir[1], eDir[0]) / Math.PI / 2 * 360 + const angle = (eAngle - sAngle).toFixed(2) + commands.push(`A ${r},${r} ${angle} 0,1 ${x},${y}`) + } + }) + }) + + const svgPath = commands.join(' ') + const viewWidth = convert(dimensions[0]) + const viewHeight = convert(dimensions[1]) + const fillStyle = opt.fillStyle || 'none' + const strokeStyle = opt.strokeStyle || 'black' + const lineWidth = opt.lineWidth || DEFAULT_SVG_LINE_WIDTH + + return ` + + + + + +` +} + +console.log('press shift-S to send a plot localhost:8080 to be written to disk') +window.addEventListener('keypress', (e) => { + if (e.code === 'KeyS' && e.shiftKey) { + e.preventDefault() + e.stopPropagation() + + const opts: Opts = { + dimensions: [WIDTH / PIXELS_PER_CM, HEIGHT / PIXELS_PER_CM], // in cm + lineWidth: settings.lineWidthMM / 10 // in cm + } + const halfPxDimensions = [WIDTH / 2, HEIGHT / 2] + const svg = shapesToSVG(shapes.map((shape: Shape) => + shape.map(seg => + seg.map((v: Vec2) => { + const pt = vec2.add([], v, halfPxDimensions) + vec2.scale(pt, pt, 1 / PIXELS_PER_CM) + return pt + }) as Segment + ) as Shape + ), opts) + + console.log('THE SVG:', svg) + + // TODO: hash the params + const hash = settings.seed + const filename = `${PLOTNAME}-plot-hash-${hash}.svg` + fetch('http://localhost:8080/save-plot', { + method: 'POST', + body: JSON.stringify({ filename, svg }) + }).then(res => { + if (res.status !== 200) { + console.error('Attempt to save plot failed') + } else { + console.log(`Saved plot: ${filename}`) + } + }) + } +}) diff --git a/sketches/2023.12.01-22.37.42.ts b/sketches/2023.12.01-22.37.42.ts new file mode 100644 index 0000000..f680931 --- /dev/null +++ b/sketches/2023.12.01-22.37.42.ts @@ -0,0 +1,474 @@ +import * as canvasSketch from 'canvas-sketch' +import * as random from 'canvas-sketch-util/random' +import { GUI } from 'dat-gui' +import * as vec2 from 'gl-vec2' + +const PLOTNAME = '2023.12.01-22.37.42' + +const MM_PER_INCH = 25.4 +const PIXELS_PER_INCH = 200 +const WIDTH = 6 * PIXELS_PER_INCH +const HEIGHT = 4 * PIXELS_PER_INCH +const PIXELS_PER_MM = PIXELS_PER_INCH / MM_PER_INCH +const PIXELS_PER_CM = PIXELS_PER_MM * 10 + +const settings = { + seed: 1842, + canvasMargin: 0.33, + minDist: 5, + maxDist: 8, + loops: 1, + lineNoiseFreq: 4, + lineNoiseMag: 2.7, + lineDivisionSize: 10, + lineWidthMM: 0.1 +} + +let shapes: Shape[] + +type SketchArgs = { context: CanvasRenderingContext2D, viewportWidth: number, viewportHeight: number } + +canvasSketch(({ render }) => { + const gui = new GUI() + gui.add(settings, 'seed', 0, 9999).step(1).onChange(render) + gui.add(settings, 'canvasMargin', 0, 0.5).step(0.01).onChange(render) + gui.add(settings, 'minDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'maxDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'loops', 0, 20).step(1).onChange(render) + gui.add(settings, 'lineNoiseFreq', 0, 10).onChange(render) + gui.add(settings, 'lineNoiseMag', 0, 5).step(0.01).onChange(render) + gui.add(settings, 'lineDivisionSize', 1, 100).step(1).onChange(render) + gui.add(settings, 'lineWidthMM', 0.05, 2).step(0.01).onChange(render) + + return (args: SketchArgs) => { + const { context, viewportWidth, viewportHeight } = args + const width = viewportWidth + const height = viewportHeight + + shapes = [] + const margin = settings.canvasMargin * height + + context.clearRect(0, 0, width, height) + context.fillStyle = 'white' + context.fillRect(0, 0, width, height) + + const rand = random.createRandom(settings.seed) + + function perturbLine (line: Line): Shape { + const [start, end] = line + const dir: Vec2 = vec2.sub([], end, start) + vec2.normalize(dir, dir) + const norm: Vec2 = [dir[1], -dir[0]] + const tStart = rand.range(1000000) + + const divisions = Math.max(1, Math.floor(vec2.distance(start, end) / settings.lineDivisionSize)) + const shape: Shape = [] + let prevPt = null + for (let i = 0; i <= divisions; i++) { + const t = i / divisions + const pt = vec2.lerp([], start, end, t) + const mag = rand.noise1D(t + tStart, settings.lineNoiseFreq, settings.lineNoiseMag) + vec2.add(pt, pt, vec2.scale([], norm, mag)) + if (prevPt !== null) { + shape.push([[prevPt[0], prevPt[1]], [pt[0], pt[1]]]) + } + prevPt = pt + } + + return shape + } + + // draw a rectangle in the center of the canvas + const rectWidth = width - margin * 2 + const rectHeight = height - margin * 2 + const mainShape: Shape = [ + [[margin, margin], [margin + rectWidth, margin]], + [[margin + rectWidth, margin], [margin + rectWidth, margin + rectHeight]], + [[margin + rectWidth, margin + rectHeight], [margin, margin + rectHeight]], + [[margin, margin + rectHeight], [margin, margin]] + ] + + // extend the rectangle out by n pixels + const n = 15 + shapes.push([ + [[margin - n, margin - n], [margin + rectWidth + n, margin - n]], + [[margin + rectWidth + n, margin - n], [margin + rectWidth + n, margin + rectHeight + n]], + [[margin + rectWidth + n, margin + rectHeight + n], [margin - n, margin + rectHeight + n]], + [[margin - n, margin + rectHeight + n], [margin - n, margin - n]] + ]) + + let loops = settings.loops + while (loops--) { + const angle = rand.range(Math.PI * 2) + const dir: Vec2 = [Math.cos(angle), Math.sin(angle)] + + const perpDir: Vec2 = [dir[1], -dir[0]] + + const initOffset: Vec2 = [width / 2, height / 2] + vec2.add(initOffset, initOffset, vec2.scale([], vec2.random([]), rand.range(50))) + + let offset = initOffset.slice() + let lineCount = 0 + while (true) { + const line: Line = [ + vec2.scaleAndAdd([], offset, dir, -1000), + vec2.scaleAndAdd([], offset, dir, 1000) + ] + vec2.add(offset, offset, vec2.scale([], perpDir, rand.range(settings.minDist, settings.maxDist))) + + const intersections = shapeLineIntersections(line, mainShape) + lineCount += 1 + if (!intersections.length || lineCount > 100000) break + shapes.push([ + [intersections[0], intersections[1]] + ]) + } + + offset = initOffset.slice() + lineCount = 0 + while (true) { + vec2.add(offset, offset, vec2.scale([], perpDir, rand.range(-settings.minDist, -settings.maxDist))) + const line: Line = [ + vec2.scaleAndAdd([], offset, dir, -1000), + vec2.scaleAndAdd([], offset, dir, 1000) + ] + + const intersections = shapeLineIntersections(line, mainShape) + lineCount += 1 + if (!intersections.length || lineCount > 100000) break + shapes.push(perturbLine([intersections[0], intersections[1]])) + } + } + + for (const shape of shapes) { + drawShape(context, shape) + } + } +}, { + dimensions: [WIDTH, HEIGHT] +}) + +type Vec2 = [number, number] +type Point = Vec2 +type Dir = Vec2 +type Line = [Point, Point] +type Curve = [Point, Point, Point] // start, end, center +type Ray = [Point, Dir] +type Segment = Line | Curve +type Shape = Segment[] +type ArcVals = { + center: Point + startAngle: number + endAngle: number + radius: number +} + +function isPoint(obj: any): obj is Point { + return Array.isArray(obj) && obj.length === 2 && typeof obj[0] === 'number' && typeof obj[1] === 'number' +} + +function isLine(obj: any): obj is Line { + return Array.isArray(obj) && obj.length === 2 && obj.every(isPoint) +} + +function isCurve(obj: any): obj is Curve { + return Array.isArray(obj) && obj.length === 3 && obj.every(isPoint) +} + +// make sure line is well extended beyond bounds of the canvas +// because it just checks for segments +// function divideShapeWithLine (line: Line, shape: Shape): [Shape, Shape] | null { +// const shape1: Segment[] = [] +// const shape2: Segment[] = [] +// const intersections: Point[] = [] +// for (const segment of shape) { +// let pushTo = intersections.length === 1 ? shape2 : shape1 +// if (intersections.length === 2) { +// pushTo.push(segment) +// continue +// } +// const int = isLine(segment) ? segmentIntersection(segment, line) : getValidCurveIntersections(segment, line) +// if (int === null) { +// pushTo.push(segment) +// continue +// } +// const firstChunk: Segment = isLine(segment) ? [segment[0], int] : [segment[0], int, segment[2]] +// pushTo.push(firstChunk) +// if (intersections.length === 1) { +// shape2.push([int, intersections[0]]) +// shape1.push([intersections[0], int]) +// } +// intersections.push(int) +// pushTo = intersections.length === 1 ? shape2 : shape1 +// const secondChunk: Segment = isLine(segment) ? [int, segment[1]] : [int, segment[1], segment[2]] +// pushTo.push(secondChunk) +// } + +// if (intersections.length !== 2) return null +// return [shape1, shape2] +// } + +// make sure line is well extended beyond bounds of the canvas +// because it just checks for segments + +function shapeLineIntersections (line: Line, shape: Shape): Point[] { + const intersections: Point[] = [] + for (const segment of shape) { + if (isLine(segment)) { + const int = segmentIntersection(segment, line) + if (int !== null) intersections.push(int) + continue + } + const ints = curveLineIntersection(segment, line) + if (ints !== null) { + for (const pt of ints) intersections.push(pt) + } + } + return intersections +} + +function distRayToShape (ray: Ray, shape: Shape): number | null { + const [pt, dir] = ray + const p2 = vec2.scaleAndAdd([], pt, dir, 1000000) + const intersections = shapeLineIntersections([pt, p2], shape) + let min = Infinity + for (const int of intersections) { + const dist = vec2.squaredDistance(pt, int) + min = Math.min(min, dist) + } + return Number.isFinite(min) ? Math.sqrt(min) : null +} + +function getArcValsForCurve (curve: Curve): ArcVals { + const [start, end, center] = curve + const radius = vec2.distance(start, center) + const dirStart: Vec2 = vec2.sub([], start, center) + const dirEnd: Vec2 = vec2.sub([], end, center) + const startAngle = Math.atan2(dirStart[1], dirStart[0]) + const endAngle = Math.atan2(dirEnd[1], dirEnd[0]) + return { center, radius, startAngle, endAngle } +} + +// special function that returns null if there's not exactly one intersection +// for a given curve +function getValidCurveIntersections(curve: Curve, line: Line): Point | null { + const intersections = curveLineIntersection(curve, line) + return intersections.length === 1 ? intersections[0] : null +} + +// special function that returns null if no intersection or if line is tangent to curve +function curveLineIntersection(curve: Curve, line: Line): Point[] { + const [start, end, center] = curve + const radius = vec2.distance(start, center) + const intersections = findCircleLineIntersections(radius, center, line[0], line[1]) + if (intersections.length < 2) return [] + const dirStart: Vec2 = vec2.sub([], start, center) + const dirEnd: Vec2 = vec2.sub([], end, center) + const startAngle = Math.atan2(dirStart[1], dirStart[0]) + let endAngle = Math.atan2(dirEnd[1], dirEnd[0]) + if (endAngle < startAngle) endAngle += Math.PI * 2 + const filteredIntersections = intersections.filter(pt => { + const dir: Vec2 = vec2.sub([], pt, center) + let angle = Math.atan2(dir[1], dir[0]) + if (angle < startAngle) angle += Math.PI * 2 + else if (angle > endAngle) angle -= Math.PI * 2 + return startAngle < angle && endAngle > angle + }) + return filteredIntersections +} + +function findCircleLineIntersections (r: number, center: Point, p1: Point, p2: Point): Point[] { + // circle: (x - h)^2 + (y - k)^2 = r^2 + // line: y = m * x + n + // r: circle radius + // center: circle center + // p1 & p2: two points on the intersecting line + + // m: slope + // n: y-intercept + const h = center[0] + const k = center[1] + const m = (p2[1] - p1[1]) / (p2[0] - p1[0]) + const n = p2[1] - m * p2[0] + + // get a, b, c values + const a = 1 + m * m + const b = -h * 2 + (m * (n - k)) * 2 + const c = h * h + (n - k) * (n - k) - r * r + + // insert into quadratic formula + const i1 = getPt((-b + Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a)) + const i2 = getPt((-b - Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a)) + + if (isEqual(i1[0], i2[0]) && isEqual(i1[1], i2[1])) { + return [i1] + } + + return [i1, i2].filter(pt => Number.isFinite(pt[0]) && Number.isFinite(pt[1])) + + function getPt (x: number): Point { + return [x, m * x + n] + } +} + +function isEqual (a: number, b: number) { + return Math.abs(a - b) < 1 +} + +const EPS = 0.0000001 +function between (a: number, b: number, c: number): boolean { + return a - EPS <= b && b <= c + EPS +} +function segmentIntersection (segment1: Line, segment2: Line): Point | null { + const [[x1, y1], [x2, y2]] = segment1 + const [[x3, y3], [x4, y4]] = segment2 + var x = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + var y = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + if (isNaN(x) || isNaN(y)) { + return null + } + if (x1 >= x2) { + if (!between(x2, x, x1)) return null + } else { + if (!between(x1, x, x2)) return null + } + if (y1 >= y2) { + if (!between(y2, y, y1)) return null + } else { + if (!between(y1, y, y2)) return null + } + if (x3 >= x4) { + if (!between(x4, x, x3)) return null + } else { + if (!between(x3, x, x4)) return null + } + if (y3 >= y4) { + if (!between(y4, y, y3)) return null + } else { + if (!between(y3, y, y4)) return null + } + return [x, y] +} + +// not really a centroid +function getCentroid (shape: Shape): Point { + const pts: Point[] = shape.map(segment => segment[0]) + const total: Point = [0, 0] + pts.forEach(pt => { + total[0] += pt[0] / pts.length + total[1] += pt[1] / pts.length + }) + return total +} + +function drawShape(context: CanvasRenderingContext2D, shape: Shape) { + context.beginPath() + context.moveTo(shape[0][0][0], shape[0][0][1]) + for (const seg of shape) { + if (isLine(seg)) context.lineTo(seg[1][0], seg[1][1]) + if (isCurve(seg)) { + const { center, radius, startAngle, endAngle } = getArcValsForCurve(seg) + context.arc(center[0], center[1], radius, startAngle, endAngle) + } + } + context.strokeStyle = 'rgba(0, 0, 0, 0.5)' + context.stroke() +} + +// stolen from penplot by mattdesl (couldn't require it because it uses import/export) +const TO_PX = 35.43307 +const DEFAULT_SVG_LINE_WIDTH = 0.03 + +const convert = (num: number) => Number((TO_PX * num).toFixed(5)) + +type Opts = { + dimensions: Vec2 + fillStyle?: string + strokeStyle?: string + lineWidth?: number +} +function shapesToSVG (shapes: Shape[], opt: Opts) { + const dimensions = opt?.dimensions + if (!dimensions) throw new TypeError('must specify dimensions currently') + + const commands: string[] = [] + shapes.forEach(shape => { + const start = shape[0][0] + commands.push(`M ${convert(start[0])},${convert(start[1])}`) + shape.forEach(segment => { + const x = convert(segment[1][0]) + const y = convert(segment[1][1]) + if (isLine(segment)) { + commands.push(`L ${x},${y}`) + } else { + // rx ry angle large-arc-flag sweep-flag x y + const [start, end, center] = segment + const r = convert(vec2.distance(start, center)) + const sDir = vec2.sub([], start, center) + const eDir = vec2.sub([], end, center) + const sAngle = Math.atan2(sDir[1], sDir[0]) / Math.PI / 2 * 360 + const eAngle = Math.atan2(eDir[1], eDir[0]) / Math.PI / 2 * 360 + const angle = (eAngle - sAngle).toFixed(2) + commands.push(`A ${r},${r} ${angle} 0,1 ${x},${y}`) + } + }) + }) + + const svgPath = commands.join(' ') + const viewWidth = convert(dimensions[0]) + const viewHeight = convert(dimensions[1]) + const fillStyle = opt.fillStyle || 'none' + const strokeStyle = opt.strokeStyle || 'black' + const lineWidth = opt.lineWidth || DEFAULT_SVG_LINE_WIDTH + + return ` + + + + + +` +} + +console.log('press shift-S to send a plot localhost:8080 to be written to disk') +window.addEventListener('keypress', (e) => { + if (e.code === 'KeyS' && e.shiftKey) { + e.preventDefault() + e.stopPropagation() + + const opts: Opts = { + dimensions: [WIDTH / PIXELS_PER_CM, HEIGHT / PIXELS_PER_CM], // in cm + lineWidth: settings.lineWidthMM / 10 // in cm + } + const halfPxDimensions = [WIDTH / 2, HEIGHT / 2] + const svg = shapesToSVG(shapes.map((shape: Shape) => + shape.map(seg => + seg.map((v: Vec2) => { + const pt = vec2.add([], v, halfPxDimensions) + vec2.scale(pt, pt, 1 / PIXELS_PER_CM) + return pt + }) as Segment + ) as Shape + ), opts) + + console.log('THE SVG:', svg) + + // TODO: hash the params + const hash = settings.seed + const filename = `${PLOTNAME}-plot-hash-${hash}.svg` + fetch('http://localhost:8080/save-plot', { + method: 'POST', + body: JSON.stringify({ filename, svg }) + }).then(res => { + if (res.status !== 200) { + console.error('Attempt to save plot failed') + } else { + console.log(`Saved plot: ${filename}`) + } + }) + } +}) diff --git a/sketches/2023.12.01-23.19.47.ts b/sketches/2023.12.01-23.19.47.ts new file mode 100644 index 0000000..bfca620 --- /dev/null +++ b/sketches/2023.12.01-23.19.47.ts @@ -0,0 +1,458 @@ +import * as canvasSketch from 'canvas-sketch' +import * as random from 'canvas-sketch-util/random' +import { GUI } from 'dat-gui' +import * as vec2 from 'gl-vec2' + +const PLOTNAME = '2023.12.01-22.37.42' + +const MM_PER_INCH = 25.4 +const PIXELS_PER_INCH = 200 +const WIDTH = 6 * PIXELS_PER_INCH +const HEIGHT = 4 * PIXELS_PER_INCH +const PIXELS_PER_MM = PIXELS_PER_INCH / MM_PER_INCH +const PIXELS_PER_CM = PIXELS_PER_MM * 10 + +const settings = { + seed: 1842, + canvasMargin: 0.33, + minDist: 5, + maxDist: 8, + loops: 1, + lineNoiseFreq: 1.5, + lineNoiseMag: 3, + lineDivisionSize: 25, + lineWidthMM: 0.1 +} + +let shapes: Shape[] + +type SketchArgs = { context: CanvasRenderingContext2D, viewportWidth: number, viewportHeight: number } + +canvasSketch(({ render }) => { + const gui = new GUI() + gui.add(settings, 'seed', 0, 9999).step(1).onChange(render) + gui.add(settings, 'canvasMargin', 0, 0.5).step(0.01).onChange(render) + gui.add(settings, 'minDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'maxDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'loops', 0, 20).step(1).onChange(render) + gui.add(settings, 'lineNoiseFreq', 0, 10).onChange(render) + gui.add(settings, 'lineNoiseMag', 0, 5).step(0.01).onChange(render) + gui.add(settings, 'lineDivisionSize', 1, 100).step(1).onChange(render) + gui.add(settings, 'lineWidthMM', 0.05, 2).step(0.01).onChange(render) + + return (args: SketchArgs) => { + const { context, viewportWidth, viewportHeight } = args + const width = viewportWidth + const height = viewportHeight + + shapes = [] + const margin = settings.canvasMargin * height + + context.clearRect(0, 0, width, height) + context.fillStyle = 'white' + context.fillRect(0, 0, width, height) + + const rand = random.createRandom(settings.seed) + + function perturbLine (line: Line): Shape { + const [start, end] = line + const dir: Vec2 = vec2.sub([], end, start) + vec2.normalize(dir, dir) + const norm: Vec2 = [dir[1], -dir[0]] + const tStart = rand.range(1000000) + + const divisions = Math.max(1, Math.floor(vec2.distance(start, end) / settings.lineDivisionSize)) + const shape: Shape = [] + let prevPt = null + for (let i = 0; i <= divisions; i++) { + const t = i / divisions + const pt = vec2.lerp([], start, end, t) + const mag = rand.noise1D(t + tStart, settings.lineNoiseFreq, settings.lineNoiseMag) + vec2.add(pt, pt, vec2.scale([], norm, mag)) + if (prevPt !== null) { + shape.push([[prevPt[0], prevPt[1]], [pt[0], pt[1]]]) + } + prevPt = pt + } + + return shape + } + + // draw a rectangle in the center of the canvas + const rectWidth = width - margin * 2 + const rectHeight = height - margin * 2 + const mainShape: Shape = [ + [[margin, margin], [margin + rectWidth, margin]], + [[margin + rectWidth, margin], [margin + rectWidth, margin + rectHeight]], + [[margin + rectWidth, margin + rectHeight], [margin, margin + rectHeight]], + [[margin, margin + rectHeight], [margin, margin]] + ] + + // extend the rectangle out by n pixels + const n = 15 + shapes.push(perturbLine([[margin - n, margin - n], [margin + rectWidth + n, margin - n]])) + shapes.push(perturbLine([[margin + rectWidth + n, margin - n], [margin + rectWidth + n, margin + rectHeight + n]])) + shapes.push(perturbLine([[margin + rectWidth + n, margin + rectHeight + n], [margin - n, margin + rectHeight + n]])) + shapes.push(perturbLine([[margin - n, margin + rectHeight + n], [margin - n, margin - n]])) + + let loops = settings.loops + while (loops--) { + const angle = rand.range(Math.PI * 2) + const dir: Vec2 = [Math.cos(angle), Math.sin(angle)] + + const perpDir: Vec2 = [dir[1], -dir[0]] + + const initOffset: Vec2 = [width / 2, height / 2] + vec2.add(initOffset, initOffset, rand.onCircle(rand.range(50))) + + ;[-1, 1].forEach(mult => { + const offset = initOffset.slice() + let lineCount = 0 + if (mult === -1) vec2.add(offset, offset, vec2.scale([], perpDir, rand.range(mult * settings.minDist, mult * settings.maxDist))) + while (true) { + const line: Line = [ + vec2.scaleAndAdd([], offset, dir, -1000), + vec2.scaleAndAdd([], offset, dir, 1000) + ] + vec2.add(offset, offset, vec2.scale([], perpDir, rand.range(mult * settings.minDist, mult * settings.maxDist))) + + const intersections = shapeLineIntersections(line, mainShape) + lineCount += 1 + if (!intersections.length || lineCount > 100000) break + shapes.push(perturbLine([intersections[0], intersections[1]])) + } + }) + } + + for (const shape of shapes) { + drawShape(context, shape) + } + } +}, { + dimensions: [WIDTH, HEIGHT] +}) + +type Vec2 = [number, number] +type Point = Vec2 +type Dir = Vec2 +type Line = [Point, Point] +type Curve = [Point, Point, Point] // start, end, center +type Ray = [Point, Dir] +type Segment = Line | Curve +type Shape = Segment[] +type ArcVals = { + center: Point + startAngle: number + endAngle: number + radius: number +} + +function isPoint(obj: any): obj is Point { + return Array.isArray(obj) && obj.length === 2 && typeof obj[0] === 'number' && typeof obj[1] === 'number' +} + +function isLine(obj: any): obj is Line { + return Array.isArray(obj) && obj.length === 2 && obj.every(isPoint) +} + +function isCurve(obj: any): obj is Curve { + return Array.isArray(obj) && obj.length === 3 && obj.every(isPoint) +} + +// make sure line is well extended beyond bounds of the canvas +// because it just checks for segments +// function divideShapeWithLine (line: Line, shape: Shape): [Shape, Shape] | null { +// const shape1: Segment[] = [] +// const shape2: Segment[] = [] +// const intersections: Point[] = [] +// for (const segment of shape) { +// let pushTo = intersections.length === 1 ? shape2 : shape1 +// if (intersections.length === 2) { +// pushTo.push(segment) +// continue +// } +// const int = isLine(segment) ? segmentIntersection(segment, line) : getValidCurveIntersections(segment, line) +// if (int === null) { +// pushTo.push(segment) +// continue +// } +// const firstChunk: Segment = isLine(segment) ? [segment[0], int] : [segment[0], int, segment[2]] +// pushTo.push(firstChunk) +// if (intersections.length === 1) { +// shape2.push([int, intersections[0]]) +// shape1.push([intersections[0], int]) +// } +// intersections.push(int) +// pushTo = intersections.length === 1 ? shape2 : shape1 +// const secondChunk: Segment = isLine(segment) ? [int, segment[1]] : [int, segment[1], segment[2]] +// pushTo.push(secondChunk) +// } + +// if (intersections.length !== 2) return null +// return [shape1, shape2] +// } + +// make sure line is well extended beyond bounds of the canvas +// because it just checks for segments + +function shapeLineIntersections (line: Line, shape: Shape): Point[] { + const intersections: Point[] = [] + for (const segment of shape) { + if (isLine(segment)) { + const int = segmentIntersection(segment, line) + if (int !== null) intersections.push(int) + continue + } + const ints = curveLineIntersection(segment, line) + if (ints !== null) { + for (const pt of ints) intersections.push(pt) + } + } + return intersections +} + +function distRayToShape (ray: Ray, shape: Shape): number | null { + const [pt, dir] = ray + const p2 = vec2.scaleAndAdd([], pt, dir, 1000000) + const intersections = shapeLineIntersections([pt, p2], shape) + let min = Infinity + for (const int of intersections) { + const dist = vec2.squaredDistance(pt, int) + min = Math.min(min, dist) + } + return Number.isFinite(min) ? Math.sqrt(min) : null +} + +function getArcValsForCurve (curve: Curve): ArcVals { + const [start, end, center] = curve + const radius = vec2.distance(start, center) + const dirStart: Vec2 = vec2.sub([], start, center) + const dirEnd: Vec2 = vec2.sub([], end, center) + const startAngle = Math.atan2(dirStart[1], dirStart[0]) + const endAngle = Math.atan2(dirEnd[1], dirEnd[0]) + return { center, radius, startAngle, endAngle } +} + +// special function that returns null if there's not exactly one intersection +// for a given curve +function getValidCurveIntersections(curve: Curve, line: Line): Point | null { + const intersections = curveLineIntersection(curve, line) + return intersections.length === 1 ? intersections[0] : null +} + +// special function that returns null if no intersection or if line is tangent to curve +function curveLineIntersection(curve: Curve, line: Line): Point[] { + const [start, end, center] = curve + const radius = vec2.distance(start, center) + const intersections = findCircleLineIntersections(radius, center, line[0], line[1]) + if (intersections.length < 2) return [] + const dirStart: Vec2 = vec2.sub([], start, center) + const dirEnd: Vec2 = vec2.sub([], end, center) + const startAngle = Math.atan2(dirStart[1], dirStart[0]) + let endAngle = Math.atan2(dirEnd[1], dirEnd[0]) + if (endAngle < startAngle) endAngle += Math.PI * 2 + const filteredIntersections = intersections.filter(pt => { + const dir: Vec2 = vec2.sub([], pt, center) + let angle = Math.atan2(dir[1], dir[0]) + if (angle < startAngle) angle += Math.PI * 2 + else if (angle > endAngle) angle -= Math.PI * 2 + return startAngle < angle && endAngle > angle + }) + return filteredIntersections +} + +function findCircleLineIntersections (r: number, center: Point, p1: Point, p2: Point): Point[] { + // circle: (x - h)^2 + (y - k)^2 = r^2 + // line: y = m * x + n + // r: circle radius + // center: circle center + // p1 & p2: two points on the intersecting line + + // m: slope + // n: y-intercept + const h = center[0] + const k = center[1] + const m = (p2[1] - p1[1]) / (p2[0] - p1[0]) + const n = p2[1] - m * p2[0] + + // get a, b, c values + const a = 1 + m * m + const b = -h * 2 + (m * (n - k)) * 2 + const c = h * h + (n - k) * (n - k) - r * r + + // insert into quadratic formula + const i1 = getPt((-b + Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a)) + const i2 = getPt((-b - Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a)) + + if (isEqual(i1[0], i2[0]) && isEqual(i1[1], i2[1])) { + return [i1] + } + + return [i1, i2].filter(pt => Number.isFinite(pt[0]) && Number.isFinite(pt[1])) + + function getPt (x: number): Point { + return [x, m * x + n] + } +} + +function isEqual (a: number, b: number) { + return Math.abs(a - b) < 1 +} + +const EPS = 0.0000001 +function between (a: number, b: number, c: number): boolean { + return a - EPS <= b && b <= c + EPS +} +function segmentIntersection (segment1: Line, segment2: Line): Point | null { + const [[x1, y1], [x2, y2]] = segment1 + const [[x3, y3], [x4, y4]] = segment2 + var x = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + var y = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + if (isNaN(x) || isNaN(y)) { + return null + } + if (x1 >= x2) { + if (!between(x2, x, x1)) return null + } else { + if (!between(x1, x, x2)) return null + } + if (y1 >= y2) { + if (!between(y2, y, y1)) return null + } else { + if (!between(y1, y, y2)) return null + } + if (x3 >= x4) { + if (!between(x4, x, x3)) return null + } else { + if (!between(x3, x, x4)) return null + } + if (y3 >= y4) { + if (!between(y4, y, y3)) return null + } else { + if (!between(y3, y, y4)) return null + } + return [x, y] +} + +// not really a centroid +function getCentroid (shape: Shape): Point { + const pts: Point[] = shape.map(segment => segment[0]) + const total: Point = [0, 0] + pts.forEach(pt => { + total[0] += pt[0] / pts.length + total[1] += pt[1] / pts.length + }) + return total +} + +function drawShape(context: CanvasRenderingContext2D, shape: Shape) { + context.beginPath() + context.moveTo(shape[0][0][0], shape[0][0][1]) + for (const seg of shape) { + if (isLine(seg)) context.lineTo(seg[1][0], seg[1][1]) + if (isCurve(seg)) { + const { center, radius, startAngle, endAngle } = getArcValsForCurve(seg) + context.arc(center[0], center[1], radius, startAngle, endAngle) + } + } + context.strokeStyle = 'rgba(0, 0, 0, 0.5)' + context.stroke() +} + +// stolen from penplot by mattdesl (couldn't require it because it uses import/export) +const TO_PX = 35.43307 +const DEFAULT_SVG_LINE_WIDTH = 0.03 + +const convert = (num: number) => Number((TO_PX * num).toFixed(5)) + +type Opts = { + dimensions: Vec2 + fillStyle?: string + strokeStyle?: string + lineWidth?: number +} +function shapesToSVG (shapes: Shape[], opt: Opts) { + const dimensions = opt?.dimensions + if (!dimensions) throw new TypeError('must specify dimensions currently') + + const commands: string[] = [] + shapes.forEach(shape => { + const start = shape[0][0] + commands.push(`M ${convert(start[0])},${convert(start[1])}`) + shape.forEach(segment => { + const x = convert(segment[1][0]) + const y = convert(segment[1][1]) + if (isLine(segment)) { + commands.push(`L ${x},${y}`) + } else { + // rx ry angle large-arc-flag sweep-flag x y + const [start, end, center] = segment + const r = convert(vec2.distance(start, center)) + const sDir = vec2.sub([], start, center) + const eDir = vec2.sub([], end, center) + const sAngle = Math.atan2(sDir[1], sDir[0]) / Math.PI / 2 * 360 + const eAngle = Math.atan2(eDir[1], eDir[0]) / Math.PI / 2 * 360 + const angle = (eAngle - sAngle).toFixed(2) + commands.push(`A ${r},${r} ${angle} 0,1 ${x},${y}`) + } + }) + }) + + const svgPath = commands.join(' ') + const viewWidth = convert(dimensions[0]) + const viewHeight = convert(dimensions[1]) + const fillStyle = opt.fillStyle || 'none' + const strokeStyle = opt.strokeStyle || 'black' + const lineWidth = opt.lineWidth || DEFAULT_SVG_LINE_WIDTH + + return ` + + + + + +` +} + +console.log('press shift-S to send a plot localhost:8080 to be written to disk') +window.addEventListener('keypress', (e) => { + if (e.code === 'KeyS' && e.shiftKey) { + e.preventDefault() + e.stopPropagation() + + const opts: Opts = { + dimensions: [WIDTH / PIXELS_PER_CM, HEIGHT / PIXELS_PER_CM], // in cm + lineWidth: settings.lineWidthMM / 10 // in cm + } + const halfPxDimensions = [WIDTH / 2, HEIGHT / 2] + const svg = shapesToSVG(shapes.map((shape: Shape) => + shape.map(seg => + seg.map((v: Vec2) => { + const pt = vec2.add([], v, halfPxDimensions) + vec2.scale(pt, pt, 1 / PIXELS_PER_CM) + return pt + }) as Segment + ) as Shape + ), opts) + + console.log('THE SVG:', svg) + + // TODO: hash the params + const hash = settings.seed + const filename = `${PLOTNAME}-plot-hash-${hash}.svg` + fetch('http://localhost:8080/save-plot', { + method: 'POST', + body: JSON.stringify({ filename, svg }) + }).then(res => { + if (res.status !== 200) { + console.error('Attempt to save plot failed') + } else { + console.log(`Saved plot: ${filename}`) + } + }) + } +}) diff --git a/sketches/2023.12.03-13.27.57.ts b/sketches/2023.12.03-13.27.57.ts new file mode 100644 index 0000000..9778190 --- /dev/null +++ b/sketches/2023.12.03-13.27.57.ts @@ -0,0 +1,115 @@ +import * as canvasSketch from 'canvas-sketch' +import * as random from 'canvas-sketch-util/random' +import { GUI } from 'dat-gui' +import { vec2 } from 'gl-matrix' + +const WIDTH = 2048 +const HEIGHT = 2048 + +const settings = { + seed: 1, + lines: 15, + lineHeight: 50, + lineLength: 1200, + lineDivisions: 35, + maxControlPtPerturb: 15, + perturbNoiseFreq: 0.5, + perturbNoiseMag: 13, + perturbDivisionSize: 2, +} + +type SketchArgs = { context: CanvasRenderingContext2D, viewportWidth: number, viewportHeight: number } +type Vec2 = [number, number] +type Line = Vec2[] + +canvasSketch(({ render }) => { + const gui = new GUI() + gui.add(settings, 'seed', 0, 9999).step(1).onChange(render) + gui.add(settings, 'lines', 1, 30).step(1).onChange(render) + gui.add(settings, 'lineHeight', 10, 200).step(1).onChange(render) + gui.add(settings, 'lineLength', 100, 2000).step(1).onChange(render) + gui.add(settings, 'lineDivisions', 1, 100).step(1).onChange(render) + gui.add(settings, 'maxControlPtPerturb', 1, 100).step(1).onChange(render) + gui.add(settings, 'perturbNoiseFreq', 0, 4).onChange(render) + gui.add(settings, 'perturbNoiseMag', 0, 20).step(0.01).onChange(render) + gui.add(settings, 'perturbDivisionSize', 1, 20).step(1).onChange(render) + + return (args: SketchArgs) => { + const { context, viewportWidth, viewportHeight } = args + const width = viewportWidth + const height = viewportHeight + + const rand = random.createRandom(settings.seed) + + const lineSectionStart = [ + (width - settings.lineLength) / 2, + (height - (settings.lineHeight * settings.lines)) / 2 + ] + + const lines: Line[] = Array.from({ length: settings.lines }, (_, i) => { + const y = lineSectionStart[1] + (i * settings.lineHeight) + settings.lineHeight / 2 + const startX = lineSectionStart[0] + const start: Vec2 = [lineSectionStart[0], y] + const pts = [start] + + for (let j = 0; j < settings.lineDivisions; j++) { + const t = (j + 1) / settings.lineDivisions + const x = startX + t * settings.lineLength + const pt: Vec2 = [x, y] + const dir = rand.insideCircle(settings.maxControlPtPerturb, vec2.create()) + vec2.add(pt, pt, dir) + pts.push(pt) + } + + return perturbLine(pts) + }) + + function perturbLine (line: Line): Line { + const outLine: Line = [] + for (let j = 0; j < line.length - 1; j++) { + const start = line[j] + const end = line[j + 1] + const dir = vec2.sub(vec2.create(), end, start) + vec2.normalize(dir, dir) + const norm = vec2.fromValues(dir[1], -dir[0]) + const tStart = rand.range(1000000) + + outLine.push(start) + const divisions = Math.max(1, Math.floor(vec2.distance(start, end) / settings.perturbDivisionSize)) + // skipping first and last pt because we don't want to perturb the control points + for (let i = 1; i < divisions; i++) { + const t = i / divisions + const noiseMagT = Math.sin(t * Math.PI) + const pt = vec2.lerp(vec2.create(), start, end, t) as Vec2 + const mag = rand.noise1D(t + tStart, settings.perturbNoiseFreq, settings.perturbNoiseMag * noiseMagT) + vec2.add(pt, pt, vec2.scale(vec2.create(), norm, mag)) + outLine.push(pt) + } + } + // don't forget to push very last control pt + outLine.push(line[line.length - 1]) + + return outLine + } + + // Render + + context.fillStyle = 'white' + context.fillRect(0, 0, width, height) + + for (const pts of lines) { + const firstPt = pts[0] + context.beginPath() + context.moveTo(firstPt[0], firstPt[1]) + for (const pt of pts.slice(1)) { + context.lineTo(pt[0], pt[1]) + } + context.strokeStyle = 'black' + context.lineWidth = 3 + context.stroke() + } + } +}, { + dimensions: [WIDTH, HEIGHT], + animate: true +}) diff --git a/sketches/2023.12.07-20.41.27.ts b/sketches/2023.12.07-20.41.27.ts new file mode 100644 index 0000000..6884207 --- /dev/null +++ b/sketches/2023.12.07-20.41.27.ts @@ -0,0 +1,135 @@ +// subdivision study (newspaper layout) + +import * as canvasSketch from 'canvas-sketch' +import * as random from 'canvas-sketch-util/random' +import { GUI } from 'dat-gui' + +const HEIGHT = 2048 +const WIDTH = HEIGHT * 2 / 3 + +const settings = { + seed: 1, + boxes: 20, + minMargin: 170, // minimum margin around the canvas + minHeight: 5, // each box must be at least this tall (in lineHeight units) + minWidth: 300, // each box must be at least this wide + lineHeight: 15, // each box height must be divisible by this number + boxMargin: 1, // margin between boxes (in lineHeight units) +} + +type Box = { + x: number, + y: number, + width: number, + height: number, +} + +type SketchArgs = { context: CanvasRenderingContext2D, viewportWidth: number, viewportHeight: number } + +canvasSketch(({ render }) => { + const gui = new GUI() + gui.add(settings, 'seed', 0, 9999).step(1).onChange(render) + gui.add(settings, 'boxes', 1, 40).step(1).onChange(render) + gui.add(settings, 'minMargin', 1, 300).step(1).onChange(render) + gui.add(settings, 'minHeight', 1, 20).step(1).onChange(render) + gui.add(settings, 'minWidth', 10, 1000).step(1).onChange(render) + gui.add(settings, 'lineHeight', 10, 200).step(1).onChange(render) + gui.add(settings, 'boxMargin', 0.5, 4).step(0.5).onChange(render) + + return (args: SketchArgs) => { + const { context, viewportWidth, viewportHeight } = args + const width = viewportWidth + const height = viewportHeight + + const rand = random.createRandom(settings.seed) + + // height needs to be divisible by lineHeight + const h = Math.floor((height - settings.minMargin * 2) / settings.lineHeight) * settings.lineHeight + const actualMargin = (height - h) / 2 + const w = width - actualMargin * 2 + + const boxes: Box[] = [{ + x: actualMargin, + y: actualMargin, + width: w, + height: h, + }] + + let iterations = 0 + while (boxes.length < settings.boxes) { + if (iterations > 10000) break + iterations += 1 + const idx = rand.rangeFloor(boxes.length) + const box = boxes.splice(idx, 1)[0] + divideBox(box).filter(Boolean).forEach(b => boxes.push(b)) + } + + const boxMargin = settings.boxMargin * settings.lineHeight + for (const box of boxes) { + box.x += boxMargin + box.y += boxMargin + box.width -= boxMargin * 2 + box.height -= boxMargin * 2 + } + + context.fillStyle = 'white' + context.fillRect(0, 0, width, height) + + for (const box of boxes) { + context.strokeStyle = 'black' + context.lineWidth = 2 + context.strokeRect(box.x, box.y, box.width, box.height) + } + + function divideBox(box: Box): [Box, Box] | [Box] { + const { x, y, width, height } = box + + // divide horizontally + if (rand.chance(0.5)) { + const minX = x + settings.minWidth + const maxX = x + width - settings.minWidth + if (maxX < minX) return [box] + const divX = rand.rangeFloor(minX, maxX) + const box1: Box = { + x, + y, + width: divX - x, + height, + } + const box2: Box = { + x: divX, + y, + width: width - box1.width, + height, + } + return [box1, box2] + } + + // otherwise divide vertically + // boxMargin + // boxes must have a minimum vertical height + const minH = settings.minHeight * settings.lineHeight + const minY = y + minH + const maxY = y + height - minH + if (maxY < minY) return [box] + const span = maxY - minY + const divY = rand.rangeFloor(0, span / settings.lineHeight) * settings.lineHeight + minY + const box1: Box = { + x, + y, + width, + height: divY - y, + } + const box2: Box = { + x, + y: divY, + width, + height: height - box1.height, + } + return [box1, box2] + } + } +}, { + dimensions: [WIDTH, HEIGHT], + animate: true +}) diff --git a/sketches/2023.12.09-14.14.13.ts b/sketches/2023.12.09-14.14.13.ts new file mode 100644 index 0000000..e071e0f --- /dev/null +++ b/sketches/2023.12.09-14.14.13.ts @@ -0,0 +1,446 @@ +// subdivision study (newspaper layout) + +import * as canvasSketch from 'canvas-sketch' +import * as random from 'canvas-sketch-util/random' +import { GUI } from 'dat-gui' +import { vec2 } from 'gl-matrix' + +const PLOTNAME = '2023.12.09-14.14.13' + +const MM_PER_INCH = 25.4 +const PIXELS_PER_INCH = 200 +const WIDTH = 4 * PIXELS_PER_INCH +const HEIGHT = 6 * PIXELS_PER_INCH +const PIXELS_PER_MM = PIXELS_PER_INCH / MM_PER_INCH +const PIXELS_PER_CM = PIXELS_PER_MM * 10 + +const settings = { + seed: 7153, + boxes: 35, + minMargin: 200, // minimum margin around the canvas + minHeight: 3, // each box must be at least this tall (in lineHeight units) + minWidth: 30, // each box must be at least this wide + lineHeight: 12, // each box height must be divisible by this number + boxMargin: 0.5, // margin between boxes (in lineHeight units) + + // handwriting settings + lineDivisions: 8, + maxControlPtPerturb: 1, + perturbNoiseFreq: 1.5, + perturbNoiseMag: 4.3, + perturbDivisionSize: 6, + + // box fill settings + minDist: 3, + maxDist: 4, + fillPasses: 1, + lineNoiseFreq: 0.7, + lineNoiseMag: 2.7, + lineDivisionSize: 1, + lineWidthMM: 0.1 +} + +type Line = vec2[] +type Box = { + x: number, + y: number, + width: number, + height: number, +} + +type SketchArgs = { context: CanvasRenderingContext2D, viewportWidth: number, viewportHeight: number } + +let lines: Line[] = [] + +canvasSketch(({ render }) => { + const gui = new GUI() + gui.add(settings, 'seed', 0, 9999).step(1).onChange(render) + gui.add(settings, 'boxes', 1, 40).step(1).onChange(render) + gui.add(settings, 'minMargin', 1, 300).step(1).onChange(render) + gui.add(settings, 'minHeight', 1, 20).step(1).onChange(render) + gui.add(settings, 'minWidth', 10, 400).step(1).onChange(render) + gui.add(settings, 'lineHeight', 10, 70).step(1).onChange(render) + gui.add(settings, 'boxMargin', 0.5, 4).step(0.5).onChange(render) + gui.add(settings, 'lineDivisions', 1, 100).step(1).onChange(render) + gui.add(settings, 'maxControlPtPerturb', 1, 100).step(1).onChange(render) + gui.add(settings, 'perturbNoiseFreq', 0, 4).onChange(render) + gui.add(settings, 'perturbNoiseMag', 0, 20).step(0.01).onChange(render) + gui.add(settings, 'perturbDivisionSize', 1, 20).step(1).onChange(render) + gui.add(settings, 'minDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'maxDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'fillPasses', 0, 5).step(1).onChange(render) + gui.add(settings, 'lineNoiseFreq', 0, 3).onChange(render) + gui.add(settings, 'lineNoiseMag', 0, 5).step(0.01).onChange(render) + gui.add(settings, 'lineDivisionSize', 1, 100).step(1).onChange(render) + gui.add(settings, 'lineWidthMM', 0.05, 2).step(0.01).onChange(render) + + return (args: SketchArgs) => { + const { context, viewportWidth, viewportHeight } = args + const width = viewportWidth + const height = viewportHeight + + const rand = random.createRandom(settings.seed) + + // height needs to be divisible by lineHeight + const h = Math.floor((height - settings.minMargin * 2) / settings.lineHeight) * settings.lineHeight + const actualMargin = (height - h) / 2 + const w = width - actualMargin * 2 + + const boxes: Box[] = [{ + x: actualMargin, + y: actualMargin, + width: w, + height: h, + }] + + let iterations = 0 + while (boxes.length < settings.boxes) { + if (iterations > 10000) break + iterations += 1 + const idx = rand.rangeFloor(boxes.length) + const box = boxes.splice(idx, 1)[0] + divideBox(box).filter(Boolean).forEach(b => boxes.push(b)) + } + + lines = [] + + const boxMargin = settings.boxMargin * settings.lineHeight + for (const box of boxes) { + box.x += boxMargin + box.y += boxMargin + box.width -= boxMargin * 2 + box.height -= boxMargin * 2 + + if (rand.chance(0.5)) { + lines.push(...handwriting(box, rand)) + } else { + lines.push(...boxFill(box, rand)) + } + } + + context.fillStyle = 'white' + context.fillRect(0, 0, width, height) + + for (const pts of lines) { + const firstPt = pts[0] + context.beginPath() + context.moveTo(firstPt[0], firstPt[1]) + for (const pt of pts.slice(1)) { + context.lineTo(pt[0], pt[1]) + } + context.strokeStyle = 'rgba(0, 0, 0, 0.5)' + context.lineWidth = 2 + context.stroke() + } + + function divideBox(box: Box): [Box, Box] | [Box] { + const { x, y, width, height } = box + + // divide horizontally + if (rand.chance(0.5)) { + const minX = x + settings.minWidth + const maxX = x + width - settings.minWidth + if (maxX < minX) return [box] + const divX = rand.rangeFloor(minX, maxX) + const box1: Box = { + x, + y, + width: divX - x, + height, + } + const box2: Box = { + x: divX, + y, + width: width - box1.width, + height, + } + return [box1, box2] + } + + // otherwise divide vertically + // boxMargin + // boxes must have a minimum vertical height + const minH = settings.minHeight * settings.lineHeight + const minY = y + minH + const maxY = y + height - minH + if (maxY < minY) return [box] + const span = maxY - minY + const divY = rand.rangeFloor(0, span / settings.lineHeight) * settings.lineHeight + minY + const box1: Box = { + x, + y, + width, + height: divY - y, + } + const box2: Box = { + x, + y: divY, + width, + height: height - box1.height, + } + return [box1, box2] + } + } +}, { + dimensions: [WIDTH, HEIGHT], + animate: true +}) + +function boxFill(box: Box, rand: random.RandomGenerator): Line[] { + const lines: Line[] = [[ + [box.x, box.y], + [box.x + box.width, box.y], + [box.x + box.width, box.y + box.height], + [box.x, box.y + box.height], + [box.x, box.y], + ]] + + let loops = settings.fillPasses + while (loops--) { + const angle = rand.range(Math.PI * 2) + const dir = vec2.fromValues(Math.cos(angle), Math.sin(angle)) + + const perpDir = vec2.fromValues(dir[1], -dir[0]) + + const initOffset = vec2.fromValues(box.width / 2 + box.x, box.height / 2 + box.y) + vec2.add(initOffset, initOffset, rand.onCircle(rand.range(50))) + + ;[-1, 1].forEach(mult => { + const offset = initOffset.slice() as vec2 + let lineCount = 0 + if (mult === -1) vec2.add(offset, offset, vec2.scale(vec2.create(), perpDir, rand.range(mult * settings.minDist, mult * settings.maxDist))) + while (true) { + const segment: [vec2, vec2] = [ + vec2.scaleAndAdd(vec2.create(), offset, dir, -1000), + vec2.scaleAndAdd(vec2.create(), offset, dir, 1000) + ] + vec2.add(offset, offset, vec2.scale(vec2.create(), perpDir, rand.range(mult * settings.minDist, mult * settings.maxDist))) + + const intersections = boxSegmentIntersections(segment, box) + lineCount += 1 + if (!intersections.length || lineCount > 100000) break + lines.push(perturbLine([intersections[0], intersections[1]])) + } + }) + } + + function perturbLine (segment: [vec2, vec2]): Line { + const [start, end] = segment + const dir = vec2.sub(vec2.create(), end, start) + vec2.normalize(dir, dir) + const norm: vec2 = [dir[1], -dir[0]] + const tStart = rand.range(1000000) + + const divisions = Math.max(1, Math.floor(vec2.distance(start, end) / settings.lineDivisionSize)) + const line: Line = [] + let prevPt: vec2 | null = null + for (let i = 0; i <= divisions; i++) { + const t = i / divisions + const noiseMagT = Math.sin(t * Math.PI) + const pt = vec2.lerp(vec2.create(), start, end, t) + const mag = rand.noise1D(t + tStart, settings.lineNoiseFreq, settings.lineNoiseMag * noiseMagT) + vec2.add(pt, pt, vec2.scale(vec2.create(), norm, mag)) + if (prevPt !== null) { + line.push([prevPt[0], prevPt[1]]) + } + prevPt = pt + } + return line + } + + return lines +} + +function handwriting(box: Box, rand: random.RandomGenerator): Line[] { + const lineCount = Math.floor(box.height / settings.lineHeight) + const lineSectionStart = [box.x, box.y] + return Array.from({ length: lineCount }, (_, i) => { + const y = lineSectionStart[1] + (i * settings.lineHeight) + settings.lineHeight / 2 + const startX = lineSectionStart[0] + const start: vec2 = [lineSectionStart[0], y] + const pts = [start] + + for (let j = 0; j < settings.lineDivisions; j++) { + const t = (j + 1) / settings.lineDivisions + const x = startX + t * box.width + const pt: vec2 = [x, y] + const dir = rand.insideCircle(settings.maxControlPtPerturb, vec2.create()) + vec2.add(pt, pt, dir) + pts.push(pt) + } + + return perturbLine(pts) + }) + + function perturbLine (line: Line): Line { + const outLine: Line = [] + for (let j = 0; j < line.length - 1; j++) { + const start = line[j] + const end = line[j + 1] + const dir = vec2.sub(vec2.create(), end, start) + vec2.normalize(dir, dir) + const norm = vec2.fromValues(dir[1], -dir[0]) + const tStart = rand.range(1000000) + + outLine.push(start) + const divisions = Math.max(1, Math.floor(vec2.distance(start, end) / settings.perturbDivisionSize)) + // skipping first and last pt because we don't want to perturb the control points + for (let i = 1; i < divisions; i++) { + const t = i / divisions + const noiseMagT = Math.sin(t * Math.PI) + const pt = vec2.lerp(vec2.create(), start, end, t) + const mag = rand.noise1D(t + tStart, settings.perturbNoiseFreq, settings.perturbNoiseMag * noiseMagT) + vec2.add(pt, pt, vec2.scale(vec2.create(), norm, mag)) + outLine.push(pt) + } + } + // don't forget to push very last control pt + outLine.push(line[line.length - 1]) + + return outLine + } +} + +function boxSegmentIntersections (line: [vec2, vec2], box: Box): vec2[] { + const segments: [vec2, vec2][] = [ + [[box.x, box.y], [box.x + box.width, box.y]], + [[box.x + box.width, box.y], [box.x + box.width, box.y + box.height]], + [[box.x + box.width, box.y + box.height], [box.x, box.y + box.height]], + [[box.x, box.y + box.height], [box.x, box.y]], + ] + const intersections: vec2[] = [] + for (const segment of segments) { + const int = segmentIntersection(segment, line) + if (int !== null) intersections.push(int) + } + return intersections +} + +function segmentIntersection (segment1: [vec2, vec2], segment2: [vec2, vec2]): vec2 | null { + const x1 = segment1[0][0] + const y1 = segment1[0][1] + const x2 = segment1[1][0] + const y2 = segment1[1][1] + + const x3 = segment2[0][0] + const y3 = segment2[0][1] + const x4 = segment2[1][0] + const y4 = segment2[1][1] + + var x = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + var y = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + if (isNaN(x) || isNaN(y)) { + return null + } + if (x1 >= x2) { + if (!between(x2, x, x1)) return null + } else { + if (!between(x1, x, x2)) return null + } + if (y1 >= y2) { + if (!between(y2, y, y1)) return null + } else { + if (!between(y1, y, y2)) return null + } + if (x3 >= x4) { + if (!between(x4, x, x3)) return null + } else { + if (!between(x3, x, x4)) return null + } + if (y3 >= y4) { + if (!between(y4, y, y3)) return null + } else { + if (!between(y3, y, y4)) return null + } + return [x, y] +} + +const EPS = 0.0000001 +function between (a: number, b: number, c: number): boolean { + return a - EPS <= b && b <= c + EPS +} + + +// stolen from penplot by mattdesl (couldn't require it because it uses import/export) +const TO_PX = 35.43307 +const DEFAULT_SVG_LINE_WIDTH = 0.03 + +const convert = (num: number) => Number((TO_PX * num).toFixed(5)) + +type Opts = { + dimensions: vec2 + fillStyle?: string + strokeStyle?: string + lineWidth?: number +} +function linesToSVG (lines: Line[], opt: Opts) { + const dimensions = opt?.dimensions + if (!dimensions) throw new TypeError('must specify dimensions currently') + + const commands: string[] = [] + lines.forEach(line => { + const start = line[0] + commands.push(`M ${convert(start[0])},${convert(start[1])}`) + line.slice(1).forEach(pt => { + const x = convert(pt[0]) + const y = convert(pt[1]) + commands.push(`L ${x},${y}`) + }) + }) + + const svgPath = commands.join(' ') + const viewWidth = convert(dimensions[0]) + const viewHeight = convert(dimensions[1]) + const fillStyle = opt.fillStyle || 'none' + const strokeStyle = opt.strokeStyle || 'black' + const lineWidth = opt.lineWidth || DEFAULT_SVG_LINE_WIDTH + + return ` + + + + + +` +} + +console.log('press shift-S to send a plot localhost:8080 to be written to disk') +window.addEventListener('keypress', (e) => { + if (e.code === 'KeyS' && e.shiftKey) { + e.preventDefault() + e.stopPropagation() + + const opts: Opts = { + dimensions: [WIDTH / PIXELS_PER_CM, HEIGHT / PIXELS_PER_CM], // in cm + lineWidth: settings.lineWidthMM / 10 // in cm + } + const halfPxDimensions: vec2 = [WIDTH / 2, HEIGHT / 2] + const svg = linesToSVG(lines.map(line => + line.map(v => { + const pt = vec2.add(vec2.create(), v, halfPxDimensions) + vec2.scale(pt, pt, 1 / PIXELS_PER_CM) + return pt + }) + ), opts) + + console.log('THE SVG:', svg) + + // TODO: hash the params + const hash = settings.seed + const filename = `${PLOTNAME}-plot-hash-${hash}.svg` + fetch('http://localhost:8080/save-plot', { + method: 'POST', + body: JSON.stringify({ filename, svg }) + }).then(res => { + if (res.status !== 200) { + console.error('Attempt to save plot failed') + } else { + console.log(`Saved plot: ${filename}`) + } + }) + } +}) diff --git a/sketches/2023.12.11-20.09.04.ts b/sketches/2023.12.11-20.09.04.ts new file mode 100644 index 0000000..eabe80c --- /dev/null +++ b/sketches/2023.12.11-20.09.04.ts @@ -0,0 +1,464 @@ +// subdivision study (newspaper layout) +import * as optimizePathOrder from '../plots/optimize-path-order' +import * as canvasSketch from 'canvas-sketch' +import * as random from 'canvas-sketch-util/random' +import { GUI } from 'dat-gui' +import { vec2 } from 'gl-matrix' + +const PLOTNAME = '2023.12.11-20.09.04' + +const MM_PER_INCH = 25.4 +const PIXELS_PER_INCH = 200 +const WIDTH = 4.5 * PIXELS_PER_INCH +const HEIGHT = 6.25 * PIXELS_PER_INCH +const PIXELS_PER_MM = PIXELS_PER_INCH / MM_PER_INCH +const PIXELS_PER_CM = PIXELS_PER_MM * 10 + +const settings = { + seed: 7153, + boxes: 35, + minMargin: 200, // minimum margin around the canvas + minHeight: 3, // each box must be at least this tall (in lineHeight units) + minWidth: 30, // each box must be at least this wide + lineHeight: 12, // each box height must be divisible by this number + boxMargin: 0.5, // margin between boxes (in lineHeight units) + + // handwriting settings + lineDivisions: 8, + maxControlPtPerturb: 1, + perturbNoiseFreq: 1.5, + perturbNoiseMag: 4.3, + perturbDivisionSize: 6, + + // box fill settings + minDist: 4, + maxDist: 5, + fillPasses: 1, + lineNoiseFreq: 0.7, + lineNoiseMag: 1.7, + lineDivisionSize: 1, + lineWidthMM: 0.1 +} + +type Line = vec2[] +type Box = { + x: number, + y: number, + width: number, + height: number, +} + +type SketchArgs = { context: CanvasRenderingContext2D, viewportWidth: number, viewportHeight: number } + +let lines: Line[] = [] + +canvasSketch(({ render }) => { + const gui = new GUI() + gui.add(settings, 'seed', 0, 9999).step(1).onChange(render) + // gui.add(settings, 'boxes', 1, 40).step(1).onChange(render) + gui.add(settings, 'minMargin', 1, 300).step(1).onChange(render) + // gui.add(settings, 'minHeight', 1, 20).step(1).onChange(render) + gui.add(settings, 'minWidth', 10, 400).step(1).onChange(render) + // gui.add(settings, 'lineHeight', 10, 70).step(1).onChange(render) + gui.add(settings, 'boxMargin', 0.5, 4).step(0.5).onChange(render) + gui.add(settings, 'lineDivisions', 1, 100).step(1).onChange(render) + gui.add(settings, 'maxControlPtPerturb', 1, 100).step(1).onChange(render) + gui.add(settings, 'perturbNoiseFreq', 0, 4).onChange(render) + gui.add(settings, 'perturbNoiseMag', 0, 20).step(0.01).onChange(render) + gui.add(settings, 'perturbDivisionSize', 1, 20).step(1).onChange(render) + gui.add(settings, 'minDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'maxDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'fillPasses', 0, 5).step(1).onChange(render) + gui.add(settings, 'lineNoiseFreq', 0, 3).onChange(render) + gui.add(settings, 'lineNoiseMag', 0, 5).step(0.01).onChange(render) + gui.add(settings, 'lineDivisionSize', 1, 100).step(1).onChange(render) + gui.add(settings, 'lineWidthMM', 0.05, 2).step(0.01).onChange(render) + + return (args: SketchArgs) => { + const { context, viewportWidth, viewportHeight } = args + const width = viewportWidth + const height = viewportHeight + + const rand = random.createRandom(settings.seed) + + settings.boxes = rand.rangeFloor(5, 40) + settings.minHeight = Math.floor(Math.pow(rand.value(), 6) * 15 + 2) + settings.lineHeight = Math.floor(Math.pow(rand.value(), 3) * 10 + 10) + + // height needs to be divisible by lineHeight + const h = Math.floor((height - settings.minMargin * 2) / settings.lineHeight) * settings.lineHeight + const actualMargin = (height - h) / 2 + const w = width - actualMargin * 2 + + const boxes: Box[] = [{ + x: actualMargin, + y: actualMargin, + width: w, + height: h, + }] + + let iterations = 0 + while (boxes.length < settings.boxes) { + if (iterations > 10000) break + iterations += 1 + const idx = rand.rangeFloor(boxes.length) + const box = boxes.splice(idx, 1)[0] + divideBox(box).filter(Boolean).forEach(b => boxes.push(b)) + } + + lines = [] + + const boxMargin = settings.boxMargin * settings.lineHeight + for (const box of boxes) { + box.x += boxMargin + box.y += boxMargin + box.width -= boxMargin * 2 + box.height -= boxMargin * 2 + + if (rand.chance(0.02)) continue + + if (rand.chance(0.7)) { + if (rand.chance(0.1)) { + lines.push(...boxEmpty(box)) + } else { + lines.push(...handwriting(box, rand)) + } + } else { + if (rand.chance(0.1)) { + lines.push(...boxFill(box, 2, rand)) + } else { + lines.push(...boxFill(box, 1, rand)) + } + } + } + + context.fillStyle = 'white' + context.fillRect(0, 0, width, height) + + for (const pts of lines) { + const firstPt = pts[0] + context.beginPath() + context.moveTo(firstPt[0], firstPt[1]) + for (const pt of pts.slice(1)) { + context.lineTo(pt[0], pt[1]) + } + context.strokeStyle = 'rgba(0, 0, 0, 0.5)' + context.lineWidth = 2 + context.stroke() + } + + function divideBox(box: Box): [Box, Box] | [Box] { + const { x, y, width, height } = box + + // divide horizontally + if (rand.chance(0.5)) { + const minX = x + settings.minWidth + const maxX = x + width - settings.minWidth + if (maxX < minX) return [box] + const divX = rand.rangeFloor(minX, maxX) + const box1: Box = { + x, + y, + width: divX - x, + height, + } + const box2: Box = { + x: divX, + y, + width: width - box1.width, + height, + } + return [box1, box2] + } + + // otherwise divide vertically + // boxMargin + // boxes must have a minimum vertical height + const minH = settings.minHeight * settings.lineHeight + const minY = y + minH + const maxY = y + height - minH + if (maxY < minY) return [box] + const span = maxY - minY + const divY = rand.rangeFloor(0, span / settings.lineHeight) * settings.lineHeight + minY + const box1: Box = { + x, + y, + width, + height: divY - y, + } + const box2: Box = { + x, + y: divY, + width, + height: height - box1.height, + } + return [box1, box2] + } + } +}, { + dimensions: [WIDTH, HEIGHT], + animate: true +}) + +function boxEmpty(box: Box): Line[] { + return [[ + [box.x, box.y], + [box.x + box.width, box.y], + [box.x + box.width, box.y + box.height], + [box.x, box.y + box.height], + [box.x, box.y], + ]] +} + +function boxFill(box: Box, passes: number, rand: random.RandomGenerator): Line[] { + const lines: Line[] = [[ + [box.x, box.y], + [box.x + box.width, box.y], + [box.x + box.width, box.y + box.height], + [box.x, box.y + box.height], + [box.x, box.y], + ]] + + let loops = passes + while (loops--) { + const angle = rand.range(Math.PI * 2) + const dir = vec2.fromValues(Math.cos(angle), Math.sin(angle)) + + const perpDir = vec2.fromValues(dir[1], -dir[0]) + + const initOffset = vec2.fromValues(box.width / 2 + box.x, box.height / 2 + box.y) + vec2.add(initOffset, initOffset, rand.onCircle(rand.range(50))) + + ;[-1, 1].forEach(mult => { + const offset = initOffset.slice() as vec2 + let lineCount = 0 + if (mult === -1) vec2.add(offset, offset, vec2.scale(vec2.create(), perpDir, rand.range(mult * settings.minDist, mult * settings.maxDist))) + while (true) { + const segment: [vec2, vec2] = [ + vec2.scaleAndAdd(vec2.create(), offset, dir, -1000), + vec2.scaleAndAdd(vec2.create(), offset, dir, 1000) + ] + vec2.add(offset, offset, vec2.scale(vec2.create(), perpDir, rand.range(mult * settings.minDist, mult * settings.maxDist))) + + const intersections = boxSegmentIntersections(segment, box) + lineCount += 1 + if (!intersections.length || lineCount > 100000) break + lines.push(perturbLine([intersections[0], intersections[1]])) + } + }) + } + + function perturbLine (segment: [vec2, vec2]): Line { + const [start, end] = segment + const dir = vec2.sub(vec2.create(), end, start) + vec2.normalize(dir, dir) + const norm: vec2 = [dir[1], -dir[0]] + const tStart = rand.range(1000000) + + const divisions = Math.max(1, Math.floor(vec2.distance(start, end) / settings.lineDivisionSize)) + const line: Line = [] + let prevPt: vec2 | null = null + for (let i = 0; i <= divisions; i++) { + const t = i / divisions + const noiseMagT = Math.sin(t * Math.PI) + const pt = vec2.lerp(vec2.create(), start, end, t) + const mag = rand.noise1D(t + tStart, settings.lineNoiseFreq, settings.lineNoiseMag * noiseMagT) + vec2.add(pt, pt, vec2.scale(vec2.create(), norm, mag)) + if (prevPt !== null) { + line.push([prevPt[0], prevPt[1]]) + } + prevPt = pt + } + return line + } + + return lines +} + +function handwriting(box: Box, rand: random.RandomGenerator): Line[] { + const lineCount = Math.floor(box.height / settings.lineHeight) + const lineSectionStart = [box.x, box.y] + return Array.from({ length: lineCount }, (_, i) => { + const y = lineSectionStart[1] + (i * settings.lineHeight) + settings.lineHeight / 2 + const startX = lineSectionStart[0] + const start: vec2 = [lineSectionStart[0], y] + const pts = [start] + + for (let j = 0; j < settings.lineDivisions; j++) { + const t = (j + 1) / settings.lineDivisions + const x = startX + t * box.width + const pt: vec2 = [x, y] + const dir = rand.insideCircle(settings.maxControlPtPerturb, vec2.create()) + vec2.add(pt, pt, dir) + pts.push(pt) + } + + return perturbLine(pts) + }) + + function perturbLine (line: Line): Line { + const outLine: Line = [] + for (let j = 0; j < line.length - 1; j++) { + const start = line[j] + const end = line[j + 1] + const dir = vec2.sub(vec2.create(), end, start) + vec2.normalize(dir, dir) + const norm = vec2.fromValues(dir[1], -dir[0]) + const tStart = rand.range(1000000) + + outLine.push(start) + const divisions = Math.max(1, Math.floor(vec2.distance(start, end) / settings.perturbDivisionSize)) + // skipping first and last pt because we don't want to perturb the control points + for (let i = 1; i < divisions; i++) { + const t = i / divisions + const noiseMagT = Math.sin(t * Math.PI) + const pt = vec2.lerp(vec2.create(), start, end, t) + const mag = rand.noise1D(t + tStart, settings.perturbNoiseFreq, settings.perturbNoiseMag * noiseMagT) + vec2.add(pt, pt, vec2.scale(vec2.create(), norm, mag)) + outLine.push(pt) + } + } + // don't forget to push very last control pt + outLine.push(line[line.length - 1]) + + return outLine + } +} + +function boxSegmentIntersections (line: [vec2, vec2], box: Box): vec2[] { + const segments: [vec2, vec2][] = [ + [[box.x, box.y], [box.x + box.width, box.y]], + [[box.x + box.width, box.y], [box.x + box.width, box.y + box.height]], + [[box.x + box.width, box.y + box.height], [box.x, box.y + box.height]], + [[box.x, box.y + box.height], [box.x, box.y]], + ] + const intersections: vec2[] = [] + for (const segment of segments) { + const int = segmentIntersection(segment, line) + if (int !== null) intersections.push(int) + } + return intersections +} + +function segmentIntersection (segment1: [vec2, vec2], segment2: [vec2, vec2]): vec2 | null { + const x1 = segment1[0][0] + const y1 = segment1[0][1] + const x2 = segment1[1][0] + const y2 = segment1[1][1] + + const x3 = segment2[0][0] + const y3 = segment2[0][1] + const x4 = segment2[1][0] + const y4 = segment2[1][1] + + var x = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + var y = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + if (isNaN(x) || isNaN(y)) { + return null + } + if (x1 >= x2) { + if (!between(x2, x, x1)) return null + } else { + if (!between(x1, x, x2)) return null + } + if (y1 >= y2) { + if (!between(y2, y, y1)) return null + } else { + if (!between(y1, y, y2)) return null + } + if (x3 >= x4) { + if (!between(x4, x, x3)) return null + } else { + if (!between(x3, x, x4)) return null + } + if (y3 >= y4) { + if (!between(y4, y, y3)) return null + } else { + if (!between(y3, y, y4)) return null + } + return [x, y] +} + +const EPS = 0.0000001 +function between (a: number, b: number, c: number): boolean { + return a - EPS <= b && b <= c + EPS +} + + +// stolen from penplot by mattdesl (couldn't require it because it uses import/export) +const TO_PX = 35.43307 +const DEFAULT_SVG_LINE_WIDTH = 0.03 + +const convert = (num: number) => Number((TO_PX * num).toFixed(5)) + +type Opts = { + dimensions: vec2 + fillStyle?: string + strokeStyle?: string + lineWidth?: number +} +function linesToSVG (lines: Line[], opt: Opts) { + const dimensions = opt?.dimensions + if (!dimensions) throw new TypeError('must specify dimensions currently') + + const commands: string[] = [] + lines.forEach(line => { + const start = line[0] + commands.push(`M ${convert(start[0])},${convert(start[1])}`) + line.slice(1).forEach(pt => { + const x = convert(pt[0]) + const y = convert(pt[1]) + commands.push(`L ${x},${y}`) + }) + }) + + const svgPath = commands.join(' ') + const viewWidth = convert(dimensions[0]) + const viewHeight = convert(dimensions[1]) + const fillStyle = opt.fillStyle || 'none' + const strokeStyle = opt.strokeStyle || 'black' + const lineWidth = opt.lineWidth || DEFAULT_SVG_LINE_WIDTH + + return ` + + + + + +` +} + +console.log('press shift-S to send a plot localhost:8080 to be written to disk') +window.addEventListener('keypress', (e) => { + if (e.code === 'KeyS' && e.shiftKey) { + e.preventDefault() + e.stopPropagation() + + const optimizedLines = optimizePathOrder(lines, false) + + const svg = linesToSVG(optimizedLines.map(line => line.map(v => vec2.scale(v, v, 1 / PIXELS_PER_CM))), { + dimensions: [WIDTH / PIXELS_PER_CM, HEIGHT / PIXELS_PER_CM], // in cm + lineWidth: settings.lineWidthMM / 10 // in cm + }) + + console.log('THE SVG:', svg) + + // TODO: hash the params + const hash = settings.seed + const filename = `${PLOTNAME}-plot-hash-${hash}.svg` + fetch('http://localhost:8080/save-plot', { + method: 'POST', + body: JSON.stringify({ filename, svg }) + }).then(res => { + if (res.status !== 200) { + console.error('Attempt to save plot failed') + } else { + console.log(`Saved plot: ${filename}`) + } + }) + } +}) diff --git a/sketches/2023.12.13-23.14.17.ts b/sketches/2023.12.13-23.14.17.ts new file mode 100644 index 0000000..2e299d6 --- /dev/null +++ b/sketches/2023.12.13-23.14.17.ts @@ -0,0 +1,466 @@ +// subdivision study (newspaper layout) +import * as optimizePathOrder from '../plots/optimize-path-order' +import * as canvasSketch from 'canvas-sketch' +import * as random from 'canvas-sketch-util/random' +import { GUI } from 'dat-gui' +import { vec2 } from 'gl-matrix' + +const PLOTNAME = '2023.12.13-23.14.17' + +const MM_PER_INCH = 25.4 +const PIXELS_PER_INCH = 200 +const WIDTH = 6 * PIXELS_PER_INCH +const HEIGHT = 4 * PIXELS_PER_INCH +const PIXELS_PER_MM = PIXELS_PER_INCH / MM_PER_INCH +const PIXELS_PER_CM = PIXELS_PER_MM * 10 + +const settings = { + seed: 6611, + boxes: 35, + minMargin: 105, // minimum margin around the canvas + minHeight: 3, // each box must be at least this tall (in lineHeight units) + minWidth: 20, // each box must be at least this wide + lineHeight: 12, // each box height must be divisible by this number + boxMargin: 1.5, // margin between boxes (in lineHeight units) + + // handwriting settings + lineDivisions: 6, + maxControlPtPerturb: 1, + perturbNoiseFreq: 0.3, + perturbNoiseMag: 8.5, + perturbDivisionSize: 20, + + // box fill settings + minDist: 2, + maxDist: 4, + fillPasses: 1, + lineNoiseFreq: 0.46, + lineNoiseMag: 1.8, + lineDivisionSize: 1, + lineWidthMM: 0.1 +} + +type Line = vec2[] +type Box = { + x: number, + y: number, + width: number, + height: number, +} + +type SketchArgs = { context: CanvasRenderingContext2D, viewportWidth: number, viewportHeight: number } + +let lines: Line[] = [] + +canvasSketch(({ render }) => { + const gui = new GUI() + gui.add(settings, 'seed', 0, 9999).step(1).onChange(render) + // gui.add(settings, 'boxes', 1, 40).step(1).onChange(render) + gui.add(settings, 'minMargin', 1, 300).step(1).onChange(render) + // gui.add(settings, 'minHeight', 1, 20).step(1).onChange(render) + gui.add(settings, 'minWidth', 10, 400).step(1).onChange(render) + // gui.add(settings, 'lineHeight', 10, 70).step(1).onChange(render) + gui.add(settings, 'boxMargin', 0.5, 4).step(0.5).onChange(render) + gui.add(settings, 'lineDivisions', 1, 100).step(1).onChange(render) + gui.add(settings, 'maxControlPtPerturb', 1, 100).step(1).onChange(render) + gui.add(settings, 'perturbNoiseFreq', 0, 4).onChange(render) + gui.add(settings, 'perturbNoiseMag', 0, 20).step(0.01).onChange(render) + gui.add(settings, 'perturbDivisionSize', 1, 20).step(1).onChange(render) + gui.add(settings, 'minDist', 0, 50).step(1).onChange(render) + gui.add(settings, 'maxDist', 0, 50).step(1).onChange(render) + // gui.add(settings, 'fillPasses', 0, 5).step(1).onChange(render) + gui.add(settings, 'lineNoiseFreq', 0, 3).onChange(render) + gui.add(settings, 'lineNoiseMag', 0, 5).step(0.01).onChange(render) + // gui.add(settings, 'lineDivisionSize', 1, 100).step(1).onChange(render) + gui.add(settings, 'lineWidthMM', 0.05, 2).step(0.01).onChange(render) + + return (args: SketchArgs) => { + const { context, viewportWidth, viewportHeight } = args + const width = viewportWidth + const height = viewportHeight + + const rand = random.createRandom(settings.seed) + + settings.boxes = rand.rangeFloor(5, 60) + settings.minHeight = Math.floor(Math.pow(rand.value(), 6) * 15 + 2) + settings.lineHeight = Math.floor(Math.pow(rand.value(), 3) * 10 + 5) + + // height needs to be divisible by lineHeight + const h = Math.floor((height - settings.minMargin * 2) / settings.lineHeight) * settings.lineHeight + const actualMargin = (height - h) / 2 + const w = width - actualMargin * 2 + + const boxes: Box[] = [{ + x: actualMargin, + y: actualMargin, + width: w, + height: h, + }] + + let iterations = 0 + while (boxes.length < settings.boxes) { + if (iterations > 10000) break + iterations += 1 + const idx = rand.rangeFloor(boxes.length) + const box = boxes.splice(idx, 1)[0] + divideBox(box).filter(Boolean).forEach(b => boxes.push(b)) + } + + lines = [] + + const boxMargin = settings.boxMargin * settings.lineHeight + for (const box of boxes) { + box.x += boxMargin + box.y += boxMargin + box.width -= boxMargin * 2 + box.height -= boxMargin * 2 + + if (rand.chance(0.02)) continue + + if (rand.chance(0.7)) { + if (rand.chance(0.1)) { + lines.push(...boxEmpty(box)) + } else { + lines.push(...handwriting(box, rand)) + } + } else { + if (rand.chance(0.1)) { + lines.push(...boxFill(box, 3, rand)) + } else if (rand.chance(0.2)) { + lines.push(...boxFill(box, 2, rand)) + } else { + lines.push(...boxFill(box, 1, rand)) + } + } + } + + context.fillStyle = 'white' + context.fillRect(0, 0, width, height) + + for (const pts of lines) { + const firstPt = pts[0] + context.beginPath() + context.moveTo(firstPt[0], firstPt[1]) + for (const pt of pts.slice(1)) { + context.lineTo(pt[0], pt[1]) + } + context.strokeStyle = 'rgba(0, 0, 0, 0.5)' + context.lineWidth = 2 + context.stroke() + } + + function divideBox(box: Box): [Box, Box] | [Box] { + const { x, y, width, height } = box + + // divide horizontally + if (rand.chance(0.5)) { + const minX = x + settings.minWidth + const maxX = x + width - settings.minWidth + if (maxX < minX) return [box] + const divX = rand.rangeFloor(minX, maxX) + const box1: Box = { + x, + y, + width: divX - x, + height, + } + const box2: Box = { + x: divX, + y, + width: width - box1.width, + height, + } + return [box1, box2] + } + + // otherwise divide vertically + // boxMargin + // boxes must have a minimum vertical height + const minH = settings.minHeight * settings.lineHeight + const minY = y + minH + const maxY = y + height - minH + if (maxY < minY) return [box] + const span = maxY - minY + const divY = rand.rangeFloor(0, span / settings.lineHeight) * settings.lineHeight + minY + const box1: Box = { + x, + y, + width, + height: divY - y, + } + const box2: Box = { + x, + y: divY, + width, + height: height - box1.height, + } + return [box1, box2] + } + } +}, { + dimensions: [WIDTH, HEIGHT], + animate: true +}) + +function boxEmpty(box: Box): Line[] { + return [[ + [box.x, box.y], + [box.x + box.width, box.y], + [box.x + box.width, box.y + box.height], + [box.x, box.y + box.height], + [box.x, box.y], + ]] +} + +function boxFill(box: Box, passes: number, rand: random.RandomGenerator): Line[] { + const lines: Line[] = [[ + [box.x, box.y], + [box.x + box.width, box.y], + [box.x + box.width, box.y + box.height], + [box.x, box.y + box.height], + [box.x, box.y], + ]] + + let loops = passes + while (loops--) { + const angle = rand.range(Math.PI * 2) + const dir = vec2.fromValues(Math.cos(angle), Math.sin(angle)) + + const perpDir = vec2.fromValues(dir[1], -dir[0]) + + const initOffset = vec2.fromValues(box.width / 2 + box.x, box.height / 2 + box.y) + vec2.add(initOffset, initOffset, rand.onCircle(rand.range(50))) + + ;[-1, 1].forEach(mult => { + const offset = initOffset.slice() as vec2 + let lineCount = 0 + if (mult === -1) vec2.add(offset, offset, vec2.scale(vec2.create(), perpDir, rand.range(mult * settings.minDist, mult * settings.maxDist))) + while (true) { + const segment: [vec2, vec2] = [ + vec2.scaleAndAdd(vec2.create(), offset, dir, -1000), + vec2.scaleAndAdd(vec2.create(), offset, dir, 1000) + ] + vec2.add(offset, offset, vec2.scale(vec2.create(), perpDir, rand.range(mult * settings.minDist, mult * settings.maxDist))) + + const intersections = boxSegmentIntersections(segment, box) + lineCount += 1 + if (!intersections.length || lineCount > 100000) break + lines.push(perturbLine([intersections[0], intersections[1]])) + } + }) + } + + function perturbLine (segment: [vec2, vec2]): Line { + const [start, end] = segment + const dir = vec2.sub(vec2.create(), end, start) + vec2.normalize(dir, dir) + const norm: vec2 = [dir[1], -dir[0]] + const tStart = rand.range(1000000) + + const divisions = Math.max(1, Math.floor(vec2.distance(start, end) / settings.lineDivisionSize)) + const line: Line = [] + let prevPt: vec2 | null = null + for (let i = 0; i <= divisions; i++) { + const t = i / divisions + const noiseMagT = Math.sin(t * Math.PI) + const pt = vec2.lerp(vec2.create(), start, end, t) + const mag = rand.noise1D(t + tStart, settings.lineNoiseFreq, settings.lineNoiseMag * noiseMagT) + vec2.add(pt, pt, vec2.scale(vec2.create(), norm, mag)) + if (prevPt !== null) { + line.push([prevPt[0], prevPt[1]]) + } + prevPt = pt + } + return line + } + + return lines +} + +function handwriting(box: Box, rand: random.RandomGenerator): Line[] { + const lineCount = Math.floor(box.height / settings.lineHeight) + const lineSectionStart = [box.x, box.y] + return Array.from({ length: lineCount }, (_, i) => { + const y = lineSectionStart[1] + (i * settings.lineHeight) + settings.lineHeight / 2 + const startX = lineSectionStart[0] + const start: vec2 = [lineSectionStart[0], y] + const pts = [start] + + for (let j = 0; j < settings.lineDivisions; j++) { + const t = (j + 1) / settings.lineDivisions + const x = startX + t * box.width + const pt: vec2 = [x, y] + const dir = rand.insideCircle(settings.maxControlPtPerturb, vec2.create()) + vec2.add(pt, pt, dir) + pts.push(pt) + } + + return perturbLine(pts) + }) + + function perturbLine (line: Line): Line { + const outLine: Line = [] + for (let j = 0; j < line.length - 1; j++) { + const start = line[j] + const end = line[j + 1] + const dir = vec2.sub(vec2.create(), end, start) + vec2.normalize(dir, dir) + const norm = vec2.fromValues(dir[1], -dir[0]) + const tStart = rand.range(1000000) + + outLine.push(start) + const divisions = Math.max(1, Math.floor(vec2.distance(start, end) / settings.perturbDivisionSize)) + // skipping first and last pt because we don't want to perturb the control points + for (let i = 1; i < divisions; i++) { + const t = i / divisions + const noiseMagT = Math.sin(t * Math.PI) + const pt = vec2.lerp(vec2.create(), start, end, t) + const mag = rand.noise1D(t + tStart, settings.perturbNoiseFreq, settings.perturbNoiseMag * noiseMagT) + vec2.add(pt, pt, vec2.scale(vec2.create(), norm, mag)) + outLine.push(pt) + } + } + // don't forget to push very last control pt + outLine.push(line[line.length - 1]) + + return outLine + } +} + +function boxSegmentIntersections (line: [vec2, vec2], box: Box): vec2[] { + const segments: [vec2, vec2][] = [ + [[box.x, box.y], [box.x + box.width, box.y]], + [[box.x + box.width, box.y], [box.x + box.width, box.y + box.height]], + [[box.x + box.width, box.y + box.height], [box.x, box.y + box.height]], + [[box.x, box.y + box.height], [box.x, box.y]], + ] + const intersections: vec2[] = [] + for (const segment of segments) { + const int = segmentIntersection(segment, line) + if (int !== null) intersections.push(int) + } + return intersections +} + +function segmentIntersection (segment1: [vec2, vec2], segment2: [vec2, vec2]): vec2 | null { + const x1 = segment1[0][0] + const y1 = segment1[0][1] + const x2 = segment1[1][0] + const y2 = segment1[1][1] + + const x3 = segment2[0][0] + const y3 = segment2[0][1] + const x4 = segment2[1][0] + const y4 = segment2[1][1] + + var x = ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + var y = ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / + ((x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)) + if (isNaN(x) || isNaN(y)) { + return null + } + if (x1 >= x2) { + if (!between(x2, x, x1)) return null + } else { + if (!between(x1, x, x2)) return null + } + if (y1 >= y2) { + if (!between(y2, y, y1)) return null + } else { + if (!between(y1, y, y2)) return null + } + if (x3 >= x4) { + if (!between(x4, x, x3)) return null + } else { + if (!between(x3, x, x4)) return null + } + if (y3 >= y4) { + if (!between(y4, y, y3)) return null + } else { + if (!between(y3, y, y4)) return null + } + return [x, y] +} + +const EPS = 0.0000001 +function between (a: number, b: number, c: number): boolean { + return a - EPS <= b && b <= c + EPS +} + + +// stolen from penplot by mattdesl (couldn't require it because it uses import/export) +const TO_PX = 35.43307 +const DEFAULT_SVG_LINE_WIDTH = 0.03 + +const convert = (num: number) => Number((TO_PX * num).toFixed(5)) + +type Opts = { + dimensions: vec2 + fillStyle?: string + strokeStyle?: string + lineWidth?: number +} +function linesToSVG (lines: Line[], opt: Opts) { + const dimensions = opt?.dimensions + if (!dimensions) throw new TypeError('must specify dimensions currently') + + const commands: string[] = [] + lines.forEach(line => { + const start = line[0] + commands.push(`M ${convert(start[0])},${convert(start[1])}`) + line.slice(1).forEach(pt => { + const x = convert(pt[0]) + const y = convert(pt[1]) + commands.push(`L ${x},${y}`) + }) + }) + + const svgPath = commands.join(' ') + const viewWidth = convert(dimensions[0]) + const viewHeight = convert(dimensions[1]) + const fillStyle = opt.fillStyle || 'none' + const strokeStyle = opt.strokeStyle || 'black' + const lineWidth = opt.lineWidth || DEFAULT_SVG_LINE_WIDTH + + return ` + + + + + +` +} + +console.log('press shift-S to send a plot localhost:8080 to be written to disk') +window.addEventListener('keypress', (e) => { + if (e.code === 'KeyS' && e.shiftKey) { + e.preventDefault() + e.stopPropagation() + + const optimizedLines = optimizePathOrder(lines, false) + + const svg = linesToSVG(optimizedLines.map(line => line.map(v => vec2.scale(v, v, 1 / PIXELS_PER_CM))), { + dimensions: [WIDTH / PIXELS_PER_CM, HEIGHT / PIXELS_PER_CM], // in cm + lineWidth: settings.lineWidthMM / 10 // in cm + }) + + console.log('THE SVG:', svg) + + // TODO: hash the params + const hash = settings.seed + const filename = `${PLOTNAME}-plot-hash-${hash}.svg` + fetch('http://localhost:8080/save-plot', { + method: 'POST', + body: JSON.stringify({ filename, svg }) + }).then(res => { + if (res.status !== 200) { + console.error('Attempt to save plot failed') + } else { + console.log(`Saved plot: ${filename}`) + } + }) + } +})