cadcode 0.2.0 → 0.3.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/LICENSE +21 -0
- package/README.md +22 -10
- package/client/favicon.svg +17 -0
- package/client/index.html +48 -35
- package/client/render-worker.js +333 -0
- package/client/scene.js +166 -0
- package/client/viewer.css +421 -112
- package/client/viewer.js +127 -159
- package/package.json +9 -3
- package/src/core/evaluate.js +59 -0
- package/src/core/program.js +4 -44
- package/src/dev/server.js +8 -3
- package/src/dev/worker.js +4 -1
- package/src/export/stl.js +9 -7
package/client/scene.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The page's side of the three.js view shared by the dev viewer and the site's
|
|
2
|
+
// playground. The scene itself runs in client/render-worker.js on an OffscreenCanvas,
|
|
3
|
+
// so building a large mesh never stalls the page. This side hands the canvas over,
|
|
4
|
+
// forwards input and size, and turns commands into messages:
|
|
5
|
+
//
|
|
6
|
+
// to the worker: init, resize, inset, event, show, fit, snapshot, set-camera, display
|
|
7
|
+
// from the worker: reply (to show and snapshot), camera (on every move), cursor
|
|
8
|
+
|
|
9
|
+
/** The event fields OrbitControls reads. */
|
|
10
|
+
const FIELDS = ['pointerId', 'pointerType', 'button', 'buttons', 'clientX', 'clientY', 'pageX', 'pageY', 'deltaX', 'deltaY', 'deltaMode', 'ctrlKey', 'metaKey', 'shiftKey', 'altKey', 'key', 'code'];
|
|
11
|
+
|
|
12
|
+
const copyEvent = (event) => {
|
|
13
|
+
const data = { type: event.type };
|
|
14
|
+
for (const field of FIELDS) if (event[field] !== undefined) data[field] = event[field];
|
|
15
|
+
return data;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {HTMLCanvasElement} canvas
|
|
20
|
+
* @param {{ workerUrl: string | URL, colors?: { background?: number, gridMajor?: number, gridMinor?: number, edges?: number }, onCamera?: (camera: { position: number[], target: number[], zoom: number }) => void }} options
|
|
21
|
+
* The colours default to the dev viewer's, and a null background leaves the canvas
|
|
22
|
+
* transparent; onCamera hears of every camera move.
|
|
23
|
+
*/
|
|
24
|
+
export function createScene(canvas, { workerUrl, colors, onCamera }) {
|
|
25
|
+
const worker = new Worker(workerUrl, { type: 'module', name: 'render' });
|
|
26
|
+
const pending = new Map(); // id → { resolve, reject } of a show or snapshot
|
|
27
|
+
let nextId = 1;
|
|
28
|
+
|
|
29
|
+
const request = (message, transfer = []) =>
|
|
30
|
+
new Promise((resolve, reject) => {
|
|
31
|
+
const id = nextId++;
|
|
32
|
+
pending.set(id, { resolve, reject });
|
|
33
|
+
worker.postMessage({ ...message, id }, transfer);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
worker.onmessage = ({ data }) => {
|
|
37
|
+
if (data.type === 'camera') return onCamera?.(data.camera);
|
|
38
|
+
if (data.type === 'cursor') return void (canvas.style.cursor = data.value);
|
|
39
|
+
if (data.type !== 'reply') return;
|
|
40
|
+
const { resolve, reject } = pending.get(data.id);
|
|
41
|
+
pending.delete(data.id);
|
|
42
|
+
if (data.error) reject(new Error(data.error));
|
|
43
|
+
else resolve(data.value);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// A worker that fails to load or throws would leave the page waiting for replies.
|
|
47
|
+
worker.onerror = (event) => {
|
|
48
|
+
const error = new Error(event.message || 'The view failed to start');
|
|
49
|
+
for (const { reject } of pending.values()) reject(error);
|
|
50
|
+
pending.clear();
|
|
51
|
+
console.error(error);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// --- Size --------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const measure = () => {
|
|
57
|
+
const box = canvas.getBoundingClientRect();
|
|
58
|
+
return { width: canvas.clientWidth, height: canvas.clientHeight, left: box.left, top: box.top, dpr: window.devicePixelRatio };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const offscreen = canvas.transferControlToOffscreen();
|
|
62
|
+
worker.postMessage({ type: 'init', canvas: offscreen, colors, ...measure() }, [offscreen]);
|
|
63
|
+
new ResizeObserver(() => worker.postMessage({ type: 'resize', ...measure() })).observe(canvas);
|
|
64
|
+
|
|
65
|
+
// --- Input -------------------------------------------------------------------
|
|
66
|
+
// The page captures the pointer on the canvas, so a drag keeps reporting when it
|
|
67
|
+
// leaves it; OrbitControls hears pointermove and pointerup on its "document".
|
|
68
|
+
|
|
69
|
+
canvas.style.touchAction = 'none';
|
|
70
|
+
const forward = (target, event) => worker.postMessage({ type: 'event', target, event: copyEvent(event) });
|
|
71
|
+
|
|
72
|
+
canvas.addEventListener('pointerdown', (event) => {
|
|
73
|
+
canvas.setPointerCapture(event.pointerId);
|
|
74
|
+
forward('element', event);
|
|
75
|
+
});
|
|
76
|
+
canvas.addEventListener('pointercancel', (event) => forward('element', event));
|
|
77
|
+
canvas.addEventListener('pointermove', (event) => forward('document', event));
|
|
78
|
+
canvas.addEventListener('pointerup', (event) => forward('document', event));
|
|
79
|
+
canvas.addEventListener('contextmenu', (event) => {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
forward('element', event);
|
|
82
|
+
});
|
|
83
|
+
canvas.addEventListener(
|
|
84
|
+
'wheel',
|
|
85
|
+
(event) => {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
forward('element', event);
|
|
88
|
+
},
|
|
89
|
+
{ passive: false },
|
|
90
|
+
);
|
|
91
|
+
// OrbitControls watches the modifier keys to change what a drag or wheel does.
|
|
92
|
+
for (const type of ['keydown', 'keyup']) window.addEventListener(type, (event) => forward('document', event));
|
|
93
|
+
|
|
94
|
+
// --- Commands ----------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
/**
|
|
98
|
+
* Replaces the model with mesh groups ({ color, positions, indices }); their
|
|
99
|
+
* buffers are handed to the worker and are unusable here afterwards.
|
|
100
|
+
* @returns {Promise<{ triangles: number, size: [number, number, number] | null }>}
|
|
101
|
+
*/
|
|
102
|
+
showModel(groups) {
|
|
103
|
+
const buffers = new Set(groups.flatMap((g) => [g.positions.buffer, g.indices.buffer]));
|
|
104
|
+
return request({ type: 'show', groups }, [...buffers]);
|
|
105
|
+
},
|
|
106
|
+
fitView() {
|
|
107
|
+
worker.postMessage({ type: 'fit' });
|
|
108
|
+
},
|
|
109
|
+
/** How much of the canvas a panel covers, in CSS pixels; the view centres on the rest. */
|
|
110
|
+
setInset(inset) {
|
|
111
|
+
worker.postMessage({ type: 'inset', inset });
|
|
112
|
+
},
|
|
113
|
+
setCamera(camera) {
|
|
114
|
+
worker.postMessage({ type: 'set-camera', camera });
|
|
115
|
+
},
|
|
116
|
+
/** How the model is drawn: 'solid', 'wire' (every triangle edge, no faces) or 'both'. */
|
|
117
|
+
setDisplay(mode) {
|
|
118
|
+
worker.postMessage({ type: 'display', mode });
|
|
119
|
+
},
|
|
120
|
+
/** The current view as a PNG, without the grid and axes. @returns {Promise<Blob>} */
|
|
121
|
+
snapshotPNG() {
|
|
122
|
+
return request({ type: 'snapshot' });
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Turns buttons with data-display="wire" or "both" into a pair of radio-like
|
|
129
|
+
* toggles for the view's display mode: pressing one switches to it, pressing the
|
|
130
|
+
* pressed one goes back to solid. The mode is remembered across reloads.
|
|
131
|
+
* @param {{ setDisplay: (mode: string) => void }} view
|
|
132
|
+
* @param {HTMLButtonElement[]} buttons
|
|
133
|
+
*/
|
|
134
|
+
export function bindDisplayButtons(view, buttons) {
|
|
135
|
+
const KEY = 'cadcode:display';
|
|
136
|
+
const apply = (mode) => {
|
|
137
|
+
view.setDisplay(mode);
|
|
138
|
+
for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.display === mode));
|
|
139
|
+
try {
|
|
140
|
+
localStorage.setItem(KEY, mode);
|
|
141
|
+
} catch {
|
|
142
|
+
// Storage unavailable (private window): the mode just isn't remembered.
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
let saved = null;
|
|
146
|
+
try {
|
|
147
|
+
saved = localStorage.getItem(KEY);
|
|
148
|
+
} catch {}
|
|
149
|
+
let mode = buttons.some((button) => button.dataset.display === saved) ? saved : 'solid';
|
|
150
|
+
for (const button of buttons) {
|
|
151
|
+
button.addEventListener('click', () => {
|
|
152
|
+
mode = mode === button.dataset.display ? 'solid' : button.dataset.display;
|
|
153
|
+
apply(mode);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
apply(mode);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Starts a download of a blob under the given file name. */
|
|
160
|
+
export function saveBlob(blob, name) {
|
|
161
|
+
const link = document.createElement('a');
|
|
162
|
+
link.href = URL.createObjectURL(blob);
|
|
163
|
+
link.download = name;
|
|
164
|
+
link.click();
|
|
165
|
+
setTimeout(() => URL.revokeObjectURL(link.href)); // after the browser has started the download
|
|
166
|
+
}
|