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.
Files changed (113) hide show
  1. package/lib/dist/core/2d/aline.d.ts +0 -8
  2. package/lib/dist/core/2d/aline.js +3 -22
  3. package/lib/dist/core/2d/arc.d.ts +1 -23
  4. package/lib/dist/core/2d/arc.js +11 -28
  5. package/lib/dist/core/2d/circle.d.ts +1 -8
  6. package/lib/dist/core/2d/circle.js +7 -24
  7. package/lib/dist/core/2d/ellipse.d.ts +1 -17
  8. package/lib/dist/core/2d/ellipse.js +7 -45
  9. package/lib/dist/core/2d/hline.d.ts +0 -7
  10. package/lib/dist/core/2d/hline.js +5 -27
  11. package/lib/dist/core/2d/intersect.d.ts +0 -7
  12. package/lib/dist/core/2d/intersect.js +0 -13
  13. package/lib/dist/core/2d/line.d.ts +1 -8
  14. package/lib/dist/core/2d/line.js +6 -24
  15. package/lib/dist/core/2d/offset.d.ts +1 -11
  16. package/lib/dist/core/2d/offset.js +0 -16
  17. package/lib/dist/core/2d/polygon.d.ts +1 -17
  18. package/lib/dist/core/2d/polygon.js +18 -37
  19. package/lib/dist/core/2d/project.d.ts +0 -7
  20. package/lib/dist/core/2d/project.js +0 -13
  21. package/lib/dist/core/2d/rect.d.ts +1 -9
  22. package/lib/dist/core/2d/rect.js +0 -19
  23. package/lib/dist/core/2d/slot.d.ts +0 -23
  24. package/lib/dist/core/2d/slot.js +18 -37
  25. package/lib/dist/core/2d/text.d.ts +0 -7
  26. package/lib/dist/core/2d/text.js +0 -14
  27. package/lib/dist/core/2d/vline.d.ts +0 -7
  28. package/lib/dist/core/2d/vline.js +5 -23
  29. package/lib/dist/core/fillet.js +7 -0
  30. package/lib/dist/core/index.d.ts +1 -1
  31. package/lib/dist/core/interfaces.d.ts +43 -0
  32. package/lib/dist/core/plane.d.ts +26 -6
  33. package/lib/dist/core/plane.js +21 -44
  34. package/lib/dist/features/2d/connect.d.ts +2 -0
  35. package/lib/dist/features/2d/connect.js +3 -0
  36. package/lib/dist/features/2d/intersect.js +4 -1
  37. package/lib/dist/features/2d/offset.js +2 -2
  38. package/lib/dist/features/frozen-geometry.d.ts +29 -0
  39. package/lib/dist/features/frozen-geometry.js +62 -0
  40. package/lib/dist/features/loft.d.ts +38 -1
  41. package/lib/dist/features/loft.js +133 -8
  42. package/lib/dist/features/plane-from-object.d.ts +16 -4
  43. package/lib/dist/features/plane-from-object.js +101 -8
  44. package/lib/dist/features/rib.js +11 -1
  45. package/lib/dist/features/shell.d.ts +1 -1
  46. package/lib/dist/features/shell.js +9 -0
  47. package/lib/dist/features/sweep.d.ts +11 -1
  48. package/lib/dist/features/sweep.js +43 -7
  49. package/lib/dist/helpers/clone-transform.js +62 -5
  50. package/lib/dist/helpers/scene-helpers.d.ts +12 -0
  51. package/lib/dist/helpers/scene-helpers.js +26 -0
  52. package/lib/dist/math/bspline-interpolation.d.ts +33 -6
  53. package/lib/dist/math/bspline-interpolation.js +187 -84
  54. package/lib/dist/oc/loft/constrained-loft.d.ts +32 -0
  55. package/lib/dist/oc/loft/constrained-loft.js +61 -0
  56. package/lib/dist/oc/loft/curve-data.d.ts +18 -0
  57. package/lib/dist/oc/loft/curve-data.js +54 -0
  58. package/lib/dist/oc/loft/curve-eval.d.ts +23 -0
  59. package/lib/dist/oc/loft/curve-eval.js +84 -0
  60. package/lib/dist/oc/loft/guided-loft.d.ts +89 -0
  61. package/lib/dist/oc/loft/guided-loft.js +368 -0
  62. package/lib/dist/oc/loft/section-compatibility.d.ts +131 -0
  63. package/lib/dist/oc/loft/section-compatibility.js +481 -0
  64. package/lib/dist/oc/loft/section-curve.d.ts +53 -0
  65. package/lib/dist/oc/loft/section-curve.js +264 -0
  66. package/lib/dist/oc/loft/skinning.d.ts +104 -0
  67. package/lib/dist/oc/loft/skinning.js +299 -0
  68. package/lib/dist/oc/loft-ops.d.ts +28 -1
  69. package/lib/dist/oc/loft-ops.js +31 -1
  70. package/lib/dist/oc/ncollection.d.ts +19 -0
  71. package/lib/dist/oc/ncollection.js +69 -0
  72. package/lib/dist/oc/rib-ops.js +5 -17
  73. package/lib/dist/oc/sweep-ops.d.ts +7 -16
  74. package/lib/dist/oc/sweep-ops.js +67 -99
  75. package/lib/dist/oc/thin-face-maker.d.ts +0 -19
  76. package/lib/dist/oc/thin-face-maker.js +3 -68
  77. package/lib/dist/oc/wire-extend-ops.d.ts +12 -0
  78. package/lib/dist/oc/wire-extend-ops.js +42 -0
  79. package/lib/dist/oc/wire-ops.d.ts +28 -2
  80. package/lib/dist/oc/wire-ops.js +88 -4
  81. package/lib/dist/oc/wrap-ops.js +8 -15
  82. package/lib/dist/tests/features/2d/circle.test.js +0 -10
  83. package/lib/dist/tests/features/2d/ellipse.test.js +0 -30
  84. package/lib/dist/tests/features/2d/line.test.js +0 -5
  85. package/lib/dist/tests/features/2d/offset.test.js +74 -11
  86. package/lib/dist/tests/features/2d/polygon.test.js +0 -16
  87. package/lib/dist/tests/features/2d/rect.test.js +0 -11
  88. package/lib/dist/tests/features/2d/slot.test.js +0 -8
  89. package/lib/dist/tests/features/fillet2d.test.js +37 -1
  90. package/lib/dist/tests/features/loft-conditions.test.d.ts +1 -0
  91. package/lib/dist/tests/features/loft-conditions.test.js +246 -0
  92. package/lib/dist/tests/features/loft-guides.test.d.ts +1 -0
  93. package/lib/dist/tests/features/loft-guides.test.js +276 -0
  94. package/lib/dist/tests/features/plane.test.js +95 -0
  95. package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.d.ts +1 -0
  96. package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.js +35 -0
  97. package/lib/dist/tests/features/rib.test.js +50 -0
  98. package/lib/dist/tests/features/shell.test.js +35 -1
  99. package/lib/dist/tests/features/sweep.test.js +127 -1
  100. package/lib/dist/tests/features/text.test.js +33 -14
  101. package/lib/dist/tests/math/bspline-interpolation.test.js +97 -1
  102. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  103. package/llm-docs/api/index.json +1 -1
  104. package/llm-docs/api/types/loft.md +36 -1
  105. package/llm-docs/api/types/sweep.md +13 -1
  106. package/llm-docs/api/types/text.md +2 -2
  107. package/llm-docs/index.json +5 -5
  108. package/package.json +1 -1
  109. package/server/dist/fluidcad-server.d.ts +1 -1
  110. package/server/dist/fluidcad-server.js +11 -1
  111. package/server/dist/routes/params.js +1 -1
  112. package/ui/dist/assets/{index-no7mtr5s.js → index-D8zV21wB.js} +83 -83
  113. package/ui/dist/index.html +1 -1
