@absolutejs/geometry 0.1.0 → 0.2.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 +56 -0
- package/dist/alpha.d.ts +5 -0
- package/dist/alpha.js +37 -0
- package/dist/closedComponents.d.ts +7 -0
- package/dist/closedComponents.js +76 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/solidComponents.d.ts +16 -0
- package/dist/solidComponents.js +94 -0
- package/dist/solidPointMembership.d.ts +6 -0
- package/dist/solidPointMembership.js +94 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -75,3 +75,59 @@ Run `bun install --frozen-lockfile` and `bun run check:package`.
|
|
|
75
75
|
and its development asset tooling; see docs/canopy-audit/README.md.
|
|
76
76
|
|
|
77
77
|
Licensed under BSL-1.1; see LICENSE for the additional use grant and change date.
|
|
78
|
+
|
|
79
|
+
## Worker-side alpha preparation (0.1.1)
|
|
80
|
+
|
|
81
|
+
`alphaFromRgba(rgba, width, height)` copies one alpha byte per RGBA8 texel into an
|
|
82
|
+
owned Uint8Array. It accepts Uint8Array or Uint8ClampedArray and validates the
|
|
83
|
+
exact byte length and the same 4,194,304-texel budget before allocation.
|
|
84
|
+
|
|
85
|
+
`alphaFromImageBitmap(bitmap)` reads an already decoded bitmap using a temporary
|
|
86
|
+
OffscreenCanvas 2D context with `willReadFrequently: true`. Call it in a worker;
|
|
87
|
+
it performs synchronous CPU readback wherever it is called. The temporary canvas
|
|
88
|
+
is released afterward. It does not create a WebGL context, close the input bitmap,
|
|
89
|
+
or detach any borrowed renderer resource. The caller owns bitmap cleanup.
|
|
90
|
+
Unsupported runtimes, closed/invalid bitmaps and oversized images fail explicitly.
|
|
91
|
+
There is no hidden main-thread fallback.
|
|
92
|
+
|
|
93
|
+
ImageBitmap supports structured cloning as well as transfer. The browser-worker
|
|
94
|
+
qualification checks exact alpha values and that closing a cloned worker image
|
|
95
|
+
leaves its source usable. Transfer only owned copies if using a transfer list.
|
|
96
|
+
See the [HTML ImageBitmap ownership contract](https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html#imagebitmap)
|
|
97
|
+
and [OffscreenCanvas](https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas).
|
|
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).
|
package/dist/alpha.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Copy one alpha byte per row-major RGBA8 texel. Does not mutate borrowed pixels. */
|
|
2
|
+
export declare function alphaFromRgba(rgba: Uint8Array | Uint8ClampedArray, width: number, height: number): Uint8Array;
|
|
3
|
+
/** Worker/setup-time CPU readback. The caller owns and closes the bitmap.
|
|
4
|
+
* This never closes/detaches the caller's image and never creates a WebGL context. */
|
|
5
|
+
export declare function alphaFromImageBitmap(bitmap: ImageBitmap): Uint8Array;
|
package/dist/alpha.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const MAX_TEXELS = 4_194_304;
|
|
2
|
+
function validateSize(width, height) {
|
|
3
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1 || width * height > MAX_TEXELS)
|
|
4
|
+
throw Error('Invalid or oversized alpha source');
|
|
5
|
+
}
|
|
6
|
+
/** Copy one alpha byte per row-major RGBA8 texel. Does not mutate borrowed pixels. */
|
|
7
|
+
export function alphaFromRgba(rgba, width, height) {
|
|
8
|
+
validateSize(width, height);
|
|
9
|
+
if (!(rgba instanceof Uint8Array || rgba instanceof Uint8ClampedArray) || rgba.length !== width * height * 4)
|
|
10
|
+
throw Error('Expected tightly packed RGBA8 alpha source');
|
|
11
|
+
const alpha = new Uint8Array(width * height);
|
|
12
|
+
for (let i = 0; i < alpha.length; i++)
|
|
13
|
+
alpha[i] = rgba[i * 4 + 3];
|
|
14
|
+
return alpha;
|
|
15
|
+
}
|
|
16
|
+
/** Worker/setup-time CPU readback. The caller owns and closes the bitmap.
|
|
17
|
+
* This never closes/detaches the caller's image and never creates a WebGL context. */
|
|
18
|
+
export function alphaFromImageBitmap(bitmap) {
|
|
19
|
+
if (typeof ImageBitmap === 'undefined' || !(bitmap instanceof ImageBitmap))
|
|
20
|
+
throw Error('Expected an available ImageBitmap');
|
|
21
|
+
validateSize(bitmap.width, bitmap.height);
|
|
22
|
+
if (typeof OffscreenCanvas === 'undefined')
|
|
23
|
+
throw Error('OffscreenCanvas is unavailable for alpha readback');
|
|
24
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
25
|
+
try {
|
|
26
|
+
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
27
|
+
if (!context)
|
|
28
|
+
throw Error('Canvas2D is unavailable for alpha readback');
|
|
29
|
+
context.drawImage(bitmap, 0, 0);
|
|
30
|
+
const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
31
|
+
return alphaFromRgba(pixels.data, pixels.width, pixels.height);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
canvas.width = 0;
|
|
35
|
+
canvas.height = 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -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 +1,5 @@
|
|
|
1
1
|
export { projectAlphaCoverage, type AlphaProjectionInput, type AlphaProjection, } from "./projection.js";
|
|
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';
|
package/dist/index.js
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
1
|
export { projectAlphaCoverage, } from "./projection.js";
|
|
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';
|
|
@@ -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,94 @@
|
|
|
1
|
+
import { closedComponents } from './closedComponents.js';
|
|
2
|
+
import { solidPointMembership } from './solidPointMembership.js';
|
|
3
|
+
/** Material components of a valid oriented Boolean boundary, restricted by explicit
|
|
4
|
+
* source identity. Input shells of each owner must not intersect or touch. This is
|
|
5
|
+
* not a triangle-soup repair or intersection validator. Positive islands remain
|
|
6
|
+
* separate; negative cavity walls attach to the smallest enclosing positive shell.
|
|
7
|
+
* All cavity vertices are checked, with a bounded winding-query budget. */
|
|
8
|
+
export function solidComponents(positions, indices, owners, maxTriangleTests = 2_000_000) {
|
|
9
|
+
if (!(owners instanceof Int16Array || owners instanceof Uint16Array) ||
|
|
10
|
+
owners.length !== indices.length / 3 || !owners.every(v => v >= 0) ||
|
|
11
|
+
!Number.isSafeInteger(maxTriangleTests) || maxTriangleTests < 1 || maxTriangleTests > 8_000_000)
|
|
12
|
+
throw Error('Invalid material component input');
|
|
13
|
+
const shells = closedComponents(positions, indices).map(mesh => {
|
|
14
|
+
const owner = owners[mesh.sourceTriangles[0]];
|
|
15
|
+
if (mesh.sourceTriangles.some(t => owners[t] !== owner))
|
|
16
|
+
throw Error('Mixed shell ownership');
|
|
17
|
+
const volume = signedVolume(mesh.positions, mesh.indices);
|
|
18
|
+
if (!Number.isFinite(volume) || volume === 0)
|
|
19
|
+
throw Error('Unresolved shell volume');
|
|
20
|
+
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
21
|
+
mesh.positions.forEach((v, i) => { const k = i % 3; min[k] = Math.min(min[k], v); max[k] = Math.max(max[k], v); });
|
|
22
|
+
return { ...mesh, owner, volume, min, max };
|
|
23
|
+
});
|
|
24
|
+
const positive = shells.filter(s => s.volume > 0), holes = shells.filter(s => s.volume < 0), children = positive.map(() => []), classifiers = new Map();
|
|
25
|
+
let pointQueries = 0, triangleTests = 0;
|
|
26
|
+
for (const hole of holes) {
|
|
27
|
+
const enclosing = [];
|
|
28
|
+
for (const [id, outer] of positive.entries()) {
|
|
29
|
+
if (hole.owner !== outer.owner || hole.min.some((v, k) => v < outer.min[k] || hole.max[k] > outer.max[k]))
|
|
30
|
+
continue;
|
|
31
|
+
let classify = classifiers.get(id);
|
|
32
|
+
if (!classify) {
|
|
33
|
+
classify = solidPointMembership(outer.positions, outer.indices);
|
|
34
|
+
classifiers.set(id, classify);
|
|
35
|
+
}
|
|
36
|
+
let inside = 0, outside = 0;
|
|
37
|
+
for (let v = 0; v < hole.positions.length; v += 3) {
|
|
38
|
+
triangleTests += outer.indices.length / 3;
|
|
39
|
+
if (triangleTests > maxTriangleTests)
|
|
40
|
+
throw Error('Material containment budget exceeded');
|
|
41
|
+
pointQueries++;
|
|
42
|
+
const membership = classify([hole.positions[v], hole.positions[v + 1], hole.positions[v + 2]]);
|
|
43
|
+
if (membership === 'boundary')
|
|
44
|
+
throw Error('Touching material shells');
|
|
45
|
+
if (membership === 'inside')
|
|
46
|
+
inside++;
|
|
47
|
+
else
|
|
48
|
+
outside++;
|
|
49
|
+
if (inside && outside)
|
|
50
|
+
throw Error('Crossing material shells');
|
|
51
|
+
}
|
|
52
|
+
if (inside)
|
|
53
|
+
enclosing.push(id);
|
|
54
|
+
}
|
|
55
|
+
enclosing.sort((a, b) => positive[a].volume - positive[b].volume);
|
|
56
|
+
if (!enclosing.length)
|
|
57
|
+
throw Error('Cavity has no enclosing source solid');
|
|
58
|
+
if (enclosing.length > 1 && positive[enclosing[0]].volume === positive[enclosing[1]].volume)
|
|
59
|
+
throw Error('Ambiguous cavity parent');
|
|
60
|
+
children[enclosing[0]].push(hole);
|
|
61
|
+
}
|
|
62
|
+
const components = positive.map((outer, id) => {
|
|
63
|
+
const parts = [outer, ...children[id]], p = [], i = [], triangles = [];
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
const offset = p.length / 3;
|
|
66
|
+
for (const v of part.positions)
|
|
67
|
+
p.push(v);
|
|
68
|
+
for (const v of part.indices)
|
|
69
|
+
i.push(v + offset);
|
|
70
|
+
for (const t of part.sourceTriangles)
|
|
71
|
+
triangles.push(t);
|
|
72
|
+
}
|
|
73
|
+
const mesh = { positions: Float64Array.from(p), indices: Uint32Array.from(i), sourceTriangles: Uint32Array.from(triangles) };
|
|
74
|
+
if (!(signedVolume(mesh.positions, mesh.indices) > 0))
|
|
75
|
+
throw Error('Nonpositive material component');
|
|
76
|
+
return mesh;
|
|
77
|
+
});
|
|
78
|
+
return { components, shells: shells.length, cavities: holes.length, pointQueries, triangleTests };
|
|
79
|
+
}
|
|
80
|
+
function signedVolume(p, i) {
|
|
81
|
+
const o = i[0] * 3, ox = p[o], oy = p[o + 1], oz = p[o + 2];
|
|
82
|
+
let sum = 0, error = 0;
|
|
83
|
+
for (let t = 0; t < i.length; t += 3) {
|
|
84
|
+
const a = i[t] * 3, b = i[t + 1] * 3, c = i[t + 2] * 3;
|
|
85
|
+
const ax = p[a] - ox, ay = p[a + 1] - oy, az = p[a + 2] - oz;
|
|
86
|
+
const bx = p[b] - ox, by = p[b + 1] - oy, bz = p[b + 2] - oz;
|
|
87
|
+
const cx = p[c] - ox, cy = p[c + 1] - oy, cz = p[c + 2] - oz;
|
|
88
|
+
const term = (ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx)) / 6;
|
|
89
|
+
const increment = term - error, next = sum + increment;
|
|
90
|
+
error = next - sum - increment;
|
|
91
|
+
sum = next;
|
|
92
|
+
}
|
|
93
|
+
return sum;
|
|
94
|
+
}
|
|
@@ -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.
|
|
4
|
-
"description": "Bounded
|
|
3
|
+
"version": "0.2.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",
|