3draft 0.0.1 → 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/index.d.ts CHANGED
@@ -1,6 +1,204 @@
1
1
  export const name: '3draft';
2
2
  export const version: string;
3
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
+
4
202
  export interface Info {
5
203
  name: string;
6
204
  version: string;
package/src/index.js CHANGED
@@ -1,9 +1,22 @@
1
1
  // 3Draft: minimalist AI first 3D engine.
2
- // Early placeholder release: the real engine (palette-texture meshes,
3
- // WASM core, WebGPU/WebGL2 renderer, SVG preview) is built on top of this.
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.
4
6
 
5
7
  export const name = '3draft';
6
- export const version = '0.0.1';
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';
7
20
 
8
21
  /** Short, machine-readable description of the current state of the package. */
9
22
  export const info = () => ({
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
+ };
package/src/order.js ADDED
@@ -0,0 +1,94 @@
1
+ // Back-to-front painting order, Newell-Sancha style.
2
+ //
3
+ // Faces that overlap on screen are ordered by plane tests: face A can be painted
4
+ // after face B when A lies wholly on the eye side of B's plane, or when B lies
5
+ // wholly behind A's plane. The resulting "paint this first" relations are then
6
+ // sorted topologically. Centroid depth only breaks ties, and orders faces that
7
+ // cannot be compared (intersecting solids, cycles) as well as before.
8
+
9
+ import { dot, sub } from './vec.js';
10
+
11
+ const PLANE_EPS = 1e-6; // plane test tolerance, world units
12
+ const PX = 0.01; // overlaps thinner than this are treated as touching
13
+
14
+ // Do two convex screen polygons overlap by more than a sliver? (separating axis test)
15
+ const overlaps = (a, b) => {
16
+ for (const poly of [a, b]) {
17
+ for (let i = 0; i < poly.length; i++) {
18
+ const p = poly[i];
19
+ const q = poly[(i + 1) % poly.length];
20
+ const nx = q.y - p.y;
21
+ const ny = p.x - q.x;
22
+ const length = Math.hypot(nx, ny);
23
+ if (!length) continue;
24
+ let minA = Infinity;
25
+ let maxA = -Infinity;
26
+ let minB = Infinity;
27
+ let maxB = -Infinity;
28
+ for (const v of a) {
29
+ const d = (v.x * nx + v.y * ny) / length;
30
+ if (d < minA) minA = d;
31
+ if (d > maxA) maxA = d;
32
+ }
33
+ for (const v of b) {
34
+ const d = (v.x * nx + v.y * ny) / length;
35
+ if (d < minB) minB = d;
36
+ if (d > maxB) maxB = d;
37
+ }
38
+ if (maxA <= minB + PX || maxB <= minA + PX) return false;
39
+ }
40
+ }
41
+ return true;
42
+ };
43
+
44
+ // Signed distances of points to a face's plane, positive on the eye side.
45
+ const distances = (points, face) => points.map((v) => dot(face.n, sub(v, face.p)) * face.eye);
46
+
47
+ /**
48
+ * Items need: { i, world, screen, n, p, eye, depth }
49
+ * world face points in world space screen the same, projected { x, y }
50
+ * n, p unit normal and a point on it eye +1 if the face faces the camera, else -1
51
+ * depth mean depth, used for ties i face index, used for ties
52
+ */
53
+ export const paintOrder = (items) => {
54
+ const count = items.length;
55
+ const after = items.map(() => []);
56
+ const waiting = new Array(count).fill(0);
57
+ const link = (first, second) => {
58
+ after[first].push(second);
59
+ waiting[second] += 1;
60
+ };
61
+
62
+ for (let i = 0; i < count; i++) {
63
+ for (let j = i + 1; j < count; j++) {
64
+ const A = items[i];
65
+ const B = items[j];
66
+ if (!overlaps(A.screen, B.screen)) continue;
67
+ const a = distances(A.world, B); // A against B's plane
68
+ const b = distances(B.world, A); // B against A's plane
69
+ const aAfterB = a.every((d) => d >= -PLANE_EPS) || b.every((d) => d <= PLANE_EPS);
70
+ const bAfterA = b.every((d) => d >= -PLANE_EPS) || a.every((d) => d <= PLANE_EPS);
71
+ if (aAfterB && !bAfterA) link(j, i);
72
+ else if (bAfterA && !aAfterB) link(i, j);
73
+ }
74
+ }
75
+
76
+ const farther = (x, y) =>
77
+ items[x].depth > items[y].depth || (items[x].depth === items[y].depth && items[x].i < items[y].i);
78
+ const done = new Array(count).fill(false);
79
+ const out = [];
80
+ while (out.length < count) {
81
+ let best = -1;
82
+ for (let k = 0; k < count; k++) {
83
+ if (!done[k] && waiting[k] === 0 && (best < 0 || farther(k, best))) best = k;
84
+ }
85
+ if (best < 0) {
86
+ // a cycle: take the farthest face that is left
87
+ for (let k = 0; k < count; k++) if (!done[k] && (best < 0 || farther(k, best))) best = k;
88
+ }
89
+ done[best] = true;
90
+ out.push(items[best]);
91
+ for (const k of after[best]) waiting[k] -= 1;
92
+ }
93
+ return out;
94
+ };
package/src/pick.js ADDED
@@ -0,0 +1,83 @@
1
+ // Surface sensing: what is under a screen pixel, and a marker that lies on it.
2
+ // Picking casts a real ray against the face geometry, so it is exact even where
3
+ // the painter-sorted drawing is not.
4
+
5
+ import { add, cross, dot, norm, scale, sub } from './vec.js';
6
+ import { faceNormal } from './mesh.js';
7
+ import { ray } from './camera.js';
8
+
9
+ const EPS = 1e-9;
10
+
11
+ // Moller-Trumbore ray/triangle test. Distance along the ray, or null.
12
+ const hitTriangle = (o, d, a, b, c) => {
13
+ const e1 = sub(b, a);
14
+ const e2 = sub(c, a);
15
+ const h = cross(d, e2);
16
+ const det = dot(e1, h);
17
+ if (Math.abs(det) < EPS) return null;
18
+ const inv = 1 / det;
19
+ const s = sub(o, a);
20
+ const u = inv * dot(s, h);
21
+ if (u < 0 || u > 1) return null;
22
+ const q = cross(s, e1);
23
+ const v = inv * dot(d, q);
24
+ if (v < 0 || u + v > 1) return null;
25
+ const t = inv * dot(e2, q);
26
+ return t > EPS ? t : null;
27
+ };
28
+
29
+ /**
30
+ * The nearest surface under panel pixel (x, y), or null.
31
+ * { face, distance, point, normal, front, color }
32
+ * `normal` is a unit vector facing the camera; `front` tells whether the face
33
+ * itself faces the camera (always true while `cull` is on). Faces are assumed
34
+ * convex and planar.
35
+ */
36
+ export const pick = (mesh, cam, x, y, { cull = true } = {}) => {
37
+ const { origin, dir } = ray(cam, x, y);
38
+ let best = null;
39
+ mesh.faces.forEach((face, i) => {
40
+ const pts = face.v.map((k) => mesh.positions[k]);
41
+ const outward = norm(faceNormal(pts));
42
+ const front = dot(outward, dir) < 0;
43
+ if (cull && !front) return;
44
+ for (let j = 1; j < pts.length - 1; j++) {
45
+ const t = hitTriangle(origin, dir, pts[0], pts[j], pts[j + 1]);
46
+ if (t !== null && (!best || t < best.distance)) {
47
+ best = {
48
+ face: i,
49
+ distance: t,
50
+ point: add(origin, scale(dir, t)),
51
+ normal: front ? outward : scale(outward, -1),
52
+ front,
53
+ color: face.color,
54
+ };
55
+ }
56
+ }
57
+ });
58
+ return best;
59
+ };
60
+
61
+ /**
62
+ * A target cross lying flat on the picked surface (perpendicular to its
63
+ * normal): four arms with a gap in the middle and a ring, as 3D polylines.
64
+ * It is aligned to the first edge of the face, so it turns with the object.
65
+ * `radius` is the ring radius in world units.
66
+ */
67
+ export const crosshair = (mesh, hit, radius = 0.1) => {
68
+ const [a, b] = mesh.faces[hit.face].v;
69
+ const t1 = norm(sub(mesh.positions[b], mesh.positions[a]));
70
+ const t2 = norm(cross(hit.normal, t1));
71
+ const at = (p, q) => add(hit.point, add(scale(t1, p * radius), scale(t2, q * radius)));
72
+ const ring = Array.from({ length: 33 }, (_, i) => {
73
+ const angle = (i / 32) * 2 * Math.PI;
74
+ return at(Math.cos(angle), Math.sin(angle));
75
+ });
76
+ return [
77
+ [at(-1.7, 0), at(-0.4, 0)],
78
+ [at(0.4, 0), at(1.7, 0)],
79
+ [at(0, -1.7), at(0, -0.4)],
80
+ [at(0, 0.4), at(0, 1.7)],
81
+ ring,
82
+ ];
83
+ };
package/src/place.js ADDED
@@ -0,0 +1,23 @@
1
+ // Putting things down on surfaces.
2
+
3
+ import { add, cross, norm, scale, sub } from './vec.js';
4
+ import { box } from './mesh.js';
5
+
6
+ /**
7
+ * A square prism standing on the picked surface: its base lies on the surface,
8
+ * centered at the hit point, and it rises along the surface normal. Its sides
9
+ * line up with the first edge of the face. The height is `3 * base` unless given.
10
+ * Returns a new mesh; merge() it into the scene.
11
+ */
12
+ export const standOn = (mesh, hit, { base = 0.15, height = 3 * base, color = '#c8c8c8' } = {}) => {
13
+ const [a, b] = mesh.faces[hit.face].v;
14
+ const ex = norm(sub(mesh.positions[b], mesh.positions[a]));
15
+ const ey = hit.normal;
16
+ const ez = cross(ex, ey);
17
+ return box({
18
+ size: [base, height, base],
19
+ at: add(hit.point, scale(ey, height / 2)),
20
+ axes: [ex, ey, ez],
21
+ color,
22
+ });
23
+ };
package/src/svg.js ADDED
@@ -0,0 +1,119 @@
1
+ // Flat SVG preview: points, edges, faces. No shading, no textures.
2
+ // Faces are painted far to near (see order.js), back faces culled. The polygon stroke
3
+ // is the edge layer, so hidden edges stay hidden. Output is deterministic.
4
+
5
+ import { sub, dot, norm } from './vec.js';
6
+ import { faceNormal } from './mesh.js';
7
+ import { paintOrder } from './order.js';
8
+ import { VIEW_ORDER, makeCamera, project } from './camera.js';
9
+
10
+ const defaults = {
11
+ size: 400,
12
+ cull: true,
13
+ points: true,
14
+ stroke: '#1d1d1f',
15
+ pointColor: '#d6336c',
16
+ background: '#fafafa',
17
+ };
18
+
19
+ const num = (n) => String(Math.round(n * 100) / 100 + 0); // +0 turns -0 into 0
20
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
21
+
22
+ const panel = (mesh, view, o, x = 0, y = 0) => {
23
+ const cam = makeCamera(view, mesh, o.size, o.fit);
24
+ const name = esc(cam.name);
25
+ const proj = mesh.positions.map((p) => project(cam, p));
26
+ const items = [];
27
+
28
+ mesh.faces.forEach((face, i) => {
29
+ const world = face.v.map((k) => mesh.positions[k]);
30
+ const screen = face.v.map((k) => proj[k]);
31
+ if (screen.some((s) => s.depth <= 0.01)) return; // behind the camera
32
+ const dir = cam.ortho ? cam.f : sub(world[0], cam.eye);
33
+ const normal = faceNormal(world);
34
+ const facing = dot(normal, dir) < 0;
35
+ if (o.cull && !facing) return; // back face
36
+ items.push({
37
+ i,
38
+ vs: face.v,
39
+ world,
40
+ screen,
41
+ n: norm(normal),
42
+ p: world[0],
43
+ eye: facing ? 1 : -1,
44
+ color: face.color ?? '#c8c8c8',
45
+ depth: screen.reduce((s, p) => s + p.depth, 0) / screen.length,
46
+ pts: screen.map((p) => `${num(p.x)},${num(p.y)}`).join(' '),
47
+ });
48
+ });
49
+
50
+ const ordered = paintOrder(items); // far to near
51
+
52
+ // A point is drawn right after the nearest painted face that holds it, so
53
+ // faces painted later (closer to the camera) still cover it.
54
+ const last = new Map();
55
+ ordered.forEach((it, idx) => it.vs.forEach((k) => last.set(k, idx)));
56
+
57
+ const body = ordered.flatMap((it, idx) => {
58
+ const polygon =
59
+ `<polygon data-face="${it.i}" data-depth="${num(it.depth)}" points="${it.pts}" ` +
60
+ `fill="${it.color}" stroke="${o.stroke}" stroke-width="1.5" stroke-linejoin="round"/>`;
61
+ const dots = o.points
62
+ ? it.vs
63
+ .filter((k) => last.get(k) === idx)
64
+ .sort((a, b) => a - b)
65
+ .map(
66
+ (k) =>
67
+ `<circle data-v="${k}" cx="${num(proj[k].x)}" cy="${num(proj[k].y)}" r="3" fill="${o.pointColor}"/>`,
68
+ )
69
+ : [];
70
+ return [polygon, ...dots];
71
+ });
72
+
73
+ // 3D polylines (e.g. a surface cursor) drawn on top of everything else.
74
+ const marks = (o.marks ?? []).flatMap((m) =>
75
+ m.lines.flatMap((line) => {
76
+ const pts = line.map((p) => project(cam, p));
77
+ if (pts.some((p) => p.depth <= 0.01)) return [];
78
+ return [
79
+ `<polyline data-mark="${esc(m.name ?? 'mark')}" points="${pts.map((p) => `${num(p.x)},${num(p.y)}`).join(' ')}" ` +
80
+ `fill="none" stroke="${m.color ?? '#0b7285'}" stroke-width="${m.width ?? 2}" ` +
81
+ `stroke-linecap="round" stroke-linejoin="round"/>`,
82
+ ];
83
+ }),
84
+ );
85
+
86
+ return [
87
+ `<g data-view="${name}" transform="translate(${x} ${y})">`,
88
+ `<rect width="${o.size}" height="${o.size}" fill="${o.background}" stroke="#cfcfd4"/>`,
89
+ `<text x="10" y="20" font-family="monospace" font-size="13" fill="#555">${name}</text>`,
90
+ ...body,
91
+ ...marks,
92
+ '</g>',
93
+ ].join('\n');
94
+ };
95
+
96
+ const root = (w, h, body) =>
97
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">\n${body}\n</svg>\n`;
98
+
99
+ /**
100
+ * One standalone SVG for a single view: 'front' | 'side' | 'top' | 'persp',
101
+ * or a custom { dir, up, ortho, name? } spec.
102
+ */
103
+ export const renderView = (mesh, view, opts = {}) => {
104
+ const o = { ...defaults, ...opts };
105
+ return root(o.size, o.size, panel(mesh, view, o));
106
+ };
107
+
108
+ /** The four views one after another: [{ name, svg }, ...]. */
109
+ export const renderViews = (mesh, opts = {}) =>
110
+ VIEW_ORDER.map((name) => ({ name, svg: renderView(mesh, name, opts) }));
111
+
112
+ /** All four views on one 2x2 sheet: front, side / top, persp. */
113
+ export const renderSheet = (mesh, opts = {}) => {
114
+ const o = { ...defaults, ...opts };
115
+ const body = VIEW_ORDER.map((name, i) =>
116
+ panel(mesh, name, o, (i % 2) * o.size, Math.floor(i / 2) * o.size),
117
+ ).join('\n');
118
+ return root(o.size * 2, o.size * 2, body);
119
+ };