├── .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 | 
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 | 
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 |
--------------------------------------------------------------------------------