3draft 26.11.1013 → 26.11.2005
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 +66 -5
- package/dist/camera.d.ts +29 -0
- package/dist/camera.js +68 -0
- package/dist/check.d.ts +24 -0
- package/dist/check.js +83 -0
- package/dist/edit.d.ts +23 -0
- package/dist/edit.js +48 -0
- package/dist/gpu.d.ts +74 -0
- package/dist/gpu.js +319 -0
- package/dist/index.d.ts +29 -0
- package/{src → dist}/index.js +12 -14
- package/dist/kernel-bytes.d.ts +1 -0
- package/dist/kernel-bytes.js +2 -0
- package/dist/kernel.d.ts +14 -0
- package/dist/kernel.js +81 -0
- package/dist/mesh.d.ts +40 -0
- package/dist/mesh.js +130 -0
- package/dist/order.d.ts +22 -0
- package/dist/order.js +125 -0
- package/dist/pick.d.ts +18 -0
- package/dist/pick.js +82 -0
- package/dist/place.d.ts +23 -0
- package/{src → dist}/place.js +7 -13
- package/dist/svg.d.ts +28 -0
- package/dist/svg.js +126 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.js +2 -0
- package/dist/vec.d.ts +14 -0
- package/dist/vec.js +22 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +7 -0
- package/package.json +17 -9
- package/src/camera.js +0 -75
- package/src/check.js +0 -79
- package/src/edit.js +0 -47
- package/src/gpu.js +0 -252
- package/src/index.d.ts +0 -209
- package/src/mesh.js +0 -127
- package/src/order.js +0 -94
- package/src/pick.js +0 -83
- package/src/svg.js +0 -119
- package/src/vec.js +0 -16
- package/src/version.js +0 -8
package/dist/gpu.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// WebGPU renderer: flat-colored faces, one simple lit material, one global light.
|
|
2
|
+
// It draws the same scene through the same camera as the SVG renderer, so picking,
|
|
3
|
+
// the cross marks and the framing all stay valid. The pure helpers (viewProjection,
|
|
4
|
+
// buildVertices) need no GPU and are unit tested; createGpu() needs a browser.
|
|
5
|
+
import { dot, norm } from './vec.js';
|
|
6
|
+
import { faceNormal } from './mesh.js';
|
|
7
|
+
import { makeCamera } from './camera.js';
|
|
8
|
+
const FLOATS = 9; // position(3) normal(3) color(3)
|
|
9
|
+
/**
|
|
10
|
+
* Column-major 4x4 matrix (for WebGPU, z in 0..1) that maps world points exactly
|
|
11
|
+
* like camera.project(): the same pixels, plus a depth between near and far.
|
|
12
|
+
*/
|
|
13
|
+
export const viewProjection = (cam, near = 0.03, far = 1000) => {
|
|
14
|
+
const kk = cam.k / (cam.size / 2);
|
|
15
|
+
const row = (v, s) => [v[0] * s, v[1] * s, v[2] * s, -dot(v, cam.eye) * s];
|
|
16
|
+
const a = cam.ortho ? 1 / (far - near) : far / (far - near);
|
|
17
|
+
const zr = row(cam.f, a);
|
|
18
|
+
zr[3] -= a * near;
|
|
19
|
+
const rows = [row(cam.r, kk), row(cam.u, kk), zr, cam.ortho ? [0, 0, 0, 1] : row(cam.f, 1)];
|
|
20
|
+
const m = new Float32Array(16);
|
|
21
|
+
rows.forEach((r, i) => r.forEach((v, j) => (m[j * 4 + i] = v)));
|
|
22
|
+
return m;
|
|
23
|
+
};
|
|
24
|
+
const rgb = (color) => {
|
|
25
|
+
const h = String(color ?? '').replace('#', '');
|
|
26
|
+
const full = h.length === 3 ? [...h].map((c) => c + c).join('') : h;
|
|
27
|
+
if (!/^[0-9a-f]{6}$/i.test(full))
|
|
28
|
+
return [0.78, 0.78, 0.78];
|
|
29
|
+
return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
|
|
30
|
+
};
|
|
31
|
+
/** Triangle list for the GPU: flat face normals and face colors. Float32Array, 9 floats per vertex. */
|
|
32
|
+
export const buildVertices = (mesh) => {
|
|
33
|
+
let tris = 0;
|
|
34
|
+
for (const f of mesh.faces)
|
|
35
|
+
tris += Math.max(0, f.v.length - 2);
|
|
36
|
+
const out = new Float32Array(tris * 3 * FLOATS);
|
|
37
|
+
let o = 0;
|
|
38
|
+
for (const face of mesh.faces) {
|
|
39
|
+
const pts = face.v.map((k) => mesh.positions[k]);
|
|
40
|
+
const n = norm(faceNormal(pts));
|
|
41
|
+
const c = rgb(face.color);
|
|
42
|
+
for (let j = 1; j < pts.length - 1; j++) {
|
|
43
|
+
for (const p of [pts[0], pts[j], pts[j + 1]]) {
|
|
44
|
+
out.set([...p, ...n, ...c], o);
|
|
45
|
+
o += FLOATS;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
};
|
|
51
|
+
const INSTANCE_FLOATS = 15; // at(3) ex(3) ey(3) ez(3) color(3)
|
|
52
|
+
/**
|
|
53
|
+
* Per-instance data for drawInstances: one entry { at, axes: [ex, ey, ez], color } per copy of a
|
|
54
|
+
* prototype mesh (modelled around the origin). Float32Array, 15 floats per instance.
|
|
55
|
+
*/
|
|
56
|
+
export const buildInstances = (items) => {
|
|
57
|
+
const out = new Float32Array(items.length * INSTANCE_FLOATS);
|
|
58
|
+
items.forEach((it, i) => out.set([...it.at, ...it.axes[0], ...it.axes[1], ...it.axes[2], ...rgb(it.color)], i * INSTANCE_FLOATS));
|
|
59
|
+
return out;
|
|
60
|
+
};
|
|
61
|
+
export const defaultLight = { direction: norm([0.5, 0.8, 0.6]), sky: [1, 0.97, 0.92], ground: [0.42, 0.38, 0.36] };
|
|
62
|
+
export const defaultMaterial = { ambient: 0.62, diffuse: 0.78, specular: 0.2, shininess: 30 };
|
|
63
|
+
const SHADER = /* wgsl */ `
|
|
64
|
+
struct U {
|
|
65
|
+
vp : mat4x4<f32>,
|
|
66
|
+
light : vec4<f32>, // xyz: direction towards the light
|
|
67
|
+
sky : vec4<f32>,
|
|
68
|
+
ground : vec4<f32>,
|
|
69
|
+
eye : vec4<f32>,
|
|
70
|
+
mat : vec4<f32>, // ambient, diffuse, specular, shininess
|
|
71
|
+
};
|
|
72
|
+
@group(0) @binding(0) var<uniform> u : U;
|
|
73
|
+
|
|
74
|
+
struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32> };
|
|
75
|
+
struct VOut {
|
|
76
|
+
@builtin(position) p : vec4<f32>,
|
|
77
|
+
@location(0) n : vec3<f32>,
|
|
78
|
+
@location(1) c : vec3<f32>,
|
|
79
|
+
@location(2) w : vec3<f32>,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
@vertex fn vs(i : VIn) -> VOut {
|
|
83
|
+
var o : VOut;
|
|
84
|
+
o.p = u.vp * vec4<f32>(i.pos, 1.0);
|
|
85
|
+
o.n = i.nrm;
|
|
86
|
+
o.c = i.col;
|
|
87
|
+
o.w = i.pos;
|
|
88
|
+
return o;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
struct IIn {
|
|
92
|
+
@location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>,
|
|
93
|
+
@location(3) at : vec3<f32>, @location(4) ex : vec3<f32>, @location(5) ey : vec3<f32>, @location(6) ez : vec3<f32>,
|
|
94
|
+
@location(7) col : vec3<f32>,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// the same prototype drawn many times: its frame (ex, ey, ez, scaled axes scale it) and position come per instance
|
|
98
|
+
@vertex fn vsi(i : IIn) -> VOut {
|
|
99
|
+
var o : VOut;
|
|
100
|
+
let w = i.at + i.ex * i.pos.x + i.ey * i.pos.y + i.ez * i.pos.z;
|
|
101
|
+
o.p = u.vp * vec4<f32>(w, 1.0);
|
|
102
|
+
// the axes may be scaled (a thin long bar from the same prototype): normals follow the unit axes
|
|
103
|
+
o.n = normalize(i.ex) * i.nrm.x + normalize(i.ey) * i.nrm.y + normalize(i.ez) * i.nrm.z;
|
|
104
|
+
o.c = i.col;
|
|
105
|
+
o.w = w;
|
|
106
|
+
return o;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@fragment fn fs(i : VOut, @builtin(front_facing) front : bool) -> @location(0) vec4<f32> {
|
|
110
|
+
var n = normalize(i.n);
|
|
111
|
+
if (!front) { n = -n; }
|
|
112
|
+
let l = normalize(u.light.xyz);
|
|
113
|
+
let v = normalize(u.eye.xyz - i.w);
|
|
114
|
+
let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x;
|
|
115
|
+
let diff = max(dot(n, l), 0.0) * u.mat.y;
|
|
116
|
+
let h = normalize(l + v);
|
|
117
|
+
let spec = pow(max(dot(n, h), 0.0), u.mat.w) * u.mat.z * step(0.0, dot(n, l));
|
|
118
|
+
let lit = i.c * (hemi + vec3<f32>(diff)) + vec3<f32>(spec);
|
|
119
|
+
return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
|
|
120
|
+
}
|
|
121
|
+
`;
|
|
122
|
+
/**
|
|
123
|
+
* Sets up WebGPU on a canvas. Rejects when WebGPU is not available.
|
|
124
|
+
* Returns { render(mesh, view, options), destroy() }.
|
|
125
|
+
* render options: size (CSS px, the canvas is made size x size), fit, instances { mesh, data } (many copies of one
|
|
126
|
+
* prototype mesh, data from buildInstances; drawn after the mesh), background [r,g,b] 0..1,
|
|
127
|
+
* light { direction, sky, ground }, material { ambient, diffuse, specular, shininess }, dpr.
|
|
128
|
+
*/
|
|
129
|
+
export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
130
|
+
if (!globalThis.navigator?.gpu)
|
|
131
|
+
throw new Error('WebGPU is not available in this browser');
|
|
132
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
133
|
+
if (!adapter)
|
|
134
|
+
throw new Error('No WebGPU adapter found');
|
|
135
|
+
const device = await adapter.requestDevice();
|
|
136
|
+
device.addEventListener('uncapturederror', (e) => console.error('WebGPU:', e.error?.message ?? e));
|
|
137
|
+
device.lost.then((info) => console.error('WebGPU device lost:', info.reason, info.message));
|
|
138
|
+
const context = canvas.getContext('webgpu');
|
|
139
|
+
if (!context)
|
|
140
|
+
throw new Error('Could not get a WebGPU canvas context');
|
|
141
|
+
const format = navigator.gpu.getPreferredCanvasFormat();
|
|
142
|
+
context.configure({ device, format, alphaMode: 'opaque', });
|
|
143
|
+
const module = device.createShaderModule({ code: SHADER });
|
|
144
|
+
const meshLayout = {
|
|
145
|
+
arrayStride: FLOATS * 4,
|
|
146
|
+
attributes: [
|
|
147
|
+
{ shaderLocation: 0, offset: 0, format: 'float32x3' },
|
|
148
|
+
{ shaderLocation: 1, offset: 12, format: 'float32x3' },
|
|
149
|
+
{ shaderLocation: 2, offset: 24, format: 'float32x3' },
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
const instanceLayout = {
|
|
153
|
+
arrayStride: INSTANCE_FLOATS * 4,
|
|
154
|
+
stepMode: 'instance',
|
|
155
|
+
attributes: [3, 4, 5, 6, 7].map((loc, k) => ({ shaderLocation: loc, offset: k * 12, format: 'float32x3' })),
|
|
156
|
+
};
|
|
157
|
+
const make = (entry, buffers) => device.createRenderPipeline({
|
|
158
|
+
layout: 'auto',
|
|
159
|
+
vertex: { module, entryPoint: entry, buffers },
|
|
160
|
+
fragment: { module, entryPoint: 'fs', targets: [{ format }] },
|
|
161
|
+
primitive: { topology: 'triangle-list', cullMode: 'none' },
|
|
162
|
+
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
|
|
163
|
+
multisample: { count: sampleCount },
|
|
164
|
+
});
|
|
165
|
+
const pipeline = make('vs', [meshLayout]);
|
|
166
|
+
// the instanced pipeline reads position and normal only from the prototype (locations 0, 1)
|
|
167
|
+
const instPipeline = make('vsi', [{ ...meshLayout, attributes: meshLayout.attributes.slice(0, 2) }, instanceLayout]);
|
|
168
|
+
const uniforms = device.createBuffer({ size: 144, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
|
169
|
+
const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
|
|
170
|
+
const instBind = device.createBindGroup({ layout: instPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
|
|
171
|
+
// Geometry that did not change since the last frame is not rebuilt or uploaded again.
|
|
172
|
+
let uploaded = null; // the mesh now in vertexBuffer
|
|
173
|
+
let vertexCount = 0;
|
|
174
|
+
let protoMesh = null;
|
|
175
|
+
let protoBuffer = null;
|
|
176
|
+
let protoCount = 0;
|
|
177
|
+
let instBuffer = null;
|
|
178
|
+
let instCapacity = 0;
|
|
179
|
+
let instData = null;
|
|
180
|
+
let instCount = 0;
|
|
181
|
+
let vertexBuffer = null;
|
|
182
|
+
let capacity = 0;
|
|
183
|
+
let px = 0;
|
|
184
|
+
let depth = null;
|
|
185
|
+
let color = null;
|
|
186
|
+
const targets = (n) => {
|
|
187
|
+
if (n === px)
|
|
188
|
+
return;
|
|
189
|
+
px = n;
|
|
190
|
+
canvas.width = n;
|
|
191
|
+
canvas.height = n;
|
|
192
|
+
depth?.destroy();
|
|
193
|
+
color?.destroy();
|
|
194
|
+
depth = device.createTexture({ size: [n, n], format: 'depth24plus', sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT });
|
|
195
|
+
color = sampleCount > 1 ? device.createTexture({ size: [n, n], format, sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT }) : null;
|
|
196
|
+
};
|
|
197
|
+
const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, dpr = globalThis.devicePixelRatio || 1, instances } = {}, readback = false) => {
|
|
198
|
+
const L = { ...defaultLight, ...light };
|
|
199
|
+
const M = { ...defaultMaterial, ...material };
|
|
200
|
+
const cam = makeCamera(view, mesh, size, fit);
|
|
201
|
+
targets(Math.max(1, Math.round(size * dpr)));
|
|
202
|
+
if (mesh !== uploaded) {
|
|
203
|
+
const verts = buildVertices(mesh);
|
|
204
|
+
if (verts.byteLength > capacity) {
|
|
205
|
+
vertexBuffer?.destroy();
|
|
206
|
+
capacity = Math.max(verts.byteLength, 4096);
|
|
207
|
+
vertexBuffer = device.createBuffer({ size: capacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
|
|
208
|
+
}
|
|
209
|
+
if (verts.byteLength)
|
|
210
|
+
device.queue.writeBuffer(vertexBuffer, 0, verts);
|
|
211
|
+
vertexCount = verts.length / FLOATS;
|
|
212
|
+
uploaded = mesh;
|
|
213
|
+
}
|
|
214
|
+
if (instances?.mesh && instances.mesh !== protoMesh) {
|
|
215
|
+
const v = buildVertices(instances.mesh);
|
|
216
|
+
protoBuffer?.destroy();
|
|
217
|
+
protoBuffer = device.createBuffer({ size: Math.max(v.byteLength, 256), usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
|
|
218
|
+
device.queue.writeBuffer(protoBuffer, 0, v);
|
|
219
|
+
protoCount = v.length / FLOATS;
|
|
220
|
+
protoMesh = instances.mesh;
|
|
221
|
+
}
|
|
222
|
+
if (instances?.data && instances.data !== instData) {
|
|
223
|
+
const d = instances.data;
|
|
224
|
+
if (d.byteLength > instCapacity) {
|
|
225
|
+
instBuffer?.destroy();
|
|
226
|
+
instCapacity = Math.max(d.byteLength, 4096);
|
|
227
|
+
instBuffer = device.createBuffer({ size: instCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
|
|
228
|
+
}
|
|
229
|
+
if (d.byteLength)
|
|
230
|
+
device.queue.writeBuffer(instBuffer, 0, d);
|
|
231
|
+
instData = d;
|
|
232
|
+
instCount = d.length / INSTANCE_FLOATS;
|
|
233
|
+
}
|
|
234
|
+
if (!instances)
|
|
235
|
+
instCount = 0;
|
|
236
|
+
const u = new Float32Array(36);
|
|
237
|
+
u.set(viewProjection(cam), 0);
|
|
238
|
+
u.set([...(norm(L.direction)), 0], 16);
|
|
239
|
+
u.set([...L.sky, 0], 20);
|
|
240
|
+
u.set([...L.ground, 0], 24);
|
|
241
|
+
u.set([...cam.eye, 0], 28);
|
|
242
|
+
u.set([M.ambient, M.diffuse, M.specular, M.shininess], 32);
|
|
243
|
+
device.queue.writeBuffer(uniforms, 0, u);
|
|
244
|
+
const encoder = device.createCommandEncoder();
|
|
245
|
+
const offscreen = readback
|
|
246
|
+
? device.createTexture({ size: [px, px], format, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC })
|
|
247
|
+
: null;
|
|
248
|
+
const target = (offscreen ?? context.getCurrentTexture()).createView();
|
|
249
|
+
const pass = encoder.beginRenderPass({
|
|
250
|
+
colorAttachments: [
|
|
251
|
+
{
|
|
252
|
+
view: color ? color.createView() : target,
|
|
253
|
+
resolveTarget: color ? target : undefined,
|
|
254
|
+
clearValue: { r: background[0], g: background[1], b: background[2], a: 1 },
|
|
255
|
+
loadOp: 'clear',
|
|
256
|
+
storeOp: color ? 'discard' : 'store',
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
depthStencilAttachment: { view: depth.createView(), depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'discard' },
|
|
260
|
+
});
|
|
261
|
+
if (vertexCount) {
|
|
262
|
+
pass.setPipeline(pipeline);
|
|
263
|
+
pass.setBindGroup(0, bind);
|
|
264
|
+
pass.setVertexBuffer(0, vertexBuffer);
|
|
265
|
+
pass.draw(vertexCount);
|
|
266
|
+
}
|
|
267
|
+
if (instCount && protoCount) {
|
|
268
|
+
pass.setPipeline(instPipeline);
|
|
269
|
+
pass.setBindGroup(0, instBind);
|
|
270
|
+
pass.setVertexBuffer(0, protoBuffer);
|
|
271
|
+
pass.setVertexBuffer(1, instBuffer);
|
|
272
|
+
pass.draw(protoCount, instCount);
|
|
273
|
+
}
|
|
274
|
+
pass.end();
|
|
275
|
+
if (!offscreen) {
|
|
276
|
+
device.queue.submit([encoder.finish()]);
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const row = Math.ceil((px * 4) / 256) * 256;
|
|
280
|
+
const out = device.createBuffer({ size: row * px, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
|
|
281
|
+
encoder.copyTextureToBuffer({ texture: offscreen }, { buffer: out, bytesPerRow: row }, [px, px]);
|
|
282
|
+
device.queue.submit([encoder.finish()]);
|
|
283
|
+
return out.mapAsync(GPUMapMode.READ).then(() => {
|
|
284
|
+
const raw = new Uint8Array(out.getMappedRange());
|
|
285
|
+
const data = new Uint8ClampedArray(px * px * 4);
|
|
286
|
+
const swap = format.startsWith('bgra');
|
|
287
|
+
for (let y = 0; y < px; y++) {
|
|
288
|
+
for (let x = 0; x < px; x++) {
|
|
289
|
+
const i = y * row + x * 4;
|
|
290
|
+
const o = (y * px + x) * 4;
|
|
291
|
+
data[o] = raw[i + (swap ? 2 : 0)];
|
|
292
|
+
data[o + 1] = raw[i + 1];
|
|
293
|
+
data[o + 2] = raw[i + (swap ? 0 : 2)];
|
|
294
|
+
data[o + 3] = 255;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
out.unmap();
|
|
298
|
+
out.destroy();
|
|
299
|
+
offscreen.destroy();
|
|
300
|
+
return { width: px, height: px, data };
|
|
301
|
+
});
|
|
302
|
+
};
|
|
303
|
+
/** Draws a frame onto the canvas. */
|
|
304
|
+
const render = (mesh, view, options) => {
|
|
305
|
+
draw(mesh, view, options);
|
|
306
|
+
};
|
|
307
|
+
/** Draws a frame into an offscreen texture and reads it back: Promise<{ width, height, data: RGBA }>. */
|
|
308
|
+
const pixels = (mesh, view, options) => draw(mesh, view, options, true);
|
|
309
|
+
const destroy = () => {
|
|
310
|
+
vertexBuffer?.destroy();
|
|
311
|
+
protoBuffer?.destroy();
|
|
312
|
+
instBuffer?.destroy();
|
|
313
|
+
depth?.destroy();
|
|
314
|
+
color?.destroy();
|
|
315
|
+
uniforms.destroy();
|
|
316
|
+
device.destroy();
|
|
317
|
+
};
|
|
318
|
+
return { render, pixels, destroy, device, adapter };
|
|
319
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const name: "3draft";
|
|
2
|
+
export declare const version = "26.11.2005";
|
|
3
|
+
export type { Vec3, Face, Mesh, Axes, ViewName, ViewSpec, Fit, Camera, Hit, Mark } from './types.js';
|
|
4
|
+
export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
|
|
5
|
+
export type { BoxOptions } from './mesh.js';
|
|
6
|
+
export { validate } from './check.js';
|
|
7
|
+
export type { Validation } from './check.js';
|
|
8
|
+
export { extrudeFace, moveVertices } from './edit.js';
|
|
9
|
+
export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
|
|
10
|
+
export { pick, crosshair } from './pick.js';
|
|
11
|
+
export { standOn } from './place.js';
|
|
12
|
+
export type { StandOptions, Frame } from './place.js';
|
|
13
|
+
export { paintOrder } from './order.js';
|
|
14
|
+
export type { PaintItem } from './order.js';
|
|
15
|
+
export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
|
|
16
|
+
export type { Gpu, GpuLight, GpuMaterial, GpuRenderOptions, Instance, Pixels } from './gpu.js';
|
|
17
|
+
export { renderView, renderViews, renderSheet } from './svg.js';
|
|
18
|
+
export type { RenderOptions } from './svg.js';
|
|
19
|
+
export { displayVersion } from './version.js';
|
|
20
|
+
export interface Info {
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
stage: 'pre-alpha';
|
|
24
|
+
goals: string[];
|
|
25
|
+
}
|
|
26
|
+
/** Short, machine-readable description of the current state of the package. */
|
|
27
|
+
export declare const info: () => Info;
|
|
28
|
+
export { initKernel } from './kernel.js';
|
|
29
|
+
export type { Kernel } from './kernel.js';
|
package/{src → dist}/index.js
RENAMED
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
// from four fixed cameras, plus surface picking (point, normal, distance) and
|
|
4
4
|
// a target cross that lies on the surface under the cursor. No textures, no
|
|
5
5
|
// shading yet.
|
|
6
|
-
|
|
7
6
|
export const name = '3draft';
|
|
8
|
-
export const version = '26.11.
|
|
9
|
-
|
|
7
|
+
export const version = '26.11.2005';
|
|
10
8
|
export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
|
|
11
9
|
export { validate } from './check.js';
|
|
12
10
|
export { extrudeFace, moveVertices } from './edit.js';
|
|
@@ -14,19 +12,19 @@ export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
|
|
|
14
12
|
export { pick, crosshair } from './pick.js';
|
|
15
13
|
export { standOn } from './place.js';
|
|
16
14
|
export { paintOrder } from './order.js';
|
|
17
|
-
export { createGpu, viewProjection, buildVertices, defaultLight, defaultMaterial } from './gpu.js';
|
|
15
|
+
export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
|
|
18
16
|
export { renderView, renderViews, renderSheet } from './svg.js';
|
|
19
17
|
export { displayVersion } from './version.js';
|
|
20
|
-
|
|
21
18
|
/** Short, machine-readable description of the current state of the package. */
|
|
22
19
|
export const info = () => ({
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
name,
|
|
21
|
+
version,
|
|
22
|
+
stage: 'pre-alpha',
|
|
23
|
+
goals: [
|
|
24
|
+
'single palette texture, UVs point at one texel per face',
|
|
25
|
+
'WASM core (HEXAW / Rust) behind a thin ABI',
|
|
26
|
+
'WebGPU renderer with WebGL2 fallback',
|
|
27
|
+
'deterministic SVG preview for AI feedback loops',
|
|
28
|
+
],
|
|
32
29
|
});
|
|
30
|
+
export { initKernel } from './kernel.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const KERNEL_BYTES = "AGFzbQEAAAABDwFgCnx8fHx8fH9/f38BfwMCAQAFBQEBAYACBxECBHBpY2sAAAZtZW1vcnkCAAr/AwH8AwIGfxh8QQAhCkF/IQ9EnHUAiDzkN34hEAJAA0AgCiAITg0BIAcgCkEEdGohCyALKAIAQRhsIAZqIQwgCygCBEEYbCAGaiENIAsoAghBGGwgBmohDgJAIAwrAwAhESAMKwMIIRIgDCsDECETIA0rAwAgEaEhFCANKwMIIBKhIRUgDSsDECAToSEWIA4rAwAgEaEhFyAOKwMIIBKhIRggDisDECAToSEZIAQgGaIgBSAYoqEhGiAFIBeiIAMgGaKhIRsgAyAYoiAEIBeioSEcIBQgGqIgFSAboqAgFiAcoqAhHSAdmUSV1iboCy4RPmMNACAJIB1EAAAAAAAAAABlcQ0ARAAAAAAAAPA/IB2jIR4gACARoSEfIAEgEqEhICACIBOhISEgHiAfIBqiICAgG6KgICEgHKKgoiEiICJEAAAAAAAAAABjICJEAAAAAAAA8D9kcg0AICAgFqIgISAVoqEhIyAhIBSiIB8gFqKhISQgHyAVoiAgIBSioSElIB4gAyAjoiAEICSioCAFICWioKIhJiAmRAAAAAAAAAAAYyAiICagRAAAAAAAAPA/ZHINACAeIBcgI6IgGCAkoqAgGSAloqCiIScgJ0SV1iboCy4RPmUNACAnIBBmDQAgJyEQIAsoAgwhDwsgCkEBaiEKDAALC0EAIBA5AwAgDws=";
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Generated by scripts/build-kernel.mjs from kernel/pick.hexaw. Do not edit.
|
|
2
|
+
export const KERNEL_BYTES = 'AGFzbQEAAAABDwFgCnx8fHx8fH9/f38BfwMCAQAFBQEBAYACBxECBHBpY2sAAAZtZW1vcnkCAAr/AwH8AwIGfxh8QQAhCkF/IQ9EnHUAiDzkN34hEAJAA0AgCiAITg0BIAcgCkEEdGohCyALKAIAQRhsIAZqIQwgCygCBEEYbCAGaiENIAsoAghBGGwgBmohDgJAIAwrAwAhESAMKwMIIRIgDCsDECETIA0rAwAgEaEhFCANKwMIIBKhIRUgDSsDECAToSEWIA4rAwAgEaEhFyAOKwMIIBKhIRggDisDECAToSEZIAQgGaIgBSAYoqEhGiAFIBeiIAMgGaKhIRsgAyAYoiAEIBeioSEcIBQgGqIgFSAboqAgFiAcoqAhHSAdmUSV1iboCy4RPmMNACAJIB1EAAAAAAAAAABlcQ0ARAAAAAAAAPA/IB2jIR4gACARoSEfIAEgEqEhICACIBOhISEgHiAfIBqiICAgG6KgICEgHKKgoiEiICJEAAAAAAAAAABjICJEAAAAAAAA8D9kcg0AICAgFqIgISAVoqEhIyAhIBSiIB8gFqKhISQgHyAVoiAgIBSioSElIB4gAyAjoiAEICSioCAFICWioKIhJiAmRAAAAAAAAAAAYyAiICagRAAAAAAAAPA/ZHINACAeIBcgI6IgGCAkoqAgGSAloqCiIScgJ0SV1iboCy4RPmUNACAnIBBmDQAgJyEQIAsoAgwhDwsgCkEBaiEKDAALC0EAIBA5AwAgDws=';
|
package/dist/kernel.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Camera, Hit, Mesh } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Starts the kernel. Resolves a picker { pick(mesh, cam, x, y, options), loaded } or rejects when
|
|
4
|
+
* WebAssembly is not available.
|
|
5
|
+
*/
|
|
6
|
+
/** The HEXAW kernel (kernel/pick.hexaw compiled to WebAssembly): ray picking with the same results as pick(). */
|
|
7
|
+
export interface Kernel {
|
|
8
|
+
pick(mesh: Mesh, camera: Camera, x: number, y: number, options?: {
|
|
9
|
+
cull?: boolean;
|
|
10
|
+
}): Hit | null;
|
|
11
|
+
readonly loaded: Mesh | null;
|
|
12
|
+
instance: WebAssembly.Instance;
|
|
13
|
+
}
|
|
14
|
+
export declare const initKernel: () => Promise<Kernel>;
|
package/dist/kernel.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The HEXAW kernel: ray picking in WebAssembly written in HEXAW (kernel/pick.hexaw).
|
|
2
|
+
// The mesh is copied into the kernel's linear memory once, as long as it is the same object,
|
|
3
|
+
// then every pick is one call that walks all triangles. Same answers as pick() in pick.ts.
|
|
4
|
+
import { dot, norm, plus, times } from './vec.js';
|
|
5
|
+
import { faceNormal } from './mesh.js';
|
|
6
|
+
import { ray } from './camera.js';
|
|
7
|
+
import { KERNEL_BYTES } from './kernel-bytes.js';
|
|
8
|
+
const POS = 64; // vertices start here, 24 bytes each (f64 x y z)
|
|
9
|
+
const decode = (b64) => {
|
|
10
|
+
if (typeof Buffer !== 'undefined')
|
|
11
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
12
|
+
const s = atob(b64);
|
|
13
|
+
return Uint8Array.from(s, (c) => c.charCodeAt(0));
|
|
14
|
+
};
|
|
15
|
+
export const initKernel = async () => {
|
|
16
|
+
if (typeof WebAssembly === 'undefined')
|
|
17
|
+
throw new Error('WebAssembly is not available');
|
|
18
|
+
const { instance } = (await WebAssembly.instantiate(decode(KERNEL_BYTES)));
|
|
19
|
+
const { pick: kPick, memory } = instance.exports;
|
|
20
|
+
let uploaded = null; // the mesh now in the kernel's memory
|
|
21
|
+
let triBase = 0;
|
|
22
|
+
let triCount = 0;
|
|
23
|
+
const upload = (mesh) => {
|
|
24
|
+
const nv = mesh.positions.length;
|
|
25
|
+
let nt = 0;
|
|
26
|
+
for (const f of mesh.faces)
|
|
27
|
+
nt += Math.max(0, f.v.length - 2);
|
|
28
|
+
triBase = (POS + nv * 24 + 7) & ~7;
|
|
29
|
+
const need = Math.ceil((triBase + nt * 16 + 8) / 65536);
|
|
30
|
+
const have = memory.buffer.byteLength / 65536;
|
|
31
|
+
if (need > have)
|
|
32
|
+
memory.grow(need - have);
|
|
33
|
+
// grow() detaches the old buffer, so the views are made after it
|
|
34
|
+
const f64 = new Float64Array(memory.buffer, POS, nv * 3);
|
|
35
|
+
for (let i = 0; i < nv; i++) {
|
|
36
|
+
const p = mesh.positions[i];
|
|
37
|
+
f64[i * 3] = p[0];
|
|
38
|
+
f64[i * 3 + 1] = p[1];
|
|
39
|
+
f64[i * 3 + 2] = p[2];
|
|
40
|
+
}
|
|
41
|
+
// faces are fanned into triangles (v0, vj, vj+1), each remembering its face number
|
|
42
|
+
const i32 = new Int32Array(memory.buffer, triBase, nt * 4);
|
|
43
|
+
let at = 0;
|
|
44
|
+
mesh.faces.forEach((f, face) => {
|
|
45
|
+
for (let j = 1; j < f.v.length - 1; j++) {
|
|
46
|
+
i32[at] = f.v[0];
|
|
47
|
+
i32[at + 1] = f.v[j];
|
|
48
|
+
i32[at + 2] = f.v[j + 1];
|
|
49
|
+
i32[at + 3] = face;
|
|
50
|
+
at += 4;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
triCount = nt;
|
|
54
|
+
uploaded = mesh;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The nearest surface under panel pixel (x, y), or null: the same result as pick() in pick.js,
|
|
58
|
+
* { face, distance, point, normal, front, color }.
|
|
59
|
+
*/
|
|
60
|
+
const pick = (mesh, cam, x, y, { cull = true } = {}) => {
|
|
61
|
+
if (mesh !== uploaded)
|
|
62
|
+
upload(mesh);
|
|
63
|
+
const { origin: o, dir: d } = ray(cam, x, y);
|
|
64
|
+
const face = kPick(o[0], o[1], o[2], d[0], d[1], d[2], POS, triBase, triCount, cull ? 1 : 0);
|
|
65
|
+
if (face < 0)
|
|
66
|
+
return null;
|
|
67
|
+
const distance = new Float64Array(memory.buffer, 0, 1)[0];
|
|
68
|
+
const f = mesh.faces[face];
|
|
69
|
+
const outward = norm(faceNormal(f.v.map((k) => mesh.positions[k])));
|
|
70
|
+
const front = dot(outward, d) < 0;
|
|
71
|
+
return {
|
|
72
|
+
face,
|
|
73
|
+
distance,
|
|
74
|
+
point: plus(o)(times(distance)(d)),
|
|
75
|
+
normal: front ? outward : times(-1)(outward),
|
|
76
|
+
front,
|
|
77
|
+
color: f.color,
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
return { pick, get loaded() { return uploaded; }, instance };
|
|
81
|
+
};
|
package/dist/mesh.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Axes, Mesh, Vec3 } from './types.js';
|
|
2
|
+
export interface BoxOptions {
|
|
3
|
+
size?: Vec3;
|
|
4
|
+
at?: Vec3;
|
|
5
|
+
color?: string;
|
|
6
|
+
/** Orthonormal local x/y/z axes (default: the world axes). */
|
|
7
|
+
axes?: Axes;
|
|
8
|
+
}
|
|
9
|
+
/** Box: 8 points, 12 edges, 6 quad faces. Axis-aligned unless `axes` turns it. */
|
|
10
|
+
export declare const box: ({ size, at, color, axes }?: BoxOptions) => Mesh;
|
|
11
|
+
/** Concatenate meshes into one, re-indexing the faces. */
|
|
12
|
+
export declare const merge: (...meshes: Mesh[]) => Mesh;
|
|
13
|
+
/** Bounding box center and bounding sphere radius around that center. */
|
|
14
|
+
export declare const bounds: (mesh: Mesh) => {
|
|
15
|
+
center: Vec3;
|
|
16
|
+
radius: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Turntable rotation: yaw around Y first, then pitch around X (radians),
|
|
20
|
+
* both around `center` (default: bounding-box center). Faces are shared.
|
|
21
|
+
*/
|
|
22
|
+
export declare const rotate: (mesh: Mesh, { yaw, pitch }?: {
|
|
23
|
+
yaw?: number;
|
|
24
|
+
pitch?: number;
|
|
25
|
+
}, center?: Vec3) => Mesh;
|
|
26
|
+
/** Face normal (not normalized) by Newell's method; points CCW => outward. */
|
|
27
|
+
export declare const faceNormal: (pts: Vec3[]) => Vec3;
|
|
28
|
+
/** Mean of the face points. */
|
|
29
|
+
export declare const faceCenter: (pts: Vec3[]) => Vec3;
|
|
30
|
+
/**
|
|
31
|
+
* Prism from a convex footprint: `polygon` is [[x, z], ...] on the ground plane (any
|
|
32
|
+
* winding), it is extruded up from `y` by `height`. Faces: top, bottom, then one
|
|
33
|
+
* quad per footprint edge, all CCW from outside.
|
|
34
|
+
*/
|
|
35
|
+
export declare const extrude: ({ polygon, height, y, color }: {
|
|
36
|
+
polygon: [number, number][];
|
|
37
|
+
height?: number;
|
|
38
|
+
y?: number;
|
|
39
|
+
color?: string;
|
|
40
|
+
}) => Mesh;
|
package/dist/mesh.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Mesh = { positions: [[x, y, z], ...], faces: [{ v: [i, j, k, ...], color }] }
|
|
2
|
+
// Faces are CCW when seen from outside. No normals, UVs or textures yet.
|
|
3
|
+
import { add, len, scale, sub } from './vec.js';
|
|
4
|
+
/** Box: 8 points, 12 edges, 6 quad faces. Axis-aligned unless `axes` turns it. */
|
|
5
|
+
export const box = ({ size = [1, 1, 1], at = [0, 0, 0], color = '#c8c8c8', axes } = {}) => {
|
|
6
|
+
const [hx, hy, hz] = size.map((s) => s / 2);
|
|
7
|
+
// `axes` = [ex, ey, ez], an orthonormal right-handed frame (default: x, y, z)
|
|
8
|
+
const [ex, ey, ez] = axes ?? [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
|
|
9
|
+
const at3 = (x, y, z) => add(at, add(add(scale(ex, x), scale(ey, y)), scale(ez, z)));
|
|
10
|
+
const positions = [
|
|
11
|
+
at3(-hx, -hy, -hz), // 0
|
|
12
|
+
at3(hx, -hy, -hz), // 1
|
|
13
|
+
at3(hx, hy, -hz), // 2
|
|
14
|
+
at3(-hx, hy, -hz), // 3
|
|
15
|
+
at3(-hx, -hy, hz), // 4
|
|
16
|
+
at3(hx, -hy, hz), // 5
|
|
17
|
+
at3(hx, hy, hz), // 6
|
|
18
|
+
at3(-hx, hy, hz), // 7
|
|
19
|
+
];
|
|
20
|
+
const faces = [
|
|
21
|
+
[4, 5, 6, 7], // +z
|
|
22
|
+
[1, 0, 3, 2], // -z
|
|
23
|
+
[1, 2, 6, 5], // +x
|
|
24
|
+
[0, 4, 7, 3], // -x
|
|
25
|
+
[3, 7, 6, 2], // +y
|
|
26
|
+
[0, 1, 5, 4], // -y
|
|
27
|
+
].map((v) => ({ v, color }));
|
|
28
|
+
return { positions, faces };
|
|
29
|
+
};
|
|
30
|
+
/** Concatenate meshes into one, re-indexing the faces. */
|
|
31
|
+
export const merge = (...meshes) => {
|
|
32
|
+
const positions = [];
|
|
33
|
+
const faces = [];
|
|
34
|
+
for (const m of meshes) {
|
|
35
|
+
const offset = positions.length;
|
|
36
|
+
positions.push(...m.positions);
|
|
37
|
+
for (const f of m.faces)
|
|
38
|
+
faces.push({ ...f, v: f.v.map((i) => i + offset) });
|
|
39
|
+
}
|
|
40
|
+
return { positions, faces };
|
|
41
|
+
};
|
|
42
|
+
/** Bounding box center and bounding sphere radius around that center. */
|
|
43
|
+
export const bounds = (mesh) => {
|
|
44
|
+
if (!mesh.positions.length)
|
|
45
|
+
return { center: [0, 0, 0], radius: 0 };
|
|
46
|
+
const min = [Infinity, Infinity, Infinity];
|
|
47
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
48
|
+
for (const p of mesh.positions) {
|
|
49
|
+
for (let a = 0; a < 3; a++) {
|
|
50
|
+
if (p[a] < min[a])
|
|
51
|
+
min[a] = p[a];
|
|
52
|
+
if (p[a] > max[a])
|
|
53
|
+
max[a] = p[a];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const center = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
|
|
57
|
+
let radius = 0;
|
|
58
|
+
for (const p of mesh.positions)
|
|
59
|
+
radius = Math.max(radius, len(sub(p, center)));
|
|
60
|
+
return { center, radius };
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Turntable rotation: yaw around Y first, then pitch around X (radians),
|
|
64
|
+
* both around `center` (default: bounding-box center). Faces are shared.
|
|
65
|
+
*/
|
|
66
|
+
export const rotate = (mesh, { yaw = 0, pitch = 0 } = {}, center = bounds(mesh).center) => {
|
|
67
|
+
const cy = Math.cos(yaw);
|
|
68
|
+
const sy = Math.sin(yaw);
|
|
69
|
+
const cp = Math.cos(pitch);
|
|
70
|
+
const sp = Math.sin(pitch);
|
|
71
|
+
const positions = mesh.positions.map((p) => {
|
|
72
|
+
const x = p[0] - center[0];
|
|
73
|
+
const y = p[1] - center[1];
|
|
74
|
+
const z = p[2] - center[2];
|
|
75
|
+
const x1 = cy * x + sy * z;
|
|
76
|
+
const z1 = -sy * x + cy * z;
|
|
77
|
+
return [x1 + center[0], cp * y - sp * z1 + center[1], sp * y + cp * z1 + center[2]];
|
|
78
|
+
});
|
|
79
|
+
return { positions, faces: mesh.faces };
|
|
80
|
+
};
|
|
81
|
+
/** Face normal (not normalized) by Newell's method; points CCW => outward. */
|
|
82
|
+
export const faceNormal = (pts) => {
|
|
83
|
+
const n = [0, 0, 0];
|
|
84
|
+
for (let i = 0; i < pts.length; i++) {
|
|
85
|
+
const a = pts[i];
|
|
86
|
+
const b = pts[(i + 1) % pts.length];
|
|
87
|
+
n[0] += (a[1] - b[1]) * (a[2] + b[2]);
|
|
88
|
+
n[1] += (a[2] - b[2]) * (a[0] + b[0]);
|
|
89
|
+
n[2] += (a[0] - b[0]) * (a[1] + b[1]);
|
|
90
|
+
}
|
|
91
|
+
return n;
|
|
92
|
+
};
|
|
93
|
+
/** Mean of the face points. */
|
|
94
|
+
export const faceCenter = (pts) => {
|
|
95
|
+
const s = pts.reduce((acc, p) => add(acc, p), [0, 0, 0]);
|
|
96
|
+
return [s[0] / pts.length, s[1] / pts.length, s[2] / pts.length];
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Prism from a convex footprint: `polygon` is [[x, z], ...] on the ground plane (any
|
|
100
|
+
* winding), it is extruded up from `y` by `height`. Faces: top, bottom, then one
|
|
101
|
+
* quad per footprint edge, all CCW from outside.
|
|
102
|
+
*/
|
|
103
|
+
export const extrude = ({ polygon, height = 1, y = 0, color = '#c8c8c8' }) => {
|
|
104
|
+
if (!Array.isArray(polygon) || polygon.length < 3)
|
|
105
|
+
throw new Error('polygon needs at least 3 [x, z] points');
|
|
106
|
+
if (!(height > 0))
|
|
107
|
+
throw new Error('height must be positive');
|
|
108
|
+
let pts = polygon.map((p) => [p[0], p[1]]);
|
|
109
|
+
const n = pts.length;
|
|
110
|
+
const turn = (a, b, c) => (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]); // y of edge cross, + is CCW from above
|
|
111
|
+
let area = 0;
|
|
112
|
+
for (let i = 0; i < n; i++)
|
|
113
|
+
area += pts[i][1] * pts[(i + 1) % n][0] - pts[i][0] * pts[(i + 1) % n][1];
|
|
114
|
+
if (Math.abs(area) < 1e-12)
|
|
115
|
+
throw new Error('polygon has no area');
|
|
116
|
+
if (area < 0)
|
|
117
|
+
pts = pts.reverse();
|
|
118
|
+
for (let i = 0; i < n; i++) {
|
|
119
|
+
if (turn(pts[i], pts[(i + 1) % n], pts[(i + 2) % n]) < -1e-12)
|
|
120
|
+
throw new Error('polygon must be convex');
|
|
121
|
+
}
|
|
122
|
+
const positions = [...pts.map(([x, z]) => [x, y, z]), ...pts.map(([x, z]) => [x, y + height, z])];
|
|
123
|
+
const ring = [...Array(n).keys()];
|
|
124
|
+
const faces = [
|
|
125
|
+
{ v: ring.map((i) => n + i), color },
|
|
126
|
+
{ v: [...ring].reverse(), color },
|
|
127
|
+
...ring.map((i) => ({ v: [i, (i + 1) % n, n + ((i + 1) % n), n + i], color })),
|
|
128
|
+
];
|
|
129
|
+
return { positions, faces };
|
|
130
|
+
};
|