@metamesh/modeling 0.0.0-bootstrap.0 → 0.6.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/CHANGELOG.md +105 -0
- package/README.md +313 -6
- package/THIRD_PARTY_NOTICES.md +153 -0
- package/dist/api/engine.d.ts +54 -0
- package/dist/api/geometry.d.ts +2912 -0
- package/dist/api/index.d.ts +1648 -0
- package/dist/api/text.d.ts +77 -0
- package/dist/engine/construction-operations.js +189 -0
- package/dist/engine/execution.js +227 -0
- package/dist/engine/export-3mf.js +100 -0
- package/dist/engine/extrusion-finishing.js +82 -0
- package/dist/engine/finalization.js +214 -0
- package/dist/engine/index.js +72 -0
- package/dist/engine/inspection-operations.js +52 -0
- package/dist/engine/kernel/LICENSE +201 -0
- package/dist/engine/kernel/manifold.js +2 -0
- package/dist/engine/kernel/manifold.wasm +0 -0
- package/dist/engine/loft-operations.js +156 -0
- package/dist/engine/mechanical-operations.js +51 -0
- package/dist/engine/modeling-context.js +38 -0
- package/dist/engine/native/adapter.js +20 -0
- package/dist/engine/native/context.js +209 -0
- package/dist/engine/native/copied-views.js +46 -0
- package/dist/engine/native/index.js +13 -0
- package/dist/engine/native/operations.js +115 -0
- package/dist/engine/native/ownership.js +98 -0
- package/dist/engine/native/regions.js +138 -0
- package/dist/engine/native/scoped-primitives.js +127 -0
- package/dist/engine/native/scoped-threads.js +34 -0
- package/dist/engine/native/threads.js +52 -0
- package/dist/engine/native/transforms.js +111 -0
- package/dist/engine/operation-environment.js +20 -0
- package/dist/engine/pattern-operations.js +36 -0
- package/dist/engine/pattern-placements.js +176 -0
- package/dist/engine/primitive-anchors.js +70 -0
- package/dist/engine/primitive-correspondence.js +74 -0
- package/dist/engine/primitive-operations.js +151 -0
- package/dist/engine/primitive-profile-types.js +1 -0
- package/dist/engine/primitive-profiles.js +250 -0
- package/dist/engine/profile-mesh.js +279 -0
- package/dist/engine/section-operations.js +93 -0
- package/dist/engine/shape-anchors.js +88 -0
- package/dist/engine/transform-operations.js +225 -0
- package/dist/engine/types.js +1 -0
- package/dist/geometry/anchors.js +226 -0
- package/dist/geometry/curves/evaluation.js +162 -0
- package/dist/geometry/curves/sampling.js +265 -0
- package/dist/geometry/curves/types.js +1 -0
- package/dist/geometry/cylinder.js +117 -0
- package/dist/geometry/edge-finishing.js +160 -0
- package/dist/geometry/errors.js +76 -0
- package/dist/geometry/extrusion.js +150 -0
- package/dist/geometry/heightfield.js +107 -0
- package/dist/geometry/index.js +42 -0
- package/dist/geometry/loft-correspondence.js +204 -0
- package/dist/geometry/loft-geometry.js +184 -0
- package/dist/geometry/loft-options.js +56 -0
- package/dist/geometry/loft-simple-mesh.js +92 -0
- package/dist/geometry/loft-triangulation.js +64 -0
- package/dist/geometry/loft.js +80 -0
- package/dist/geometry/math/vector3.js +41 -0
- package/dist/geometry/mechanical/gear-motion.js +94 -0
- package/dist/geometry/mechanical/gears.js +484 -0
- package/dist/geometry/mechanical/index.js +13 -0
- package/dist/geometry/mechanical/standards.js +67 -0
- package/dist/geometry/mechanical/thread-helices.js +44 -0
- package/dist/geometry/mechanical/thread-mesh-plan.js +358 -0
- package/dist/geometry/mechanical/thread-mesh.js +142 -0
- package/dist/geometry/mechanical/thread-profiles.js +368 -0
- package/dist/geometry/mechanical/thread-solids.js +230 -0
- package/dist/geometry/mechanical/thread-surface.js +433 -0
- package/dist/geometry/mechanical/threads.js +138 -0
- package/dist/geometry/mesh-planar.js +408 -0
- package/dist/geometry/mesh-quality.js +396 -0
- package/dist/geometry/mesh-signature.js +137 -0
- package/dist/geometry/mesh.js +300 -0
- package/dist/geometry/model-limits.js +50 -0
- package/dist/geometry/numeric.js +11 -0
- package/dist/geometry/paths/frames.js +118 -0
- package/dist/geometry/paths/helix.js +49 -0
- package/dist/geometry/paths/path.js +30 -0
- package/dist/geometry/paths/planar.js +135 -0
- package/dist/geometry/paths/resampling.js +124 -0
- package/dist/geometry/polygon-clipping.js +52 -0
- package/dist/geometry/primitive-options.js +1 -0
- package/dist/geometry/primitive-resolution.js +17 -0
- package/dist/geometry/primitive-resolvers.js +235 -0
- package/dist/geometry/profiles/matched.js +184 -0
- package/dist/geometry/profiles/open-profile.js +137 -0
- package/dist/geometry/profiles/profiles.js +70 -0
- package/dist/geometry/profiles/rectangle.js +235 -0
- package/dist/geometry/profiles/shapes.js +34 -0
- package/dist/geometry/profiles/span-sampling.js +28 -0
- package/dist/geometry/profiles/types.js +1 -0
- package/dist/geometry/profiles/validation.js +20 -0
- package/dist/geometry/regions.js +371 -0
- package/dist/geometry/resolution.js +236 -0
- package/dist/geometry/revolve/revolve-mesh.js +123 -0
- package/dist/geometry/sampling-limits.js +29 -0
- package/dist/geometry/stroke-path.js +181 -0
- package/dist/geometry/svg/errors.js +23 -0
- package/dist/geometry/svg/fill.js +172 -0
- package/dist/geometry/svg/index.js +1 -0
- package/dist/geometry/svg/matrix.js +130 -0
- package/dist/geometry/svg/parser.js +536 -0
- package/dist/geometry/svg/path.js +349 -0
- package/dist/geometry/svg/types.js +1 -0
- package/dist/geometry/svg-import.js +662 -0
- package/dist/geometry/sweeps/options.js +28 -0
- package/dist/geometry/sweeps/orientation.js +106 -0
- package/dist/geometry/sweeps/stations.js +161 -0
- package/dist/geometry/sweeps/sweep-closed.js +96 -0
- package/dist/geometry/sweeps/sweep-mesh.js +20 -0
- package/dist/geometry/sweeps/sweep-open.js +76 -0
- package/dist/geometry/sweeps/validation.js +116 -0
- package/dist/geometry/text-contours.js +32 -0
- package/dist/geometry/text-layout.js +1 -0
- package/dist/geometry/tolerance.js +53 -0
- package/dist/geometry/transform-geometry.js +41 -0
- package/dist/geometry/transform3.js +340 -0
- package/dist/geometry/types.js +1 -0
- package/dist/index.js +1 -0
- package/dist/model/execution-binding.js +30 -0
- package/dist/model/operations.js +152 -0
- package/dist/model/options.js +1 -0
- package/dist/model/types.js +1 -0
- package/dist/text/index.js +48 -0
- package/dist/text/vector-text-layout.js +279 -0
- package/package.json +89 -8
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { alignLoftRows, assertLoftGrid, loftSubdivisions, sliceLoftRows, subdivideLoftRow, } from "./loft-correspondence.js";
|
|
3
|
+
import { loftFrame, resolveLoftControls } from "./loft-options.js";
|
|
4
|
+
import { normalizeLoop2, normalizeRegion2, signedAreaLoop2 } from "./regions.js";
|
|
5
|
+
import { resolveGeometryResolution } from "./resolution.js";
|
|
6
|
+
import { assertSamplingCount } from "./sampling-limits.js";
|
|
7
|
+
import { frameTransform3 } from "./transform3.js";
|
|
8
|
+
const fail = (message) => {
|
|
9
|
+
throw new GeometryError("INVALID_ARGUMENT", message, { operation: "loft" });
|
|
10
|
+
};
|
|
11
|
+
const contours = (profile) => profile.components.flatMap((component) => [component.outer, ...component.holes]);
|
|
12
|
+
/** @internal Counts and validates the explicit profile protocol before expanding its grids. */
|
|
13
|
+
export function loftProfilePointCount(profile) {
|
|
14
|
+
const identity = (id) => typeof id === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) && id.length <= 64;
|
|
15
|
+
if (!Array.isArray(profile.components) ||
|
|
16
|
+
profile.components.length === 0 ||
|
|
17
|
+
profile.components.length > 1000)
|
|
18
|
+
fail("Loft profiles need one through 1000 identified components.");
|
|
19
|
+
const components = new Set();
|
|
20
|
+
let count = 0;
|
|
21
|
+
for (const component of profile.components) {
|
|
22
|
+
if (!identity(component.id) || components.has(component.id) || !Array.isArray(component.holes))
|
|
23
|
+
fail("Loft component identities must be canonical and unique.");
|
|
24
|
+
components.add(component.id);
|
|
25
|
+
const names = new Set();
|
|
26
|
+
for (const contour of [component.outer, ...component.holes]) {
|
|
27
|
+
if (!identity(contour.id) || names.has(contour.id))
|
|
28
|
+
fail("Loft contour identities must be canonical and unique within a component.");
|
|
29
|
+
names.add(contour.id);
|
|
30
|
+
if (!Array.isArray(contour.points) ||
|
|
31
|
+
!Array.isArray(contour.spans) ||
|
|
32
|
+
contour.points.length < 3 ||
|
|
33
|
+
contour.points.length !== contour.spans.length ||
|
|
34
|
+
contour.spans.some((span) => !identity(span)))
|
|
35
|
+
fail("Loft contours require at least three logical points and paired span identities.");
|
|
36
|
+
count += contour.points.length;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return count;
|
|
40
|
+
}
|
|
41
|
+
/** @internal Makes plain point data an indexed, single-component profile without inventing landmarks. */
|
|
42
|
+
export function pointLoftProfile(points) {
|
|
43
|
+
return {
|
|
44
|
+
components: [
|
|
45
|
+
{
|
|
46
|
+
id: "component-0",
|
|
47
|
+
holes: [],
|
|
48
|
+
outer: { id: "outer", points, spans: points.map((_, index) => `point-${index}`) },
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** @internal Validates winding while retaining repeated logical vertices and each input seam. */
|
|
54
|
+
function checkedLoop(points, outer, authoritative, limits) {
|
|
55
|
+
if (!Array.isArray(points))
|
|
56
|
+
fail("Loft contours must be ordered point arrays.");
|
|
57
|
+
assertSamplingCount(points.length, "loft", limits.maxProfilePoints);
|
|
58
|
+
normalizeLoop2(points, outer);
|
|
59
|
+
const sameWinding = signedAreaLoop2(points) > 0 === outer;
|
|
60
|
+
if (!sameWinding && authoritative)
|
|
61
|
+
fail("Identified loft contours must retain outer CCW and hole CW winding.");
|
|
62
|
+
const copied = points.map((point) => [point[0], point[1]]);
|
|
63
|
+
if (!authoritative)
|
|
64
|
+
while (copied.length > 3 &&
|
|
65
|
+
copied[0][0] === copied.at(-1)[0] &&
|
|
66
|
+
copied[0][1] === copied.at(-1)[1])
|
|
67
|
+
copied.pop();
|
|
68
|
+
return sameWinding ? copied : [copied[0], ...copied.slice(1).reverse()];
|
|
69
|
+
}
|
|
70
|
+
/** @internal Resolves grids for native hole caps and pure simple-loop caps using the same controls. */
|
|
71
|
+
export function prepareLoftGeometry(input, options, limits, authoritative, recipeSampled) {
|
|
72
|
+
if (!Array.isArray(input) || input.length < 2)
|
|
73
|
+
fail("Loft requires at least two stations.");
|
|
74
|
+
assertSamplingCount(input.length, "loft", limits.maxPathPoints);
|
|
75
|
+
const controls = resolveLoftControls(options, input.length);
|
|
76
|
+
assertSamplingCount(input.reduce((sum, station) => sum + loftProfilePointCount(station.profile), 0), "loft", 1_000_000);
|
|
77
|
+
if (authoritative && controls.methods.some((method) => method !== "direct"))
|
|
78
|
+
fail("Identified loft landmarks are authoritative and cannot be remapped by geometric correspondence methods.");
|
|
79
|
+
const finalRows = input.length + controls.slices.reduce((sum, count) => sum + count, 0);
|
|
80
|
+
assertSamplingCount(finalRows, "loft", limits.maxPathPoints);
|
|
81
|
+
const frames = input.map((station) => loftFrame(station));
|
|
82
|
+
const transforms = frames.map((frame) => frameTransform3(frame));
|
|
83
|
+
const first = input[0].profile;
|
|
84
|
+
if (!Array.isArray(first.components) ||
|
|
85
|
+
first.components.length === 0 ||
|
|
86
|
+
first.components.length > 1000)
|
|
87
|
+
fail("Loft profiles need one through 1000 components.");
|
|
88
|
+
const lists = input.map((station) => {
|
|
89
|
+
const profile = station.profile;
|
|
90
|
+
if (!Array.isArray(profile.components) || profile.components.length !== first.components.length)
|
|
91
|
+
fail("Loft component topology cannot change between stations.");
|
|
92
|
+
profile.components.forEach((component, index) => {
|
|
93
|
+
const expected = first.components[index];
|
|
94
|
+
if (component.id !== expected.id ||
|
|
95
|
+
!Array.isArray(component.holes) ||
|
|
96
|
+
component.holes.length !== expected.holes.length)
|
|
97
|
+
fail("Loft component identities and hole counts must retain their input order.");
|
|
98
|
+
});
|
|
99
|
+
return contours(profile);
|
|
100
|
+
});
|
|
101
|
+
const reference = lists[0];
|
|
102
|
+
const allRows = [];
|
|
103
|
+
const allSpans = [];
|
|
104
|
+
const budget = { comparisons: 0 };
|
|
105
|
+
const resolution = resolveGeometryResolution(options.resolution, limits);
|
|
106
|
+
const maxLengths = input.map((_, index) => recipeSampled?.[index] === true ? undefined : resolution.maxSegmentLength);
|
|
107
|
+
// Validate every original station's filled topology, including middle stations.
|
|
108
|
+
// Inserted world grids may have no useful local plane and are checked by mesh topology.
|
|
109
|
+
input.forEach((station) => normalizeRegion2(station.profile.components.map((component) => ({
|
|
110
|
+
outer: component.outer.points,
|
|
111
|
+
holes: component.holes.map((hole) => hole.points),
|
|
112
|
+
}))));
|
|
113
|
+
let otherContours = 0;
|
|
114
|
+
reference.forEach((expected, contourIndex) => {
|
|
115
|
+
const outer = first.components.some((component) => component.outer === expected);
|
|
116
|
+
const rows = lists.map((list, stationIndex) => {
|
|
117
|
+
const contour = list[contourIndex];
|
|
118
|
+
if (contour.id !== expected.id)
|
|
119
|
+
fail("Loft contour identities must retain their input order.");
|
|
120
|
+
if (authoritative &&
|
|
121
|
+
(contour.points.length !== expected.points.length ||
|
|
122
|
+
contour.spans.length !== expected.spans.length ||
|
|
123
|
+
contour.spans.some((span, index) => span !== expected.spans[index])))
|
|
124
|
+
fail("Identified loft contours must preserve every paired landmark and logical slot.");
|
|
125
|
+
const points = checkedLoop(contour.points, outer, authoritative, limits), matrix = transforms[stationIndex];
|
|
126
|
+
const world = points.map(([x, y]) => {
|
|
127
|
+
const point = [
|
|
128
|
+
matrix[0] * x + matrix[1] * y + matrix[3],
|
|
129
|
+
matrix[4] * x + matrix[5] * y + matrix[7],
|
|
130
|
+
matrix[8] * x + matrix[9] * y + matrix[11],
|
|
131
|
+
];
|
|
132
|
+
if (!point.every((coordinate) => Number.isFinite(Math.fround(coordinate))))
|
|
133
|
+
throw new GeometryError("NON_FINITE_VALUE", "Loft world positions must fit finite Float32 storage.", { operation: "loft" });
|
|
134
|
+
return point;
|
|
135
|
+
});
|
|
136
|
+
return { points, world };
|
|
137
|
+
});
|
|
138
|
+
let aligned, spans;
|
|
139
|
+
if (authoritative) {
|
|
140
|
+
const demands = rows.map((row, index) => loftSubdivisions(row, row.points.length * controls.refine[index], controls.distribution, maxLengths[index]));
|
|
141
|
+
const counts = expected.points.map((_, index) => Math.max(...demands.map((demand) => demand[index])));
|
|
142
|
+
const count = counts.reduce((sum, count) => sum + count, 0);
|
|
143
|
+
assertLoftGrid(count, finalRows, otherContours, limits);
|
|
144
|
+
aligned = rows.map((row) => subdivideLoftRow(row, counts));
|
|
145
|
+
spans = counts.flatMap((count, index) => Array(count).fill(expected.spans[index]));
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
aligned = alignLoftRows(rows, controls.methods, controls.distribution, controls.refine, finalRows, otherContours, limits, budget, maxLengths);
|
|
149
|
+
spans = aligned[0].points.map((_, index) => `point-${index}`);
|
|
150
|
+
}
|
|
151
|
+
otherContours += aligned[0].points.length;
|
|
152
|
+
allRows.push(sliceLoftRows(aligned, controls.slices));
|
|
153
|
+
allSpans.push(spans);
|
|
154
|
+
});
|
|
155
|
+
const templates = first.components;
|
|
156
|
+
const stationFrames = [frames[0]];
|
|
157
|
+
for (let gap = 0; gap < controls.slices.length; gap++) {
|
|
158
|
+
// Intermediate frames are placeholders only. Their vertices use the explicit world grids.
|
|
159
|
+
for (let index = 0; index < controls.slices[gap]; index++)
|
|
160
|
+
stationFrames.push(frames[gap]);
|
|
161
|
+
stationFrames.push(frames[gap + 1]);
|
|
162
|
+
}
|
|
163
|
+
const stations = stationFrames.map((frame, row) => {
|
|
164
|
+
let contourIndex = 0;
|
|
165
|
+
const convert = (contour) => {
|
|
166
|
+
const index = contourIndex++;
|
|
167
|
+
return { ...contour, points: allRows[index][row].points, spans: allSpans[index] };
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
frame,
|
|
171
|
+
profile: {
|
|
172
|
+
components: templates.map((component) => ({
|
|
173
|
+
...component,
|
|
174
|
+
outer: convert(component.outer),
|
|
175
|
+
holes: component.holes.map(convert),
|
|
176
|
+
})),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
stations,
|
|
182
|
+
world: stationFrames.map((_, row) => allRows.map((contour) => contour[row].world)),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { frameTransform3 } from "./transform3.js";
|
|
3
|
+
/** @internal Resolves placement without sorting stations or interpolating their axes. */
|
|
4
|
+
export function loftFrame(placement, operation = "loft") {
|
|
5
|
+
if (typeof placement !== "object" ||
|
|
6
|
+
placement === null ||
|
|
7
|
+
Array.isArray(placement) ||
|
|
8
|
+
"z" in placement === "frame" in placement)
|
|
9
|
+
throw new GeometryError("INVALID_ARGUMENT", "Each loft station must specify exactly one z or frame.", { operation });
|
|
10
|
+
if ("z" in placement) {
|
|
11
|
+
if (!Number.isFinite(placement.z))
|
|
12
|
+
throw new GeometryError("NON_FINITE_VALUE", "Loft station z must be finite millimetres.", {
|
|
13
|
+
operation,
|
|
14
|
+
});
|
|
15
|
+
return { origin: [0, 0, placement.z], xAxis: [1, 0, 0], yAxis: [0, 1, 0], zAxis: [0, 0, 1] };
|
|
16
|
+
}
|
|
17
|
+
frameTransform3(placement.frame);
|
|
18
|
+
return placement.frame;
|
|
19
|
+
}
|
|
20
|
+
/** @internal Validated controls shared by every loft construction route. */
|
|
21
|
+
export function resolveLoftControls(options, stationCount) {
|
|
22
|
+
if (typeof options !== "object" || options === null || Array.isArray(options))
|
|
23
|
+
throw new GeometryError("INVALID_ARGUMENT", "Loft options must be an object.", {
|
|
24
|
+
operation: "loft",
|
|
25
|
+
});
|
|
26
|
+
const expand = (value, count, fallback, label) => {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
if (value.length !== count)
|
|
29
|
+
fail(`${label} requires exactly ${count} entries in station order.`);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
return Array(count).fill(value === undefined ? fallback : value);
|
|
33
|
+
};
|
|
34
|
+
const methods = expand(options.correspondence, stationCount - 1, "direct", "correspondence");
|
|
35
|
+
for (const method of methods)
|
|
36
|
+
if (!["direct", "reindex", "fast-distance", "distance"].includes(method))
|
|
37
|
+
fail("Unsupported loft correspondence method.");
|
|
38
|
+
const duplicate = methods.some((method) => method === "distance" || method === "fast-distance");
|
|
39
|
+
const distribution = options.distribution ?? (duplicate ? "segments" : "perimeter");
|
|
40
|
+
if (distribution !== "perimeter" && distribution !== "segments")
|
|
41
|
+
fail("Loft distribution must be perimeter or segments.");
|
|
42
|
+
if (duplicate && distribution !== "segments")
|
|
43
|
+
fail("Vertex-duplication correspondence requires segments distribution.");
|
|
44
|
+
const refine = expand(options.refine, stationCount, 1, "refine");
|
|
45
|
+
const slices = expand(options.slices, stationCount - 1, 0, "slices");
|
|
46
|
+
for (const count of refine)
|
|
47
|
+
if (!Number.isSafeInteger(count) || count < 1)
|
|
48
|
+
fail("Loft refine values must be positive safe integers.");
|
|
49
|
+
for (const count of slices)
|
|
50
|
+
if (!Number.isSafeInteger(count) || count < 0)
|
|
51
|
+
fail("Loft slices values must be nonnegative safe integers.");
|
|
52
|
+
return { methods, distribution, refine, slices };
|
|
53
|
+
}
|
|
54
|
+
const fail = (message) => {
|
|
55
|
+
throw new GeometryError("INVALID_ARGUMENT", message, { operation: "loft" });
|
|
56
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { triangulateLoop2 } from "./loft-triangulation.js";
|
|
3
|
+
import { measureTriangleMesh, validateTriangleMesh } from "./mesh.js";
|
|
4
|
+
import { assertMeshAllocationWithinLimits, } from "./model-limits.js";
|
|
5
|
+
import { writeMeshVertex } from "./numeric.js";
|
|
6
|
+
/** @internal Pure closed simple-contour strips, retaining repeated correspondence slots as shared physical vertices. */
|
|
7
|
+
export function simpleLoftMesh(geometry, limits, allocationGuard) {
|
|
8
|
+
const count = geometry.world[0][0].length, rowCount = geometry.world.length;
|
|
9
|
+
const maximumVertices = count * rowCount, maximumTriangles = 2 * count * (rowCount - 1) + 2 * (count - 2);
|
|
10
|
+
const bytes = 12 * (maximumVertices + maximumTriangles);
|
|
11
|
+
assertMeshAllocationWithinLimits(maximumVertices, maximumTriangles, bytes, "loftMesh", limits);
|
|
12
|
+
allocationGuard?.(maximumVertices, maximumTriangles, bytes, "loftMesh");
|
|
13
|
+
const positions = new Float32Array(maximumVertices * 3), indices = new Uint32Array(maximumTriangles * 3);
|
|
14
|
+
let vertexCount = 0, cursor = 0;
|
|
15
|
+
const rows = geometry.world.map((contours, row) => {
|
|
16
|
+
const points = [], vertices = [], slots = [];
|
|
17
|
+
contours[0].forEach((point, slot) => {
|
|
18
|
+
const coordinates = point.map(Math.fround), previous = vertices.at(-1);
|
|
19
|
+
if (previous !== undefined &&
|
|
20
|
+
coordinates.every((coordinate, axis) => coordinate === positions[previous * 3 + axis]))
|
|
21
|
+
slots.push(previous);
|
|
22
|
+
else {
|
|
23
|
+
writeMeshVertex(positions, vertexCount * 3, point, "loftMesh");
|
|
24
|
+
slots.push(vertexCount);
|
|
25
|
+
vertices.push(vertexCount++);
|
|
26
|
+
points.push(geometry.stations[row].profile.components[0].outer.points[slot]);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
const start = vertices[0], end = vertices.at(-1);
|
|
30
|
+
if (start !== end &&
|
|
31
|
+
[0, 1, 2].every((axis) => positions[start * 3 + axis] === positions[end * 3 + axis])) {
|
|
32
|
+
slots.forEach((slot, index) => {
|
|
33
|
+
if (slot === end)
|
|
34
|
+
slots[index] = start;
|
|
35
|
+
});
|
|
36
|
+
vertices.pop();
|
|
37
|
+
points.pop();
|
|
38
|
+
vertexCount--;
|
|
39
|
+
}
|
|
40
|
+
if (vertices.length < 3)
|
|
41
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Loft contours cannot collapse their filled topology.", { operation: "loftMesh" });
|
|
42
|
+
return { points, vertices, slots };
|
|
43
|
+
});
|
|
44
|
+
const triangle = (a, b, c) => {
|
|
45
|
+
if (a === b || b === c || c === a)
|
|
46
|
+
return;
|
|
47
|
+
const ab = [
|
|
48
|
+
positions[b * 3] - positions[a * 3],
|
|
49
|
+
positions[b * 3 + 1] - positions[a * 3 + 1],
|
|
50
|
+
positions[b * 3 + 2] - positions[a * 3 + 2],
|
|
51
|
+
];
|
|
52
|
+
const ac = [
|
|
53
|
+
positions[c * 3] - positions[a * 3],
|
|
54
|
+
positions[c * 3 + 1] - positions[a * 3 + 1],
|
|
55
|
+
positions[c * 3 + 2] - positions[a * 3 + 2],
|
|
56
|
+
];
|
|
57
|
+
if (ab[1] * ac[2] - ab[2] * ac[1] === 0 &&
|
|
58
|
+
ab[2] * ac[0] - ab[0] * ac[2] === 0 &&
|
|
59
|
+
ab[0] * ac[1] - ab[1] * ac[0] === 0)
|
|
60
|
+
return;
|
|
61
|
+
indices[cursor++] = a;
|
|
62
|
+
indices[cursor++] = b;
|
|
63
|
+
indices[cursor++] = c;
|
|
64
|
+
};
|
|
65
|
+
for (let row = 0; row < rows.length - 1; row++)
|
|
66
|
+
for (let index = 0; index < count; index++) {
|
|
67
|
+
const next = (index + 1) % count, a = rows[row], b = rows[row + 1];
|
|
68
|
+
triangle(a.slots[index], a.slots[next], b.slots[next]);
|
|
69
|
+
triangle(a.slots[index], b.slots[next], b.slots[index]);
|
|
70
|
+
}
|
|
71
|
+
const cap = (row, top) => {
|
|
72
|
+
const contour = rows[row], triangles = triangulateLoop2(contour.points);
|
|
73
|
+
for (let index = 0; index < triangles.length; index += 3)
|
|
74
|
+
triangle(contour.vertices[triangles[index]], contour.vertices[triangles[index + (top ? 1 : 2)]], contour.vertices[triangles[index + (top ? 2 : 1)]]);
|
|
75
|
+
};
|
|
76
|
+
cap(0, false);
|
|
77
|
+
cap(rows.length - 1, true);
|
|
78
|
+
const mesh = {
|
|
79
|
+
positions: positions.slice(0, vertexCount * 3),
|
|
80
|
+
indices: indices.slice(0, cursor),
|
|
81
|
+
};
|
|
82
|
+
const volume = measureTriangleMesh(mesh).signedVolume;
|
|
83
|
+
if (volume === 0)
|
|
84
|
+
throw new GeometryError("DEGENERATE_GEOMETRY", "Loft stations collapse to zero enclosed volume.", { operation: "loftMesh" });
|
|
85
|
+
if (volume < 0)
|
|
86
|
+
for (let index = 0; index < mesh.indices.length; index += 3) {
|
|
87
|
+
const b = mesh.indices[index + 1];
|
|
88
|
+
mesh.indices[index + 1] = mesh.indices[index + 2];
|
|
89
|
+
mesh.indices[index + 2] = b;
|
|
90
|
+
}
|
|
91
|
+
return validateTriangleMesh(mesh, { requireClosed: true });
|
|
92
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { normalizeLoop2 } from "./regions.js";
|
|
3
|
+
const maximumComparisons = 8_000_000;
|
|
4
|
+
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
5
|
+
const pointInTriangle = (point, a, b, c, epsilon) => {
|
|
6
|
+
const ab = cross(a, b, point);
|
|
7
|
+
const bc = cross(b, c, point);
|
|
8
|
+
const ca = cross(c, a, point);
|
|
9
|
+
return ab >= -epsilon && bc >= -epsilon && ca >= -epsilon;
|
|
10
|
+
};
|
|
11
|
+
/** Ear-clips one normalized simple CCW loop into stable local vertex indices. */
|
|
12
|
+
export function triangulateLoop2(loop) {
|
|
13
|
+
const normalized = normalizeLoop2(loop, true);
|
|
14
|
+
// Subdividing a straight edge introduces roundoff-sized turns. Treat them consistently
|
|
15
|
+
// in both ear convexity and containment so they cannot consume a false ear or block the cap.
|
|
16
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
17
|
+
for (const [x, y] of normalized) {
|
|
18
|
+
minX = Math.min(minX, x);
|
|
19
|
+
minY = Math.min(minY, y);
|
|
20
|
+
maxX = Math.max(maxX, x);
|
|
21
|
+
maxY = Math.max(maxY, y);
|
|
22
|
+
}
|
|
23
|
+
const extent = Math.max(maxX - minX, maxY - minY);
|
|
24
|
+
const epsilon = extent * extent * Number.EPSILON * 32;
|
|
25
|
+
const remaining = normalized.map((_, index) => index);
|
|
26
|
+
const triangles = [];
|
|
27
|
+
let comparisons = 0;
|
|
28
|
+
const consume = () => {
|
|
29
|
+
if (++comparisons > maximumComparisons)
|
|
30
|
+
throw new GeometryError("COMPLEXITY_LIMIT", "Profile cap triangulation exceeds its eight-million-comparison work limit.", {
|
|
31
|
+
operation: "triangulateLoop2",
|
|
32
|
+
details: { requiredCount: comparisons, allowedCount: maximumComparisons },
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
while (remaining.length > 3) {
|
|
36
|
+
let clipped = false;
|
|
37
|
+
for (let offset = 0; offset < remaining.length; offset += 1) {
|
|
38
|
+
consume();
|
|
39
|
+
const previous = remaining[(offset - 1 + remaining.length) % remaining.length];
|
|
40
|
+
const current = remaining[offset];
|
|
41
|
+
const next = remaining[(offset + 1) % remaining.length];
|
|
42
|
+
if (cross(normalized[previous], normalized[current], normalized[next]) <= epsilon)
|
|
43
|
+
continue;
|
|
44
|
+
if (remaining.some((candidate) => {
|
|
45
|
+
consume();
|
|
46
|
+
return (candidate !== previous &&
|
|
47
|
+
candidate !== current &&
|
|
48
|
+
candidate !== next &&
|
|
49
|
+
pointInTriangle(normalized[candidate], normalized[previous], normalized[current], normalized[next], epsilon));
|
|
50
|
+
})) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
triangles.push(previous, current, next);
|
|
54
|
+
remaining.splice(offset, 1);
|
|
55
|
+
clipped = true;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (!clipped) {
|
|
59
|
+
throw new GeometryError("INVALID_TOPOLOGY", "Profile cap triangulation could not find a valid ear.", { operation: "triangulateLoop2" });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
triangles.push(remaining[0], remaining[1], remaining[2]);
|
|
63
|
+
return new Uint32Array(triangles);
|
|
64
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { GeometryError } from "./errors.js";
|
|
2
|
+
import { resolveExtrusionControls, resolveExtrusionDivisions, } from "./extrusion.js";
|
|
3
|
+
import { pointLoftProfile, prepareLoftGeometry } from "./loft-geometry.js";
|
|
4
|
+
import {} from "./loft-options.js";
|
|
5
|
+
import { simpleLoftMesh } from "./loft-simple-mesh.js";
|
|
6
|
+
import { assertMeshAllocationWithinLimits, resolveModelingLimits, } from "./model-limits.js";
|
|
7
|
+
import { assertSamplingCount } from "./sampling-limits.js";
|
|
8
|
+
export { triangulateLoop2 } from "./loft-triangulation.js";
|
|
9
|
+
/**
|
|
10
|
+
* Lofts simple point loops at z heights or explicit right-handed frames, in input order.
|
|
11
|
+
*
|
|
12
|
+
* An explicit correspondence method is required. CCW-normalized loops retain their
|
|
13
|
+
* seam and original vertices; unequal counts gain subdivisions or repeated fan slots.
|
|
14
|
+
* Both ends use ear-clipped caps, supporting concave profiles without center fans.
|
|
15
|
+
* Intermediate slices interpolate placed world vertices, never frame axes.
|
|
16
|
+
* Transformed coordinates must fit finite Float32 mesh storage.
|
|
17
|
+
*/
|
|
18
|
+
export function loftMesh(stations, options) {
|
|
19
|
+
return buildLoftMesh(stations, options);
|
|
20
|
+
}
|
|
21
|
+
/** @internal Shared pure loft with host allocation preflight. */
|
|
22
|
+
export function buildLoftMesh(stations, options, allocationGuard) {
|
|
23
|
+
if (options?.correspondence === undefined)
|
|
24
|
+
throw new GeometryError("INVALID_ARGUMENT", "Point-loop lofts require an explicit correspondence method.", { operation: "loftMesh" });
|
|
25
|
+
const limits = resolveModelingLimits(options.limits);
|
|
26
|
+
if (!Array.isArray(stations) || stations.length < 2)
|
|
27
|
+
throw new GeometryError("INVALID_ARGUMENT", "Loft requires at least two stations.", {
|
|
28
|
+
operation: "loftMesh",
|
|
29
|
+
});
|
|
30
|
+
const input = stations;
|
|
31
|
+
assertSamplingCount(input.length, "loftMesh", limits.maxPathPoints);
|
|
32
|
+
assertSamplingCount(input.reduce((sum, station) => {
|
|
33
|
+
if (!Array.isArray(station.profile))
|
|
34
|
+
throw new GeometryError("INVALID_ARGUMENT", "Pure loft profiles must be ordered point arrays.", { operation: "loftMesh" });
|
|
35
|
+
return sum + station.profile.length;
|
|
36
|
+
}, 0), "loftMesh", 1_000_000);
|
|
37
|
+
const profiles = input.map((station) => ({
|
|
38
|
+
...station,
|
|
39
|
+
profile: pointLoftProfile(station.profile),
|
|
40
|
+
}));
|
|
41
|
+
return simpleLoftMesh(prepareLoftGeometry(profiles, options, limits, false), limits, allocationGuard);
|
|
42
|
+
}
|
|
43
|
+
/** Linearly extrudes one simple profile along +Z with robust ear-clipped caps. */
|
|
44
|
+
export function extrudeMesh(profile, options) {
|
|
45
|
+
const { height } = options;
|
|
46
|
+
const { center, scales, twistDegrees } = resolveExtrusionControls(height, options, "extrudeMesh");
|
|
47
|
+
if (!Number.isFinite(height) || height <= 0) {
|
|
48
|
+
throw new GeometryError("INVALID_ARGUMENT", "Extrusion height must be finite and positive.", {
|
|
49
|
+
operation: "extrudeMesh",
|
|
50
|
+
details: { height },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const bottom = center ? -height / 2 : 0;
|
|
54
|
+
const limits = resolveModelingLimits(options.limits);
|
|
55
|
+
assertSamplingCount(profile.length, "extrudeMesh", limits.maxProfilePoints);
|
|
56
|
+
const divisions = resolveExtrusionDivisions(profile, height, options);
|
|
57
|
+
const count = profile.length * (divisions + 2);
|
|
58
|
+
const triangles = (divisions + 1) * profile.length * 2 + (profile.length - 2) * 2;
|
|
59
|
+
assertMeshAllocationWithinLimits(count, triangles, (count + triangles) * 12, "extrudeMesh", limits);
|
|
60
|
+
const frame = (amount) => ({
|
|
61
|
+
origin: [0, 0, bottom + height * amount],
|
|
62
|
+
xAxis: [1, 0, 0],
|
|
63
|
+
yAxis: [0, 1, 0],
|
|
64
|
+
zAxis: [0, 0, 1],
|
|
65
|
+
});
|
|
66
|
+
const stations = Array.from({ length: divisions + 2 }, (_, index) => {
|
|
67
|
+
const amount = index / (divisions + 1);
|
|
68
|
+
const angle = ((twistDegrees * Math.PI) / 180) * amount;
|
|
69
|
+
const cosine = Math.cos(angle), sine = Math.sin(angle);
|
|
70
|
+
const sx = 1 + (scales[0] - 1) * amount, sy = 1 + (scales[1] - 1) * amount;
|
|
71
|
+
return {
|
|
72
|
+
frame: frame(amount),
|
|
73
|
+
profile: profile.map(([x, y]) => [
|
|
74
|
+
x * sx * cosine - y * sy * sine,
|
|
75
|
+
x * sx * sine + y * sy * cosine,
|
|
76
|
+
]),
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
return loftMesh(stations, { correspondence: "direct", limits });
|
|
80
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Adds two finite XYZ vectors component-wise. @example `add3([1, 2, 3], [4, 5, 6])` */
|
|
2
|
+
export const add3 = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
3
|
+
/** Subtracts `b` from `a`. @example `subtract3([4, 5, 6], [1, 2, 3])` */
|
|
4
|
+
export const subtract3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
5
|
+
/** Multiplies a vector by a dimensionless scalar. @example `scale3([1, 2, 3], 2)` */
|
|
6
|
+
export const scale3 = (vector, scale) => [
|
|
7
|
+
vector[0] * scale,
|
|
8
|
+
vector[1] * scale,
|
|
9
|
+
vector[2] * scale,
|
|
10
|
+
];
|
|
11
|
+
/** Returns the scalar dot product. @example `dot3([1, 0, 0], [0, 1, 0])` */
|
|
12
|
+
export const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
13
|
+
/** Returns the right-handed cross product. @example `cross3([1, 0, 0], [0, 1, 0])` */
|
|
14
|
+
export const cross3 = (a, b) => [
|
|
15
|
+
a[1] * b[2] - a[2] * b[1],
|
|
16
|
+
a[2] * b[0] - a[0] * b[2],
|
|
17
|
+
a[0] * b[1] - a[1] * b[0],
|
|
18
|
+
];
|
|
19
|
+
/** Returns Euclidean vector length in the vector's coordinate units. */
|
|
20
|
+
export const length3 = (vector) => Math.hypot(vector[0], vector[1], vector[2]);
|
|
21
|
+
/** Normalizes a finite vector and rejects nonfinite lengths or lengths at or below `epsilon`. */
|
|
22
|
+
export const normalize3 = (vector, epsilon = 1e-12) => {
|
|
23
|
+
const length = length3(vector);
|
|
24
|
+
if (!Number.isFinite(length)) {
|
|
25
|
+
throw new Error("Cannot normalize a vector without finite length.");
|
|
26
|
+
}
|
|
27
|
+
if (length <= epsilon) {
|
|
28
|
+
throw new Error("Cannot normalize a zero-length vector.");
|
|
29
|
+
}
|
|
30
|
+
return scale3(vector, 1 / length);
|
|
31
|
+
};
|
|
32
|
+
/** Linearly interpolates two vectors; `t` is dimensionless and may extrapolate. */
|
|
33
|
+
export const lerp3 = (a, b, t) => add3(a, scale3(subtract3(b, a), t));
|
|
34
|
+
/** Removes the component parallel to a normalized plane normal. */
|
|
35
|
+
export const projectOntoPlane3 = (vector, normal) => subtract3(vector, scale3(normal, dot3(vector, normal)));
|
|
36
|
+
/** Rotates a vector right-handed around an axis by `angle` radians. */
|
|
37
|
+
export const rotateAroundAxis3 = (vector, axis, angle) => {
|
|
38
|
+
const cosine = Math.cos(angle);
|
|
39
|
+
const sine = Math.sin(angle);
|
|
40
|
+
return add3(add3(scale3(vector, cosine), scale3(cross3(axis, vector), sine)), scale3(axis, dot3(axis, vector) * (1 - cosine)));
|
|
41
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { GeometryError } from "../errors.js";
|
|
2
|
+
import { polygonClipping } from "../polygon-clipping.js";
|
|
3
|
+
import { resolveCylindricalGearPair, sampleCylindricalGearOutline, sampleInternalCylindricalGearSpaceOutline, } from "./gears.js";
|
|
4
|
+
const DEFAULT_MOTION_SAMPLES = 24;
|
|
5
|
+
const MAXIMUM_MOTION_SAMPLES = 360;
|
|
6
|
+
const transformedLoop = (loop, angle, translationX = 0) => {
|
|
7
|
+
const cosine = Math.cos(angle);
|
|
8
|
+
const sine = Math.sin(angle);
|
|
9
|
+
return Object.freeze(loop.map(([x, y]) => Object.freeze([x * cosine - y * sine + translationX, x * sine + y * cosine])));
|
|
10
|
+
};
|
|
11
|
+
const clippingPolygon = (loop) => [
|
|
12
|
+
[...loop.map(([x, y]) => [x, y]), [...loop[0]]],
|
|
13
|
+
];
|
|
14
|
+
const contactWindowPolygon = (centerDistance, driverOutsideRadius, ringTipRadius) => {
|
|
15
|
+
// The internal-space loop contains the complete disk inside the ring tooth tips. A driver point
|
|
16
|
+
// can therefore overlap ring material only when its distance from the ring center is at least
|
|
17
|
+
// ringTipRadius. Since |y| cannot exceed the driver's outside radius, every possible overlap is
|
|
18
|
+
// conservatively confined to this left-hand X slab. Cropping both periodic outlines before the
|
|
19
|
+
// expensive difference keeps interactive validation proportional to the contact zone rather
|
|
20
|
+
// than to both complete high-tooth-count polygons.
|
|
21
|
+
const radialMargin = Math.max(1, driverOutsideRadius, ringTipRadius) * 1e-10;
|
|
22
|
+
const squaredHorizontalReach = ringTipRadius ** 2 - driverOutsideRadius ** 2;
|
|
23
|
+
const maximumX = Math.min(driverOutsideRadius, squaredHorizontalReach > 0
|
|
24
|
+
? centerDistance - Math.sqrt(squaredHorizontalReach) + radialMargin
|
|
25
|
+
: driverOutsideRadius);
|
|
26
|
+
const minimumX = -driverOutsideRadius - radialMargin;
|
|
27
|
+
const minimumY = -driverOutsideRadius - radialMargin;
|
|
28
|
+
const maximumY = driverOutsideRadius + radialMargin;
|
|
29
|
+
return [
|
|
30
|
+
[
|
|
31
|
+
[minimumX, minimumY],
|
|
32
|
+
[maximumX, minimumY],
|
|
33
|
+
[maximumX, maximumY],
|
|
34
|
+
[minimumX, maximumY],
|
|
35
|
+
[minimumX, minimumY],
|
|
36
|
+
],
|
|
37
|
+
];
|
|
38
|
+
};
|
|
39
|
+
const ringArea = (ring) => {
|
|
40
|
+
let doubled = 0;
|
|
41
|
+
for (let index = 0; index + 1 < ring.length; index += 1) {
|
|
42
|
+
const current = ring[index];
|
|
43
|
+
const next = ring[index + 1];
|
|
44
|
+
doubled += current[0] * next[1] - next[0] * current[1];
|
|
45
|
+
}
|
|
46
|
+
return doubled / 2;
|
|
47
|
+
};
|
|
48
|
+
const multiPolygonArea = (multiPolygon) => multiPolygon.reduce((total, polygon) => total +
|
|
49
|
+
polygon.reduce((area, ring, index) => area + (index === 0 ? Math.abs(ringArea(ring)) : -Math.abs(ringArea(ring))), 0), 0);
|
|
50
|
+
/**
|
|
51
|
+
* Measures sampled kinematic interference over one tooth cycle of an internal gear pair.
|
|
52
|
+
*
|
|
53
|
+
* The driver and ring rotate in the same direction at their resolved tooth-count ratio. Each
|
|
54
|
+
* sampled pinion outline is subtracted from the ring's center-and-space opening; remaining area is
|
|
55
|
+
* solid overlap. This catches involute, tip, and trimming interference that a single assembled pose
|
|
56
|
+
* misses. It is a deterministic model check, not a certified gear-rating calculation.
|
|
57
|
+
*/
|
|
58
|
+
export function measureInternalCylindricalGearPairMotion(spec, options = {}) {
|
|
59
|
+
if (spec.mesh !== "internal") {
|
|
60
|
+
throw new GeometryError("INVALID_ARGUMENT", "Motion analysis requires an internal gear pair.", {
|
|
61
|
+
operation: "measureInternalCylindricalGearPairMotion",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const samples = options.samples ?? DEFAULT_MOTION_SAMPLES;
|
|
65
|
+
if (!Number.isInteger(samples) || samples < 4 || samples > MAXIMUM_MOTION_SAMPLES) {
|
|
66
|
+
throw new GeometryError("INVALID_ARGUMENT", `Motion samples must be an integer from 4 through ${MAXIMUM_MOTION_SAMPLES}.`, { operation: "measureInternalCylindricalGearPairMotion", details: { samples } });
|
|
67
|
+
}
|
|
68
|
+
const pair = resolveCylindricalGearPair(spec);
|
|
69
|
+
const driver = sampleCylindricalGearOutline(spec.driver, options.outline);
|
|
70
|
+
const ringSpace = sampleInternalCylindricalGearSpaceOutline(spec.driven, options.outline);
|
|
71
|
+
const ringTipRadius = pair.driven.pitchRadius - pair.driven.addendum;
|
|
72
|
+
const contactWindow = contactWindowPolygon(pair.centerDistance, pair.driver.outsideRadius, ringTipRadius);
|
|
73
|
+
const initialRingPhase = pair.driven.toothCount % 2 === 0 ? 0 : pair.driven.angularPitch / 2;
|
|
74
|
+
let maximumOverlapArea = 0;
|
|
75
|
+
let maximumPhase = 0;
|
|
76
|
+
for (let sample = 0; sample < samples; sample += 1) {
|
|
77
|
+
const driverPhase = (pair.driver.angularPitch * sample) / samples;
|
|
78
|
+
const drivenPhase = initialRingPhase + driverPhase / pair.ratio;
|
|
79
|
+
const posedDriver = clippingPolygon(transformedLoop(driver, driverPhase));
|
|
80
|
+
const posedSpace = clippingPolygon(transformedLoop(ringSpace, drivenPhase, pair.centerDistance));
|
|
81
|
+
const contactDriver = polygonClipping.intersection(posedDriver, contactWindow);
|
|
82
|
+
const contactSpace = polygonClipping.intersection(posedSpace, contactWindow);
|
|
83
|
+
const overlapArea = multiPolygonArea(polygonClipping.difference(contactDriver, contactSpace));
|
|
84
|
+
if (overlapArea > maximumOverlapArea) {
|
|
85
|
+
maximumOverlapArea = overlapArea;
|
|
86
|
+
maximumPhase = driverPhase;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return Object.freeze({
|
|
90
|
+
driverPhaseRadians: maximumPhase,
|
|
91
|
+
maximumOverlapArea,
|
|
92
|
+
samples,
|
|
93
|
+
});
|
|
94
|
+
}
|