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/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
+ };
package/src/vec.js ADDED
@@ -0,0 +1,16 @@
1
+ // Tiny vec3 helpers on plain [x, y, z] arrays.
2
+
3
+ export const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
4
+ export const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
5
+ export const scale = (a, s) => [a[0] * s, a[1] * s, a[2] * s];
6
+ export const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
7
+ export const cross = (a, b) => [
8
+ a[1] * b[2] - a[2] * b[1],
9
+ a[2] * b[0] - a[0] * b[2],
10
+ a[0] * b[1] - a[1] * b[0],
11
+ ];
12
+ export const len = (a) => Math.hypot(a[0], a[1], a[2]);
13
+ export const norm = (a) => {
14
+ const l = len(a);
15
+ return l ? scale(a, 1 / l) : [0, 0, 0];
16
+ };
package/src/version.js ADDED
@@ -0,0 +1,8 @@
1
+ // Versions are date based and stored as semver YY.M.DNNN (see scripts/bump.js).
2
+ // This gives the readable form: '26.11.1001' -> '26.11.01.001'.
3
+
4
+ export const displayVersion = (version) => {
5
+ const [yy, mm, p] = version.split('.').map(Number);
6
+ const pad = (n, w) => String(n).padStart(w, '0');
7
+ return `${pad(yy, 2)}.${pad(mm, 2)}.${pad(Math.floor(p / 1000), 2)}.${pad(p % 1000, 3)}`;
8
+ };