fluidcad 0.0.37 → 0.0.39
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/lib/dist/core/2d/aline.d.ts +0 -8
- package/lib/dist/core/2d/aline.js +3 -22
- package/lib/dist/core/2d/arc.d.ts +1 -23
- package/lib/dist/core/2d/arc.js +11 -28
- package/lib/dist/core/2d/circle.d.ts +1 -8
- package/lib/dist/core/2d/circle.js +7 -24
- package/lib/dist/core/2d/ellipse.d.ts +1 -17
- package/lib/dist/core/2d/ellipse.js +7 -45
- package/lib/dist/core/2d/hline.d.ts +0 -7
- package/lib/dist/core/2d/hline.js +5 -27
- package/lib/dist/core/2d/intersect.d.ts +0 -7
- package/lib/dist/core/2d/intersect.js +0 -13
- package/lib/dist/core/2d/line.d.ts +1 -8
- package/lib/dist/core/2d/line.js +6 -24
- package/lib/dist/core/2d/offset.d.ts +1 -11
- package/lib/dist/core/2d/offset.js +0 -16
- package/lib/dist/core/2d/polygon.d.ts +1 -17
- package/lib/dist/core/2d/polygon.js +18 -37
- package/lib/dist/core/2d/project.d.ts +0 -7
- package/lib/dist/core/2d/project.js +0 -13
- package/lib/dist/core/2d/rect.d.ts +1 -9
- package/lib/dist/core/2d/rect.js +0 -19
- package/lib/dist/core/2d/slot.d.ts +0 -23
- package/lib/dist/core/2d/slot.js +18 -37
- package/lib/dist/core/2d/text.d.ts +0 -7
- package/lib/dist/core/2d/text.js +0 -14
- package/lib/dist/core/2d/vline.d.ts +0 -7
- package/lib/dist/core/2d/vline.js +5 -23
- package/lib/dist/core/fillet.js +7 -0
- package/lib/dist/core/index.d.ts +1 -1
- package/lib/dist/core/interfaces.d.ts +43 -0
- package/lib/dist/core/plane.d.ts +26 -6
- package/lib/dist/core/plane.js +21 -44
- package/lib/dist/features/2d/connect.d.ts +2 -0
- package/lib/dist/features/2d/connect.js +3 -0
- package/lib/dist/features/2d/intersect.js +4 -1
- package/lib/dist/features/2d/offset.js +2 -2
- package/lib/dist/features/frozen-geometry.d.ts +29 -0
- package/lib/dist/features/frozen-geometry.js +62 -0
- package/lib/dist/features/loft.d.ts +38 -1
- package/lib/dist/features/loft.js +133 -8
- package/lib/dist/features/plane-from-object.d.ts +16 -4
- package/lib/dist/features/plane-from-object.js +101 -8
- package/lib/dist/features/rib.js +11 -1
- package/lib/dist/features/shell.d.ts +1 -1
- package/lib/dist/features/shell.js +9 -0
- package/lib/dist/features/sweep.d.ts +11 -1
- package/lib/dist/features/sweep.js +43 -7
- package/lib/dist/helpers/clone-transform.js +62 -5
- package/lib/dist/helpers/scene-helpers.d.ts +12 -0
- package/lib/dist/helpers/scene-helpers.js +26 -0
- package/lib/dist/math/bspline-interpolation.d.ts +33 -6
- package/lib/dist/math/bspline-interpolation.js +187 -84
- package/lib/dist/oc/loft/constrained-loft.d.ts +32 -0
- package/lib/dist/oc/loft/constrained-loft.js +61 -0
- package/lib/dist/oc/loft/curve-data.d.ts +18 -0
- package/lib/dist/oc/loft/curve-data.js +54 -0
- package/lib/dist/oc/loft/curve-eval.d.ts +23 -0
- package/lib/dist/oc/loft/curve-eval.js +84 -0
- package/lib/dist/oc/loft/guided-loft.d.ts +89 -0
- package/lib/dist/oc/loft/guided-loft.js +368 -0
- package/lib/dist/oc/loft/section-compatibility.d.ts +131 -0
- package/lib/dist/oc/loft/section-compatibility.js +481 -0
- package/lib/dist/oc/loft/section-curve.d.ts +53 -0
- package/lib/dist/oc/loft/section-curve.js +264 -0
- package/lib/dist/oc/loft/skinning.d.ts +104 -0
- package/lib/dist/oc/loft/skinning.js +299 -0
- package/lib/dist/oc/loft-ops.d.ts +28 -1
- package/lib/dist/oc/loft-ops.js +31 -1
- package/lib/dist/oc/ncollection.d.ts +19 -0
- package/lib/dist/oc/ncollection.js +69 -0
- package/lib/dist/oc/rib-ops.js +5 -17
- package/lib/dist/oc/sweep-ops.d.ts +7 -16
- package/lib/dist/oc/sweep-ops.js +67 -99
- package/lib/dist/oc/thin-face-maker.d.ts +0 -19
- package/lib/dist/oc/thin-face-maker.js +3 -68
- package/lib/dist/oc/wire-extend-ops.d.ts +12 -0
- package/lib/dist/oc/wire-extend-ops.js +42 -0
- package/lib/dist/oc/wire-ops.d.ts +28 -2
- package/lib/dist/oc/wire-ops.js +88 -4
- package/lib/dist/oc/wrap-ops.js +8 -15
- package/lib/dist/tests/features/2d/circle.test.js +0 -10
- package/lib/dist/tests/features/2d/ellipse.test.js +0 -30
- package/lib/dist/tests/features/2d/line.test.js +0 -5
- package/lib/dist/tests/features/2d/offset.test.js +74 -11
- package/lib/dist/tests/features/2d/polygon.test.js +0 -16
- package/lib/dist/tests/features/2d/rect.test.js +0 -11
- package/lib/dist/tests/features/2d/slot.test.js +0 -8
- package/lib/dist/tests/features/fillet2d.test.js +37 -1
- package/lib/dist/tests/features/loft-conditions.test.d.ts +1 -0
- package/lib/dist/tests/features/loft-conditions.test.js +246 -0
- package/lib/dist/tests/features/loft-guides.test.d.ts +1 -0
- package/lib/dist/tests/features/loft-guides.test.js +276 -0
- package/lib/dist/tests/features/plane.test.js +95 -0
- package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.d.ts +1 -0
- package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.js +35 -0
- package/lib/dist/tests/features/rib.test.js +50 -0
- package/lib/dist/tests/features/shell.test.js +35 -1
- package/lib/dist/tests/features/sweep.test.js +127 -1
- package/lib/dist/tests/features/text.test.js +33 -14
- package/lib/dist/tests/math/bspline-interpolation.test.js +97 -1
- package/lib/dist/tsconfig.tsbuildinfo +1 -1
- package/llm-docs/api/index.json +1 -1
- package/llm-docs/api/types/loft.md +36 -1
- package/llm-docs/api/types/sweep.md +13 -1
- package/llm-docs/api/types/text.md +2 -2
- package/llm-docs/index.json +5 -5
- package/package.json +1 -1
- package/server/dist/fluidcad-server.d.ts +1 -1
- package/server/dist/fluidcad-server.js +11 -1
- package/server/dist/routes/params.js +1 -1
- package/ui/dist/assets/{index-no7mtr5s.js → index-D8zV21wB.js} +83 -83
- package/ui/dist/index.html +1 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { getOC } from "../init.js";
|
|
2
|
+
import { Vector3d } from "../../math/vector3d.js";
|
|
3
|
+
import { NCollections } from "../ncollection.js";
|
|
4
|
+
import { SectionCurve } from "./section-curve.js";
|
|
5
|
+
import { CurveData } from "./curve-data.js";
|
|
6
|
+
import { closestCurveParameter } from "./curve-eval.js";
|
|
7
|
+
/**
|
|
8
|
+
* Brings profile wires onto a common B-spline basis for skinning:
|
|
9
|
+
*
|
|
10
|
+
* 1. each wire becomes a single clamped curve over [0, 1] (`SectionCurve`),
|
|
11
|
+
* 2. winding directions are made consistent along the loft,
|
|
12
|
+
* 3. seams are chained to the nearest point of the previous section's seam
|
|
13
|
+
* (minimal twist),
|
|
14
|
+
* 4. degrees are raised and knot vectors merged to a common union,
|
|
15
|
+
* 5. weight vectors must then agree across sections — when they don't (e.g.
|
|
16
|
+
* a circle lofted to a rectangle), all rational pieces are re-approximated
|
|
17
|
+
* as polynomials and the pipeline reruns once, making everything weightless.
|
|
18
|
+
*/
|
|
19
|
+
export class SectionCompatibility {
|
|
20
|
+
static SAMPLES = 128;
|
|
21
|
+
/** Knots closer than this (curves live on [0, 1]) are treated as one. */
|
|
22
|
+
static KNOT_TOLERANCE = 1e-9;
|
|
23
|
+
static WEIGHT_TOLERANCE = 1e-9;
|
|
24
|
+
/** Tangent turns above this (radians) across a knot count as a profile corner. */
|
|
25
|
+
static CREASE_ANGLE = 0.01;
|
|
26
|
+
/**
|
|
27
|
+
* Relative curvature jumps above this across a knot count as a feature
|
|
28
|
+
* boundary (e.g. the tangent line→arc junctions of an offset wire's
|
|
29
|
+
* rounded corner). Well below any real feature transition, well above the
|
|
30
|
+
* ~1% wobble of polynomial-approximated conics.
|
|
31
|
+
*/
|
|
32
|
+
static CREASE_CURVATURE_JUMP = 0.3;
|
|
33
|
+
static build(wires) {
|
|
34
|
+
// Mixed rational/polynomial sections can never share a weight vector —
|
|
35
|
+
// skip the doomed rational pass and go straight to the polynomial one.
|
|
36
|
+
const curves = wires.map(wire => SectionCurve.fromWire(wire));
|
|
37
|
+
const rationalCount = curves.filter(curve => curve.IsRational()).length;
|
|
38
|
+
const mixed = rationalCount > 0 && rationalCount < curves.length;
|
|
39
|
+
if (!mixed) {
|
|
40
|
+
const firstTry = SectionCompatibility.buildFromCurves(curves);
|
|
41
|
+
if (firstTry) {
|
|
42
|
+
return firstTry;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
for (const curve of curves) {
|
|
47
|
+
curve.delete();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const secondTry = SectionCompatibility.buildFromCurves(wires.map(wire => SectionCurve.fromWire(wire, true)));
|
|
51
|
+
if (!secondTry) {
|
|
52
|
+
throw new Error("Loft sections could not be reduced to a common rational basis.");
|
|
53
|
+
}
|
|
54
|
+
return secondTry;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Re-proportions every section's parameterization so that the given
|
|
58
|
+
* per-section parameters land on shared targets (their cross-section
|
|
59
|
+
* averages), then re-unifies the basis. Rail-guided lofts use this to make
|
|
60
|
+
* each rail's contact sit at one parameter on every profile — otherwise
|
|
61
|
+
* the surface's flow lines (and any profile corner riding a rail) drift
|
|
62
|
+
* away from the rail between and at the profiles.
|
|
63
|
+
*
|
|
64
|
+
* `sectionParams[k]` lists the contact parameters of section k, one per
|
|
65
|
+
* rail, in matching order across sections. Parameters at the seam are
|
|
66
|
+
* aligned already and are ignored. Returns the input unchanged when there
|
|
67
|
+
* is nothing to align or when the re-unified sections would lose their
|
|
68
|
+
* shared weight vector (exotic rational cases degrade gracefully).
|
|
69
|
+
*/
|
|
70
|
+
static alignParameters(compatible, sectionParams) {
|
|
71
|
+
const sectionCount = compatible.sections.length;
|
|
72
|
+
const railCount = sectionParams[0].length;
|
|
73
|
+
const noTargets = new Array(railCount).fill(null);
|
|
74
|
+
// Rails aligned by the seam itself (or wrapping across it on some
|
|
75
|
+
// profile) are left alone; only rails interior on every profile move.
|
|
76
|
+
const interior = (u) => u > 1e-4 && u < 1 - 1e-4;
|
|
77
|
+
const interiorRails = [];
|
|
78
|
+
for (let g = 0; g < railCount; g++) {
|
|
79
|
+
if (sectionParams.every(params => interior(params[g]))) {
|
|
80
|
+
interiorRails.push(g);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (interiorRails.length === 0) {
|
|
84
|
+
return { compatible, targets: noTargets };
|
|
85
|
+
}
|
|
86
|
+
interiorRails.sort((a, b) => sectionParams[0][a] - sectionParams[0][b]);
|
|
87
|
+
// Rails must keep the same order around every profile.
|
|
88
|
+
const splits = sectionParams.map(params => interiorRails.map(g => params[g]));
|
|
89
|
+
for (const params of splits) {
|
|
90
|
+
for (let j = 1; j < params.length; j++) {
|
|
91
|
+
if (params[j] <= params[j - 1]) {
|
|
92
|
+
return { compatible, targets: noTargets };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const targets = interiorRails.map((_, j) => splits.reduce((sum, params) => sum + params[j], 0) / sectionCount);
|
|
97
|
+
const railTargets = [...noTargets];
|
|
98
|
+
interiorRails.forEach((g, j) => {
|
|
99
|
+
railTargets[g] = targets[j];
|
|
100
|
+
});
|
|
101
|
+
const aligned = splits.every(params => params.every((u, j) => Math.abs(u - targets[j]) < SectionCompatibility.KNOT_TOLERANCE));
|
|
102
|
+
if (aligned) {
|
|
103
|
+
return { compatible, targets: railTargets };
|
|
104
|
+
}
|
|
105
|
+
const oc = getOC();
|
|
106
|
+
const curves = compatible.sections.map((section, k) => {
|
|
107
|
+
const curve = CurveData.build({
|
|
108
|
+
poles: section.poles,
|
|
109
|
+
weights: compatible.weights,
|
|
110
|
+
knots: compatible.knots,
|
|
111
|
+
multiplicities: compatible.multiplicities,
|
|
112
|
+
degree: compatible.degree,
|
|
113
|
+
});
|
|
114
|
+
const bounds = [0, ...splits[k], 1];
|
|
115
|
+
const pieces = [];
|
|
116
|
+
for (let i = 0; i + 1 < bounds.length; i++) {
|
|
117
|
+
pieces.push(oc.GeomConvert.SplitBSplineCurve(curve, bounds[i], bounds[i + 1], 1e-9, true));
|
|
118
|
+
}
|
|
119
|
+
curve.delete();
|
|
120
|
+
const targetSpans = [targets[0], ...targets.slice(1).map((t, j) => t - targets[j]), 1 - targets[targets.length - 1]];
|
|
121
|
+
const reproportioned = SectionCurve.concatenate(pieces, true, targetSpans);
|
|
122
|
+
for (const piece of pieces) {
|
|
123
|
+
piece.delete();
|
|
124
|
+
}
|
|
125
|
+
return reproportioned;
|
|
126
|
+
});
|
|
127
|
+
const rebuilt = SectionCompatibility.rebuildAligned(curves, compatible);
|
|
128
|
+
if (!rebuilt) {
|
|
129
|
+
return { compatible, targets: noTargets };
|
|
130
|
+
}
|
|
131
|
+
return { compatible: rebuilt, targets: railTargets };
|
|
132
|
+
}
|
|
133
|
+
/** Re-unifies re-proportioned section curves, keeping the original frames. */
|
|
134
|
+
static rebuildAligned(curves, original) {
|
|
135
|
+
try {
|
|
136
|
+
SectionCompatibility.unifyDegree(curves);
|
|
137
|
+
SectionCompatibility.unifyKnots(curves);
|
|
138
|
+
const datas = curves.map(curve => CurveData.read(curve));
|
|
139
|
+
const poleCount = datas[0].poles.length;
|
|
140
|
+
for (const data of datas) {
|
|
141
|
+
if (data.poles.length !== poleCount) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const weights = SectionCompatibility.sharedWeights(datas.map(data => data.weights), poleCount);
|
|
146
|
+
if (weights === undefined) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
degree: datas[0].degree,
|
|
151
|
+
knots: datas[0].knots,
|
|
152
|
+
multiplicities: datas[0].multiplicities,
|
|
153
|
+
weights,
|
|
154
|
+
creases: SectionCompatibility.detectCreases(curves),
|
|
155
|
+
sections: datas.map((data, i) => ({
|
|
156
|
+
poles: data.poles,
|
|
157
|
+
centroid: original.sections[i].centroid,
|
|
158
|
+
normal: original.sections[i].normal,
|
|
159
|
+
})),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
for (const curve of curves) {
|
|
164
|
+
curve.delete();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Returns null when the sections end up with mismatched weight vectors. */
|
|
169
|
+
static buildFromCurves(curves) {
|
|
170
|
+
try {
|
|
171
|
+
const frames = SectionCompatibility.orientConsistently(curves);
|
|
172
|
+
SectionCompatibility.alignSeams(curves);
|
|
173
|
+
SectionCompatibility.unifyDegree(curves);
|
|
174
|
+
SectionCompatibility.unifyKnots(curves);
|
|
175
|
+
const datas = curves.map(curve => CurveData.read(curve));
|
|
176
|
+
const poleCount = datas[0].poles.length;
|
|
177
|
+
for (const data of datas) {
|
|
178
|
+
if (data.poles.length !== poleCount) {
|
|
179
|
+
throw new Error("Loft internal error: unified sections have differing pole counts.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const weights = SectionCompatibility.sharedWeights(datas.map(data => data.weights), poleCount);
|
|
183
|
+
if (weights === undefined) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
degree: datas[0].degree,
|
|
188
|
+
knots: datas[0].knots,
|
|
189
|
+
multiplicities: datas[0].multiplicities,
|
|
190
|
+
weights,
|
|
191
|
+
creases: SectionCompatibility.detectCreases(curves),
|
|
192
|
+
sections: datas.map((data, i) => ({
|
|
193
|
+
poles: data.poles,
|
|
194
|
+
centroid: frames[i].centroid,
|
|
195
|
+
normal: frames[i].normal,
|
|
196
|
+
})),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
for (const curve of curves) {
|
|
201
|
+
curve.delete();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The shared weight vector: null if every section is polynomial, the common
|
|
207
|
+
* vector if all rational sections agree (after normalizing to weight 1 on
|
|
208
|
+
* the first pole), and undefined — "no shared basis" — otherwise.
|
|
209
|
+
*/
|
|
210
|
+
static sharedWeights(weightVectors, poleCount) {
|
|
211
|
+
if (weightVectors.every(w => w === null)) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const normalized = weightVectors.map(w => {
|
|
215
|
+
if (w === null) {
|
|
216
|
+
return new Array(poleCount).fill(1);
|
|
217
|
+
}
|
|
218
|
+
const scale = 1 / w[0];
|
|
219
|
+
return w.map(value => value * scale);
|
|
220
|
+
});
|
|
221
|
+
const reference = normalized[0];
|
|
222
|
+
for (const candidate of normalized) {
|
|
223
|
+
for (let i = 0; i < poleCount; i++) {
|
|
224
|
+
if (Math.abs(candidate[i] - reference[i]) > SectionCompatibility.WEIGHT_TOLERANCE) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (reference.every(w => Math.abs(w - 1) < SectionCompatibility.WEIGHT_TOLERANCE)) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
return reference;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Makes all sections wind the same way along the loft: the first section's
|
|
236
|
+
* normal (from its winding, via Newell's method) is oriented towards the
|
|
237
|
+
* next centroid, then each further section is flipped whenever its winding
|
|
238
|
+
* normal opposes its predecessor's. Reversing the curve flips its normal.
|
|
239
|
+
*/
|
|
240
|
+
static orientConsistently(curves) {
|
|
241
|
+
const frames = curves.map(curve => SectionCompatibility.sectionFrame(curve));
|
|
242
|
+
if (curves.length > 1) {
|
|
243
|
+
const advance = frames[1].centroid.subtract(frames[0].centroid);
|
|
244
|
+
if (advance.length() > 1e-9 && frames[0].normal.dot(advance) < 0) {
|
|
245
|
+
curves[0].Reverse();
|
|
246
|
+
frames[0] = { ...frames[0], normal: frames[0].normal.multiply(-1) };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (let i = 1; i < curves.length; i++) {
|
|
250
|
+
if (frames[i].normal.dot(frames[i - 1].normal) < 0) {
|
|
251
|
+
curves[i].Reverse();
|
|
252
|
+
frames[i] = { ...frames[i], normal: frames[i].normal.multiply(-1) };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return frames;
|
|
256
|
+
}
|
|
257
|
+
/** Centroid and winding normal (Newell's method) from uniform parameter samples. */
|
|
258
|
+
static sectionFrame(curve) {
|
|
259
|
+
const points = SectionCompatibility.samplePoints(curve, SectionCompatibility.SAMPLES);
|
|
260
|
+
let cx = 0;
|
|
261
|
+
let cy = 0;
|
|
262
|
+
let cz = 0;
|
|
263
|
+
for (const p of points) {
|
|
264
|
+
cx += p[0];
|
|
265
|
+
cy += p[1];
|
|
266
|
+
cz += p[2];
|
|
267
|
+
}
|
|
268
|
+
const centroid = new Vector3d(cx / points.length, cy / points.length, cz / points.length);
|
|
269
|
+
let nx = 0;
|
|
270
|
+
let ny = 0;
|
|
271
|
+
let nz = 0;
|
|
272
|
+
for (let i = 0; i < points.length; i++) {
|
|
273
|
+
const a = points[i];
|
|
274
|
+
const b = points[(i + 1) % points.length];
|
|
275
|
+
const ax = a[0] - centroid.x;
|
|
276
|
+
const ay = a[1] - centroid.y;
|
|
277
|
+
const az = a[2] - centroid.z;
|
|
278
|
+
const bx = b[0] - centroid.x;
|
|
279
|
+
const by = b[1] - centroid.y;
|
|
280
|
+
const bz = b[2] - centroid.z;
|
|
281
|
+
nx += ay * bz - az * by;
|
|
282
|
+
ny += az * bx - ax * bz;
|
|
283
|
+
nz += ax * by - ay * bx;
|
|
284
|
+
}
|
|
285
|
+
const normal = new Vector3d(nx, ny, nz);
|
|
286
|
+
if (normal.length() < 1e-12) {
|
|
287
|
+
throw new Error("Loft profile is degenerate (no measurable winding).");
|
|
288
|
+
}
|
|
289
|
+
return { centroid, normal: normal.normalize() };
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Chains each section's seam (curve origin) to the point nearest the
|
|
293
|
+
* previous section's seam, so the skinned surface doesn't twist. Closed
|
|
294
|
+
* clamped curves can't shift their origin in place; the curve is split at
|
|
295
|
+
* the new seam and re-concatenated.
|
|
296
|
+
*/
|
|
297
|
+
static alignSeams(curves) {
|
|
298
|
+
const oc = getOC();
|
|
299
|
+
const seam = new oc.gp_Pnt();
|
|
300
|
+
curves[0].D0(0, seam);
|
|
301
|
+
let reference = [seam.X(), seam.Y(), seam.Z()];
|
|
302
|
+
seam.delete();
|
|
303
|
+
for (let i = 1; i < curves.length; i++) {
|
|
304
|
+
const t = SectionCompatibility.closestParameter(curves[i], reference);
|
|
305
|
+
curves[i] = SectionCompatibility.moveSeam(curves[i], t);
|
|
306
|
+
const point = new oc.gp_Pnt();
|
|
307
|
+
curves[i].D0(0, point);
|
|
308
|
+
reference = [point.X(), point.Y(), point.Z()];
|
|
309
|
+
point.delete();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Parameter of the point on the curve closest to `target` (sampling + golden-section refine). */
|
|
313
|
+
static closestParameter(curve, target) {
|
|
314
|
+
const oc = getOC();
|
|
315
|
+
const point = new oc.gp_Pnt();
|
|
316
|
+
const result = closestCurveParameter(t => {
|
|
317
|
+
curve.D0(t, point);
|
|
318
|
+
return [point.X(), point.Y(), point.Z()];
|
|
319
|
+
}, target);
|
|
320
|
+
point.delete();
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Returns the curve re-parameterized so its origin sits at parameter `t`
|
|
325
|
+
* (splitting and re-concatenating), consuming the input. No-op near the
|
|
326
|
+
* existing seam.
|
|
327
|
+
*/
|
|
328
|
+
static moveSeam(curve, t) {
|
|
329
|
+
const oc = getOC();
|
|
330
|
+
if (t < 1e-6 || t > 1 - 1e-6) {
|
|
331
|
+
return curve;
|
|
332
|
+
}
|
|
333
|
+
const tail = oc.GeomConvert.SplitBSplineCurve(curve, t, 1, 1e-9, true);
|
|
334
|
+
const head = oc.GeomConvert.SplitBSplineCurve(curve, 0, t, 1e-9, true);
|
|
335
|
+
const moved = SectionCurve.concatenate([tail, head], true);
|
|
336
|
+
tail.delete();
|
|
337
|
+
head.delete();
|
|
338
|
+
curve.delete();
|
|
339
|
+
return moved;
|
|
340
|
+
}
|
|
341
|
+
static unifyDegree(curves) {
|
|
342
|
+
const degree = Math.max(...curves.map(curve => curve.Degree()));
|
|
343
|
+
for (const curve of curves) {
|
|
344
|
+
if (curve.Degree() < degree) {
|
|
345
|
+
curve.IncreaseDegree(degree);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Merges every section's interior knots into one union (values clustered
|
|
351
|
+
* within `KNOT_TOLERANCE` are snapped to a single representative first)
|
|
352
|
+
* and inserts the union into each curve, so all sections share one knot
|
|
353
|
+
* vector — and therefore one pole count.
|
|
354
|
+
*/
|
|
355
|
+
static unifyKnots(curves) {
|
|
356
|
+
const degree = curves[0].Degree();
|
|
357
|
+
// Cluster interior knot values across sections.
|
|
358
|
+
const allKnots = [];
|
|
359
|
+
for (const curve of curves) {
|
|
360
|
+
for (let i = 2; i < curve.NbKnots(); i++) {
|
|
361
|
+
allKnots.push(curve.Knot(i));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
allKnots.sort((a, b) => a - b);
|
|
365
|
+
const representatives = [];
|
|
366
|
+
for (const knot of allKnots) {
|
|
367
|
+
if (representatives.length === 0
|
|
368
|
+
|| knot - representatives[representatives.length - 1] > SectionCompatibility.KNOT_TOLERANCE) {
|
|
369
|
+
representatives.push(knot);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// Snap each curve's interior knots to their cluster representative.
|
|
373
|
+
for (const curve of curves) {
|
|
374
|
+
for (let i = 2; i < curve.NbKnots(); i++) {
|
|
375
|
+
const knot = curve.Knot(i);
|
|
376
|
+
const representative = SectionCompatibility.nearestRepresentative(representatives, knot);
|
|
377
|
+
if (representative !== knot
|
|
378
|
+
&& representative > curve.Knot(i - 1)
|
|
379
|
+
&& representative < curve.Knot(i + 1)) {
|
|
380
|
+
curve.SetKnot(i, representative);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Union multiplicities per representative, then insert into every curve.
|
|
385
|
+
const targetMults = representatives.map(representative => {
|
|
386
|
+
let mult = 1;
|
|
387
|
+
for (const curve of curves) {
|
|
388
|
+
for (let i = 2; i < curve.NbKnots(); i++) {
|
|
389
|
+
if (Math.abs(curve.Knot(i) - representative) <= SectionCompatibility.KNOT_TOLERANCE) {
|
|
390
|
+
mult = Math.max(mult, curve.Multiplicity(i));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return Math.min(mult, degree);
|
|
395
|
+
});
|
|
396
|
+
if (representatives.length === 0) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
for (const curve of curves) {
|
|
400
|
+
const [knots, disposeKnots] = NCollections.toArray1Double(representatives);
|
|
401
|
+
const [mults, disposeMults] = NCollections.toArray1Int(targetMults);
|
|
402
|
+
curve.InsertKnots(knots, mults, SectionCompatibility.KNOT_TOLERANCE, false);
|
|
403
|
+
disposeKnots();
|
|
404
|
+
disposeMults();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Interior knots where any section changes character: only knots of full
|
|
409
|
+
* multiplicity (structurally C0) can. Two kinds of feature boundary count:
|
|
410
|
+
* a tangent kink (a profile corner — turns by degrees, while smooth
|
|
411
|
+
* junctions from concatenation or seam moves turn by ~0), and a curvature
|
|
412
|
+
* jump (the tangent line→arc junctions of rounded/offset corners — the
|
|
413
|
+
* legacy loft splits faces there too, and without the split the near-crease
|
|
414
|
+
* band renders smeared and offers no edge).
|
|
415
|
+
*/
|
|
416
|
+
static detectCreases(curves) {
|
|
417
|
+
const oc = getOC();
|
|
418
|
+
const degree = curves[0].Degree();
|
|
419
|
+
const point = new oc.gp_Pnt();
|
|
420
|
+
const first = new oc.gp_Vec();
|
|
421
|
+
const second = new oc.gp_Vec();
|
|
422
|
+
const stateAt = (curve, t) => {
|
|
423
|
+
curve.D2(t, point, first, second);
|
|
424
|
+
const tangent = new Vector3d(first.X(), first.Y(), first.Z());
|
|
425
|
+
const speed = tangent.length();
|
|
426
|
+
const curvature = tangent
|
|
427
|
+
.cross(new Vector3d(second.X(), second.Y(), second.Z()))
|
|
428
|
+
.length() / (speed * speed * speed);
|
|
429
|
+
return { direction: tangent.normalize(), curvature };
|
|
430
|
+
};
|
|
431
|
+
const creases = [];
|
|
432
|
+
for (let i = 2; i < curves[0].NbKnots(); i++) {
|
|
433
|
+
if (curves[0].Multiplicity(i) < degree) {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const knot = curves[0].Knot(i);
|
|
437
|
+
const step = Math.min(knot - curves[0].Knot(i - 1), curves[0].Knot(i + 1) - knot) * 1e-3;
|
|
438
|
+
for (const curve of curves) {
|
|
439
|
+
const before = stateAt(curve, knot - step);
|
|
440
|
+
const after = stateAt(curve, knot + step);
|
|
441
|
+
const angle = Math.acos(Math.min(1, Math.max(-1, before.direction.dot(after.direction))));
|
|
442
|
+
const maxCurvature = Math.max(before.curvature, after.curvature);
|
|
443
|
+
const curvatureJump = maxCurvature > 1e-4
|
|
444
|
+
? Math.abs(before.curvature - after.curvature) / maxCurvature
|
|
445
|
+
: 0;
|
|
446
|
+
if (angle > SectionCompatibility.CREASE_ANGLE
|
|
447
|
+
|| curvatureJump > SectionCompatibility.CREASE_CURVATURE_JUMP) {
|
|
448
|
+
creases.push(knot);
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
point.delete();
|
|
454
|
+
first.delete();
|
|
455
|
+
second.delete();
|
|
456
|
+
return creases;
|
|
457
|
+
}
|
|
458
|
+
static nearestRepresentative(representatives, value) {
|
|
459
|
+
let best = value;
|
|
460
|
+
let bestDistance = Infinity;
|
|
461
|
+
for (const representative of representatives) {
|
|
462
|
+
const distance = Math.abs(representative - value);
|
|
463
|
+
if (distance < bestDistance) {
|
|
464
|
+
bestDistance = distance;
|
|
465
|
+
best = representative;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return bestDistance <= SectionCompatibility.KNOT_TOLERANCE ? best : value;
|
|
469
|
+
}
|
|
470
|
+
static samplePoints(curve, count) {
|
|
471
|
+
const oc = getOC();
|
|
472
|
+
const point = new oc.gp_Pnt();
|
|
473
|
+
const points = [];
|
|
474
|
+
for (let i = 0; i < count; i++) {
|
|
475
|
+
curve.D0(i / count, point);
|
|
476
|
+
points.push([point.X(), point.Y(), point.Z()]);
|
|
477
|
+
}
|
|
478
|
+
point.delete();
|
|
479
|
+
return points;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Geom_BSplineCurve, TopoDS_Wire } from "ocjs-fluidcad";
|
|
2
|
+
/**
|
|
3
|
+
* Turns a profile wire into a single clamped B-spline curve, parameterized
|
|
4
|
+
* over [0, 1] with spans proportional to edge arc length. Corners survive as
|
|
5
|
+
* interior knots of multiplicity `degree` (C0), so any planar profile — a
|
|
6
|
+
* polygon, a slot, a circle — becomes exactly one curve. That single-curve
|
|
7
|
+
* form is what makes profiles with different edge counts loftable against
|
|
8
|
+
* each other: compatibility reduces to sharing one degree and one knot
|
|
9
|
+
* vector instead of matching edges pairwise.
|
|
10
|
+
*/
|
|
11
|
+
export declare class SectionCurve {
|
|
12
|
+
private static readonly APPROX_TOLERANCE;
|
|
13
|
+
private static readonly LENGTH_SAMPLES;
|
|
14
|
+
/**
|
|
15
|
+
* With `forcePolynomial`, rational pieces (arcs, circles) are approximated
|
|
16
|
+
* as polynomial B-splines before concatenation — piece-by-piece, so profile
|
|
17
|
+
* corners stay sharp. Used when sections with different weight structures
|
|
18
|
+
* must share one surface (see `SectionCompatibility`).
|
|
19
|
+
*/
|
|
20
|
+
static fromWire(wire: TopoDS_Wire, forcePolynomial?: boolean): Geom_BSplineCurve;
|
|
21
|
+
/**
|
|
22
|
+
* Concatenates ordered, connected pieces into one clamped B-spline over
|
|
23
|
+
* [0, 1]: junctions become interior knots of multiplicity `degree` and the
|
|
24
|
+
* shared endpoint poles collapse into one. If any piece is rational, every
|
|
25
|
+
* piece contributes weights. A NURBS is unchanged under a global weight
|
|
26
|
+
* scale, and the only cross-piece constraint is agreement at the shared
|
|
27
|
+
* junction pole — so each piece is chain-scaled to match its predecessor's
|
|
28
|
+
* trailing weight (the first piece is anchored at weight 1).
|
|
29
|
+
*
|
|
30
|
+
* Pieces span parameter ranges proportional to their arc length, unless
|
|
31
|
+
* `spans` prescribes the fractions explicitly (rail-contact alignment
|
|
32
|
+
* re-proportions sections so matching features share parameters).
|
|
33
|
+
*/
|
|
34
|
+
static concatenate(pieces: Geom_BSplineCurve[], closed: boolean, spans?: number[]): Geom_BSplineCurve;
|
|
35
|
+
/**
|
|
36
|
+
* Approximates any B-spline with a polynomial (non-rational) one within
|
|
37
|
+
* `tolerance`, as a chain of cubic Hermite segments (position + tangent
|
|
38
|
+
* matched at the ends of every segment, so the result stays G1). Segments
|
|
39
|
+
* split at the curve's own knots — rational conversions are typically only
|
|
40
|
+
* C1 there, and keeping the reduced-continuity points on segment
|
|
41
|
+
* boundaries preserves O(h⁴) convergence — then subdivide until every
|
|
42
|
+
* segment fits. Pole count stays proportional to the geometry's curvature
|
|
43
|
+
* (a circle needs a few dozen poles), never to a sample budget.
|
|
44
|
+
*/
|
|
45
|
+
static toPolynomial(curve: Geom_BSplineCurve, tolerance: number): Geom_BSplineCurve;
|
|
46
|
+
/** Largest distance between the Hermite segments and the curve, sampled inside each segment. */
|
|
47
|
+
private static maxHermiteDeviation;
|
|
48
|
+
/** Joins cubic Bézier segments into one B-spline over the original parameter range. */
|
|
49
|
+
private static assembleCubicSegments;
|
|
50
|
+
/** Curve arc length approximated by chord sampling — used only to proportion knot spans. */
|
|
51
|
+
private static approximateLength;
|
|
52
|
+
private static edgeToBSpline;
|
|
53
|
+
}
|