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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aleksandr Ivashkin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
# cadcode
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
CAD for people who write code. If you have a 3D printer and know JavaScript, you don't have to learn a CAD program to make a bracket: describe the part in code, watch it update in the browser on every save, and send the STL to your slicer.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Try it in the browser: [the playground](https://jwo1f.github.io/cadcode/playground.html).
|
|
6
|
+
|
|
7
|
+
- **Nothing new to learn.** Variables, loops and functions instead of menus and modes.
|
|
8
|
+
- **Models live in git.** A model is a text file: diff it, branch it, revert the change that broke the fit.
|
|
9
|
+
- **Tweak, don't redraw.** Sizes are `params`; reprint a variant with `--set width=80`.
|
|
10
|
+
- **Ready for the slicer.** Geometry runs on [manifold](https://github.com/elalish/manifold), the boolean engine recent OpenSCAD uses, so solids always come out watertight.
|
|
11
|
+
|
|
12
|
+
Coming from OpenSCAD? The API keeps its ideas (cube, cylinder, hull, difference, `$fn`), so models port line by line.
|
|
6
13
|
|
|
7
14
|
```js
|
|
8
|
-
import { cube
|
|
15
|
+
import { cube } from 'cadcode';
|
|
16
|
+
|
|
17
|
+
export const params = { width: 60, depth: 40, height: 25, wall: 2 };
|
|
9
18
|
|
|
10
|
-
export default
|
|
11
|
-
cube([
|
|
12
|
-
|
|
13
|
-
);
|
|
19
|
+
export default (p) =>
|
|
20
|
+
cube([p.width, p.depth, p.height]).subtract(
|
|
21
|
+
cube([p.width - 2 * p.wall, p.depth - 2 * p.wall, p.height]).translate([p.wall, p.wall, p.wall]),
|
|
22
|
+
);
|
|
14
23
|
```
|
|
15
24
|
|
|
16
25
|
```bash
|
|
17
|
-
npx cadcode dev
|
|
26
|
+
npx cadcode dev box.js
|
|
27
|
+
npx cadcode stl box.js --set width=80
|
|
18
28
|
```
|
|
19
29
|
|
|
20
30
|
## Install
|
|
@@ -60,8 +70,10 @@ cadcode png case.js --part assembly --view sheet --size 1600x1200
|
|
|
60
70
|
- **Pan**: shift+drag or right-drag
|
|
61
71
|
- **Zoom**: mouse wheel
|
|
62
72
|
- **Fit the model to the view**: F, or the Fit button
|
|
63
|
-
- **
|
|
64
|
-
- **
|
|
73
|
+
- **Wireframe**: the Wire button shows only the triangle edges, Solid+Wire draws them over the solid. Press the lit button again to go back to the plain solid.
|
|
74
|
+
- **Switch parts**: the Part menu, shown when the program has more than one part
|
|
75
|
+
- **Download the part as STL**: the Download STL button. It is the same file `cadcode stl` writes.
|
|
76
|
+
- **Save the view as PNG**: the PNG button. It takes the camera as it is, without the grid and axes.
|
|
65
77
|
|
|
66
78
|
Errors appear in the page and in the terminal, with the line in your file. The last good model stays on screen until you fix them. Whatever the program prints with `console.log` appears in the corner of the page.
|
|
67
79
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<!-- The README's first model: a cube with a hole through it, in isometric view. -->
|
|
3
|
+
<defs>
|
|
4
|
+
<clipPath id="hole">
|
|
5
|
+
<ellipse cx="32" cy="19" rx="9.5" ry="5.5" />
|
|
6
|
+
</clipPath>
|
|
7
|
+
</defs>
|
|
8
|
+
<g stroke="#1d2228" stroke-width="2.5" stroke-linejoin="round">
|
|
9
|
+
<path d="M32 6 55 19 32 32 9 19Z" fill="#f6cf6a" />
|
|
10
|
+
<path d="M9 19 32 32V58L9 45Z" fill="#d9a321" />
|
|
11
|
+
<path d="M55 19 32 32V58L55 45Z" fill="#b07f17" />
|
|
12
|
+
</g>
|
|
13
|
+
<!-- The hole: its far wall catches light, the near side is in shadow. -->
|
|
14
|
+
<ellipse cx="32" cy="19" rx="9.5" ry="5.5" fill="#8a6412" />
|
|
15
|
+
<ellipse cx="32" cy="23" rx="9.5" ry="5.5" fill="#1d2228" clip-path="url(#hole)" />
|
|
16
|
+
<ellipse cx="32" cy="19" rx="9.5" ry="5.5" fill="none" stroke="#1d2228" stroke-width="2" />
|
|
17
|
+
</svg>
|
package/client/index.html
CHANGED
|
@@ -4,50 +4,63 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>cadcode</title>
|
|
7
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" />
|
|
7
11
|
<link rel="stylesheet" href="/viewer.css" />
|
|
8
|
-
<script type="importmap">
|
|
9
|
-
{
|
|
10
|
-
"imports": {
|
|
11
|
-
"three": "/vendor/three/build/three.module.js",
|
|
12
|
-
"three/addons/": "/vendor/three/examples/jsm/"
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
</script>
|
|
16
12
|
</head>
|
|
17
13
|
<body>
|
|
18
|
-
|
|
14
|
+
<!-- The whole page is the stage; everything else floats on it, in the corners. -->
|
|
15
|
+
<main id="stage" class="stage" data-empty data-busy>
|
|
16
|
+
<canvas id="view"></canvas>
|
|
17
|
+
<div class="progress" aria-hidden="true"></div>
|
|
18
|
+
<p id="empty" class="empty">Waiting for the model…</p>
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
<span class="
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
<span>part</span>
|
|
28
|
-
<select id="part"></select>
|
|
29
|
-
</label>
|
|
30
|
-
<span id="status" class="status" data-state="connecting"
|
|
31
|
-
>connecting</span
|
|
32
|
-
>
|
|
33
|
-
<button id="fit" type="button" title="Fit model to view (F)">
|
|
34
|
-
Fit
|
|
35
|
-
</button>
|
|
36
|
-
<button id="download" type="button" title="Download the part as STL">
|
|
37
|
-
STL
|
|
20
|
+
<header class="corner top-left">
|
|
21
|
+
<span class="brand"><img src="/favicon.svg" alt="" width="26" height="26" />cadcode</span>
|
|
22
|
+
<span id="file" class="pill file" hidden><span class="dir"></span><span class="name"></span></span>
|
|
23
|
+
<button id="part-open" class="pill field" type="button" popovertarget="part-menu" aria-haspopup="menu" hidden>
|
|
24
|
+
<span>Part</span>
|
|
25
|
+
<strong id="part-title"></strong>
|
|
26
|
+
<svg viewBox="0 0 10 6" width="10" height="6" aria-hidden="true"><path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" /></svg>
|
|
38
27
|
</button>
|
|
39
28
|
</header>
|
|
40
29
|
|
|
41
|
-
<
|
|
42
|
-
|
|
30
|
+
<nav class="corner top-right" aria-label="Export">
|
|
31
|
+
<button id="png" class="pill" type="button" title="Save the current view as PNG">PNG</button>
|
|
32
|
+
<button id="download" class="pill primary" type="button" title="Download the part as STL"><span class="wide">Download </span>STL</button>
|
|
33
|
+
</nav>
|
|
34
|
+
|
|
35
|
+
<section id="logs" class="card logs" aria-label="Program output" hidden></section>
|
|
43
36
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
<footer class="corner bottom-left">
|
|
38
|
+
<section id="error" class="card error" hidden>
|
|
39
|
+
<pre id="error-text"></pre>
|
|
40
|
+
</section>
|
|
41
|
+
<div class="readout" aria-live="polite">
|
|
42
|
+
<p id="readout-size" class="size"> </p>
|
|
43
|
+
<p class="meta">
|
|
44
|
+
<span id="status" class="status" data-state="connecting">Connecting</span>
|
|
45
|
+
<span id="readout-mesh"></span>
|
|
46
|
+
<span id="readout-time"></span>
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
<p class="hint">Drag to rotate, shift-drag or right-drag to pan, scroll to zoom, F to fit</p>
|
|
50
|
+
</footer>
|
|
51
|
+
|
|
52
|
+
<div class="corner bottom-right">
|
|
53
|
+
<div class="tools" role="group" aria-label="Display">
|
|
54
|
+
<button class="pill" type="button" data-display="wire" aria-pressed="false" title="Show only the triangle edges">Wire</button>
|
|
55
|
+
<button class="pill" type="button" data-display="both" aria-pressed="false" title="Show the solid with its triangle edges">Solid+Wire</button>
|
|
56
|
+
</div>
|
|
57
|
+
<button id="fit" class="pill round" type="button" aria-label="Fit the model to the view" title="Fit the model to the view (F)">
|
|
58
|
+
<svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="M3 7V3h4M13 3h4v4M17 13v4h-4M7 17H3v-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
</main>
|
|
47
62
|
|
|
48
|
-
<
|
|
49
|
-
drag: rotate · shift+drag or right-drag: pan · wheel: zoom · F: fit
|
|
50
|
-
</footer>
|
|
63
|
+
<div id="part-menu" class="menu" popover role="menu" aria-label="Parts"></div>
|
|
51
64
|
|
|
52
65
|
<script type="module" src="/viewer.js"></script>
|
|
53
66
|
</body>
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// The three.js view, run in a worker on an OffscreenCanvas so that building a large
|
|
2
|
+
// mesh never stalls the page: renderer, camera and controls, the grid, and turning
|
|
3
|
+
// mesh groups into meshes. client/scene.js is the page's side: it hands over the
|
|
4
|
+
// canvas, forwards input and size, and sends commands; see it for the messages.
|
|
5
|
+
import * as THREE from 'three';
|
|
6
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_COLOR = '#f2c14e';
|
|
9
|
+
const EDGE_ANGLE = 25;
|
|
10
|
+
|
|
11
|
+
THREE.Object3D.DEFAULT_UP.set(0, 0, 1); // CAD convention: Z is up
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Stands in for the canvas element, which OrbitControls listens to and measures.
|
|
15
|
+
* The page forwards its events here; pointermove and pointerup go to the
|
|
16
|
+
* "document", where OrbitControls listens for them during a drag.
|
|
17
|
+
*/
|
|
18
|
+
class ElementProxy extends EventTarget {
|
|
19
|
+
ownerDocument = new EventTarget();
|
|
20
|
+
clientWidth = 1;
|
|
21
|
+
clientHeight = 1;
|
|
22
|
+
left = 0;
|
|
23
|
+
top = 0;
|
|
24
|
+
// OrbitControls sets the cursor while dragging; the page applies it to the real canvas.
|
|
25
|
+
style = new Proxy(
|
|
26
|
+
{},
|
|
27
|
+
{
|
|
28
|
+
set(target, key, value) {
|
|
29
|
+
if (key === 'cursor' && target.cursor !== value) self.postMessage({ type: 'cursor', value });
|
|
30
|
+
target[key] = value;
|
|
31
|
+
return true;
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
getBoundingClientRect() {
|
|
37
|
+
const { left, top, clientWidth: width, clientHeight: height } = this;
|
|
38
|
+
return { left, top, width, height, x: left, y: top, right: left + width, bottom: top + height };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getRootNode() {
|
|
42
|
+
return this.ownerDocument;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
setPointerCapture() {} // the page captures the pointer on the real canvas
|
|
46
|
+
releasePointerCapture() {}
|
|
47
|
+
focus() {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Re-creates a forwarded event; the page has already called preventDefault where it matters. */
|
|
51
|
+
function dispatch(target, data) {
|
|
52
|
+
const event = new Event(data.type, { cancelable: true });
|
|
53
|
+
for (const [key, value] of Object.entries(data)) if (key !== 'type') Object.defineProperty(event, key, { value });
|
|
54
|
+
target.dispatchEvent(event);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let view = null;
|
|
58
|
+
const queue = []; // messages that arrived before init
|
|
59
|
+
|
|
60
|
+
self.onmessage = ({ data }) => {
|
|
61
|
+
if (data.type === 'init') {
|
|
62
|
+
view = createView(data);
|
|
63
|
+
for (const message of queue.splice(0)) handle(message);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (view) handle(data);
|
|
67
|
+
else queue.push(data);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
async function handle(message) {
|
|
71
|
+
switch (message.type) {
|
|
72
|
+
case 'resize':
|
|
73
|
+
return view.resize(message);
|
|
74
|
+
case 'event':
|
|
75
|
+
return dispatch(message.target === 'document' ? view.element.ownerDocument : view.element, message.event);
|
|
76
|
+
case 'fit':
|
|
77
|
+
return view.fitView();
|
|
78
|
+
case 'inset':
|
|
79
|
+
return view.setInset(message.inset);
|
|
80
|
+
case 'set-camera':
|
|
81
|
+
return view.setCamera(message.camera);
|
|
82
|
+
case 'display':
|
|
83
|
+
return view.setDisplay(message.mode);
|
|
84
|
+
}
|
|
85
|
+
// The rest answer the page.
|
|
86
|
+
try {
|
|
87
|
+
const value = message.type === 'show' ? view.showModel(message.groups) : await view.snapshotPNG();
|
|
88
|
+
self.postMessage({ type: 'reply', id: message.id, value });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
self.postMessage({ type: 'reply', id: message.id, error: error.message });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createView({ canvas, colors, ...size }) {
|
|
95
|
+
// A null background leaves the canvas transparent, for the page to paint behind it.
|
|
96
|
+
const { background = 0xeef0f3, gridMajor = 0xb9bec7, gridMinor = 0xd6d9df, edges = 0x2a2f36 } = colors ?? {};
|
|
97
|
+
const element = new ElementProxy();
|
|
98
|
+
|
|
99
|
+
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: background === null });
|
|
100
|
+
if (background === null) renderer.setClearColor(0x000000, 0);
|
|
101
|
+
else renderer.setClearColor(background);
|
|
102
|
+
|
|
103
|
+
const scene = new THREE.Scene();
|
|
104
|
+
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 10000);
|
|
105
|
+
camera.up.set(0, 0, 1);
|
|
106
|
+
camera.position.set(120, -160, 110);
|
|
107
|
+
scene.add(camera);
|
|
108
|
+
|
|
109
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x8a8f99, 1.6));
|
|
110
|
+
const keyLight = new THREE.DirectionalLight(0xffffff, 1.5);
|
|
111
|
+
keyLight.position.set(-0.4, 0.8, 1); // relative to the camera, so shading follows the view
|
|
112
|
+
camera.add(keyLight);
|
|
113
|
+
|
|
114
|
+
const controls = new OrbitControls(camera, element);
|
|
115
|
+
controls.enableDamping = true;
|
|
116
|
+
controls.dampingFactor = 0.15;
|
|
117
|
+
controls.screenSpacePanning = true; // shift+drag and right-drag pan in the view plane
|
|
118
|
+
|
|
119
|
+
// The page keeps the camera across reloads; it hears of every move.
|
|
120
|
+
controls.addEventListener('change', () => self.postMessage({ type: 'camera', camera: getCamera() }));
|
|
121
|
+
|
|
122
|
+
const grid = new THREE.Group();
|
|
123
|
+
scene.add(grid);
|
|
124
|
+
const axes = new THREE.AxesHelper(10);
|
|
125
|
+
scene.add(axes);
|
|
126
|
+
|
|
127
|
+
let model = new THREE.Group();
|
|
128
|
+
scene.add(model);
|
|
129
|
+
|
|
130
|
+
// What the model is drawn as: 'solid' (faces and feature edges), 'wire' (every
|
|
131
|
+
// triangle edge, no faces) or 'both' (the solid with every triangle edge over it).
|
|
132
|
+
let display = 'solid';
|
|
133
|
+
let shapes = []; // per mesh group: { mesh, outline, wire, transparent }; wire is built on first need
|
|
134
|
+
|
|
135
|
+
// The page measures the canvas, which its CSS may resize at any time.
|
|
136
|
+
function resize({ width, height, left, top, dpr }) {
|
|
137
|
+
Object.assign(element, { clientWidth: width, clientHeight: height, left, top });
|
|
138
|
+
if (width === 0 || height === 0) return;
|
|
139
|
+
renderer.setPixelRatio(dpr);
|
|
140
|
+
renderer.setSize(width, height, false);
|
|
141
|
+
applyProjection();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The part of the canvas the page covers with a panel, on the right or the bottom.
|
|
145
|
+
// The view centres on what is left, so the model is never framed under the panel.
|
|
146
|
+
let inset = { right: 0, bottom: 0 };
|
|
147
|
+
|
|
148
|
+
function setInset(next) {
|
|
149
|
+
inset = { right: next.right ?? 0, bottom: next.bottom ?? 0 };
|
|
150
|
+
applyProjection();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function applyProjection() {
|
|
154
|
+
const { clientWidth: width, clientHeight: height } = element;
|
|
155
|
+
camera.aspect = width / height;
|
|
156
|
+
if (inset.right || inset.bottom) camera.setViewOffset(width, height, inset.right / 2, inset.bottom / 2, width, height);
|
|
157
|
+
else camera.clearViewOffset();
|
|
158
|
+
camera.updateProjectionMatrix();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
resize(size);
|
|
162
|
+
|
|
163
|
+
let paused = false; // while a snapshot is being encoded
|
|
164
|
+
renderer.setAnimationLoop(() => {
|
|
165
|
+
if (paused) return;
|
|
166
|
+
controls.update();
|
|
167
|
+
renderer.render(scene, camera);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
function modelSphere() {
|
|
171
|
+
return new THREE.Box3().setFromObject(model).getBoundingSphere(new THREE.Sphere());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function fitView() {
|
|
175
|
+
const sphere = modelSphere();
|
|
176
|
+
if (sphere.isEmpty()) return;
|
|
177
|
+
// Fit the sphere in the uncovered part of the canvas, by its narrower side.
|
|
178
|
+
const { clientWidth: width, clientHeight: height } = element;
|
|
179
|
+
const tanHalf = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
|
|
180
|
+
const visible = Math.min(Math.max(height - inset.bottom, 1), Math.max(width - inset.right, 1)) / height;
|
|
181
|
+
const distance = (sphere.radius / Math.sin(Math.atan(tanHalf * visible))) * 1.15;
|
|
182
|
+
const direction = new THREE.Vector3(1, -1.3, 0.9).normalize();
|
|
183
|
+
controls.target.copy(sphere.center);
|
|
184
|
+
camera.position.copy(sphere.center).addScaledVector(direction, distance);
|
|
185
|
+
camera.zoom = 1;
|
|
186
|
+
camera.updateProjectionMatrix();
|
|
187
|
+
controls.update();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getCamera() {
|
|
191
|
+
return { position: camera.position.toArray(), target: controls.target.toArray(), zoom: camera.zoom };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function setCamera({ position, target, zoom }) {
|
|
195
|
+
camera.position.fromArray(position);
|
|
196
|
+
controls.target.fromArray(target);
|
|
197
|
+
camera.zoom = zoom ?? 1;
|
|
198
|
+
camera.updateProjectionMatrix();
|
|
199
|
+
controls.update();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function setDisplay(mode) {
|
|
203
|
+
display = mode;
|
|
204
|
+
applyDisplay();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function applyDisplay() {
|
|
208
|
+
const solid = display !== 'wire';
|
|
209
|
+
const wired = display !== 'solid';
|
|
210
|
+
for (const shape of shapes) {
|
|
211
|
+
shape.mesh.visible = shape.outline.visible = solid;
|
|
212
|
+
if (wired && !shape.wire) {
|
|
213
|
+
shape.wire = new THREE.LineSegments(
|
|
214
|
+
new THREE.WireframeGeometry(shape.mesh.geometry),
|
|
215
|
+
new THREE.LineBasicMaterial({ color: edges, transparent: true }),
|
|
216
|
+
);
|
|
217
|
+
model.add(shape.wire);
|
|
218
|
+
}
|
|
219
|
+
if (!shape.wire) continue;
|
|
220
|
+
shape.wire.visible = wired;
|
|
221
|
+
// Over faces the lines only hint at the triangles; alone they are the picture.
|
|
222
|
+
shape.wire.material.opacity = solid ? (shape.transparent ? 0.15 : 0.3) : 0.7;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function disposeGroup(group) {
|
|
227
|
+
group.traverse((object) => {
|
|
228
|
+
object.geometry?.dispose();
|
|
229
|
+
object.material?.dispose();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildGrid(sphere) {
|
|
234
|
+
disposeGroup(grid);
|
|
235
|
+
grid.clear();
|
|
236
|
+
const step = 10 ** Math.floor(Math.log10(Math.max(sphere.radius, 1)));
|
|
237
|
+
const size = Math.ceil((sphere.radius * 3) / step) * step;
|
|
238
|
+
const helper = new THREE.GridHelper(size, size / step, gridMajor, gridMinor);
|
|
239
|
+
helper.rotation.x = Math.PI / 2;
|
|
240
|
+
helper.position.set(Math.round(sphere.center.x / step) * step, Math.round(sphere.center.y / step) * step, 0);
|
|
241
|
+
grid.add(helper);
|
|
242
|
+
axes.scale.setScalar(step / 10);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Replaces the model with mesh groups ({ color, positions, indices }) and returns
|
|
247
|
+
* its triangle count and bounding box size ([x, y, z], or null when empty).
|
|
248
|
+
* The camera is left where it is.
|
|
249
|
+
*/
|
|
250
|
+
function showModel(groups) {
|
|
251
|
+
const next = new THREE.Group();
|
|
252
|
+
const nextShapes = [];
|
|
253
|
+
for (const g of groups) {
|
|
254
|
+
const geometry = new THREE.BufferGeometry();
|
|
255
|
+
geometry.setAttribute('position', new THREE.BufferAttribute(g.positions, 3));
|
|
256
|
+
geometry.setIndex(new THREE.BufferAttribute(g.indices, 1));
|
|
257
|
+
// No vertex normals: flat shading derives them per fragment.
|
|
258
|
+
|
|
259
|
+
const [r, gr, b, a] = g.color ?? [...new THREE.Color(DEFAULT_COLOR).toArray(), 1];
|
|
260
|
+
const transparent = a < 0.999;
|
|
261
|
+
const material = new THREE.MeshStandardMaterial({
|
|
262
|
+
color: new THREE.Color(r, gr, b),
|
|
263
|
+
roughness: 0.65,
|
|
264
|
+
metalness: 0.05,
|
|
265
|
+
flatShading: true,
|
|
266
|
+
transparent,
|
|
267
|
+
opacity: a,
|
|
268
|
+
depthWrite: !transparent,
|
|
269
|
+
side: transparent ? THREE.DoubleSide : THREE.FrontSide,
|
|
270
|
+
polygonOffset: true,
|
|
271
|
+
polygonOffsetFactor: 1,
|
|
272
|
+
polygonOffsetUnits: 1,
|
|
273
|
+
});
|
|
274
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
275
|
+
mesh.renderOrder = transparent ? 1 : 0;
|
|
276
|
+
next.add(mesh);
|
|
277
|
+
|
|
278
|
+
const outline = new THREE.LineSegments(
|
|
279
|
+
new THREE.EdgesGeometry(geometry, EDGE_ANGLE),
|
|
280
|
+
new THREE.LineBasicMaterial({ color: edges, transparent: true, opacity: transparent ? 0.25 : 0.55 }),
|
|
281
|
+
);
|
|
282
|
+
next.add(outline);
|
|
283
|
+
nextShapes.push({ mesh, outline, wire: null, transparent });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
scene.remove(model);
|
|
287
|
+
disposeGroup(model);
|
|
288
|
+
model = next;
|
|
289
|
+
shapes = nextShapes;
|
|
290
|
+
scene.add(model);
|
|
291
|
+
applyDisplay();
|
|
292
|
+
|
|
293
|
+
const box = new THREE.Box3().setFromObject(model);
|
|
294
|
+
const sphere = box.getBoundingSphere(new THREE.Sphere());
|
|
295
|
+
if (!sphere.isEmpty()) {
|
|
296
|
+
camera.near = Math.max(sphere.radius / 1000, 0.01);
|
|
297
|
+
camera.far = sphere.radius * 200 + camera.position.distanceTo(sphere.center);
|
|
298
|
+
camera.updateProjectionMatrix();
|
|
299
|
+
buildGrid(sphere);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
triangles: groups.reduce((n, g) => n + g.indices.length / 3, 0),
|
|
304
|
+
size: box.isEmpty() ? null : box.getSize(new THREE.Vector3()).toArray(),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The current view as a PNG, without the grid and axes, cut to the part of the
|
|
310
|
+
* canvas no panel covers. The loop is paused while the frame is encoded, so no
|
|
311
|
+
* frame with the grid replaces it in the meantime.
|
|
312
|
+
*/
|
|
313
|
+
async function snapshotPNG() {
|
|
314
|
+
paused = true;
|
|
315
|
+
grid.visible = axes.visible = false;
|
|
316
|
+
try {
|
|
317
|
+
renderer.render(scene, camera);
|
|
318
|
+
if (!inset.right && !inset.bottom) return await canvas.convertToBlob({ type: 'image/png' });
|
|
319
|
+
// Copied in the same task as the render, before the drawing buffer is cleared.
|
|
320
|
+
const dpr = renderer.getPixelRatio();
|
|
321
|
+
const width = Math.max(1, canvas.width - Math.round(inset.right * dpr));
|
|
322
|
+
const height = Math.max(1, canvas.height - Math.round(inset.bottom * dpr));
|
|
323
|
+
const visible = new OffscreenCanvas(width, height);
|
|
324
|
+
visible.getContext('2d').drawImage(canvas, 0, 0, width, height, 0, 0, width, height);
|
|
325
|
+
return await visible.convertToBlob({ type: 'image/png' });
|
|
326
|
+
} finally {
|
|
327
|
+
grid.visible = axes.visible = true;
|
|
328
|
+
paused = false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return { element, resize, setInset, fitView, setCamera, setDisplay, showModel, snapshotPNG };
|
|
333
|
+
}
|