├── .npmignore ├── .gitignore ├── .editorconfig ├── nanometer.gif ├── p5js-screenshot.png ├── examples └── p5 │ ├── style.css │ ├── index.html │ └── sketch.js ├── src ├── bundle.ts ├── cube.ts ├── sphere.ts ├── proto.ts ├── sin.ts ├── client.ts ├── server.ts └── vector.ts ├── webpack.config.js ├── package.json ├── README.md └── tsconfig.json /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,ts}] 2 | indent_style = space 3 | indent_size = 4 4 | -------------------------------------------------------------------------------- /nanometer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharph/nanometer/HEAD/nanometer.gif -------------------------------------------------------------------------------- /p5js-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharph/nanometer/HEAD/p5js-screenshot.png -------------------------------------------------------------------------------- /examples/p5/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | canvas { 6 | display: block; 7 | } 8 | -------------------------------------------------------------------------------- /src/bundle.ts: -------------------------------------------------------------------------------- 1 | 2 | import { connect } from './client'; 3 | import * as vector from './vector'; 4 | 5 | export { connect, vector }; 6 | -------------------------------------------------------------------------------- /examples/p5/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const TerserPlugin = require("terser-webpack-plugin"); 3 | 4 | module.exports = { 5 | entry: "./src/bundle.ts", 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.tsx?$/, 10 | use: "ts-loader", 11 | exclude: /node_modules/, 12 | }, 13 | ], 14 | }, 15 | resolve: { 16 | extensions: [".tsx", ".ts", ".js"], 17 | }, 18 | target: "web", 19 | output: { 20 | filename: "bundle.js", 21 | path: path.resolve(__dirname, "dist"), 22 | clean: true, 23 | library: "Nanometer", 24 | }, 25 | mode: "production", 26 | }; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nanometer", 3 | "version": "1.0.3", 4 | "scripts": { 5 | "test": "echo \"Error: no test specified\" && exit 1", 6 | "build": "webpack" 7 | }, 8 | "sideEffects": false, 9 | "devDependencies": { 10 | "@types/three": "^0.163.0", 11 | "install": "^0.13.0", 12 | "ts-loader": "^9.5.1", 13 | "typescript": "^5.4.3", 14 | "webpack": "^5.91.0", 15 | "webpack-cli": "^5.1.4" 16 | }, 17 | "dependencies": { 18 | "@laser-dac/ether-dream": "^0.4.2", 19 | "@laser-dac/simulator": "^0.3.4", 20 | "isomorphic-ws": "^5.0.0", 21 | "msgpackr": "^1.10.1", 22 | "three": "^0.163.0", 23 | "ws": "^8.16.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/cube.ts: -------------------------------------------------------------------------------- 1 | import { makeCube, PointGroup, PerspectiveGroup } from './vector'; 2 | import { connect } from './client'; 3 | 4 | (async () => { 5 | const cube = makeCube(20, { r: 0, g: 1, b: 0 }); 6 | const cube2 = makeCube(10, { r: 1, g: 0, b: 0 }); 7 | cube2.scale({ x: 0.5, y: 0.5, z: 0.5 }); 8 | cube2.rotateX(Math.PI / 4); 9 | cube2.rotateY(Math.PI / 4); 10 | const cubeGroup = new PointGroup([cube, cube2]); 11 | const perspective = new PerspectiveGroup([cubeGroup]); 12 | perspective.translate({ z: -1 }); 13 | const client = await connect('ws://localhost:1532'); 14 | function* generatePoints() { 15 | for (const p of perspective.getPoints({ 16 | beginSamples: 30, 17 | laserOnSamples: 0, 18 | endSamples: 10, 19 | laserOffSamples: 2, 20 | })) { 21 | yield p; 22 | cubeGroup.rotateX(0.00001); 23 | cubeGroup.rotateY(0.000008); 24 | cubeGroup.rotateZ(0.000005); 25 | }; 26 | 27 | } 28 | client.attachGenerator(generatePoints); 29 | })(); 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nanometer 🔴🟢🔵 2 | 3 | ![nanometer gif animation preview](nanometer.gif) 4 | 5 | A micro framework for controlling ILDA show lasers with JavaScript/TypeScript 6 | over WebSockets, with a focus on delivering a stream of points. 7 | 8 | Based on [`@laser-dac`](https://github.com/Volst/laser-dac) which is doing 9 | much of the heavy lifting here. 10 | 11 | ## Setup 12 | 13 | ```bash 14 | npm i 15 | ``` 16 | 17 | ## Example 18 | 19 | ### Run the server 20 | 21 | ```bash 22 | npx ts-node src/server.ts 23 | ``` 24 | 25 | The server will print the URL of a laser simulator you can visit in your web 26 | browser. If you have an [Ether Dream](https://ether-dream.com/) 27 | attached you can set the `USE_ETHER_DREAM` environment variable to a truthy 28 | value and it will use it in addition to providing you with a simulator. 29 | 30 | ### Connect a client 31 | 32 | ```bash 33 | npx ts-node src/sin.ts # Math.sin example 34 | ``` 35 | 36 | ### [p5.js](https://p5js.org/) 37 | 38 | ![nanometer running in p5js screenshot](p5js-screenshot.png) 39 | 40 | You can connect a p5js sketch to the Nanometer server. See the 41 | [included example](https://editor.p5js.org/sharp/sketches/H3moGj3DP) in 42 | `examples/p5`. 43 | -------------------------------------------------------------------------------- /src/sphere.ts: -------------------------------------------------------------------------------- 1 | import { Circle, PointGroup, PerspectiveGroup } from './vector'; 2 | import { connect } from './client'; 3 | 4 | // a^2 + b^2 = 1 5 | // a^2 = (1 - b^2) 6 | // a = sqrt(1 - b^2) 7 | 8 | (async () => { 9 | let group = new PointGroup([]); 10 | for (let i = 0; i <= 16; i++) { 11 | const circle = new Circle({ r: 1, g: 0, b: 0 }, 60); 12 | const s = Math.sqrt(1 - (((i / 16) - 0.5) * 2) ** 2); 13 | circle.scale({ x: s, y: s, z: s }); 14 | circle.translate({ z: i * 2 / 16 - 1 }); 15 | group.points?.push(circle); 16 | } 17 | const perspective = new PerspectiveGroup([group]); 18 | perspective.translate({ z: -1 }); 19 | const client = await connect('ws://localhost:1532'); 20 | function* generatePoints() { 21 | for (const p of perspective.getPoints({ 22 | beginSamples: 20, 23 | laserOnSamples: 0, 24 | endSamples: 8, 25 | laserOffSamples: 0, 26 | })) { 27 | yield p; 28 | group.rotateX(0.000021); 29 | group.rotateY(0.000018); 30 | group.rotateZ(0.000011); 31 | }; 32 | 33 | } 34 | client.attachGenerator(generatePoints); 35 | })(); 36 | -------------------------------------------------------------------------------- /examples/p5/sketch.js: -------------------------------------------------------------------------------- 1 | const { makeCube, PerspectiveGroup, PointGroup } = Nanometer.vector; 2 | const nmconnect = Nanometer.connect; 3 | 4 | let pointsToDraw = []; 5 | let cubeGroup; 6 | 7 | function setup() { 8 | createCanvas(400, 400); 9 | const cube = makeCube(20, { r: 0, g: 1, b: 0 }); 10 | const cube2 = makeCube(10, { r: 1, g: 0, b: 0 }); 11 | cube2.scale({ x: 0.5, y: 0.5, z: 0.5 }); 12 | cube2.rotateX(Math.PI / 4); 13 | cube2.rotateY(Math.PI / 4); 14 | cubeGroup = new PointGroup([cube, cube2]); 15 | const perspectiveG = new PerspectiveGroup([cubeGroup]); 16 | perspectiveG.translate({ z: -1 }); 17 | function* generatePoints() { 18 | for (const p of perspectiveG.getPoints({ 19 | beginSamples: 30, 20 | laserOnSamples: 0, 21 | endSamples: 10, 22 | laserOffSamples: 2, 23 | })) { 24 | yield p; 25 | pointsToDraw.push(p); 26 | } 27 | } 28 | let client; 29 | nmconnect("ws://localhost:1532").then((c) => { 30 | client = c; 31 | client.attachGenerator(generatePoints); 32 | }); 33 | } 34 | function draw() { 35 | background(20, 40); 36 | let i = 0; 37 | noStroke(); 38 | for (const p of pointsToDraw) { 39 | fill(p.r * 255, p.g * 255, p.b * 255); 40 | circle((p.x + 1) * 200, (p.y + 1) * 200, 4); 41 | } 42 | pointsToDraw = []; 43 | cubeGroup.resetMatrix(); 44 | cubeGroup.rotateZ(Math.PI / 5); 45 | cubeGroup.rotateX(mouseX / 100); 46 | cubeGroup.rotateY(mouseY / 100); 47 | } 48 | -------------------------------------------------------------------------------- /src/proto.ts: -------------------------------------------------------------------------------- 1 | import { pack, unpack } from 'msgpackr'; 2 | import { Point } from '@laser-dac/core'; 3 | 4 | export enum MessageTypes { 5 | POINT_REQUEST = 0, 6 | POINT_RESPONSE = 1, 7 | }; 8 | 9 | export type PointRequestMessage = { 10 | type: MessageTypes.POINT_REQUEST; 11 | num: number; 12 | } 13 | 14 | export type PointResponseMessage = { 15 | type: MessageTypes.POINT_RESPONSE; 16 | points: Point[]; 17 | } 18 | 19 | export type NanometerMessage = PointResponseMessage | PointRequestMessage; 20 | 21 | export function isPointRequestMessage(msg: any): msg is PointRequestMessage { 22 | return msg.type === MessageTypes.POINT_REQUEST; 23 | } 24 | 25 | export function isPointResponseMessage(msg: any): msg is PointResponseMessage { 26 | return msg.type === MessageTypes.POINT_RESPONSE; 27 | } 28 | 29 | export async function decodeMessage(msg: Buffer | ArrayBuffer | Blob): Promise { 30 | if (msg instanceof ArrayBuffer) { 31 | msg = Buffer.from(msg); 32 | return unpack(msg as Buffer); 33 | } 34 | if (msg instanceof Blob) { 35 | msg = new Uint8Array(await msg.arrayBuffer()); 36 | return unpack(msg as Uint8Array); 37 | } 38 | return unpack(msg); 39 | } 40 | 41 | export function encodePointRequest(num: number): Buffer { 42 | return pack({ 43 | 'type': MessageTypes.POINT_REQUEST, 44 | num 45 | }); 46 | } 47 | 48 | export function encodePointResponse(points: Point[]): Buffer { 49 | return pack({ 50 | type: MessageTypes.POINT_RESPONSE, 51 | points 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /src/sin.ts: -------------------------------------------------------------------------------- 1 | 2 | import { connect } from './client'; 3 | 4 | (async () => { 5 | function* pointGenerator() { 6 | let t = 0; 7 | while (true) { 8 | let offset = 0; 9 | for (const c of [ 10 | { r: 1, g: 1, b: 1 }, 11 | { r: 1, g: 0, b: 0 }, 12 | { r: 1, g: 1, b: 0 }, 13 | { r: 0, g: 1, b: 0 }, 14 | { r: 0, g: 1, b: 1 }, 15 | { r: 0, g: 0, b: 1 }, 16 | ]) { 17 | for (let x = 0; true; x += 0.01) { 18 | const X = x - 0.5; 19 | const Y = Math.sin(x * 3.14 * 2) * (Math.sin(x * 8 + (t / 10000) + (offset * 0.1)) + (offset * 0.1)) / 2; 20 | if (x === 0) { 21 | for (let i = 0; i < 20; i++) { 22 | t++; 23 | yield { x: X, y: Y, r: 0, g: 0, b: 0 }; 24 | } 25 | } 26 | yield { x: X, y: Y, ...c }; 27 | t++; 28 | if (x > 1) { 29 | for (let i = 0; i < 5; i++) { 30 | t++; 31 | yield { x: X, y: Y, ...c }; 32 | } 33 | break; 34 | } 35 | } 36 | offset++; 37 | } 38 | } 39 | } 40 | const client = await connect('ws://localhost:1532'); 41 | console.log('connected'); 42 | client.attachGenerator(pointGenerator); 43 | })(); 44 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Point } from '@laser-dac/core'; 3 | import WebSocket from 'isomorphic-ws'; 4 | 5 | import { encodePointResponse, decodeMessage, isPointRequestMessage } from './proto'; 6 | 7 | export class NanometerClient { 8 | private getPoints: (num: number) => Promise = async (num: number) => { 9 | return Array(num).fill({ x: 0.5, y: 0.5, r: 0, g: 0, b: 0 }); 10 | } 11 | private generatorFn: (() => Generator) | null = null; 12 | private generator: Generator | null = null; 13 | 14 | constructor(private ws: WebSocket, getPoints?: (num: number) => Promise, public centerOrigin: boolean = true) { 15 | if (getPoints) { 16 | this.getPoints = getPoints 17 | } 18 | this.ws.onmessage = async (event) => { 19 | const decoded = await decodeMessage(event.data as ArrayBuffer); 20 | if (!isPointRequestMessage(decoded)) { 21 | return; 22 | } 23 | this.getPoints(decoded.num).then((points: Point[]) => { 24 | this.ws.send(encodePointResponse(this.centerOrigin ? points.map(this.transformForCenterOrigin) : points)); 25 | }); 26 | }; 27 | } 28 | 29 | private transformForCenterOrigin(point: Point) { 30 | return { 31 | x: (point.x + 1) / 2, 32 | y: (point.y + 1) / 2, 33 | r: point.r, 34 | g: point.g, 35 | b: point.b 36 | } 37 | } 38 | 39 | attachGetPoints(getPoints: typeof this.getPoints) { 40 | this.getPoints = getPoints; 41 | } 42 | 43 | attachGenerator(generatorFn: (() => Generator)) { 44 | this.generatorFn = generatorFn; 45 | this.getPoints = async (num) => { 46 | if (!this.generatorFn) { 47 | throw new Error('no iterable'); 48 | } 49 | const points: Point[] = []; 50 | for (let i = 0; i < num; i++) { 51 | if (this.generator === null) { 52 | this.generator = this.generatorFn(); 53 | } 54 | let value = this.generator.next(); 55 | while (value.done) { 56 | this.generator = this.generatorFn(); 57 | value = this.generator.next(); 58 | } 59 | points.push(value.value); 60 | } 61 | return points; 62 | }; 63 | } 64 | } 65 | 66 | export function connect(addr: string, getPoints?: ((num: number) => Promise)): Promise { 67 | const ws = new WebSocket(addr); 68 | return new Promise((res, err) => { 69 | ws.onopen = () => { 70 | res(new NanometerClient(ws, getPoints)); 71 | }; 72 | 73 | ws.onerror = (e) => err(e); 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | 2 | import { DAC, Point, Device, Scene } from '@laser-dac/core'; 3 | import { Simulator } from '@laser-dac/simulator'; 4 | import { EtherDream } from '@laser-dac/ether-dream'; 5 | import { relativeToPosition, relativeToColor } from '@laser-dac/ether-dream/dist/convert'; 6 | import { WebSocketServer, WebSocket } from 'ws'; 7 | import { encodePointRequest, isPointResponseMessage, decodeMessage } from './proto'; 8 | 9 | const USE_ETHER_DREAM = process.env.USE_ETHER_DREAM; 10 | 11 | const MIN_POINTS_PER_BUFFER = 1000; // 1000 seems to be the sweet spot? 12 | const MIN_POINTS_PER_LOAD = 100; 13 | 14 | class PullDAC extends DAC { 15 | private interval: ReturnType | null = null 16 | private nextPointCallback: Function | null = null; 17 | private pointsRate: number = 30000; 18 | private buffer: Point[] = []; 19 | private bufferToStream: Point[] = []; 20 | private clockMaster: EtherDream | null = null; 21 | private loading: Promise | null = null; 22 | 23 | use(device: Device) { 24 | if (device instanceof EtherDream) { 25 | this.clockMaster = device; 26 | } 27 | super.use(device); 28 | } 29 | 30 | async start() { 31 | const res = await super.start(); 32 | return res; 33 | } 34 | 35 | async stop() { 36 | await super.stop(); 37 | if (this.interval !== null) { 38 | clearInterval(this.interval); 39 | } 40 | } 41 | 42 | async load(num: number = MIN_POINTS_PER_LOAD, block = false) { 43 | if (this.nextPointCallback === null) { 44 | return; 45 | } 46 | if (this.loading) { 47 | if (block) { 48 | await this.loading; 49 | } 50 | return; 51 | } 52 | this.loading = this.nextPointCallback(Math.max(num, MIN_POINTS_PER_BUFFER, MIN_POINTS_PER_LOAD)); 53 | this.buffer = this.buffer.concat(await this.loading as Point[] | Point); 54 | this.loading = null; 55 | } 56 | 57 | async syntheticClockTick() { 58 | if (this.nextPointCallback === null) { 59 | return; 60 | } 61 | while (this.bufferToStream.length < MIN_POINTS_PER_BUFFER) { 62 | if (this.buffer.length < MIN_POINTS_PER_BUFFER * 2) { 63 | this.load(); 64 | } 65 | if (this.buffer.length === 0) { 66 | this.buffer.push({ x: 0.5, y: 0.5, r: 0, g: 0, b: 0 }); 67 | } 68 | this.bufferToStream.push(this.buffer.shift() as Point); 69 | } 70 | this.stream({ points: this.bufferToStream }, this.pointsRate); 71 | this.bufferToStream = []; 72 | } 73 | 74 | private startStreamingEtherDream() { 75 | if (!this.clockMaster?.connection) { 76 | throw new Error('clockMaster.connection is not setup'); 77 | } 78 | this.clockMaster.connection?.streamPoints( 79 | this.pointsRate, 80 | (num, callback) => 81 | (async () => { 82 | const pointsNeeded = Math.max(1000, num); 83 | if (this.nextPointCallback === null) { 84 | return; 85 | } 86 | while (this.buffer.length < pointsNeeded) { 87 | this.buffer.push( 88 | { x: 0.5, y: 0.5, r: 0, g: 0, b: 0 } 89 | ); 90 | } 91 | const toSend: any = []; 92 | while (toSend.length < pointsNeeded) { 93 | const shifted = this.buffer.shift(); 94 | 95 | toSend.push(((point) => ({ 96 | x: relativeToPosition(point.x), 97 | y: relativeToPosition(point.y), 98 | r: relativeToColor(point.r), 99 | g: relativeToColor(point.g), 100 | b: relativeToColor(point.b), 101 | }))(shifted as Point)); 102 | this.bufferToStream.push(shifted as Point); 103 | if (this.bufferToStream.length >= MIN_POINTS_PER_BUFFER) { 104 | this.stream({ points: this.bufferToStream }, this.pointsRate); 105 | this.bufferToStream = []; 106 | } 107 | } 108 | setTimeout(this.load.bind(this), 0, MIN_POINTS_PER_BUFFER); 109 | return toSend; 110 | })().then((res) => callback(res)) 111 | ); 112 | } 113 | 114 | stream(scene: Scene, pointsRate = 30000, fps = 30) { 115 | for (const device of this.devices) { 116 | if (device !== this.clockMaster) { 117 | device.stream(scene, pointsRate, fps); 118 | } 119 | } 120 | } 121 | 122 | streamFrom(nextPointsCallback: Function, pointsRate = 30000) { 123 | this.nextPointCallback = nextPointsCallback; 124 | this.pointsRate = pointsRate; 125 | if (!this.clockMaster) { 126 | this.interval = setInterval( 127 | this.syntheticClockTick.bind(this), 128 | MIN_POINTS_PER_BUFFER / pointsRate * 1000 129 | ); 130 | } else { 131 | this.startStreamingEtherDream() 132 | } 133 | } 134 | } 135 | 136 | (async () => { 137 | const dac = new PullDAC(); 138 | dac.use(new Simulator()); 139 | if (USE_ETHER_DREAM) { 140 | dac.use(new EtherDream()); 141 | } 142 | await dac.start(); 143 | let activeWS: WebSocket | null = null; 144 | let pointsCallback: ((points: Point[] | Point) => void) | null = null; 145 | const wss = new WebSocketServer({ 146 | port: 1532 147 | }); 148 | dac.streamFrom((num: number) => { 149 | return new Promise((res: ((points: Point[] | Point) => void)) => { 150 | if (activeWS !== null) { 151 | activeWS.send(encodePointRequest(num)); 152 | pointsCallback = res; 153 | } else { 154 | res(Array(num).fill({ x: 0.5, y: 0.5, r: 0, g: 0, b: 0 })); 155 | } 156 | }); 157 | }); 158 | wss.on('connection', (ws) => { 159 | if (activeWS) { 160 | console.warn('Client already connected'); 161 | ws.close(); 162 | return; 163 | } 164 | console.info('new client connected'); 165 | activeWS = ws; 166 | 167 | ws.on('close', () => { 168 | activeWS = null; 169 | if (pointsCallback) { 170 | pointsCallback([]); 171 | } 172 | console.info('disconnected'); 173 | }); 174 | 175 | ws.on('error', () => { 176 | activeWS = null; 177 | if (pointsCallback) { 178 | pointsCallback([]); 179 | } 180 | console.info('disconnected due to error'); 181 | }); 182 | 183 | ws.on('message', async (msg: Buffer) => { 184 | const decoded = await decodeMessage(msg); 185 | if (!isPointResponseMessage(decoded)) { 186 | return; 187 | } 188 | if (pointsCallback !== null) { 189 | pointsCallback(decoded.points); 190 | pointsCallback = null; 191 | } else { 192 | console.warn('messasge received and no callback set!'); 193 | } 194 | }); 195 | }); 196 | console.info('WebSocket server listening at ws://localhost:1532'); 197 | })(); 198 | 199 | -------------------------------------------------------------------------------- /src/vector.ts: -------------------------------------------------------------------------------- 1 | import { Vector3, Matrix4 } from 'three'; 2 | 3 | export type Color = { 4 | r: number, 5 | g: number, 6 | b: number 7 | } 8 | 9 | export type Point = { 10 | x: number, 11 | y: number, 12 | z?: number, 13 | r: number, 14 | g: number, 15 | b: number, 16 | }; 17 | 18 | export type BlankingOptions = { 19 | beginSamples: number; // amount of samples we need to hold before laser starts moving 20 | laserOnSamples: number; // amount of samples the laser needs to be "on" before it starts emitting light 21 | endSamples: number; // amount of samples we need to hold before laser stops moving 22 | laserOffSamples: number; // amount of samples the laser needs to be "off" before it actually stops emitting 23 | }; 24 | 25 | /** 26 | * Abstraction of three's Matrix implementation, so that one day it can be 27 | * rewritten or replaced so we can reduce bundle size. 28 | */ 29 | class TransformAffine { 30 | private affine; 31 | 32 | constructor() { 33 | this.affine = new Matrix4() 34 | } 35 | 36 | reset() { 37 | this.affine = new Matrix4(); 38 | } 39 | 40 | rotateX(theta: number) { 41 | this.affine = new Matrix4().makeRotationX(theta).multiply(this.affine); 42 | } 43 | 44 | rotateY(theta: number) { 45 | this.affine = new Matrix4().makeRotationY(theta).multiply(this.affine); 46 | } 47 | 48 | rotateZ(theta: number) { 49 | this.affine = new Matrix4().makeRotationZ(theta).multiply(this.affine); 50 | } 51 | 52 | scale({ x = 0, y = 0, z = 0 }) { 53 | this.affine = new Matrix4().makeScale(x, y, z).multiply(this.affine); 54 | } 55 | 56 | translate({ x = 0, y = 0, z = 0 }) { 57 | this.affine = new Matrix4().makeTranslation(new Vector3(x, y, z)).multiply(this.affine); 58 | } 59 | 60 | applyToPoint({ x, y, z = 0 }: { x: number, y: number, z: number }) { 61 | const vec = new Vector3(x, y, z); 62 | vec.applyMatrix4(this.affine); 63 | return { 64 | x: vec.x, 65 | y: vec.y, 66 | z: vec.z 67 | } 68 | } 69 | 70 | multiply(affine: TransformAffine) { 71 | const newAffine = new TransformAffine(); 72 | newAffine.affine = this.affine.clone().multiply(affine.affine); 73 | return newAffine; 74 | } 75 | } 76 | 77 | export class PointGroup { 78 | public affine: TransformAffine; 79 | 80 | constructor(public points?: (Point | PointGroup)[], public blank: boolean = false) { 81 | this.affine = new TransformAffine(); 82 | } 83 | 84 | *startBlanking(point: Point, blankingOptions?: BlankingOptions) { 85 | if (!this.blank || !blankingOptions) { 86 | return; 87 | } 88 | for (var i = 0; i < blankingOptions.beginSamples; i++) { 89 | if (i >= blankingOptions.beginSamples - blankingOptions.laserOnSamples) { 90 | yield point; 91 | } else { 92 | yield { ...point, r: 0, g: 0, b: 0 }; 93 | } 94 | } 95 | } 96 | 97 | *endBlanking(point: Point, blankingOptions?: BlankingOptions) { 98 | if (!this.blank || !blankingOptions) { 99 | return; 100 | } 101 | for (var i = 0; i < blankingOptions.endSamples; i++) { 102 | if (i < blankingOptions.endSamples - blankingOptions.laserOffSamples) { 103 | yield point; 104 | } else { 105 | yield { ...point, r: 0, g: 0, b: 0 }; 106 | } 107 | } 108 | } 109 | 110 | resetMatrix() { 111 | this.affine.reset(); 112 | } 113 | 114 | rotateX(theta: number) { 115 | this.affine.rotateX(theta); 116 | } 117 | 118 | rotateY(theta: number) { 119 | this.affine.rotateY(theta); 120 | } 121 | 122 | rotateZ(theta: number) { 123 | this.affine.rotateZ(theta); 124 | } 125 | 126 | scale({ x = 0, y = 0, z = 0 }) { 127 | this.affine.scale({ x, y, z }); 128 | } 129 | 130 | translate({ x = 0, y = 0, z = 0 }) { 131 | this.affine.translate({ x, y, z }); 132 | } 133 | 134 | applyMatrix(point: Point, affine: TransformAffine) { 135 | let { x, y, z = 0 } = point; 136 | const coords = affine.applyToPoint({ x, y, z }); 137 | [x, y, z] = [coords.x, coords.y, coords.z]; 138 | return { ...point, x, y, z }; 139 | } 140 | 141 | *getPoints(blankingOptions?: BlankingOptions, affine?: TransformAffine): Generator { 142 | if (!affine) { 143 | affine = new TransformAffine(); 144 | } 145 | const matrixToApply = affine.multiply(this.affine); 146 | if (this.points === undefined) { 147 | return; 148 | } 149 | let started = false; 150 | let lastPoint: Point | null = null; 151 | for (const point of this.points) { 152 | if (point instanceof PointGroup) { 153 | for (const pointInGroup of point.getPoints(blankingOptions, matrixToApply)) { 154 | if (!started) { 155 | yield* this.startBlanking(pointInGroup, blankingOptions); 156 | started = true; 157 | } 158 | yield pointInGroup; 159 | lastPoint = pointInGroup; 160 | } 161 | } else { 162 | const transformedPoint = this.applyMatrix(point, matrixToApply); 163 | if (!started) { 164 | yield* this.startBlanking(transformedPoint, blankingOptions); 165 | started = true; 166 | } 167 | yield transformedPoint; 168 | lastPoint = transformedPoint; 169 | } 170 | } 171 | if (lastPoint) { 172 | yield* this.endBlanking(lastPoint, blankingOptions); 173 | } 174 | } 175 | } 176 | 177 | export class PerspectiveGroup extends PointGroup { 178 | *getPoints(blankingOptions?: BlankingOptions, affine?: TransformAffine): Generator { 179 | for (const point of super.getPoints(blankingOptions, affine)) { 180 | const d = 1 / (2 - (point.z || 0)); 181 | yield { 182 | x: d * point.x, 183 | y: d * point.y, 184 | r: point.r, 185 | g: point.g, 186 | b: point.b, 187 | } 188 | } 189 | } 190 | } 191 | 192 | export abstract class ComputedPointGroup extends PointGroup { 193 | abstract computePoints(): Generator 194 | 195 | *getPoints(blankingOptions?: BlankingOptions, affine?: TransformAffine): Generator { 196 | if (!affine) { 197 | affine = new TransformAffine(); 198 | } 199 | const matrixToApply = affine.multiply(this.affine); 200 | let started = false; 201 | let lastPoint: Point | null = null; 202 | for (const point of this.computePoints()) { 203 | const transformedPoint = this.applyMatrix(point, matrixToApply); 204 | if (!started) { 205 | yield* this.startBlanking(transformedPoint, blankingOptions); 206 | started = true; 207 | } 208 | yield transformedPoint; 209 | lastPoint = transformedPoint; 210 | } 211 | if (lastPoint) { 212 | yield* this.endBlanking(lastPoint, blankingOptions); 213 | } 214 | } 215 | } 216 | 217 | export class Circle extends ComputedPointGroup { 218 | constructor(public color: Color, public numPoints: number = 100, blank: boolean = true) { 219 | super([], blank); 220 | } 221 | 222 | *computePoints() { 223 | for (var i = 0; i <= this.numPoints; i++) { 224 | yield { 225 | x: Math.sin(i / this.numPoints * Math.PI * 2), 226 | y: Math.cos(i / this.numPoints * Math.PI * 2), 227 | ...this.color 228 | } 229 | } 230 | } 231 | } 232 | 233 | export class Line extends ComputedPointGroup { 234 | constructor(public start: Point, public end: Point, public numPoints: number = 100, blank: boolean = true) { 235 | super([], blank); 236 | } 237 | 238 | *computePoints() { 239 | for (var i = 0; i <= this.numPoints; i++) { 240 | const pctEnd = i / this.numPoints; 241 | const pctStart = 1 - pctEnd; 242 | const z = (this.start.z && this.end.z) ? 243 | (pctStart * this.start.z) + (pctEnd * this.end.z) : undefined; 244 | yield { 245 | x: (pctStart * this.start.x) + (pctEnd * this.end.x), 246 | y: (pctStart * this.start.y) + (pctEnd * this.end.y), 247 | z, 248 | r: (pctStart * this.start.r) + (pctEnd * this.end.r), 249 | g: (pctStart * this.start.g) + (pctEnd * this.end.g), 250 | b: (pctStart * this.start.b) + (pctEnd * this.end.b), 251 | } 252 | } 253 | } 254 | } 255 | 256 | /* ______ 257 | * /| /| 258 | * /_|___/ | 259 | * | |___|_| 260 | * | / | / 261 | * |/_____|/ 262 | */ 263 | 264 | export function makeCube(pointsPerLine: number, color: Color, blank: boolean = true) { 265 | const coords = [ 266 | [{ x: -1, y: -1, z: -1 }, { x: 1, y: -1, z: -1 },], // x 267 | [{ x: -1, y: 1, z: -1 }, { x: 1, y: 1, z: -1 },], 268 | [{ x: -1, y: -1, z: 1 }, { x: 1, y: -1, z: 1 },], 269 | [{ x: -1, y: 1, z: 1 }, { x: 1, y: 1, z: 1 },], 270 | [{ x: -1, y: -1, z: -1 }, { x: -1, y: 1, z: -1 },], // y 271 | [{ x: 1, y: -1, z: -1 }, { x: 1, y: 1, z: -1 },], 272 | [{ x: -1, y: -1, z: 1 }, { x: -1, y: 1, z: 1 },], 273 | [{ x: 1, y: -1, z: 1 }, { x: 1, y: 1, z: 1 },], 274 | [{ x: -1, y: -1, z: -1 }, { x: -1, y: -1, z: 1 },], // z 275 | [{ x: 1, y: -1, z: -1 }, { x: 1, y: -1, z: 1 },], 276 | [{ x: -1, y: 1, z: -1 }, { x: -1, y: 1, z: 1 },], 277 | [{ x: 1, y: 1, z: -1 }, { x: 1, y: 1, z: 1 },], 278 | ]; 279 | return new PointGroup( 280 | coords.map( 281 | ([start, end]) => (new Line({ ...start, ...color }, { ...end, ...color }, pointsPerLine, blank)) 282 | ) 283 | ); 284 | } 285 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "commonjs", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ 39 | // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ 40 | // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ 41 | // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ 42 | // "resolveJsonModule": true, /* Enable importing .json files. */ 43 | // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | 46 | /* JavaScript Support */ 47 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 48 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 49 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 50 | 51 | /* Emit */ 52 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 53 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 54 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 55 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 56 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 57 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 58 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 59 | // "removeComments": true, /* Disable emitting comments. */ 60 | // "noEmit": true, /* Disable emitting files from a compilation. */ 61 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 62 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 63 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 64 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 65 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 66 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 67 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 68 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 69 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 70 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 71 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 72 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 73 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 74 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 75 | 76 | /* Interop Constraints */ 77 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 78 | // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ 79 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 80 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 81 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 82 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 83 | 84 | /* Type Checking */ 85 | "strict": true, /* Enable all strict type-checking options. */ 86 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 87 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 88 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 89 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 90 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 91 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 92 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 93 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 94 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 95 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 96 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 97 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 98 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 99 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 100 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 101 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 102 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 103 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 104 | 105 | /* Completeness */ 106 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 107 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 108 | } 109 | } 110 | --------------------------------------------------------------------------------