cadcode 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +215 -2
- package/client/index.html +54 -0
- package/client/viewer.css +221 -0
- package/client/viewer.js +331 -0
- package/package.json +34 -4
- package/src/cli.js +96 -0
- package/src/core/mesh.js +69 -0
- package/src/core/program.js +118 -0
- package/src/dev/server.js +190 -0
- package/src/dev/worker.js +46 -0
- package/src/export/png.js +52 -0
- package/src/export/raster.js +207 -0
- package/src/export/stl.js +28 -0
- package/src/lib/colors.js +56 -0
- package/src/lib/index.js +25 -0
- package/src/lib/shapes.js +489 -0
- package/src/lib/util.js +24 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { Worker } from 'node:worker_threads';
|
|
7
|
+
import * as esbuild from 'esbuild';
|
|
8
|
+
import { WebSocketServer } from 'ws';
|
|
9
|
+
import { bundleOptions } from '../core/program.js';
|
|
10
|
+
|
|
11
|
+
const CLIENT_DIR = fileURLToPath(new URL('../../client/', import.meta.url));
|
|
12
|
+
const THREE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('three'))), '..');
|
|
13
|
+
const WORKER_URL = new URL('./worker.js', import.meta.url);
|
|
14
|
+
|
|
15
|
+
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json' };
|
|
16
|
+
|
|
17
|
+
/** Serves a file from `root`, refusing paths that escape it. */
|
|
18
|
+
function serveFile(res, root, relative) {
|
|
19
|
+
const file = path.resolve(root, `.${path.posix.normalize(`/${relative}`)}`);
|
|
20
|
+
if (!file.startsWith(root) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
|
21
|
+
res.writeHead(404).end('Not found');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-cache' });
|
|
25
|
+
fs.createReadStream(file).pipe(res);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Model frames are binary: a little-endian u32 header length, a JSON header
|
|
30
|
+
* padded to 4 bytes, then each group's positions (f32) and indices (u32).
|
|
31
|
+
*/
|
|
32
|
+
function encodeModel(result, file) {
|
|
33
|
+
const header = {
|
|
34
|
+
type: 'model',
|
|
35
|
+
file,
|
|
36
|
+
part: result.part,
|
|
37
|
+
partNames: result.partNames,
|
|
38
|
+
logs: result.logs,
|
|
39
|
+
ms: result.ms,
|
|
40
|
+
groups: result.groups.map((g) => ({ body: g.body, color: g.color, vertices: g.positions.length / 3, indices: g.indices.length })),
|
|
41
|
+
};
|
|
42
|
+
let json = Buffer.from(JSON.stringify(header));
|
|
43
|
+
json = Buffer.concat([json, Buffer.alloc((4 - (json.length % 4)) % 4, 0x20)]);
|
|
44
|
+
const length = Buffer.alloc(4);
|
|
45
|
+
length.writeUInt32LE(json.length);
|
|
46
|
+
const buffers = result.groups.flatMap((g) => [
|
|
47
|
+
Buffer.from(g.positions.buffer, g.positions.byteOffset, g.positions.byteLength),
|
|
48
|
+
Buffer.from(g.indices.buffer, g.indices.byteOffset, g.indices.byteLength),
|
|
49
|
+
]);
|
|
50
|
+
return Buffer.concat([length, json, ...buffers]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function openBrowser(url) {
|
|
54
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
55
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
56
|
+
spawn(command, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Watches a program, re-evaluates it on every save, and pushes the meshes to
|
|
61
|
+
* connected browsers over a WebSocket.
|
|
62
|
+
*/
|
|
63
|
+
export async function startDevServer({ file, port, host, part, overrides, open }) {
|
|
64
|
+
const relative = path.relative(process.cwd(), path.resolve(file));
|
|
65
|
+
const displayName = relative.startsWith('..') ? path.resolve(file) : relative;
|
|
66
|
+
const baseName = path.basename(file).replace(/\.[^.]+$/, '');
|
|
67
|
+
// lastError is replayed to browsers that connect while the program is broken.
|
|
68
|
+
const state = { part, lastFrame: null, lastError: null, worker: null, bundle: null };
|
|
69
|
+
const sockets = new Set();
|
|
70
|
+
|
|
71
|
+
const broadcast = (data) => {
|
|
72
|
+
for (const ws of sockets) if (ws.readyState === ws.OPEN) ws.send(data);
|
|
73
|
+
};
|
|
74
|
+
const sendStatus = (status, extra = {}) => broadcast(JSON.stringify({ type: 'status', status, ...extra }));
|
|
75
|
+
const sendError = (error) => {
|
|
76
|
+
console.error(`✗ ${error.message}`);
|
|
77
|
+
state.lastError = JSON.stringify({ type: 'error', ...error });
|
|
78
|
+
broadcast(state.lastError);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function evaluate() {
|
|
82
|
+
if (!state.bundle) return;
|
|
83
|
+
state.worker?.terminate();
|
|
84
|
+
sendStatus('evaluating');
|
|
85
|
+
const worker = new Worker(WORKER_URL, { workerData: { bundle: state.bundle, part: state.part, overrides } });
|
|
86
|
+
state.worker = worker;
|
|
87
|
+
worker.once('message', (result) => {
|
|
88
|
+
if (state.worker !== worker) return;
|
|
89
|
+
state.worker = null;
|
|
90
|
+
worker.terminate();
|
|
91
|
+
if (!result.ok) return sendError({ message: result.message, stack: result.stack, logs: result.logs });
|
|
92
|
+
state.part = result.part;
|
|
93
|
+
state.lastError = null;
|
|
94
|
+
state.lastFrame = encodeModel(result, displayName);
|
|
95
|
+
broadcast(state.lastFrame);
|
|
96
|
+
const triangles = result.groups.reduce((n, g) => n + g.indices.length / 3, 0);
|
|
97
|
+
console.log(`✓ ${result.part}: ${triangles} triangles in ${result.ms} ms`);
|
|
98
|
+
});
|
|
99
|
+
worker.once('error', (error) => {
|
|
100
|
+
if (state.worker !== worker) return;
|
|
101
|
+
state.worker = null;
|
|
102
|
+
sendError({ message: error.message, stack: error.stack });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A download evaluates in its own worker, so it never cancels or waits for the preview.
|
|
107
|
+
function downloadSTL(res, part) {
|
|
108
|
+
if (!state.bundle) return void res.writeHead(503, { 'Content-Type': 'text/plain' }).end('The program has not built yet');
|
|
109
|
+
const worker = new Worker(WORKER_URL, { workerData: { bundle: state.bundle, part, overrides, output: 'stl' } });
|
|
110
|
+
const fail = (message) => res.writeHead(500, { 'Content-Type': 'text/plain' }).end(message);
|
|
111
|
+
worker.once('message', (result) => {
|
|
112
|
+
worker.terminate();
|
|
113
|
+
if (!result.ok) return fail(result.message);
|
|
114
|
+
const name = result.partNames.length > 1 ? `${baseName}-${result.part}.stl` : `${baseName}.stl`;
|
|
115
|
+
res.writeHead(200, { 'Content-Type': 'model/stl', 'Content-Disposition': `attachment; filename="${name}"` });
|
|
116
|
+
res.end(Buffer.from(result.stl.buffer, result.stl.byteOffset, result.stl.byteLength));
|
|
117
|
+
console.log(`↓ ${name}`);
|
|
118
|
+
});
|
|
119
|
+
worker.once('error', (error) => fail(error.message));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const options = bundleOptions(file);
|
|
123
|
+
const ctx = await esbuild.context({
|
|
124
|
+
...options,
|
|
125
|
+
plugins: [
|
|
126
|
+
...options.plugins,
|
|
127
|
+
{
|
|
128
|
+
name: 'cadcode-dev',
|
|
129
|
+
setup(build) {
|
|
130
|
+
build.onStart(() => sendStatus('building'));
|
|
131
|
+
build.onEnd((result) => {
|
|
132
|
+
if (result.errors.length > 0) {
|
|
133
|
+
const message = result.errors.map((e) => `${e.location ? `${e.location.file}:${e.location.line}:${e.location.column}: ` : ''}${e.text}`).join('\n');
|
|
134
|
+
return sendError({ message });
|
|
135
|
+
}
|
|
136
|
+
state.bundle = options.outfile;
|
|
137
|
+
evaluate();
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const server = http.createServer((req, res) => {
|
|
145
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
146
|
+
if (url.pathname === '/') return serveFile(res, CLIENT_DIR, 'index.html');
|
|
147
|
+
if (url.pathname === '/stl') return downloadSTL(res, url.searchParams.get('part') ?? state.part);
|
|
148
|
+
if (url.pathname.startsWith('/vendor/three/')) return serveFile(res, THREE_DIR, url.pathname.slice('/vendor/three/'.length));
|
|
149
|
+
serveFile(res, CLIENT_DIR, url.pathname.slice(1));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const wss = new WebSocketServer({ server });
|
|
153
|
+
wss.on('connection', (ws) => {
|
|
154
|
+
sockets.add(ws);
|
|
155
|
+
ws.on('close', () => sockets.delete(ws));
|
|
156
|
+
ws.on('message', (data) => {
|
|
157
|
+
let message;
|
|
158
|
+
try {
|
|
159
|
+
message = JSON.parse(String(data));
|
|
160
|
+
} catch {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (message.type === 'part' && message.name !== state.part) {
|
|
164
|
+
state.part = message.name;
|
|
165
|
+
evaluate();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
if (state.lastFrame) ws.send(state.lastFrame);
|
|
169
|
+
if (state.lastError) ws.send(state.lastError);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await new Promise((resolve, reject) => {
|
|
173
|
+
server.once('error', (error) => reject(error.code === 'EADDRINUSE' ? new Error(`Port ${port} is in use; pass --port`) : error));
|
|
174
|
+
server.listen(port, host, resolve);
|
|
175
|
+
});
|
|
176
|
+
const url = `http://${host.includes(':') ? `[${host}]` : host}:${port}/`;
|
|
177
|
+
console.log(`cadcode dev: ${displayName} → ${url}`);
|
|
178
|
+
if (open) openBrowser(url);
|
|
179
|
+
|
|
180
|
+
await ctx.watch();
|
|
181
|
+
|
|
182
|
+
const shutdown = async () => {
|
|
183
|
+
state.worker?.terminate();
|
|
184
|
+
await ctx.dispose();
|
|
185
|
+
server.close();
|
|
186
|
+
process.exit(0);
|
|
187
|
+
};
|
|
188
|
+
process.on('SIGINT', shutdown);
|
|
189
|
+
process.on('SIGTERM', shutdown);
|
|
190
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Evaluates a program in a worker thread, for the viewer's meshes or, with
|
|
2
|
+
// output 'stl', for a download. Each evaluation gets a fresh worker:
|
|
3
|
+
// that frees all WASM memory between runs, resets library state such as
|
|
4
|
+
// setResolution(), and lets the server kill a run that a newer save made stale.
|
|
5
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
6
|
+
import { format } from 'node:util';
|
|
7
|
+
import { evaluateProgram } from '../core/program.js';
|
|
8
|
+
import { meshGroups } from '../core/mesh.js';
|
|
9
|
+
import { toSTL } from '../export/stl.js';
|
|
10
|
+
|
|
11
|
+
// Bundles carry inline source maps; this makes stack traces point at the user's files.
|
|
12
|
+
process.setSourceMapsEnabled(true);
|
|
13
|
+
|
|
14
|
+
const logs = [];
|
|
15
|
+
for (const level of ['log', 'info', 'warn', 'error']) {
|
|
16
|
+
const original = console[level].bind(console);
|
|
17
|
+
console[level] = (...args) => {
|
|
18
|
+
logs.push({ level, text: format(...args) });
|
|
19
|
+
original(...args);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { bundle, part, overrides, output } = workerData;
|
|
24
|
+
const started = performance.now();
|
|
25
|
+
try {
|
|
26
|
+
const result = await evaluateProgram(bundle, { part, overrides, fallbackToFirst: output !== 'stl' });
|
|
27
|
+
if (output === 'stl') {
|
|
28
|
+
const stl = toSTL(result.solids);
|
|
29
|
+
parentPort.postMessage({ ok: true, partNames: result.partNames, part: result.part, stl }, [stl.buffer]);
|
|
30
|
+
} else {
|
|
31
|
+
const groups = meshGroups(result.solids);
|
|
32
|
+
parentPort.postMessage(
|
|
33
|
+
{
|
|
34
|
+
ok: true,
|
|
35
|
+
partNames: result.partNames,
|
|
36
|
+
part: result.part,
|
|
37
|
+
groups,
|
|
38
|
+
logs,
|
|
39
|
+
ms: Math.round(performance.now() - started),
|
|
40
|
+
},
|
|
41
|
+
groups.flatMap((g) => [g.positions.buffer, g.indices.buffer]),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
parentPort.postMessage({ ok: false, message: error?.message ?? String(error), stack: error?.stack, logs });
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import zlib from 'node:zlib';
|
|
2
|
+
import { meshGroups } from '../core/mesh.js';
|
|
3
|
+
import { render } from './raster.js';
|
|
4
|
+
|
|
5
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
6
|
+
let c = n;
|
|
7
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
8
|
+
return c >>> 0;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function crc32(buf) {
|
|
12
|
+
let c = 0xffffffff;
|
|
13
|
+
for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
|
|
14
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function chunk(type, data) {
|
|
18
|
+
const head = Buffer.alloc(8);
|
|
19
|
+
head.writeUInt32BE(data.length, 0);
|
|
20
|
+
head.write(type, 4, 'ascii');
|
|
21
|
+
const crc = Buffer.alloc(4);
|
|
22
|
+
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
|
|
23
|
+
return Buffer.concat([head, data, crc]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Encodes RGBA pixels as a PNG file. */
|
|
27
|
+
export function encodePNG({ width, height, pixels }) {
|
|
28
|
+
const ihdr = Buffer.alloc(13);
|
|
29
|
+
ihdr.writeUInt32BE(width, 0);
|
|
30
|
+
ihdr.writeUInt32BE(height, 4);
|
|
31
|
+
ihdr.set([8, 6, 0, 0, 0], 8); // 8-bit RGBA, no interlace
|
|
32
|
+
const raw = Buffer.alloc((width * 4 + 1) * height);
|
|
33
|
+
for (let y = 0; y < height; y++) {
|
|
34
|
+
raw[y * (width * 4 + 1)] = 0; // filter: none
|
|
35
|
+
raw.set(pixels.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1);
|
|
36
|
+
}
|
|
37
|
+
return Buffer.concat([
|
|
38
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
39
|
+
chunk('IHDR', ihdr),
|
|
40
|
+
chunk('IDAT', zlib.deflateSync(raw)),
|
|
41
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Renders a part's solids to a PNG preview.
|
|
47
|
+
* @param {import('../lib/shapes.js').Solid[]} solids
|
|
48
|
+
* @param {{ width?: number, height?: number, view?: string }} [options]
|
|
49
|
+
*/
|
|
50
|
+
export function toPNG(solids, options) {
|
|
51
|
+
return encodePNG(render(meshGroups(solids), options));
|
|
52
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// A small software renderer for preview images: orthographic camera, flat
|
|
2
|
+
// shading, outline edges, and back-to-front blending for transparent parts.
|
|
3
|
+
// No GPU or browser needed, so `cadcode png` runs anywhere Node runs.
|
|
4
|
+
|
|
5
|
+
import { parseColor } from '../lib/colors.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_COLOR = parseColor('#f2c14e');
|
|
8
|
+
|
|
9
|
+
/** Camera directions (from the model toward the camera) and up vectors. Z is up. */
|
|
10
|
+
export const VIEWS = {
|
|
11
|
+
iso: { dir: [1, -1, 1], up: [0, 0, 1] },
|
|
12
|
+
'iso-back': { dir: [-1, 1, 1], up: [0, 0, 1] },
|
|
13
|
+
front: { dir: [0, -1, 0], up: [0, 0, 1] },
|
|
14
|
+
back: { dir: [0, 1, 0], up: [0, 0, 1] },
|
|
15
|
+
left: { dir: [-1, 0, 0], up: [0, 0, 1] },
|
|
16
|
+
right: { dir: [1, 0, 0], up: [0, 0, 1] },
|
|
17
|
+
top: { dir: [0, 0, 1], up: [0, 1, 0] },
|
|
18
|
+
bottom: { dir: [0, 0, -1], up: [0, 1, 0] },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Views laid out by `view: 'sheet'`, two by two. */
|
|
22
|
+
export const SHEET = ['iso', 'front', 'right', 'top'];
|
|
23
|
+
|
|
24
|
+
const SUPERSAMPLE = 2;
|
|
25
|
+
const EDGE_NORMAL_COS = Math.cos((25 * Math.PI) / 180);
|
|
26
|
+
const EDGE_SHADE = 0.35;
|
|
27
|
+
|
|
28
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
29
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
30
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
31
|
+
const normalize = (a) => {
|
|
32
|
+
const len = Math.hypot(a[0], a[1], a[2]) || 1;
|
|
33
|
+
return [a[0] / len, a[1] / len, a[2] / len];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Renders mesh groups to an RGBA pixel buffer.
|
|
38
|
+
* @param {import('../core/mesh.js').MeshGroup[]} groups
|
|
39
|
+
* @param {{ width?: number, height?: number, view?: string }} [options]
|
|
40
|
+
* @returns {{ width: number, height: number, pixels: Uint8Array }}
|
|
41
|
+
*/
|
|
42
|
+
export function render(groups, { width = 800, height = 600, view = 'iso' } = {}) {
|
|
43
|
+
if (view !== 'sheet') return renderView(groups, width, height, view);
|
|
44
|
+
|
|
45
|
+
const tileW = Math.floor(width / 2);
|
|
46
|
+
const tileH = Math.floor(height / 2);
|
|
47
|
+
const pixels = new Uint8Array(width * height * 4).fill(255);
|
|
48
|
+
SHEET.forEach((name, i) => {
|
|
49
|
+
const tile = renderView(groups, tileW, tileH, name);
|
|
50
|
+
const ox = (i % 2) * tileW;
|
|
51
|
+
const oy = Math.floor(i / 2) * tileH;
|
|
52
|
+
for (let y = 0; y < tileH; y++) {
|
|
53
|
+
pixels.set(tile.pixels.subarray(y * tileW * 4, (y + 1) * tileW * 4), ((oy + y) * width + ox) * 4);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return { width, height, pixels };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function renderView(groups, width, height, viewName) {
|
|
60
|
+
const view = VIEWS[viewName];
|
|
61
|
+
if (!view) throw new Error(`Unknown view "${viewName}". Views: ${[...Object.keys(VIEWS), 'sheet'].join(', ')}`);
|
|
62
|
+
|
|
63
|
+
const W = width * SUPERSAMPLE;
|
|
64
|
+
const H = height * SUPERSAMPLE;
|
|
65
|
+
const toCamera = normalize(view.dir);
|
|
66
|
+
const forward = [-toCamera[0], -toCamera[1], -toCamera[2]];
|
|
67
|
+
const right = normalize(cross(forward, view.up));
|
|
68
|
+
const up = cross(right, forward);
|
|
69
|
+
const light = normalize([toCamera[0] + right[0] * -0.4 + up[0] * 0.8, toCamera[1] + right[1] * -0.4 + up[1] * 0.8, toCamera[2] + right[2] * -0.4 + up[2] * 0.8]);
|
|
70
|
+
|
|
71
|
+
// Fit the projected model into the image with a margin.
|
|
72
|
+
let [minX, maxX, minY, maxY] = [Infinity, -Infinity, Infinity, -Infinity];
|
|
73
|
+
for (const { positions } of groups) {
|
|
74
|
+
for (let i = 0; i < positions.length; i += 3) {
|
|
75
|
+
const p = [positions[i], positions[i + 1], positions[i + 2]];
|
|
76
|
+
const x = dot(p, right);
|
|
77
|
+
const y = dot(p, up);
|
|
78
|
+
minX = Math.min(minX, x);
|
|
79
|
+
maxX = Math.max(maxX, x);
|
|
80
|
+
minY = Math.min(minY, y);
|
|
81
|
+
maxY = Math.max(maxY, y);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const margin = 0.08;
|
|
85
|
+
const scale = Math.min((W * (1 - 2 * margin)) / (maxX - minX || 1), (H * (1 - 2 * margin)) / (maxY - minY || 1));
|
|
86
|
+
const cx = (minX + maxX) / 2;
|
|
87
|
+
const cy = (minY + maxY) / 2;
|
|
88
|
+
const project = (p) => [(dot(p, right) - cx) * scale + W / 2, H / 2 - (dot(p, up) - cy) * scale, dot(p, forward)];
|
|
89
|
+
|
|
90
|
+
const color = new Float32Array(W * H * 3);
|
|
91
|
+
const depth = new Float32Array(W * H).fill(Infinity);
|
|
92
|
+
const normals = new Float32Array(W * H * 3);
|
|
93
|
+
for (let y = 0; y < H; y++) {
|
|
94
|
+
const t = y / H;
|
|
95
|
+
const shade = 1 - 0.1 * t;
|
|
96
|
+
for (let x = 0; x < W; x++) color.set([shade, shade, shade * 1.01], (y * W + x) * 3);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const shadeFace = (n, rgb) => {
|
|
100
|
+
const facing = Math.abs(dot(n, toCamera));
|
|
101
|
+
const intensity = 0.28 + 0.52 * Math.max(0, dot(n, light)) + 0.25 * facing;
|
|
102
|
+
return [rgb[0] * intensity, rgb[1] * intensity, rgb[2] * intensity];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Collect triangles; opaque ones are drawn first with depth writes.
|
|
106
|
+
const opaque = [];
|
|
107
|
+
const transparent = [];
|
|
108
|
+
for (const group of groups) {
|
|
109
|
+
const rgba = group.color ?? DEFAULT_COLOR;
|
|
110
|
+
const { positions, indices } = group;
|
|
111
|
+
for (let t = 0; t < indices.length; t += 3) {
|
|
112
|
+
const tri = [indices[t], indices[t + 1], indices[t + 2]].map((v) => [positions[v * 3], positions[v * 3 + 1], positions[v * 3 + 2]]);
|
|
113
|
+
const n = normalize(cross(sub(tri[1], tri[0]), sub(tri[2], tri[0])));
|
|
114
|
+
const screen = tri.map(project);
|
|
115
|
+
const entry = { screen, n, rgb: shadeFace(n, rgba), alpha: rgba[3] };
|
|
116
|
+
if (rgba[3] >= 0.999) {
|
|
117
|
+
if (dot(n, toCamera) > 0) opaque.push(entry);
|
|
118
|
+
} else {
|
|
119
|
+
entry.depth = (screen[0][2] + screen[1][2] + screen[2][2]) / 3;
|
|
120
|
+
transparent.push(entry);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
for (const { screen, n, rgb } of opaque) {
|
|
126
|
+
rasterize(screen, W, H, (i, z) => {
|
|
127
|
+
if (z >= depth[i]) return;
|
|
128
|
+
depth[i] = z;
|
|
129
|
+
color.set(rgb, i * 3);
|
|
130
|
+
normals.set(n, i * 3);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Outlines where depth jumps or the surface turns sharply.
|
|
135
|
+
const edge = new Uint8Array(W * H);
|
|
136
|
+
const depthJump = 6 / scale;
|
|
137
|
+
const isEdge = (a, b) => {
|
|
138
|
+
const fa = depth[a] !== Infinity;
|
|
139
|
+
const fb = depth[b] !== Infinity;
|
|
140
|
+
if (fa !== fb) return true;
|
|
141
|
+
if (!fa) return false;
|
|
142
|
+
if (Math.abs(depth[a] - depth[b]) > depthJump) return true;
|
|
143
|
+
return normals[a * 3] * normals[b * 3] + normals[a * 3 + 1] * normals[b * 3 + 1] + normals[a * 3 + 2] * normals[b * 3 + 2] < EDGE_NORMAL_COS;
|
|
144
|
+
};
|
|
145
|
+
for (let y = 0; y < H - 1; y++) {
|
|
146
|
+
for (let x = 0; x < W - 1; x++) {
|
|
147
|
+
const i = y * W + x;
|
|
148
|
+
if (isEdge(i, i + 1)) edge[i] = edge[i + 1] = 1;
|
|
149
|
+
if (isEdge(i, i + W)) edge[i] = edge[i + W] = 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (let i = 0; i < W * H; i++) {
|
|
153
|
+
if (edge[i] && depth[i] !== Infinity) for (let k = 0; k < 3; k++) color[i * 3 + k] *= EDGE_SHADE;
|
|
154
|
+
else if (edge[i]) for (let k = 0; k < 3; k++) color[i * 3 + k] = color[i * 3 + k] * 0.4 + 0.12;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Transparent faces back to front, tested against opaque depth only.
|
|
158
|
+
transparent.sort((a, b) => b.depth - a.depth);
|
|
159
|
+
for (const { screen, rgb, alpha } of transparent) {
|
|
160
|
+
rasterize(screen, W, H, (i, z) => {
|
|
161
|
+
if (z >= depth[i]) return;
|
|
162
|
+
for (let k = 0; k < 3; k++) color[i * 3 + k] = color[i * 3 + k] * (1 - alpha) + rgb[k] * alpha;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Downsample the supersampled buffer.
|
|
167
|
+
const pixels = new Uint8Array(width * height * 4);
|
|
168
|
+
const area = SUPERSAMPLE * SUPERSAMPLE;
|
|
169
|
+
for (let y = 0; y < height; y++) {
|
|
170
|
+
for (let x = 0; x < width; x++) {
|
|
171
|
+
const sum = [0, 0, 0];
|
|
172
|
+
for (let sy = 0; sy < SUPERSAMPLE; sy++) {
|
|
173
|
+
for (let sx = 0; sx < SUPERSAMPLE; sx++) {
|
|
174
|
+
const i = ((y * SUPERSAMPLE + sy) * W + x * SUPERSAMPLE + sx) * 3;
|
|
175
|
+
sum[0] += color[i];
|
|
176
|
+
sum[1] += color[i + 1];
|
|
177
|
+
sum[2] += color[i + 2];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const o = (y * width + x) * 4;
|
|
181
|
+
for (let k = 0; k < 3; k++) pixels[o + k] = Math.max(0, Math.min(255, Math.round((sum[k] / area) * 255)));
|
|
182
|
+
pixels[o + 3] = 255;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { width, height, pixels };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Calls `plot(pixelIndex, depth)` for every pixel center inside the screen-space triangle. */
|
|
189
|
+
function rasterize([a, b, c], W, H, plot) {
|
|
190
|
+
const area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
191
|
+
if (Math.abs(area) < 1e-12) return;
|
|
192
|
+
const x0 = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0])));
|
|
193
|
+
const x1 = Math.min(W - 1, Math.ceil(Math.max(a[0], b[0], c[0])));
|
|
194
|
+
const y0 = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1])));
|
|
195
|
+
const y1 = Math.min(H - 1, Math.ceil(Math.max(a[1], b[1], c[1])));
|
|
196
|
+
for (let y = y0; y <= y1; y++) {
|
|
197
|
+
const py = y + 0.5;
|
|
198
|
+
for (let x = x0; x <= x1; x++) {
|
|
199
|
+
const px = x + 0.5;
|
|
200
|
+
const w0 = ((b[0] - px) * (c[1] - py) - (b[1] - py) * (c[0] - px)) / area;
|
|
201
|
+
const w1 = ((c[0] - px) * (a[1] - py) - (c[1] - py) * (a[0] - px)) / area;
|
|
202
|
+
const w2 = 1 - w0 - w1;
|
|
203
|
+
if (w0 < 0 || w1 < 0 || w2 < 0) continue;
|
|
204
|
+
plot(y * W + x, w0 * a[2] + w1 * b[2] + w2 * c[2]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mergedSolid } from '../core/mesh.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Binary STL of the part's solids merged into one.
|
|
5
|
+
* @param {import('../lib/shapes.js').Solid[]} solids
|
|
6
|
+
* @returns {Buffer}
|
|
7
|
+
*/
|
|
8
|
+
export function toSTL(solids) {
|
|
9
|
+
const { numProp, vertProperties, triVerts } = mergedSolid(solids).manifold.getMesh();
|
|
10
|
+
const triCount = triVerts.length / 3;
|
|
11
|
+
const buf = Buffer.alloc(84 + triCount * 50);
|
|
12
|
+
buf.write('cadcode', 0, 'ascii');
|
|
13
|
+
buf.writeUInt32LE(triCount, 80);
|
|
14
|
+
|
|
15
|
+
const p = (v, k) => vertProperties[v * numProp + k];
|
|
16
|
+
let o = 84;
|
|
17
|
+
for (let t = 0; t < triCount; t++) {
|
|
18
|
+
const [a, b, c] = [triVerts[t * 3], triVerts[t * 3 + 1], triVerts[t * 3 + 2]];
|
|
19
|
+
const u = [p(b, 0) - p(a, 0), p(b, 1) - p(a, 1), p(b, 2) - p(a, 2)];
|
|
20
|
+
const w = [p(c, 0) - p(a, 0), p(c, 1) - p(a, 1), p(c, 2) - p(a, 2)];
|
|
21
|
+
const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
|
|
22
|
+
const len = Math.hypot(...n) || 1;
|
|
23
|
+
for (const value of [n[0] / len, n[1] / len, n[2] / len]) (buf.writeFloatLE(value, o), (o += 4));
|
|
24
|
+
for (const v of [a, b, c]) for (let k = 0; k < 3; k++) (buf.writeFloatLE(p(v, k), o), (o += 4));
|
|
25
|
+
o += 2; // attribute byte count
|
|
26
|
+
}
|
|
27
|
+
return buf;
|
|
28
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** @typedef {[number, number, number, number]} RGBA */
|
|
2
|
+
/** @typedef {string | [number, number, number] | [number, number, number, number]} ColorInput */
|
|
3
|
+
|
|
4
|
+
// CSS named colors, as hex. The same names OpenSCAD's color() accepts.
|
|
5
|
+
const NAMED = Object.fromEntries(
|
|
6
|
+
(
|
|
7
|
+
'aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff beige:f5f5dc bisque:ffe4c4 ' +
|
|
8
|
+
'black:000000 blanchedalmond:ffebcd blue:0000ff blueviolet:8a2be2 brown:a52a2a burlywood:deb887 cadetblue:5f9ea0 ' +
|
|
9
|
+
'chartreuse:7fff00 chocolate:d2691e coral:ff7f50 cornflowerblue:6495ed cornsilk:fff8dc crimson:dc143c cyan:00ffff ' +
|
|
10
|
+
'darkblue:00008b darkcyan:008b8b darkgoldenrod:b8860b darkgray:a9a9a9 darkgreen:006400 darkgrey:a9a9a9 ' +
|
|
11
|
+
'darkkhaki:bdb76b darkmagenta:8b008b darkolivegreen:556b2f darkorange:ff8c00 darkorchid:9932cc darkred:8b0000 ' +
|
|
12
|
+
'darksalmon:e9967a darkseagreen:8fbc8f darkslateblue:483d8b darkslategray:2f4f4f darkslategrey:2f4f4f ' +
|
|
13
|
+
'darkturquoise:00ced1 darkviolet:9400d3 deeppink:ff1493 deepskyblue:00bfff dimgray:696969 dimgrey:696969 ' +
|
|
14
|
+
'dodgerblue:1e90ff firebrick:b22222 floralwhite:fffaf0 forestgreen:228b22 fuchsia:ff00ff gainsboro:dcdcdc ' +
|
|
15
|
+
'ghostwhite:f8f8ff gold:ffd700 goldenrod:daa520 gray:808080 green:008000 greenyellow:adff2f grey:808080 ' +
|
|
16
|
+
'honeydew:f0fff0 hotpink:ff69b4 indianred:cd5c5c indigo:4b0082 ivory:fffff0 khaki:f0e68c lavender:e6e6fa ' +
|
|
17
|
+
'lavenderblush:fff0f5 lawngreen:7cfc00 lemonchiffon:fffacd lightblue:add8e6 lightcoral:f08080 lightcyan:e0ffff ' +
|
|
18
|
+
'lightgoldenrodyellow:fafad2 lightgray:d3d3d3 lightgreen:90ee90 lightgrey:d3d3d3 lightpink:ffb6c1 ' +
|
|
19
|
+
'lightsalmon:ffa07a lightseagreen:20b2aa lightskyblue:87cefa lightslategray:778899 lightslategrey:778899 ' +
|
|
20
|
+
'lightsteelblue:b0c4de lightyellow:ffffe0 lime:00ff00 limegreen:32cd32 linen:faf0e6 magenta:ff00ff maroon:800000 ' +
|
|
21
|
+
'mediumaquamarine:66cdaa mediumblue:0000cd mediumorchid:ba55d3 mediumpurple:9370db mediumseagreen:3cb371 ' +
|
|
22
|
+
'mediumslateblue:7b68ee mediumspringgreen:00fa9a mediumturquoise:48d1cc mediumvioletred:c71585 ' +
|
|
23
|
+
'midnightblue:191970 mintcream:f5fffa mistyrose:ffe4e1 moccasin:ffe4b5 navajowhite:ffdead navy:000080 ' +
|
|
24
|
+
'oldlace:fdf5e6 olive:808000 olivedrab:6b8e23 orange:ffa500 orangered:ff4500 orchid:da70d6 palegoldenrod:eee8aa ' +
|
|
25
|
+
'palegreen:98fb98 paleturquoise:afeeee palevioletred:db7093 papayawhip:ffefd5 peachpuff:ffdab9 peru:cd853f ' +
|
|
26
|
+
'pink:ffc0cb plum:dda0dd powderblue:b0e0e6 purple:800080 rebeccapurple:663399 red:ff0000 rosybrown:bc8f8f ' +
|
|
27
|
+
'royalblue:4169e1 saddlebrown:8b4513 salmon:fa8072 sandybrown:f4a460 seagreen:2e8b57 seashell:fff5ee ' +
|
|
28
|
+
'sienna:a0522d silver:c0c0c0 skyblue:87ceeb slateblue:6a5acd slategray:708090 slategrey:708090 snow:fffafa ' +
|
|
29
|
+
'springgreen:00ff7f steelblue:4682b4 tan:d2b48c teal:008080 thistle:d8bfd8 tomato:ff6347 turquoise:40e0d0 ' +
|
|
30
|
+
'violet:ee82ee wheat:f5deb3 white:ffffff whitesmoke:f5f5f5 yellow:ffff00 yellowgreen:9acd32'
|
|
31
|
+
)
|
|
32
|
+
.split(' ')
|
|
33
|
+
.map((pair) => pair.split(':')),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parses a CSS color name, `#rgb`/`#rrggbb`/`#rrggbbaa`, or an [r, g, b(, a)] array of 0..1 floats.
|
|
38
|
+
* @param {ColorInput} input
|
|
39
|
+
* @param {number} [alpha] overrides the alpha channel
|
|
40
|
+
* @returns {RGBA}
|
|
41
|
+
*/
|
|
42
|
+
export function parseColor(input, alpha) {
|
|
43
|
+
/** @type {RGBA} */
|
|
44
|
+
let rgba;
|
|
45
|
+
if (Array.isArray(input)) {
|
|
46
|
+
rgba = [input[0], input[1], input[2], input[3] ?? 1];
|
|
47
|
+
} else {
|
|
48
|
+
const hex = input.startsWith('#') ? input.slice(1) : NAMED[input.toLowerCase()];
|
|
49
|
+
if (!hex || !/^([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(hex)) throw new Error(`Unknown color: ${input}`);
|
|
50
|
+
const full = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex;
|
|
51
|
+
const channel = (i) => parseInt(full.slice(i * 2, i * 2 + 2), 16) / 255;
|
|
52
|
+
rgba = [channel(0), channel(1), channel(2), full.length === 8 ? channel(3) : 1];
|
|
53
|
+
}
|
|
54
|
+
if (alpha !== undefined) rgba[3] = alpha;
|
|
55
|
+
return rgba;
|
|
56
|
+
}
|
package/src/lib/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export {
|
|
2
|
+
Solid,
|
|
3
|
+
Shape2,
|
|
4
|
+
setResolution,
|
|
5
|
+
cube,
|
|
6
|
+
cylinder,
|
|
7
|
+
sphere,
|
|
8
|
+
polyhedron,
|
|
9
|
+
square,
|
|
10
|
+
circle,
|
|
11
|
+
polygon,
|
|
12
|
+
union,
|
|
13
|
+
difference,
|
|
14
|
+
intersection,
|
|
15
|
+
hull,
|
|
16
|
+
translate,
|
|
17
|
+
rotate,
|
|
18
|
+
scale,
|
|
19
|
+
mirror,
|
|
20
|
+
color,
|
|
21
|
+
offset,
|
|
22
|
+
linearExtrude,
|
|
23
|
+
} from './shapes.js';
|
|
24
|
+
export { parseColor } from './colors.js';
|
|
25
|
+
export { range, product } from './util.js';
|