@@ -0,0 +1,368 @@
1
+ import { Vector3d } from "../../math/vector3d.js";
2
+ import { findSpan, basisFunctions } from "../../math/bspline-interpolation.js";
3
+ import { SectionCompatibility } from "./section-compatibility.js";
4
+ import { SectionCurve } from "./section-curve.js";
5
+ import { CurveData } from "./curve-data.js";
6
+ import { Skinning } from "./skinning.js";
7
+ import { evaluateBSplinePoint, closestCurveParameter, flattenKnots } from "./curve-eval.js";
8
+ /**
9
+ * Loft steered by one or two side rails. OCC's pipe-shell machinery distorts
10
+ * pinned sections along curved rails (they tilt with the sweep frame, and
11
+ * the auxiliary-spine contact mode rotates and smears non-circular sections),
12
+ * so this path builds the surface itself:
13
+ *
14
+ * 1. skin the profiles alone (`Skinning`) — the unguided base surface,
15
+ * 2. slice the base into virtual sections between the profiles,
16
+ * 3. move each virtual section rigidly so its rail-contact point rides the
17
+ * guide (one rail), or map its two contact points onto both rails with a
18
+ * rotation + axial stretch (two rails) — profiles themselves stay exact,
19
+ * 4. re-skin through the full stack and cap/sew into a solid.
20
+ *
21
+ * Guides must pass through every profile: sections are pinned where they
22
+ * were sketched, so a rail that misses a profile is unsatisfiable.
23
+ *
24
+ * Start/end conditions compose with guides: the condition's takeoff field
25
+ * fades smoothly to the natural (unconstrained) takeoff around each guide's
26
+ * contact point — the rails own their sides of the surface, the condition
27
+ * shapes the rest. The faded fields drive both the base skin (so the
28
+ * virtual sections carry the conditioned shape) and the final re-skin (so
29
+ * the takeoff is pinned exactly where the condition is in force).
30
+ */
31
+ export class GuidedLoft {
32
+ /** Virtual sections inserted between consecutive profiles. */
33
+ static SECTIONS_PER_SPAN = 7;
34
+ /** How far (mm) a guide may sit from a profile boundary and still count as touching. */
35
+ static CONTACT_TOLERANCE = 1e-3;
36
+ /** Fallback condition-falloff radius (in section parameter) for a single guide. */
37
+ static SINGLE_GUIDE_FALLOFF = 0.25;
38
+ static build(profileWires, guideWires, startCondition, endCondition) {
39
+ if (guideWires.length < 1 || guideWires.length > 2) {
40
+ throw new Error("Guided loft supports one or two guide curves.");
41
+ }
42
+ for (const wire of profileWires) {
43
+ if (!wire.isClosed()) {
44
+ throw new Error("Loft with guides requires closed profiles.");
45
+ }
46
+ }
47
+ let compatible = SectionCompatibility.build(profileWires.map(w => w.getShape()));
48
+ const guides = guideWires.map(wire => {
49
+ const curve = SectionCurve.fromWire(wire.getShape());
50
+ const data = CurveData.read(curve);
51
+ curve.delete();
52
+ return data;
53
+ });
54
+ const anchors = guides.map((guide, guideIndex) => GuidedLoft.anchorGuide(guide, guideIndex, compatible));
55
+ // Vertex matching: re-proportion the sections so each rail's contact
56
+ // sits at ONE parameter on every profile. Otherwise the flow line under
57
+ // the rail drifts sideways between profiles (a corner riding a rail
58
+ // would leave the rail), because the pinned point u-lerps while the
59
+ // corner stays at its own knot.
60
+ const wrap = (u) => ((u % 1) + 1) % 1;
61
+ const contactParams = compatible.sections.map((_, k) => guides.map((_, g) => wrap(anchors[g][k].u)));
62
+ const alignment = SectionCompatibility.alignParameters(compatible, contactParams);
63
+ compatible = alignment.compatible;
64
+ alignment.targets.forEach((target, g) => {
65
+ if (target !== null) {
66
+ for (const anchor of anchors[g]) {
67
+ anchor.u = target;
68
+ }
69
+ }
70
+ });
71
+ const base = GuidedLoft.skinBase(compatible, anchors, startCondition, endCondition);
72
+ const { sections, params } = GuidedLoft.buildSectionStack(compatible, base, guides, anchors);
73
+ const skinned = Skinning.interpolateColumns(sections, params, GuidedLoft.stackField(compatible, anchors, startCondition, base.averageLength, sections, params, false), GuidedLoft.stackField(compatible, anchors, endCondition, base.averageLength, sections, params, true));
74
+ return [Skinning.buildLoftSolid(compatible, skinned.grid, skinned.vBasis)];
75
+ }
76
+ /**
77
+ * The unguided base skin, with any end conditions already faded around the
78
+ * guide contacts (blended towards the natural chord takeoff, per column).
79
+ */
80
+ static skinBase(compatible, anchors, startCondition, endCondition) {
81
+ const { sections } = compatible;
82
+ const { params, averageLength } = Skinning.loftParameters(sections);
83
+ const columns = sections.map(section => section.poles);
84
+ const startField = GuidedLoft.stackField(compatible, anchors, startCondition, averageLength, columns, params, false);
85
+ const endField = GuidedLoft.stackField(compatible, anchors, endCondition, averageLength, columns, params, true);
86
+ const { grid, vBasis } = Skinning.interpolateColumns(columns, params, startField, endField);
87
+ return { grid, vBasis, params, averageLength };
88
+ }
89
+ /**
90
+ * The condition's takeoff field for one end of a section stack, faded to
91
+ * the stack's own natural takeoff (the chord to the neighbouring section)
92
+ * around each guide contact. Null when no condition applies to that end.
93
+ */
94
+ static stackField(compatible, anchors, condition, averageLength, sections, params, isEnd) {
95
+ if (!condition) {
96
+ return null;
97
+ }
98
+ const endIndex = isEnd ? compatible.sections.length - 1 : 0;
99
+ const conditionField = Skinning.derivativeField(compatible.sections[endIndex], condition, averageLength, isEnd);
100
+ const contacts = anchors.map(anchor => {
101
+ const u = anchor[endIndex].u;
102
+ return ((u % 1) + 1) % 1;
103
+ });
104
+ const weights = GuidedLoft.falloffWeights(compatible, contacts);
105
+ const last = sections.length - 1;
106
+ const [nearIndex, farIndex] = isEnd ? [last, last - 1] : [0, 1];
107
+ const chordSpan = Math.abs(params[nearIndex] - params[farIndex]);
108
+ return conditionField.map((target, i) => {
109
+ // Natural takeoff: the chord towards the neighbouring stack section
110
+ // (points along increasing v at both ends).
111
+ const natural = sections[nearIndex][i].map((v, d) => ((isEnd ? v - sections[farIndex][i][d] : sections[farIndex][i][d] - v)) / chordSpan);
112
+ const w = weights[i];
113
+ return target.map((t, d) => natural[d] + (t - natural[d]) * w);
114
+ });
115
+ }
116
+ /**
117
+ * Per-column condition weight: 0 at each guide contact, smoothstepping to
118
+ * 1 beyond the falloff radius (half the closest contact separation for two
119
+ * guides). Columns are located by their Greville parameters.
120
+ */
121
+ static falloffWeights(compatible, contacts) {
122
+ const circularDistance = (a, b) => {
123
+ const d = Math.abs(a - b) % 1;
124
+ return Math.min(d, 1 - d);
125
+ };
126
+ let radius = GuidedLoft.SINGLE_GUIDE_FALLOFF;
127
+ for (let a = 0; a < contacts.length; a++) {
128
+ for (let b = a + 1; b < contacts.length; b++) {
129
+ radius = Math.min(radius, circularDistance(contacts[a], contacts[b]) / 2);
130
+ }
131
+ }
132
+ radius = Math.max(radius, 0.05);
133
+ const flat = flattenKnots(compatible.knots, compatible.multiplicities);
134
+ const degree = compatible.degree;
135
+ const poleCount = compatible.sections[0].poles.length;
136
+ const weights = [];
137
+ for (let i = 0; i < poleCount; i++) {
138
+ let greville = 0;
139
+ for (let j = 1; j <= degree; j++) {
140
+ greville += flat[i + j];
141
+ }
142
+ greville /= degree;
143
+ const wrapped = ((greville % 1) + 1) % 1;
144
+ let distance = Infinity;
145
+ for (const contact of contacts) {
146
+ distance = Math.min(distance, circularDistance(wrapped, contact));
147
+ }
148
+ const t = Math.min(1, Math.max(0, distance / radius));
149
+ weights.push(t * t * (3 - 2 * t));
150
+ }
151
+ return weights;
152
+ }
153
+ /**
154
+ * Locates the guide's contact with every profile: the guide parameter
155
+ * where it crosses the profile plane, and the profile parameter of the
156
+ * nearest boundary point. Off-boundary rails are reported with their gap —
157
+ * sections stay where they were sketched, so such rails are unsatisfiable.
158
+ */
159
+ static anchorGuide(guide, guideIndex, compatible) {
160
+ const anchors = [];
161
+ for (let k = 0; k < compatible.sections.length; k++) {
162
+ const section = compatible.sections[k];
163
+ const t = GuidedLoft.planeCrossing(guide, section.centroid, section.normal);
164
+ if (t === null) {
165
+ throw new Error(`Loft guide ${guideIndex + 1} does not cross the plane of profile ${k + 1} — `
166
+ + "each guide must pass through every profile.");
167
+ }
168
+ const point = evaluateBSplinePoint(guide, t);
169
+ const sectionCurve = {
170
+ degree: compatible.degree,
171
+ knots: compatible.knots,
172
+ multiplicities: compatible.multiplicities,
173
+ poles: section.poles,
174
+ weights: compatible.weights,
175
+ };
176
+ let u = closestCurveParameter(tt => evaluateBSplinePoint(sectionCurve, tt), point);
177
+ const boundaryPoint = evaluateBSplinePoint(sectionCurve, u);
178
+ const gap = Math.hypot(boundaryPoint[0] - point[0], boundaryPoint[1] - point[1], boundaryPoint[2] - point[2]);
179
+ if (gap > GuidedLoft.CONTACT_TOLERANCE) {
180
+ throw new Error(`Loft guide ${guideIndex + 1} does not touch profile ${k + 1} `
181
+ + `(gap ${gap.toFixed(3)} mm) — guides must pass through every profile boundary.`);
182
+ }
183
+ // Sections are closed in u — unwrap so per-span interpolation of the
184
+ // contact parameter never jumps across the seam.
185
+ if (k > 0) {
186
+ while (u - anchors[k - 1].u > 0.5) {
187
+ u -= 1;
188
+ }
189
+ while (u - anchors[k - 1].u < -0.5) {
190
+ u += 1;
191
+ }
192
+ }
193
+ anchors.push({ t, u, point });
194
+ }
195
+ return anchors;
196
+ }
197
+ /**
198
+ * Guide parameter where the guide crosses the plane (centroid, normal):
199
+ * the sign-change bracket (or on-plane endpoint) whose crossing point lies
200
+ * closest to the centroid, refined by bisection. Null when the guide never
201
+ * reaches the plane.
202
+ */
203
+ static planeCrossing(guide, centroid, normal) {
204
+ const signedDistance = (t) => {
205
+ const p = evaluateBSplinePoint(guide, t);
206
+ return (p[0] - centroid.x) * normal.x + (p[1] - centroid.y) * normal.y + (p[2] - centroid.z) * normal.z;
207
+ };
208
+ const samples = 256;
209
+ const values = [];
210
+ for (let i = 0; i <= samples; i++) {
211
+ values.push(signedDistance(i / samples));
212
+ }
213
+ const candidates = [];
214
+ if (Math.abs(values[0]) <= GuidedLoft.CONTACT_TOLERANCE) {
215
+ candidates.push(0);
216
+ }
217
+ if (Math.abs(values[samples]) <= GuidedLoft.CONTACT_TOLERANCE) {
218
+ candidates.push(1);
219
+ }
220
+ for (let i = 0; i < samples; i++) {
221
+ if (values[i] === 0 || values[i] * values[i + 1] < 0) {
222
+ let low = i / samples;
223
+ let high = (i + 1) / samples;
224
+ let lowValue = values[i];
225
+ for (let iteration = 0; iteration < 60; iteration++) {
226
+ const mid = (low + high) / 2;
227
+ const midValue = signedDistance(mid);
228
+ if (lowValue * midValue <= 0) {
229
+ high = mid;
230
+ }
231
+ else {
232
+ low = mid;
233
+ lowValue = midValue;
234
+ }
235
+ }
236
+ candidates.push((low + high) / 2);
237
+ }
238
+ }
239
+ let best = null;
240
+ let bestDistance = Infinity;
241
+ for (const t of candidates) {
242
+ const p = evaluateBSplinePoint(guide, t);
243
+ const distance = Math.hypot(p[0] - centroid.x, p[1] - centroid.y, p[2] - centroid.z);
244
+ if (distance < bestDistance) {
245
+ bestDistance = distance;
246
+ best = t;
247
+ }
248
+ }
249
+ return best;
250
+ }
251
+ /**
252
+ * The full section stack: the exact profiles at their own parameters, and
253
+ * between each pair the base surface's iso-sections carried onto the rails.
254
+ */
255
+ static buildSectionStack(compatible, base, guides, anchors) {
256
+ const sections = [];
257
+ const params = [];
258
+ for (let k = 0; k < compatible.sections.length; k++) {
259
+ sections.push(compatible.sections[k].poles);
260
+ params.push(base.params[k]);
261
+ if (k === compatible.sections.length - 1) {
262
+ break;
263
+ }
264
+ for (let m = 1; m <= GuidedLoft.SECTIONS_PER_SPAN; m++) {
265
+ const s = m / (GuidedLoft.SECTIONS_PER_SPAN + 1);
266
+ const v = base.params[k] + (base.params[k + 1] - base.params[k]) * s;
267
+ const poles = GuidedLoft.isoSectionPoles(base, v);
268
+ sections.push(GuidedLoft.applyRails(poles, compatible, guides, anchors, k, s));
269
+ params.push(v);
270
+ }
271
+ }
272
+ return { sections, params };
273
+ }
274
+ /** Poles of the base surface's iso-section at loft parameter v (a v-basis row blend). */
275
+ static isoSectionPoles(base, v) {
276
+ const flat = flattenKnots(base.vBasis.knots, base.vBasis.multiplicities);
277
+ const vPoleCount = base.grid[0].length;
278
+ const span = findSpan(vPoleCount, base.vBasis.degree, v, flat);
279
+ const values = basisFunctions(span, v, base.vBasis.degree, flat);
280
+ return base.grid.map(row => {
281
+ const pole = [0, 0, 0];
282
+ for (let j = 0; j <= base.vBasis.degree; j++) {
283
+ const source = row[span - base.vBasis.degree + j];
284
+ pole[0] += values[j] * source[0];
285
+ pole[1] += values[j] * source[1];
286
+ pole[2] += values[j] * source[2];
287
+ }
288
+ return pole;
289
+ });
290
+ }
291
+ /**
292
+ * Carries a virtual section onto the rails. One rail translates the
293
+ * section rigidly; two rails additionally rotate and stretch it along the
294
+ * contact axis so both contact points land on their rails. Affine maps
295
+ * leave NURBS weights untouched, so transforming poles transforms the
296
+ * section exactly.
297
+ */
298
+ static applyRails(poles, compatible, guides, anchors, span, s) {
299
+ const sectionCurve = {
300
+ degree: compatible.degree,
301
+ knots: compatible.knots,
302
+ multiplicities: compatible.multiplicities,
303
+ poles,
304
+ weights: compatible.weights,
305
+ };
306
+ const current = [];
307
+ const target = [];
308
+ for (let g = 0; g < guides.length; g++) {
309
+ const from = anchors[g][span];
310
+ const to = anchors[g][span + 1];
311
+ const u = from.u + (to.u - from.u) * s;
312
+ const wrapped = ((u % 1) + 1) % 1;
313
+ const a = evaluateBSplinePoint(sectionCurve, wrapped);
314
+ current.push(new Vector3d(a[0], a[1], a[2]));
315
+ const t = from.t + (to.t - from.t) * s;
316
+ const A = evaluateBSplinePoint(guides[g], t);
317
+ target.push(new Vector3d(A[0], A[1], A[2]));
318
+ }
319
+ if (guides.length === 1) {
320
+ const delta = target[0].subtract(current[0]);
321
+ return poles.map(p => [p[0] + delta.x, p[1] + delta.y, p[2] + delta.z]);
322
+ }
323
+ return GuidedLoft.mapSegment(poles, current[0], current[1], target[0], target[1]);
324
+ }
325
+ /**
326
+ * Affine map taking segment (a1, a2) onto (A1, A2): stretch along the
327
+ * segment axis, minimal rotation aligning the axes, rigid perpendicular
328
+ * components. Degenerate segments fall back to the average translation.
329
+ */
330
+ static mapSegment(poles, a1, a2, A1, A2) {
331
+ const d = a2.subtract(a1);
332
+ const D = A2.subtract(A1);
333
+ if (d.length() < 1e-9 || D.length() < 1e-9) {
334
+ const delta = A1.subtract(a1).add(A2.subtract(a2)).multiply(0.5);
335
+ return poles.map(p => [p[0] + delta.x, p[1] + delta.y, p[2] + delta.z]);
336
+ }
337
+ const dHat = d.normalize();
338
+ const DHat = D.normalize();
339
+ const stretch = D.length() / d.length();
340
+ const rotate = GuidedLoft.minimalRotation(dHat, DHat);
341
+ return poles.map(p => {
342
+ const relative = new Vector3d(p[0] - a1.x, p[1] - a1.y, p[2] - a1.z);
343
+ const axial = relative.dot(dHat);
344
+ const perpendicular = relative.subtract(dHat.multiply(axial));
345
+ const mapped = DHat.multiply(axial * stretch).add(rotate(perpendicular));
346
+ return [A1.x + mapped.x, A1.y + mapped.y, A1.z + mapped.z];
347
+ });
348
+ }
349
+ /** Rodrigues rotation taking unit vector `from` to unit vector `to`. */
350
+ static minimalRotation(from, to) {
351
+ const cosAngle = from.dot(to);
352
+ const axis = from.cross(to);
353
+ const sinAngle = axis.length();
354
+ if (sinAngle < 1e-12) {
355
+ if (cosAngle > 0) {
356
+ return v => v;
357
+ }
358
+ // Antiparallel: 180° about any axis perpendicular to `from`.
359
+ const helper = Math.abs(from.x) < 0.9 ? new Vector3d(1, 0, 0) : new Vector3d(0, 1, 0);
360
+ const perpendicular = from.cross(helper).normalize();
361
+ return v => perpendicular.multiply(2 * v.dot(perpendicular)).subtract(v);
362
+ }
363
+ const axisHat = axis.normalize();
364
+ return v => v.multiply(cosAngle)
365
+ .add(axisHat.cross(v).multiply(sinAngle))
366
+ .add(axisHat.multiply(axisHat.dot(v) * (1 - cosAngle)));
367
+ }
368
+ }
@@ -0,0 +1,131 @@
1
+ import type { TopoDS_Wire } from "ocjs-fluidcad";
2
+ import { Vector3d } from "../../math/vector3d.js";
3
+ /** One profile as a compatible section: poles only — knots/degree/weights are shared. */
4
+ export interface CompatibleSection {
5
+ poles: number[][];
6
+ /** Centroid of the section (uniform parameter sampling). */
7
+ centroid: Vector3d;
8
+ /** Unit section normal, consistently oriented from first section towards last. */
9
+ normal: Vector3d;
10
+ }
11
+ /**
12
+ * Sections reduced to a common B-spline basis: every section shares the same
13
+ * degree, knot vector, and weight vector, differing only in poles — exactly
14
+ * the form a skinned `Geom_BSplineSurface` needs (its u-basis is this shared
15
+ * basis; its v-poles come from interpolating matching pole columns).
16
+ */
17
+ export interface CompatibleSections {
18
+ degree: number;
19
+ knots: number[];
20
+ multiplicities: number[];
21
+ /** Shared weight vector, or null when all sections are polynomial. */
22
+ weights: number[] | null;
23
+ /**
24
+ * Interior knots where at least one section has a real tangent kink
25
+ * (profile corners). The skinned surface must be split into separate
26
+ * faces there — a corner buried inside one face renders with smeared
27
+ * normals and offers no edge to select or fillet.
28
+ */
29
+ creases: number[];
30
+ sections: CompatibleSection[];
31
+ }
32
+ /**
33
+ * Brings profile wires onto a common B-spline basis for skinning:
34
+ *
35
+ * 1. each wire becomes a single clamped curve over [0, 1] (`SectionCurve`),
36
+ * 2. winding directions are made consistent along the loft,
37
+ * 3. seams are chained to the nearest point of the previous section's seam
38
+ * (minimal twist),
39
+ * 4. degrees are raised and knot vectors merged to a common union,
40
+ * 5. weight vectors must then agree across sections — when they don't (e.g.
41
+ * a circle lofted to a rectangle), all rational pieces are re-approximated
42
+ * as polynomials and the pipeline reruns once, making everything weightless.
43
+ */
44
+ export declare class SectionCompatibility {
45
+ private static readonly SAMPLES;
46
+ /** Knots closer than this (curves live on [0, 1]) are treated as one. */
47
+ private static readonly KNOT_TOLERANCE;
48
+ private static readonly WEIGHT_TOLERANCE;
49
+ /** Tangent turns above this (radians) across a knot count as a profile corner. */
50
+ private static readonly CREASE_ANGLE;
51
+ /**
52
+ * Relative curvature jumps above this across a knot count as a feature
53
+ * boundary (e.g. the tangent line→arc junctions of an offset wire's
54
+ * rounded corner). Well below any real feature transition, well above the
55
+ * ~1% wobble of polynomial-approximated conics.
56
+ */
57
+ private static readonly CREASE_CURVATURE_JUMP;
58
+ static build(wires: TopoDS_Wire[]): CompatibleSections;
59
+ /**
60
+ * Re-proportions every section's parameterization so that the given
61
+ * per-section parameters land on shared targets (their cross-section
62
+ * averages), then re-unifies the basis. Rail-guided lofts use this to make
63
+ * each rail's contact sit at one parameter on every profile — otherwise
64
+ * the surface's flow lines (and any profile corner riding a rail) drift
65
+ * away from the rail between and at the profiles.
66
+ *
67
+ * `sectionParams[k]` lists the contact parameters of section k, one per
68
+ * rail, in matching order across sections. Parameters at the seam are
69
+ * aligned already and are ignored. Returns the input unchanged when there
70
+ * is nothing to align or when the re-unified sections would lose their
71
+ * shared weight vector (exotic rational cases degrade gracefully).
72
+ */
73
+ static alignParameters(compatible: CompatibleSections, sectionParams: number[][]): {
74
+ compatible: CompatibleSections;
75
+ targets: (number | null)[];
76
+ };
77
+ /** Re-unifies re-proportioned section curves, keeping the original frames. */
78
+ private static rebuildAligned;
79
+ /** Returns null when the sections end up with mismatched weight vectors. */
80
+ private static buildFromCurves;
81
+ /**
82
+ * The shared weight vector: null if every section is polynomial, the common
83
+ * vector if all rational sections agree (after normalizing to weight 1 on
84
+ * the first pole), and undefined — "no shared basis" — otherwise.
85
+ */
86
+ private static sharedWeights;
87
+ /**
88
+ * Makes all sections wind the same way along the loft: the first section's
89
+ * normal (from its winding, via Newell's method) is oriented towards the
90
+ * next centroid, then each further section is flipped whenever its winding
91
+ * normal opposes its predecessor's. Reversing the curve flips its normal.
92
+ */
93
+ private static orientConsistently;
94
+ /** Centroid and winding normal (Newell's method) from uniform parameter samples. */
95
+ private static sectionFrame;
96
+ /**
97
+ * Chains each section's seam (curve origin) to the point nearest the
98
+ * previous section's seam, so the skinned surface doesn't twist. Closed
99
+ * clamped curves can't shift their origin in place; the curve is split at
100
+ * the new seam and re-concatenated.
101
+ */
102
+ private static alignSeams;
103
+ /** Parameter of the point on the curve closest to `target` (sampling + golden-section refine). */
104
+ private static closestParameter;
105
+ /**
106
+ * Returns the curve re-parameterized so its origin sits at parameter `t`
107
+ * (splitting and re-concatenating), consuming the input. No-op near the
108
+ * existing seam.
109
+ */
110
+ private static moveSeam;
111
+ private static unifyDegree;
112
+ /**
113
+ * Merges every section's interior knots into one union (values clustered
114
+ * within `KNOT_TOLERANCE` are snapped to a single representative first)
115
+ * and inserts the union into each curve, so all sections share one knot
116
+ * vector — and therefore one pole count.
117
+ */
118
+ private static unifyKnots;
119
+ /**
120
+ * Interior knots where any section changes character: only knots of full
121
+ * multiplicity (structurally C0) can. Two kinds of feature boundary count:
122
+ * a tangent kink (a profile corner — turns by degrees, while smooth
123
+ * junctions from concatenation or seam moves turn by ~0), and a curvature
124
+ * jump (the tangent line→arc junctions of rounded/offset corners — the
125
+ * legacy loft splits faces there too, and without the split the near-crease
126
+ * band renders smeared and offers no edge).
127
+ */
128
+ private static detectCreases;
129
+ private static nearestRepresentative;
130
+ private static samplePoints;
131
+ }