3draft 0.0.0-stage → 26.11.1013

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/src/gpu.js ADDED
@@ -0,0 +1,252 @@
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
+
6
+ import { sub, dot, norm } from './vec.js';
7
+ import { faceNormal } from './mesh.js';
8
+ import { makeCamera } from './camera.js';
9
+
10
+ const FLOATS = 9; // position(3) normal(3) color(3)
11
+
12
+ /**
13
+ * Column-major 4x4 matrix (for WebGPU, z in 0..1) that maps world points exactly
14
+ * like camera.project(): the same pixels, plus a depth between near and far.
15
+ */
16
+ export const viewProjection = (cam, near = 0.1, far = 1000) => {
17
+ const kk = cam.k / (cam.size / 2);
18
+ const row = (v, s) => [v[0] * s, v[1] * s, v[2] * s, -dot(v, cam.eye) * s];
19
+ const a = cam.ortho ? 1 / (far - near) : far / (far - near);
20
+ const zr = row(cam.f, a);
21
+ zr[3] -= a * near;
22
+ const rows = [row(cam.r, kk), row(cam.u, kk), zr, cam.ortho ? [0, 0, 0, 1] : row(cam.f, 1)];
23
+ const m = new Float32Array(16);
24
+ rows.forEach((r, i) => r.forEach((v, j) => (m[j * 4 + i] = v)));
25
+ return m;
26
+ };
27
+
28
+ const rgb = (color) => {
29
+ const h = String(color ?? '').replace('#', '');
30
+ const full = h.length === 3 ? [...h].map((c) => c + c).join('') : h;
31
+ if (!/^[0-9a-f]{6}$/i.test(full)) return [0.78, 0.78, 0.78];
32
+ return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
33
+ };
34
+
35
+ /** Triangle list for the GPU: flat face normals and face colors. Float32Array, 9 floats per vertex. */
36
+ export const buildVertices = (mesh) => {
37
+ let tris = 0;
38
+ for (const f of mesh.faces) tris += Math.max(0, f.v.length - 2);
39
+ const out = new Float32Array(tris * 3 * FLOATS);
40
+ let o = 0;
41
+ for (const face of mesh.faces) {
42
+ const pts = face.v.map((k) => mesh.positions[k]);
43
+ const n = norm(faceNormal(pts));
44
+ const c = rgb(face.color);
45
+ for (let j = 1; j < pts.length - 1; j++) {
46
+ for (const p of [pts[0], pts[j], pts[j + 1]]) {
47
+ out.set([...p, ...n, ...c], o);
48
+ o += FLOATS;
49
+ }
50
+ }
51
+ }
52
+ return out;
53
+ };
54
+
55
+ export const defaultLight = { direction: norm([0.5, 0.8, 0.6]), sky: [1, 0.97, 0.92], ground: [0.42, 0.38, 0.36] };
56
+ export const defaultMaterial = { ambient: 0.62, diffuse: 0.78, specular: 0.2, shininess: 30 };
57
+
58
+ const SHADER = /* wgsl */ `
59
+ struct U {
60
+ vp : mat4x4<f32>,
61
+ light : vec4<f32>, // xyz: direction towards the light
62
+ sky : vec4<f32>,
63
+ ground : vec4<f32>,
64
+ eye : vec4<f32>,
65
+ mat : vec4<f32>, // ambient, diffuse, specular, shininess
66
+ };
67
+ @group(0) @binding(0) var<uniform> u : U;
68
+
69
+ struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32> };
70
+ struct VOut {
71
+ @builtin(position) p : vec4<f32>,
72
+ @location(0) n : vec3<f32>,
73
+ @location(1) c : vec3<f32>,
74
+ @location(2) w : vec3<f32>,
75
+ };
76
+
77
+ @vertex fn vs(i : VIn) -> VOut {
78
+ var o : VOut;
79
+ o.p = u.vp * vec4<f32>(i.pos, 1.0);
80
+ o.n = i.nrm;
81
+ o.c = i.col;
82
+ o.w = i.pos;
83
+ return o;
84
+ }
85
+
86
+ @fragment fn fs(i : VOut, @builtin(front_facing) front : bool) -> @location(0) vec4<f32> {
87
+ var n = normalize(i.n);
88
+ if (!front) { n = -n; }
89
+ let l = normalize(u.light.xyz);
90
+ let v = normalize(u.eye.xyz - i.w);
91
+ let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x;
92
+ let diff = max(dot(n, l), 0.0) * u.mat.y;
93
+ let h = normalize(l + v);
94
+ let spec = pow(max(dot(n, h), 0.0), u.mat.w) * u.mat.z * step(0.0, dot(n, l));
95
+ let lit = i.c * (hemi + vec3<f32>(diff)) + vec3<f32>(spec);
96
+ return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
97
+ }
98
+ `;
99
+
100
+ /**
101
+ * Sets up WebGPU on a canvas. Rejects when WebGPU is not available.
102
+ * Returns { render(mesh, view, options), destroy() }.
103
+ * render options: size (CSS px, the canvas is made size x size), fit, background [r,g,b] 0..1,
104
+ * light { direction, sky, ground }, material { ambient, diffuse, specular, shininess }, dpr.
105
+ */
106
+ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
107
+ if (!globalThis.navigator?.gpu) throw new Error('WebGPU is not available in this browser');
108
+ const adapter = await navigator.gpu.requestAdapter();
109
+ if (!adapter) throw new Error('No WebGPU adapter found');
110
+ const device = await adapter.requestDevice();
111
+ device.addEventListener('uncapturederror', (e) => console.error('WebGPU:', e.error?.message ?? e));
112
+ device.lost.then((info) => console.error('WebGPU device lost:', info.reason, info.message));
113
+ const context = canvas.getContext('webgpu');
114
+ if (!context) throw new Error('Could not get a WebGPU canvas context');
115
+ const format = navigator.gpu.getPreferredCanvasFormat();
116
+ context.configure({ device, format, alphaMode: 'opaque', });
117
+
118
+ const module = device.createShaderModule({ code: SHADER });
119
+ const pipeline = device.createRenderPipeline({
120
+ layout: 'auto',
121
+ vertex: {
122
+ module,
123
+ entryPoint: 'vs',
124
+ buffers: [
125
+ {
126
+ arrayStride: FLOATS * 4,
127
+ attributes: [
128
+ { shaderLocation: 0, offset: 0, format: 'float32x3' },
129
+ { shaderLocation: 1, offset: 12, format: 'float32x3' },
130
+ { shaderLocation: 2, offset: 24, format: 'float32x3' },
131
+ ],
132
+ },
133
+ ],
134
+ },
135
+ fragment: { module, entryPoint: 'fs', targets: [{ format }] },
136
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
137
+ depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
138
+ multisample: { count: sampleCount },
139
+ });
140
+ const uniforms = device.createBuffer({ size: 144, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
141
+ const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
142
+
143
+ let vertexBuffer = null;
144
+ let capacity = 0;
145
+ let px = 0;
146
+ let depth = null;
147
+ let color = null;
148
+
149
+ const targets = (n) => {
150
+ if (n === px) return;
151
+ px = n;
152
+ canvas.width = n;
153
+ canvas.height = n;
154
+ depth?.destroy();
155
+ color?.destroy();
156
+ depth = device.createTexture({ size: [n, n], format: 'depth24plus', sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT });
157
+ color = sampleCount > 1 ? device.createTexture({ size: [n, n], format, sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT }) : null;
158
+ };
159
+
160
+ const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, dpr = globalThis.devicePixelRatio || 1 } = {}, readback = false) => {
161
+ const L = { ...defaultLight, ...light };
162
+ const M = { ...defaultMaterial, ...material };
163
+ const cam = makeCamera(view, mesh, size, fit);
164
+ targets(Math.max(1, Math.round(size * dpr)));
165
+
166
+ const verts = buildVertices(mesh);
167
+ if (verts.byteLength > capacity) {
168
+ vertexBuffer?.destroy();
169
+ capacity = Math.max(verts.byteLength, 4096);
170
+ vertexBuffer = device.createBuffer({ size: capacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
171
+ }
172
+ if (verts.byteLength) device.queue.writeBuffer(vertexBuffer, 0, verts);
173
+
174
+ const u = new Float32Array(36);
175
+ u.set(viewProjection(cam), 0);
176
+ u.set([...norm(L.direction), 0], 16);
177
+ u.set([...L.sky, 0], 20);
178
+ u.set([...L.ground, 0], 24);
179
+ u.set([...cam.eye, 0], 28);
180
+ u.set([M.ambient, M.diffuse, M.specular, M.shininess], 32);
181
+ device.queue.writeBuffer(uniforms, 0, u);
182
+
183
+ const encoder = device.createCommandEncoder();
184
+ const offscreen = readback
185
+ ? device.createTexture({ size: [px, px], format, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC })
186
+ : null;
187
+ const target = (offscreen ?? context.getCurrentTexture()).createView();
188
+ const pass = encoder.beginRenderPass({
189
+ colorAttachments: [
190
+ {
191
+ view: color ? color.createView() : target,
192
+ resolveTarget: color ? target : undefined,
193
+ clearValue: { r: background[0], g: background[1], b: background[2], a: 1 },
194
+ loadOp: 'clear',
195
+ storeOp: color ? 'discard' : 'store',
196
+ },
197
+ ],
198
+ depthStencilAttachment: { view: depth.createView(), depthClearValue: 1, depthLoadOp: 'clear', depthStoreOp: 'discard' },
199
+ });
200
+ if (verts.byteLength) {
201
+ pass.setPipeline(pipeline);
202
+ pass.setBindGroup(0, bind);
203
+ pass.setVertexBuffer(0, vertexBuffer);
204
+ pass.draw(verts.length / FLOATS);
205
+ }
206
+ pass.end();
207
+ if (!offscreen) {
208
+ device.queue.submit([encoder.finish()]);
209
+ return null;
210
+ }
211
+ const row = Math.ceil((px * 4) / 256) * 256;
212
+ const out = device.createBuffer({ size: row * px, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
213
+ encoder.copyTextureToBuffer({ texture: offscreen }, { buffer: out, bytesPerRow: row }, [px, px]);
214
+ device.queue.submit([encoder.finish()]);
215
+ return out.mapAsync(GPUMapMode.READ).then(() => {
216
+ const raw = new Uint8Array(out.getMappedRange());
217
+ const data = new Uint8ClampedArray(px * px * 4);
218
+ const swap = format.startsWith('bgra');
219
+ for (let y = 0; y < px; y++) {
220
+ for (let x = 0; x < px; x++) {
221
+ const i = y * row + x * 4;
222
+ const o = (y * px + x) * 4;
223
+ data[o] = raw[i + (swap ? 2 : 0)];
224
+ data[o + 1] = raw[i + 1];
225
+ data[o + 2] = raw[i + (swap ? 0 : 2)];
226
+ data[o + 3] = 255;
227
+ }
228
+ }
229
+ out.unmap();
230
+ out.destroy();
231
+ offscreen.destroy();
232
+ return { width: px, height: px, data };
233
+ });
234
+ };
235
+
236
+ /** Draws a frame onto the canvas. */
237
+ const render = (mesh, view, options) => {
238
+ draw(mesh, view, options);
239
+ };
240
+ /** Draws a frame into an offscreen texture and reads it back: Promise<{ width, height, data: RGBA }>. */
241
+ const pixels = (mesh, view, options) => draw(mesh, view, options, true);
242
+
243
+ const destroy = () => {
244
+ vertexBuffer?.destroy();
245
+ depth?.destroy();
246
+ color?.destroy();
247
+ uniforms.destroy();
248
+ device.destroy();
249
+ };
250
+
251
+ return { render, pixels, destroy, device, adapter };
252
+ };
package/src/index.d.ts ADDED
@@ -0,0 +1,209 @@
1
+ export const name: '3draft';
2
+ export const version: string;
3
+
4
+ export type Vec3 = [number, number, number];
5
+
6
+ export interface Face {
7
+ /** Point indices, CCW seen from outside. */
8
+ v: number[];
9
+ color?: string;
10
+ }
11
+
12
+ export interface Mesh {
13
+ positions: Vec3[];
14
+ faces: Face[];
15
+ }
16
+
17
+ export interface BoxOptions {
18
+ size?: Vec3;
19
+ at?: Vec3;
20
+ color?: string;
21
+ /** Orthonormal local x/y/z axes (default: the world axes). */
22
+ axes?: [Vec3, Vec3, Vec3];
23
+ }
24
+
25
+ export function box(options?: BoxOptions): Mesh;
26
+ /** Prism from a convex footprint [[x, z], ...] (any winding), extruded up from `y` by `height`. */
27
+ export function extrude(options: { polygon: [number, number][]; height?: number; y?: number; color?: string }): Mesh;
28
+ export function merge(...meshes: Mesh[]): Mesh;
29
+ /** Turntable rotation (radians): yaw around Y, then pitch around X, around `center`. */
30
+ export function rotate(mesh: Mesh, angles?: { yaw?: number; pitch?: number }, center?: Vec3): Mesh;
31
+ export function bounds(mesh: Mesh): { center: Vec3; radius: number };
32
+ export function faceNormal(points: Vec3[]): Vec3;
33
+ export function faceCenter(points: Vec3[]): Vec3;
34
+
35
+ export type ViewName = 'front' | 'side' | 'top' | 'persp';
36
+ export const VIEW_ORDER: readonly ViewName[];
37
+
38
+ /** Custom camera: `dir` points from the target towards the eye. */
39
+ export interface ViewSpec {
40
+ dir: Vec3;
41
+ up: Vec3;
42
+ ortho?: boolean;
43
+ name?: string;
44
+ }
45
+
46
+ export interface Fit {
47
+ center: Vec3;
48
+ radius: number;
49
+ }
50
+
51
+ export interface Camera {
52
+ name: string;
53
+ eye: Vec3;
54
+ f: Vec3;
55
+ r: Vec3;
56
+ u: Vec3;
57
+ ortho: boolean;
58
+ k: number;
59
+ size: number;
60
+ }
61
+
62
+ export function makeCamera(view: ViewName | ViewSpec, mesh: Mesh, size: number, fit?: Fit): Camera;
63
+ export function project(camera: Camera, p: Vec3): { x: number; y: number; depth: number };
64
+
65
+ /** Ray through a panel pixel, the inverse of `project`. `dir` is a unit vector. */
66
+ export function ray(camera: Camera, x: number, y: number): { origin: Vec3; dir: Vec3 };
67
+
68
+ export interface Hit {
69
+ /** Index into `mesh.faces`. */
70
+ face: number;
71
+ /** Distance along the ray. */
72
+ distance: number;
73
+ point: Vec3;
74
+ /** Unit normal, facing the camera. */
75
+ normal: Vec3;
76
+ /** Whether the face itself faces the camera. */
77
+ front: boolean;
78
+ color?: string;
79
+ }
80
+
81
+ /** The nearest surface under panel pixel (x, y), or null. */
82
+ export function pick(
83
+ mesh: Mesh,
84
+ camera: Camera,
85
+ x: number,
86
+ y: number,
87
+ options?: { cull?: boolean },
88
+ ): Hit | null;
89
+
90
+ /** A target cross lying on the picked surface: 4 arms and a ring, as 3D polylines. */
91
+ export function crosshair(mesh: Mesh, hit: Hit, radius?: number): Vec3[][];
92
+
93
+ /** 3D polylines drawn on top of the view. */
94
+ export interface Mark {
95
+ name?: string;
96
+ lines: Vec3[][];
97
+ color?: string;
98
+ width?: number;
99
+ }
100
+
101
+ /** '26.11.1001' -> '26.11.01.001' */
102
+ /** Turntable as a camera: the object stays put, the eye orbits (radians). */
103
+ export function orbit(angles?: { yaw?: number; pitch?: number; ortho?: boolean; name?: string }): ViewSpec;
104
+
105
+ export interface StandOptions {
106
+ /** Square base edge (default 0.15). */
107
+ base?: number;
108
+ /** Height (default 3 x base). */
109
+ height?: number;
110
+ color?: string;
111
+ }
112
+ /** A prism standing on the picked surface, along its normal. */
113
+ export function standOn(mesh: Mesh, hit: Hit, options?: StandOptions): Mesh;
114
+
115
+ /** Back-to-front paint order of projected faces (plane tests; depth fallback on cycles). */
116
+ export function paintOrder(items: unknown[]): number[];
117
+
118
+ export interface GpuLight {
119
+ /** Direction towards the light, world space. */
120
+ direction?: Vec3;
121
+ /** Hemisphere ambient colors (rgb 0..1): up and down. */
122
+ sky?: Vec3;
123
+ ground?: Vec3;
124
+ }
125
+ export interface GpuMaterial {
126
+ ambient?: number;
127
+ diffuse?: number;
128
+ specular?: number;
129
+ shininess?: number;
130
+ }
131
+ export interface GpuRenderOptions {
132
+ /** CSS pixels, the canvas becomes size x size (times dpr in device pixels). */
133
+ size?: number;
134
+ fit?: Fit;
135
+ /** Clear color, rgb 0..1. */
136
+ background?: Vec3;
137
+ light?: GpuLight;
138
+ material?: GpuMaterial;
139
+ dpr?: number;
140
+ }
141
+ export interface Gpu {
142
+ render(mesh: Mesh, view: ViewName | ViewSpec, options?: GpuRenderOptions): void;
143
+ /** Same frame, drawn offscreen and read back as RGBA (tests, thumbnails). */
144
+ pixels(mesh: Mesh, view: ViewName | ViewSpec, options?: GpuRenderOptions): Promise<{ width: number; height: number; data: Uint8ClampedArray }>;
145
+ destroy(): void;
146
+ }
147
+ /** WebGPU renderer on a canvas; rejects when WebGPU is unavailable. Same camera as the SVG renderer. */
148
+ export function createGpu(canvas: HTMLCanvasElement, options?: { sampleCount?: number }): Promise<Gpu>;
149
+ /** Column-major 4x4 matrix mapping world points to the same pixels as project(). */
150
+ export function viewProjection(camera: Camera, near?: number, far?: number): Float32Array;
151
+ /** Triangle list: position(3) normal(3) color(3) per vertex. */
152
+ export function buildVertices(mesh: Mesh): Float32Array;
153
+ export const defaultLight: Required<GpuLight>;
154
+ export const defaultMaterial: Required<GpuMaterial>;
155
+
156
+ export interface Validation {
157
+ vertices: number;
158
+ faces: number;
159
+ /** Every edge is shared by two faces, wound consistently. */
160
+ closed: boolean;
161
+ openEdges: number;
162
+ /** Signed volume; null unless closed. */
163
+ volume: number | null;
164
+ /** Closed, but the faces point inward. */
165
+ inside_out: boolean;
166
+ degenerateFaces: number[];
167
+ nonPlanarFaces: number[];
168
+ nonConvexFaces: number[];
169
+ ok: boolean;
170
+ /** Plain-text findings. */
171
+ notes: string[];
172
+ }
173
+ /** Geometry checks: closed shell, CCW from outside, planar, convex faces. */
174
+ export function validate(mesh: Mesh): Validation;
175
+
176
+ /** Extrude / taper / inset / dent a face; returns the new mesh, the cap face index and the new side quads. */
177
+ export function extrudeFace(mesh: Mesh, face: number, options?: { distance?: number; scale?: number; color?: string }): { mesh: Mesh; cap: number; sides: number[] };
178
+ export function moveVertices(mesh: Mesh, indices: number[], delta: Vec3): Mesh;
179
+
180
+ export function displayVersion(version: string): string;
181
+
182
+ export interface RenderOptions {
183
+ /** Panel size in px (default 400). */
184
+ size?: number;
185
+ /** Drop back-facing faces (default true). */
186
+ cull?: boolean;
187
+ /** Draw the vertices as circles (default true). */
188
+ points?: boolean;
189
+ stroke?: string;
190
+ pointColor?: string;
191
+ background?: string;
192
+ /** Keep the camera target/framing fixed while the mesh moves. */
193
+ fit?: Fit;
194
+ /** 3D polylines drawn on top, e.g. the surface cursor. */
195
+ marks?: Mark[];
196
+ }
197
+
198
+ export function renderView(mesh: Mesh, view: ViewName | ViewSpec, options?: RenderOptions): string;
199
+ export function renderViews(mesh: Mesh, options?: RenderOptions): { name: ViewName; svg: string }[];
200
+ export function renderSheet(mesh: Mesh, options?: RenderOptions): string;
201
+
202
+ export interface Info {
203
+ name: string;
204
+ version: string;
205
+ stage: 'pre-alpha';
206
+ goals: string[];
207
+ }
208
+
209
+ export function info(): Info;
package/src/index.js ADDED
@@ -0,0 +1,32 @@
1
+ // 3Draft: minimalist AI first 3D engine.
2
+ // Current stage: points, edges and flat faces rendered to deterministic SVG
3
+ // from four fixed cameras, plus surface picking (point, normal, distance) and
4
+ // a target cross that lies on the surface under the cursor. No textures, no
5
+ // shading yet.
6
+
7
+ export const name = '3draft';
8
+ export const version = '26.11.1013';
9
+
10
+ export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
11
+ export { validate } from './check.js';
12
+ export { extrudeFace, moveVertices } from './edit.js';
13
+ export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
14
+ export { pick, crosshair } from './pick.js';
15
+ export { standOn } from './place.js';
16
+ export { paintOrder } from './order.js';
17
+ export { createGpu, viewProjection, buildVertices, defaultLight, defaultMaterial } from './gpu.js';
18
+ export { renderView, renderViews, renderSheet } from './svg.js';
19
+ export { displayVersion } from './version.js';
20
+
21
+ /** Short, machine-readable description of the current state of the package. */
22
+ export const info = () => ({
23
+ name,
24
+ version,
25
+ stage: 'pre-alpha',
26
+ goals: [
27
+ 'single palette texture, UVs point at one texel per face',
28
+ 'WASM core (HEXAW / Rust) behind a thin ABI',
29
+ 'WebGPU renderer with WebGL2 fallback',
30
+ 'deterministic SVG preview for AI feedback loops',
31
+ ],
32
+ });
package/src/mesh.js ADDED
@@ -0,0 +1,127 @@
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
+
4
+ import { add, len, scale, sub } from './vec.js';
5
+
6
+ /** Box: 8 points, 12 edges, 6 quad faces. Axis-aligned unless `axes` turns it. */
7
+ export const box = ({ size = [1, 1, 1], at = [0, 0, 0], color = '#c8c8c8', axes } = {}) => {
8
+ const [hx, hy, hz] = size.map((s) => s / 2);
9
+ // `axes` = [ex, ey, ez], an orthonormal right-handed frame (default: x, y, z)
10
+ const [ex, ey, ez] = axes ?? [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
11
+ const at3 = (x, y, z) => add(at, add(add(scale(ex, x), scale(ey, y)), scale(ez, z)));
12
+ const positions = [
13
+ at3(-hx, -hy, -hz), // 0
14
+ at3(hx, -hy, -hz), // 1
15
+ at3(hx, hy, -hz), // 2
16
+ at3(-hx, hy, -hz), // 3
17
+ at3(-hx, -hy, hz), // 4
18
+ at3(hx, -hy, hz), // 5
19
+ at3(hx, hy, hz), // 6
20
+ at3(-hx, hy, hz), // 7
21
+ ];
22
+ const faces = [
23
+ [4, 5, 6, 7], // +z
24
+ [1, 0, 3, 2], // -z
25
+ [1, 2, 6, 5], // +x
26
+ [0, 4, 7, 3], // -x
27
+ [3, 7, 6, 2], // +y
28
+ [0, 1, 5, 4], // -y
29
+ ].map((v) => ({ v, color }));
30
+ return { positions, faces };
31
+ };
32
+
33
+ /** Concatenate meshes into one, re-indexing the faces. */
34
+ export const merge = (...meshes) => {
35
+ const positions = [];
36
+ const faces = [];
37
+ for (const m of meshes) {
38
+ const offset = positions.length;
39
+ positions.push(...m.positions);
40
+ for (const f of m.faces) faces.push({ ...f, v: f.v.map((i) => i + offset) });
41
+ }
42
+ return { positions, faces };
43
+ };
44
+
45
+ /** Bounding box center and bounding sphere radius around that center. */
46
+ export const bounds = (mesh) => {
47
+ if (!mesh.positions.length) return { center: [0, 0, 0], radius: 0 };
48
+ const min = [Infinity, Infinity, Infinity];
49
+ const max = [-Infinity, -Infinity, -Infinity];
50
+ for (const p of mesh.positions) {
51
+ for (let a = 0; a < 3; a++) {
52
+ if (p[a] < min[a]) min[a] = p[a];
53
+ if (p[a] > max[a]) 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) radius = Math.max(radius, len(sub(p, center)));
59
+ return { center, radius };
60
+ };
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
+
82
+ /** Face normal (not normalized) by Newell's method; points CCW => outward. */
83
+ export const faceNormal = (pts) => {
84
+ const n = [0, 0, 0];
85
+ for (let i = 0; i < pts.length; i++) {
86
+ const a = pts[i];
87
+ const b = pts[(i + 1) % pts.length];
88
+ n[0] += (a[1] - b[1]) * (a[2] + b[2]);
89
+ n[1] += (a[2] - b[2]) * (a[0] + b[0]);
90
+ n[2] += (a[0] - b[0]) * (a[1] + b[1]);
91
+ }
92
+ return n;
93
+ };
94
+
95
+ /** Mean of the face points. */
96
+ export const faceCenter = (pts) => {
97
+ const s = pts.reduce((acc, p) => add(acc, p), [0, 0, 0]);
98
+ return [s[0] / pts.length, s[1] / pts.length, s[2] / pts.length];
99
+ };
100
+
101
+ /**
102
+ * Prism from a convex footprint: `polygon` is [[x, z], ...] on the ground plane (any
103
+ * winding), it is extruded up from `y` by `height`. Faces: top, bottom, then one
104
+ * quad per footprint edge, all CCW from outside.
105
+ */
106
+ export const extrude = ({ polygon, height = 1, y = 0, color = '#c8c8c8' } = {}) => {
107
+ if (!Array.isArray(polygon) || polygon.length < 3) throw new Error('polygon needs at least 3 [x, z] points');
108
+ if (!(height > 0)) throw new Error('height must be positive');
109
+ let pts = polygon.map((p) => [p[0], p[1]]);
110
+ const n = pts.length;
111
+ 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
112
+ let area = 0;
113
+ for (let i = 0; i < n; i++) area += pts[i][1] * pts[(i + 1) % n][0] - pts[i][0] * pts[(i + 1) % n][1];
114
+ if (Math.abs(area) < 1e-12) throw new Error('polygon has no area');
115
+ if (area < 0) pts = pts.reverse();
116
+ for (let i = 0; i < n; i++) {
117
+ if (turn(pts[i], pts[(i + 1) % n], pts[(i + 2) % n]) < -1e-12) throw new Error('polygon must be convex');
118
+ }
119
+ const positions = [...pts.map(([x, z]) => [x, y, z]), ...pts.map(([x, z]) => [x, y + height, z])];
120
+ const ring = [...Array(n).keys()];
121
+ const faces = [
122
+ { v: ring.map((i) => n + i), color },
123
+ { v: [...ring].reverse(), color },
124
+ ...ring.map((i) => ({ v: [i, (i + 1) % n, n + ((i + 1) % n), n + i], color })),
125
+ ];
126
+ return { positions, faces };
127
+ };