@absolutejs/geometry 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,3 +96,62 @@ leaves its source usable. Transfer only owned copies if using a transfer list.
96
96
  See the [HTML ImageBitmap ownership contract](https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html#imagebitmap)
97
97
  and [OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas).
98
98
  These helpers prepare raster inputs; they do not assign hydrologic meaning to alpha.
99
+
100
+ ## Oriented solid boundaries (0.2.0)
101
+
102
+ `closedComponents(positions, indices)` splits index-connected closed oriented
103
+ boundary shells. Coincident coordinates never weld distinct authored vertices.
104
+ `solidPointMembership(positions, indices)` prepares a solid-angle classifier that
105
+ returns `inside`, `outside`, or `boundary`; oriented cavity walls cancel their
106
+ outer shell's winding inside a void.
107
+
108
+ `solidComponents(positions, indices, faceOwners, maxTriangleTests?)` returns
109
+ `{ components, shells, cavities, pointQueries, triangleTests }`. Each component
110
+ contains positions, indices and original `sourceTriangles`. It preserves separate
111
+ positive components of the same source, and assigns each inward cavity to the
112
+ smallest enclosing positive shell **of its explicitly declared source**. It never
113
+ infers source identity, takes absolute cavity mass, or uses a convex hull.
114
+
115
+ Precondition: each owner's input must be a valid, nonintersecting oriented solid
116
+ boundary, such as a validated Boolean kernel result. Shells must not touch.
117
+ Topology checks and all-cavity-vertex containment checks are **not** a complete
118
+ triangle-intersection validator; do not use this API to repair arbitrary soups.
119
+ An outward material island inside an inward cavity remains a separate component.
120
+ Deterministic order is positive-shell order, followed by each component's cavity
121
+ shells; provenance follows that same face order (not globally sorted).
122
+
123
+ Inputs are Float64 positions, Uint32 indices, and Int16/Uint16 per-face owners.
124
+ Limits: 100,000 vertices, 200,000 triangles, 128 shells. Prepared point classifiers
125
+ support up to 20,000 triangles; containment charges every tested outer triangle
126
+ per cavity vertex, with 2 million tests by default (maximum 8 million). No GPU,
127
+ Three.js, WASM, or runtime dependencies. All coordinates must share one local
128
+ frame; point boundary tolerance is 1e-7 coordinate units, winding tolerance 1e-5.
129
+ Near-degenerate faces, unresolved orientation, missing cavity parents, detected
130
+ touching/crossing shells and exhausted budgets throw rather than guess.
131
+
132
+ Sources: [Manifold's oriented boundary representation](https://manifoldcad.org/docs/jsuser/classes/Manifold.html)
133
+ and [Jacobson et al.'s generalized winding numbers](https://igl.ethz.ch/projects/winding-number/robust-inside-outside-segmentation-using-generalized-winding-numbers-siggraph-2013-jacobson-et-al.pdf).
134
+
135
+ ## Initial material overlap audit (0.3.0)
136
+
137
+ `auditSolidOverlaps(meshes, kernel, maximumQueries = 256)` checks solid intersection
138
+ volumes before physics publication. Supply `{create(mesh), intersect(a,b)}` using
139
+ your Boolean backend. Handles implement `volume()` and `delete()`; each operation
140
+ must return a new owned handle. A factory that throws must release any allocation
141
+ it made before throwing. The audit releases all successfully returned handles on
142
+ both success and failure, and passes private position/index copies to the kernel.
143
+ No Boolean engine is bundled or required at runtime.
144
+
145
+ The result includes per-mesh oriented volumes, pair indices/intersection volumes,
146
+ fraction of the smaller solid, query count, bounds skips and maximum input volume
147
+ round-trip error. Touching/disjoint bounds need no native intersection. Candidate
148
+ pairs are rebased before construction. Native volume error above 1e-5 or impossible
149
+ intersection volume rejects; tiny nonzero intersections are reported, not rounded
150
+ away. The host chooses its material-resolution threshold explicitly.
151
+
152
+ Use valid oriented material boundaries, including cavity walls. The API does not
153
+ repair overlapping shells within one input mesh, assign source ownership, union
154
+ models, predict extraction clearance or validate dynamic contact penetration.
155
+ Bounds are not proof of overlap. Limits: 64 meshes, 100,000 total vertices,
156
+ 200,000 total triangles, and at most 1,024 requested pair queries. A host worker
157
+ must impose its own lifetime bound on its injected native kernel.
@@ -0,0 +1,7 @@
1
+ /** Split welded closed components by index connectivity, never by spatial proximity.
2
+ * Coincident vertices in distinct authored solids deliberately remain distinct. */
3
+ export declare function closedComponents(positions: Float64Array, indices: Uint32Array): {
4
+ positions: Float64Array<ArrayBuffer>;
5
+ indices: Uint32Array<ArrayBuffer>;
6
+ sourceTriangles: Uint32Array<ArrayBuffer>;
7
+ }[];
@@ -0,0 +1,76 @@
1
+ /** Split welded closed components by index connectivity, never by spatial proximity.
2
+ * Coincident vertices in distinct authored solids deliberately remain distinct. */
3
+ export function closedComponents(positions, indices) {
4
+ if (!(positions instanceof Float64Array) ||
5
+ positions.length % 3 ||
6
+ positions.length > 300000 ||
7
+ !positions.every(Number.isFinite) ||
8
+ !(indices instanceof Uint32Array) ||
9
+ indices.length % 3 ||
10
+ indices.length > 600000 ||
11
+ !indices.every((i) => i < positions.length / 3))
12
+ throw Error('Invalid component mesh');
13
+ const parent = Int32Array.from({ length: positions.length / 3 }, (_, i) => i), used = new Uint8Array(parent.length);
14
+ const find = (start) => {
15
+ let root = start;
16
+ while (parent[root] !== root)
17
+ root = parent[root];
18
+ while (start !== root) {
19
+ const next = parent[start];
20
+ parent[start] = root;
21
+ start = next;
22
+ }
23
+ return root;
24
+ };
25
+ const edgeCounts = new Map();
26
+ for (let i = 0; i < indices.length; i += 3)
27
+ for (let j = 0; j < 3; j++) {
28
+ const a = indices[i + j], b = indices[i + ((j + 1) % 3)];
29
+ if (a === b)
30
+ throw Error('Degenerate component triangle');
31
+ used[a] = used[b] = 1;
32
+ const ra = find(a), rb = find(b);
33
+ if (ra !== rb)
34
+ parent[Math.max(ra, rb)] = Math.min(ra, rb);
35
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`, edge = edgeCounts.get(key);
36
+ if (edge) {
37
+ if (edge.count !== 1 || edge.a !== b || edge.b !== a)
38
+ throw Error('Nonmanifold component mesh');
39
+ edge.count++;
40
+ }
41
+ else
42
+ edgeCounts.set(key, { a, b, count: 1 });
43
+ }
44
+ if ([...edgeCounts.values()].some((e) => e.count !== 2))
45
+ throw Error('Open component mesh');
46
+ const groups = new Map();
47
+ for (let i = 0; i < parent.length; i++)
48
+ if (used[i]) {
49
+ const root = find(i);
50
+ let group = groups.get(root);
51
+ if (!group) {
52
+ if (groups.size >= 128)
53
+ throw Error('Component budget exceeded');
54
+ group = {
55
+ positions: [],
56
+ indices: [],
57
+ sourceTriangles: [],
58
+ map: new Map()
59
+ };
60
+ groups.set(root, group);
61
+ }
62
+ group.map.set(i, group.positions.length / 3);
63
+ group.positions.push(positions[i * 3], positions[i * 3 + 1], positions[i * 3 + 2]);
64
+ }
65
+ for (let t = 0; t < indices.length; t += 3) {
66
+ const group = groups.get(find(indices[t]));
67
+ for (let j = 0; j < 3; j++)
68
+ group.indices.push(group.map.get(indices[t + j]));
69
+ group.sourceTriangles.push(t / 3);
70
+ }
71
+ return [...groups.values()].map((g) => ({
72
+ positions: Float64Array.from(g.positions),
73
+ indices: Uint32Array.from(g.indices),
74
+ sourceTriangles: Uint32Array.from(g.sourceTriangles)
75
+ }));
76
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,6 @@
1
1
  export { projectAlphaCoverage, type AlphaProjectionInput, type AlphaProjection, } from "./projection.js";
2
2
  export { alphaFromRgba, alphaFromImageBitmap } from './alpha.js';
3
+ export { closedComponents } from './closedComponents.js';
4
+ export { solidPointMembership } from './solidPointMembership.js';
5
+ export { solidComponents } from './solidComponents.js';
6
+ export { auditSolidOverlaps, type OverlapMesh, type OverlapSolid, type SolidOverlapAudit } from './overlap.js';
package/dist/index.js CHANGED
@@ -1,2 +1,6 @@
1
1
  export { projectAlphaCoverage, } from "./projection.js";
2
2
  export { alphaFromRgba, alphaFromImageBitmap } from './alpha.js';
3
+ export { closedComponents } from './closedComponents.js';
4
+ export { solidPointMembership } from './solidPointMembership.js';
5
+ export { solidComponents } from './solidComponents.js';
6
+ export { auditSolidOverlaps } from './overlap.js';
@@ -0,0 +1,30 @@
1
+ export type OverlapMesh = {
2
+ positions: Float64Array;
3
+ indices: Uint32Array;
4
+ };
5
+ /** The host injects its Boolean kernel; all returned handles must be disposable. */
6
+ export type OverlapSolid = {
7
+ volume(): number;
8
+ delete(): void;
9
+ };
10
+ export type SolidOverlapAudit = ReturnType<typeof auditSolidOverlaps>;
11
+ /** Audit initial material overlap, not dynamic contact depth. Inputs must be valid
12
+ * oriented solid boundaries in one frame. No ownership, de-duplication or repair
13
+ * is inferred. Bounds skip disjoint/touching pairs; candidate pairs are rebased
14
+ * before invoking the host's kernel. Every native handle is released. */
15
+ export declare function auditSolidOverlaps<S extends OverlapSolid>(meshes: readonly OverlapMesh[], kernel: {
16
+ create(mesh: OverlapMesh): S;
17
+ intersect(a: S, b: S): S;
18
+ }, maximumQueries?: number): {
19
+ meshes: number;
20
+ volumes: number[];
21
+ pairs: {
22
+ a: number;
23
+ b: number;
24
+ volume: number;
25
+ fractionOfSmaller: number;
26
+ }[];
27
+ queries: number;
28
+ boundsSkipped: number;
29
+ maximumInputVolumeError: number;
30
+ };
@@ -0,0 +1,61 @@
1
+ import { closedComponents } from './closedComponents.js';
2
+ import { signedVolume } from './signedVolume.js';
3
+ /** Audit initial material overlap, not dynamic contact depth. Inputs must be valid
4
+ * oriented solid boundaries in one frame. No ownership, de-duplication or repair
5
+ * is inferred. Bounds skip disjoint/touching pairs; candidate pairs are rebased
6
+ * before invoking the host's kernel. Every native handle is released. */
7
+ export function auditSolidOverlaps(meshes, kernel, maximumQueries = 256) {
8
+ if (!Array.isArray(meshes) || meshes.length > 64 || !kernel || typeof kernel.create !== 'function' || typeof kernel.intersect !== 'function' ||
9
+ !Number.isSafeInteger(maximumQueries) || maximumQueries < 1 || maximumQueries > 1024 ||
10
+ meshes.reduce((n, m) => n + (m?.positions?.length ?? Infinity), 0) > 300000 ||
11
+ meshes.reduce((n, m) => n + (m?.indices?.length ?? Infinity), 0) > 600000)
12
+ throw Error('Invalid overlap audit budget');
13
+ const records = meshes.map(mesh => {
14
+ if (!mesh.indices.length)
15
+ throw Error('Empty overlap solid');
16
+ closedComponents(mesh.positions, mesh.indices);
17
+ const volume = signedVolume(mesh.positions, mesh.indices);
18
+ if (!(volume > 0) || !Number.isFinite(volume))
19
+ throw Error('Invalid overlap solid volume');
20
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
21
+ mesh.positions.forEach((v, i) => { min[i % 3] = Math.min(min[i % 3], v); max[i % 3] = Math.max(max[i % 3], v); });
22
+ return { ...mesh, volume, min, max };
23
+ });
24
+ const pairs = [];
25
+ let queries = 0, boundsSkipped = 0, maximumInputVolumeError = 0;
26
+ for (let a = 0; a < records.length; a++)
27
+ for (let b = a + 1; b < records.length; b++) {
28
+ const one = records[a], two = records[b];
29
+ if (one.min.some((v, k) => v >= two.max[k] || one.max[k] <= two.min[k])) {
30
+ boundsSkipped++;
31
+ continue;
32
+ }
33
+ if (++queries > maximumQueries)
34
+ throw Error('Overlap query budget exceeded');
35
+ const origin = one.min.map((v, k) => (Math.min(v, two.min[k]) + Math.max(one.max[k], two.max[k])) / 2);
36
+ const handles = [];
37
+ try {
38
+ const solids = [one, two].map(record => {
39
+ const local = Float64Array.from(record.positions, (v, i) => v - origin[i % 3]);
40
+ const solid = kernel.create({ positions: local, indices: record.indices.slice() });
41
+ handles.push(solid);
42
+ const error = Math.abs(solid.volume() / record.volume - 1);
43
+ if (!Number.isFinite(error) || error > 1e-5)
44
+ throw Error('Overlap input precision budget exceeded');
45
+ maximumInputVolumeError = Math.max(maximumInputVolumeError, error);
46
+ return solid;
47
+ });
48
+ const intersection = kernel.intersect(solids[0], solids[1]);
49
+ handles.push(intersection);
50
+ const volume = intersection.volume(), fractionOfSmaller = volume / Math.min(one.volume, two.volume);
51
+ if (!Number.isFinite(volume) || volume < 0 || fractionOfSmaller > 1.00001)
52
+ throw Error('Invalid overlap volume');
53
+ pairs.push({ a, b, volume, fractionOfSmaller });
54
+ }
55
+ finally {
56
+ for (const handle of handles.reverse())
57
+ handle.delete();
58
+ }
59
+ }
60
+ return { meshes: records.length, volumes: records.map(r => r.volume), pairs, queries, boundsSkipped, maximumInputVolumeError };
61
+ }
@@ -0,0 +1 @@
1
+ export declare function signedVolume(p: Float64Array, i: Uint32Array): number;
@@ -0,0 +1,15 @@
1
+ export function signedVolume(p, i) {
2
+ const o = i[0] * 3, ox = p[o], oy = p[o + 1], oz = p[o + 2];
3
+ let sum = 0, error = 0;
4
+ for (let t = 0; t < i.length; t += 3) {
5
+ const a = i[t] * 3, b = i[t + 1] * 3, c = i[t + 2] * 3;
6
+ const ax = p[a] - ox, ay = p[a + 1] - oy, az = p[a + 2] - oz;
7
+ const bx = p[b] - ox, by = p[b + 1] - oy, bz = p[b + 2] - oz;
8
+ const cx = p[c] - ox, cy = p[c + 1] - oy, cz = p[c + 2] - oz;
9
+ const term = (ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx)) / 6;
10
+ const increment = term - error, next = sum + increment;
11
+ error = next - sum - increment;
12
+ sum = next;
13
+ }
14
+ return sum;
15
+ }
@@ -0,0 +1,16 @@
1
+ /** Material components of a valid oriented Boolean boundary, restricted by explicit
2
+ * source identity. Input shells of each owner must not intersect or touch. This is
3
+ * not a triangle-soup repair or intersection validator. Positive islands remain
4
+ * separate; negative cavity walls attach to the smallest enclosing positive shell.
5
+ * All cavity vertices are checked, with a bounded winding-query budget. */
6
+ export declare function solidComponents(positions: Float64Array, indices: Uint32Array, owners: Int16Array | Uint16Array, maxTriangleTests?: number): {
7
+ components: {
8
+ positions: Float64Array<ArrayBuffer>;
9
+ indices: Uint32Array<ArrayBuffer>;
10
+ sourceTriangles: Uint32Array<ArrayBuffer>;
11
+ }[];
12
+ shells: number;
13
+ cavities: number;
14
+ pointQueries: number;
15
+ triangleTests: number;
16
+ };
@@ -0,0 +1,80 @@
1
+ import { signedVolume } from './signedVolume.js';
2
+ import { closedComponents } from './closedComponents.js';
3
+ import { solidPointMembership } from './solidPointMembership.js';
4
+ /** Material components of a valid oriented Boolean boundary, restricted by explicit
5
+ * source identity. Input shells of each owner must not intersect or touch. This is
6
+ * not a triangle-soup repair or intersection validator. Positive islands remain
7
+ * separate; negative cavity walls attach to the smallest enclosing positive shell.
8
+ * All cavity vertices are checked, with a bounded winding-query budget. */
9
+ export function solidComponents(positions, indices, owners, maxTriangleTests = 2_000_000) {
10
+ if (!(owners instanceof Int16Array || owners instanceof Uint16Array) ||
11
+ owners.length !== indices.length / 3 || !owners.every(v => v >= 0) ||
12
+ !Number.isSafeInteger(maxTriangleTests) || maxTriangleTests < 1 || maxTriangleTests > 8_000_000)
13
+ throw Error('Invalid material component input');
14
+ const shells = closedComponents(positions, indices).map(mesh => {
15
+ const owner = owners[mesh.sourceTriangles[0]];
16
+ if (mesh.sourceTriangles.some(t => owners[t] !== owner))
17
+ throw Error('Mixed shell ownership');
18
+ const volume = signedVolume(mesh.positions, mesh.indices);
19
+ if (!Number.isFinite(volume) || volume === 0)
20
+ throw Error('Unresolved shell volume');
21
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
22
+ mesh.positions.forEach((v, i) => { const k = i % 3; min[k] = Math.min(min[k], v); max[k] = Math.max(max[k], v); });
23
+ return { ...mesh, owner, volume, min, max };
24
+ });
25
+ const positive = shells.filter(s => s.volume > 0), holes = shells.filter(s => s.volume < 0), children = positive.map(() => []), classifiers = new Map();
26
+ let pointQueries = 0, triangleTests = 0;
27
+ for (const hole of holes) {
28
+ const enclosing = [];
29
+ for (const [id, outer] of positive.entries()) {
30
+ if (hole.owner !== outer.owner || hole.min.some((v, k) => v < outer.min[k] || hole.max[k] > outer.max[k]))
31
+ continue;
32
+ let classify = classifiers.get(id);
33
+ if (!classify) {
34
+ classify = solidPointMembership(outer.positions, outer.indices);
35
+ classifiers.set(id, classify);
36
+ }
37
+ let inside = 0, outside = 0;
38
+ for (let v = 0; v < hole.positions.length; v += 3) {
39
+ triangleTests += outer.indices.length / 3;
40
+ if (triangleTests > maxTriangleTests)
41
+ throw Error('Material containment budget exceeded');
42
+ pointQueries++;
43
+ const membership = classify([hole.positions[v], hole.positions[v + 1], hole.positions[v + 2]]);
44
+ if (membership === 'boundary')
45
+ throw Error('Touching material shells');
46
+ if (membership === 'inside')
47
+ inside++;
48
+ else
49
+ outside++;
50
+ if (inside && outside)
51
+ throw Error('Crossing material shells');
52
+ }
53
+ if (inside)
54
+ enclosing.push(id);
55
+ }
56
+ enclosing.sort((a, b) => positive[a].volume - positive[b].volume);
57
+ if (!enclosing.length)
58
+ throw Error('Cavity has no enclosing source solid');
59
+ if (enclosing.length > 1 && positive[enclosing[0]].volume === positive[enclosing[1]].volume)
60
+ throw Error('Ambiguous cavity parent');
61
+ children[enclosing[0]].push(hole);
62
+ }
63
+ const components = positive.map((outer, id) => {
64
+ const parts = [outer, ...children[id]], p = [], i = [], triangles = [];
65
+ for (const part of parts) {
66
+ const offset = p.length / 3;
67
+ for (const v of part.positions)
68
+ p.push(v);
69
+ for (const v of part.indices)
70
+ i.push(v + offset);
71
+ for (const t of part.sourceTriangles)
72
+ triangles.push(t);
73
+ }
74
+ const mesh = { positions: Float64Array.from(p), indices: Uint32Array.from(i), sourceTriangles: Uint32Array.from(triangles) };
75
+ if (!(signedVolume(mesh.positions, mesh.indices) > 0))
76
+ throw Error('Nonpositive material component');
77
+ return mesh;
78
+ });
79
+ return { components, shells: shells.length, cavities: holes.length, pointQueries, triangleTests };
80
+ }
@@ -0,0 +1,6 @@
1
+ type Point = readonly [number, number, number];
2
+ /** Exact solid-angle sum for a closed oriented boundary, with explicit boundary
3
+ * classification. No convex hull, nearest-center assignment or sampled rays.
4
+ * Formula: Jacobson et al. 2013; see README sources and numerical limits. */
5
+ export declare function solidPointMembership(positions: Float64Array, indices: Uint32Array): (point: Point) => "inside" | "outside" | "boundary";
6
+ export {};
@@ -0,0 +1,94 @@
1
+ import { closedComponents } from './closedComponents.js';
2
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
3
+ const cross = (a, b) => [
4
+ a[1] * b[2] - a[2] * b[1],
5
+ a[2] * b[0] - a[0] * b[2],
6
+ a[0] * b[1] - a[1] * b[0]
7
+ ];
8
+ const sub = (a, b) => [
9
+ a[0] - b[0],
10
+ a[1] - b[1],
11
+ a[2] - b[2]
12
+ ];
13
+ /** Exact solid-angle sum for a closed oriented boundary, with explicit boundary
14
+ * classification. No convex hull, nearest-center assignment or sampled rays.
15
+ * Formula: Jacobson et al. 2013; see README sources and numerical limits. */
16
+ export function solidPointMembership(positions, indices) {
17
+ if (!indices.length || indices.length > 60000)
18
+ throw Error('Solid point mesh budget exceeded');
19
+ closedComponents(positions, indices); // Require closed, consistently oriented topology.
20
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
21
+ const collapsedEdges = [];
22
+ const triangles = [];
23
+ for (let i = 0; i < indices.length; i += 3) {
24
+ const points = [0, 1, 2].map((k) => {
25
+ const id = indices[i + k] * 3;
26
+ return [
27
+ positions[id],
28
+ positions[id + 1],
29
+ positions[id + 2]
30
+ ];
31
+ });
32
+ const [a, b, c] = points, u = sub(b, a), v = sub(c, a), n = cross(u, v), area2 = dot(n, n);
33
+ for (const p of points)
34
+ for (let k = 0; k < 3; k++) {
35
+ min[k] = Math.min(min[k], p[k]);
36
+ max[k] = Math.max(max[k], p[k]);
37
+ }
38
+ // A clipping kernel may keep distinct topological vertices at identical
39
+ // coordinates. Such a face has exactly zero solid angle. Keep its edges
40
+ // for boundary queries without welding vertices or altering the mesh.
41
+ if (area2 === 0 &&
42
+ [u, v, sub(c, b)].some((edge) => edge.every((n) => n === 0))) {
43
+ collapsedEdges.push([a, b], [b, c], [c, a]);
44
+ continue;
45
+ }
46
+ if (!(area2 > 1e-30))
47
+ throw Error('Degenerate solid point face');
48
+ triangles.push({ a, b, c, u, v, n, area2 });
49
+ }
50
+ if (!triangles.length)
51
+ throw Error('Degenerate solid point mesh');
52
+ return (point) => {
53
+ if (point.length !== 3 || !point.every(Number.isFinite))
54
+ throw Error('Invalid solid query point');
55
+ const epsilon = 1e-7;
56
+ if (point.some((v, k) => v < min[k] - epsilon || v > max[k] + epsilon))
57
+ return 'outside';
58
+ for (const [a, b] of collapsedEdges) {
59
+ const edge = sub(b, a), delta = sub(point, a), length2 = dot(edge, edge);
60
+ const t = length2 === 0
61
+ ? 0
62
+ : Math.max(0, Math.min(1, dot(delta, edge) / length2));
63
+ if (Math.hypot(...delta.map((v, k) => v - t * edge[k])) <= epsilon)
64
+ return 'boundary';
65
+ }
66
+ let sum = 0, correction = 0;
67
+ for (const { a, b, c, u, v, n, area2 } of triangles) {
68
+ const delta = sub(point, a), distance = dot(delta, n);
69
+ if (distance * distance <= epsilon * epsilon * area2) {
70
+ // Barycentric coordinates of the orthogonal projection, using cross products
71
+ // instead of subtracting nearly equal Gram determinants on thin faces.
72
+ const s = dot(cross(delta, v), n) / area2, t = dot(cross(u, delta), n) / area2;
73
+ if (s >= -1e-10 && t >= -1e-10 && s + t <= 1 + 1e-10)
74
+ return 'boundary';
75
+ }
76
+ const vectors = [a, b, c].map((p) => sub(p, point));
77
+ const lengths = vectors.map((p) => Math.hypot(...p));
78
+ if (lengths.some((l) => l <= epsilon))
79
+ return 'boundary';
80
+ const [x, y, z] = vectors.map((p, i) => p.map((v) => v / lengths[i]));
81
+ const angle = 2 *
82
+ Math.atan2(dot(x, cross(y, z)), 1 + dot(x, y) + dot(y, z) + dot(z, x));
83
+ const increment = angle - correction, next = sum + increment;
84
+ correction = next - sum - increment;
85
+ sum = next;
86
+ }
87
+ const winding = Math.abs(sum) / (4 * Math.PI);
88
+ if (winding < 1e-5)
89
+ return 'outside';
90
+ if (Math.abs(winding - 1) < 1e-5)
91
+ return 'inside';
92
+ throw Error('Ambiguous solid point membership');
93
+ };
94
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@absolutejs/geometry",
3
- "version": "0.1.1",
4
- "description": "Bounded alpha-aware geometry projection for worker-side asset measurement and web 3D tooling.",
3
+ "version": "0.3.0",
4
+ "description": "Bounded geometry projection, oriented material components and point membership for worker-side web 3D tooling.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -33,7 +33,8 @@
33
33
  "check:package": "bun run build && bun test ./tests"
34
34
  },
35
35
  "devDependencies": {
36
- "typescript": "5.9.3",
37
- "@types/bun": "1.4.2"
36
+ "@types/bun": "1.4.2",
37
+ "manifold-3d": "3.5.1",
38
+ "typescript": "5.9.3"
38
39
  }
39
40
  }