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,264 @@
1
+ import { getOC } from "../init.js";
2
+ import { CurveData } from "./curve-data.js";
3
+ /**
4
+ * Turns a profile wire into a single clamped B-spline curve, parameterized
5
+ * over [0, 1] with spans proportional to edge arc length. Corners survive as
6
+ * interior knots of multiplicity `degree` (C0), so any planar profile — a
7
+ * polygon, a slot, a circle — becomes exactly one curve. That single-curve
8
+ * form is what makes profiles with different edge counts loftable against
9
+ * each other: compatibility reduces to sharing one degree and one knot
10
+ * vector instead of matching edges pairwise.
11
+ */
12
+ export class SectionCurve {
13
+ // Rational→polynomial section conversion tolerance (mm). Loose enough to
14
+ // keep pole counts small — pole count drives the cost of every downstream
15
+ // stage (knot union, skinning, surface, meshing). Caps always sew exactly
16
+ // (they share the section's poles), so this only bounds the deviation of
17
+ // the loft wall from the true profile curve.
18
+ static APPROX_TOLERANCE = 1e-4;
19
+ static LENGTH_SAMPLES = 32;
20
+ /**
21
+ * With `forcePolynomial`, rational pieces (arcs, circles) are approximated
22
+ * as polynomial B-splines before concatenation — piece-by-piece, so profile
23
+ * corners stay sharp. Used when sections with different weight structures
24
+ * must share one surface (see `SectionCompatibility`).
25
+ */
26
+ static fromWire(wire, forcePolynomial = false) {
27
+ const oc = getOC();
28
+ const pieces = [];
29
+ const explorer = new oc.BRepTools_WireExplorer(wire);
30
+ while (explorer.More()) {
31
+ const edge = explorer.Current();
32
+ if (!oc.BRep_Tool.Degenerated(edge)) {
33
+ let piece = SectionCurve.edgeToBSpline(edge);
34
+ if (forcePolynomial && piece.IsRational()) {
35
+ const polynomial = SectionCurve.toPolynomial(piece, SectionCurve.APPROX_TOLERANCE);
36
+ piece.delete();
37
+ piece = polynomial;
38
+ }
39
+ pieces.push(piece);
40
+ }
41
+ explorer.Next();
42
+ }
43
+ explorer.delete();
44
+ if (pieces.length === 0) {
45
+ throw new Error("Loft profile wire has no usable edges.");
46
+ }
47
+ const section = SectionCurve.concatenate(pieces, wire.Closed());
48
+ for (const piece of pieces) {
49
+ piece.delete();
50
+ }
51
+ return section;
52
+ }
53
+ /**
54
+ * Concatenates ordered, connected pieces into one clamped B-spline over
55
+ * [0, 1]: junctions become interior knots of multiplicity `degree` and the
56
+ * shared endpoint poles collapse into one. If any piece is rational, every
57
+ * piece contributes weights. A NURBS is unchanged under a global weight
58
+ * scale, and the only cross-piece constraint is agreement at the shared
59
+ * junction pole — so each piece is chain-scaled to match its predecessor's
60
+ * trailing weight (the first piece is anchored at weight 1).
61
+ *
62
+ * Pieces span parameter ranges proportional to their arc length, unless
63
+ * `spans` prescribes the fractions explicitly (rail-contact alignment
64
+ * re-proportions sections so matching features share parameters).
65
+ */
66
+ static concatenate(pieces, closed, spans) {
67
+ const degree = Math.max(...pieces.map(piece => piece.Degree()));
68
+ for (const piece of pieces) {
69
+ if (piece.Degree() < degree) {
70
+ piece.IncreaseDegree(degree);
71
+ }
72
+ }
73
+ const datas = pieces.map(piece => CurveData.read(piece));
74
+ const rational = datas.some(data => data.weights !== null);
75
+ if (rational) {
76
+ let junctionWeight = 1;
77
+ for (const data of datas) {
78
+ const pieceWeights = data.weights ?? new Array(data.poles.length).fill(1);
79
+ const factor = junctionWeight / pieceWeights[0];
80
+ data.weights = pieceWeights.map(w => w * factor);
81
+ junctionWeight = data.weights[data.weights.length - 1];
82
+ }
83
+ }
84
+ // Piece spans proportional to arc length, over a total range of [0, 1].
85
+ const lengths = spans ?? pieces.map(piece => SectionCurve.approximateLength(piece));
86
+ const totalLength = lengths.reduce((sum, length) => sum + length, 0);
87
+ if (totalLength <= 0) {
88
+ throw new Error("Loft profile wire is degenerate (zero total length).");
89
+ }
90
+ const breaks = [0];
91
+ for (let i = 0; i < lengths.length; i++) {
92
+ breaks.push(breaks[i] + lengths[i] / totalLength);
93
+ }
94
+ breaks[breaks.length - 1] = 1;
95
+ const knots = [0];
96
+ const multiplicities = [degree + 1];
97
+ const poles = [];
98
+ const weights = rational ? [] : null;
99
+ for (let i = 0; i < datas.length; i++) {
100
+ const data = datas[i];
101
+ const [start, end] = [breaks[i], breaks[i + 1]];
102
+ const [a, b] = [data.knots[0], data.knots[data.knots.length - 1]];
103
+ for (let k = 1; k < data.knots.length - 1; k++) {
104
+ knots.push(start + ((data.knots[k] - a) / (b - a)) * (end - start));
105
+ multiplicities.push(Math.min(data.multiplicities[k], degree));
106
+ }
107
+ if (i < datas.length - 1) {
108
+ knots.push(end);
109
+ multiplicities.push(degree);
110
+ }
111
+ else {
112
+ knots.push(1);
113
+ multiplicities.push(degree + 1);
114
+ }
115
+ // The junction pole is shared: seat it at the midpoint of the two
116
+ // coincident clamped end poles so wire-tolerance gaps split evenly.
117
+ const firstPoleIndex = i === 0 ? 0 : 1;
118
+ if (i > 0) {
119
+ const previous = poles[poles.length - 1];
120
+ const incoming = data.poles[0];
121
+ poles[poles.length - 1] = previous.map((v, d) => (v + incoming[d]) / 2);
122
+ }
123
+ for (let j = firstPoleIndex; j < data.poles.length; j++) {
124
+ poles.push(data.poles[j]);
125
+ if (weights) {
126
+ weights.push(data.weights ? data.weights[j] : 1);
127
+ }
128
+ }
129
+ }
130
+ if (closed && poles.length > 1) {
131
+ poles[poles.length - 1] = [...poles[0]];
132
+ }
133
+ return CurveData.build({ poles, weights, knots, multiplicities, degree });
134
+ }
135
+ /**
136
+ * Approximates any B-spline with a polynomial (non-rational) one within
137
+ * `tolerance`, as a chain of cubic Hermite segments (position + tangent
138
+ * matched at the ends of every segment, so the result stays G1). Segments
139
+ * split at the curve's own knots — rational conversions are typically only
140
+ * C1 there, and keeping the reduced-continuity points on segment
141
+ * boundaries preserves O(h⁴) convergence — then subdivide until every
142
+ * segment fits. Pole count stays proportional to the geometry's curvature
143
+ * (a circle needs a few dozen poles), never to a sample budget.
144
+ */
145
+ static toPolynomial(curve, tolerance) {
146
+ const oc = getOC();
147
+ const point = new oc.gp_Pnt();
148
+ const vector = new oc.gp_Vec();
149
+ const spans = [];
150
+ for (let i = 1; i < curve.NbKnots(); i++) {
151
+ spans.push([curve.Knot(i), curve.Knot(i + 1)]);
152
+ }
153
+ try {
154
+ for (let subdivisions = 1; subdivisions <= 64; subdivisions *= 2) {
155
+ const breakpoints = [spans[0][0]];
156
+ for (const [a, b] of spans) {
157
+ for (let m = 1; m <= subdivisions; m++) {
158
+ breakpoints.push(a + ((b - a) * m) / subdivisions);
159
+ }
160
+ }
161
+ const samples = breakpoints.map(t => {
162
+ curve.D1(t, point, vector);
163
+ return {
164
+ position: [point.X(), point.Y(), point.Z()],
165
+ tangent: [vector.X(), vector.Y(), vector.Z()],
166
+ };
167
+ });
168
+ // One cubic Bézier per segment in Hermite form.
169
+ const segments = [];
170
+ for (let s = 0; s + 1 < breakpoints.length; s++) {
171
+ const h = (breakpoints[s + 1] - breakpoints[s]) / 3;
172
+ const from = samples[s];
173
+ const to = samples[s + 1];
174
+ segments.push([
175
+ from.position,
176
+ from.position.map((v, d) => v + from.tangent[d] * h),
177
+ to.position.map((v, d) => v - to.tangent[d] * h),
178
+ to.position,
179
+ ]);
180
+ }
181
+ if (SectionCurve.maxHermiteDeviation(curve, breakpoints, segments) <= tolerance) {
182
+ return SectionCurve.assembleCubicSegments(breakpoints, segments);
183
+ }
184
+ }
185
+ throw new Error("Loft could not approximate a rational profile curve within tolerance.");
186
+ }
187
+ finally {
188
+ point.delete();
189
+ vector.delete();
190
+ }
191
+ }
192
+ /** Largest distance between the Hermite segments and the curve, sampled inside each segment. */
193
+ static maxHermiteDeviation(curve, breakpoints, segments) {
194
+ const oc = getOC();
195
+ const point = new oc.gp_Pnt();
196
+ let maxDeviation = 0;
197
+ for (let s = 0; s < segments.length; s++) {
198
+ const [p0, p1, p2, p3] = segments[s];
199
+ for (const local of [0.25, 0.5, 0.75]) {
200
+ const t = breakpoints[s] + (breakpoints[s + 1] - breakpoints[s]) * local;
201
+ curve.D0(t, point);
202
+ const u = 1 - local;
203
+ const b0 = u * u * u;
204
+ const b1 = 3 * u * u * local;
205
+ const b2 = 3 * u * local * local;
206
+ const b3 = local * local * local;
207
+ const dx = b0 * p0[0] + b1 * p1[0] + b2 * p2[0] + b3 * p3[0] - point.X();
208
+ const dy = b0 * p0[1] + b1 * p1[1] + b2 * p2[1] + b3 * p3[1] - point.Y();
209
+ const dz = b0 * p0[2] + b1 * p1[2] + b2 * p2[2] + b3 * p3[2] - point.Z();
210
+ maxDeviation = Math.max(maxDeviation, Math.hypot(dx, dy, dz));
211
+ }
212
+ }
213
+ point.delete();
214
+ return maxDeviation;
215
+ }
216
+ /** Joins cubic Bézier segments into one B-spline over the original parameter range. */
217
+ static assembleCubicSegments(breakpoints, segments) {
218
+ const degree = 3;
219
+ const knots = [...breakpoints];
220
+ const multiplicities = breakpoints.map((_, i) => i === 0 || i === breakpoints.length - 1 ? degree + 1 : degree);
221
+ const poles = [segments[0][0]];
222
+ for (const segment of segments) {
223
+ poles.push(segment[1], segment[2], segment[3]);
224
+ }
225
+ return CurveData.build({ poles, weights: null, knots, multiplicities, degree });
226
+ }
227
+ /** Curve arc length approximated by chord sampling — used only to proportion knot spans. */
228
+ static approximateLength(curve) {
229
+ const oc = getOC();
230
+ const first = curve.FirstParameter();
231
+ const last = curve.LastParameter();
232
+ const point = new oc.gp_Pnt();
233
+ let length = 0;
234
+ let px = 0;
235
+ let py = 0;
236
+ let pz = 0;
237
+ for (let i = 0; i <= SectionCurve.LENGTH_SAMPLES; i++) {
238
+ const t = first + ((last - first) * i) / SectionCurve.LENGTH_SAMPLES;
239
+ curve.D0(t, point);
240
+ if (i > 0) {
241
+ length += Math.hypot(point.X() - px, point.Y() - py, point.Z() - pz);
242
+ }
243
+ px = point.X();
244
+ py = point.Y();
245
+ pz = point.Z();
246
+ }
247
+ point.delete();
248
+ return length;
249
+ }
250
+ static edgeToBSpline(edge) {
251
+ const oc = getOC();
252
+ const curveInfo = oc.BRep_Tool.Curve(edge, 0, 1);
253
+ const trimmed = new oc.Geom_TrimmedCurve(curveInfo.returnValue, curveInfo.First, curveInfo.Last, true, true);
254
+ const bspline = oc.GeomConvert.CurveToBSplineCurve(trimmed);
255
+ trimmed.delete();
256
+ if (bspline.IsPeriodic()) {
257
+ bspline.SetNotPeriodic();
258
+ }
259
+ if (edge.Orientation() === oc.TopAbs_Orientation.TopAbs_REVERSED) {
260
+ bspline.Reverse();
261
+ }
262
+ return bspline;
263
+ }
264
+ }
@@ -0,0 +1,104 @@
1
+ import type { TopoDS_Shape, TopoDS_Wire } from "ocjs-fluidcad";
2
+ import { BSplineCurveData } from "../../math/bspline-interpolation.js";
3
+ import { Solid } from "../../common/solid.js";
4
+ import { CompatibleSections, CompatibleSection } from "./section-compatibility.js";
5
+ /** How a loft leaves (or arrives at) an end profile. */
6
+ export type LoftConditionKind = "normal" | "tangent";
7
+ export interface LoftEndCondition {
8
+ kind: LoftConditionKind;
9
+ /** Scales the takeoff tangent; 1 ≈ one loft length of influence. Negative flips the direction. */
10
+ magnitude: number;
11
+ }
12
+ /** The u-direction surface basis every skinned section shares. */
13
+ export interface LoftSurfaceBasis {
14
+ degree: number;
15
+ knots: number[];
16
+ multiplicities: number[];
17
+ /** Shared weight vector, or null when polynomial. */
18
+ weights: number[] | null;
19
+ /** Interior u-knots with a real profile corner — face-split points. */
20
+ creases?: number[];
21
+ }
22
+ export interface SkinnedGrid {
23
+ /** Surface poles, indexed [uIndex][vIndex] — u runs around the sections, v along the loft. */
24
+ grid: number[][][];
25
+ /** The v-direction basis produced by the column interpolation. */
26
+ vBasis: BSplineCurveData;
27
+ /** The loft parameter assigned to each input section. */
28
+ params: number[];
29
+ /** Average flow-line length — the scale reference for condition magnitudes. */
30
+ averageLength: number;
31
+ }
32
+ /**
33
+ * The shared loft-skinning pipeline: interpolates matching pole columns of
34
+ * compatible sections along the loft (optionally with end-derivative
35
+ * constraints) and assembles the resulting `Geom_BSplineSurface` plus exact
36
+ * boundary caps into a sewn solid. Used by `ConstrainedLoft` (conditions)
37
+ * and `GuidedLoft` (virtual sections along rails).
38
+ */
39
+ export declare class Skinning {
40
+ private static readonly SEWING_TOLERANCE;
41
+ /**
42
+ * Interpolates each pole column along the loft. Every column shares the
43
+ * same parameters and constraint pattern, so every column yields the same
44
+ * v-basis.
45
+ */
46
+ static skinSections(compatible: CompatibleSections, startCondition?: LoftEndCondition, endCondition?: LoftEndCondition): SkinnedGrid;
47
+ /**
48
+ * Interpolates pole columns across a stack of sections (sections[k] is the
49
+ * full pole set of section k) at the given parameters, with optional
50
+ * per-column end derivatives.
51
+ */
52
+ static interpolateColumns(sections: number[][][], params: number[], startField?: number[][] | null, endField?: number[][] | null): {
53
+ grid: number[][][];
54
+ vBasis: BSplineCurveData;
55
+ };
56
+ /**
57
+ * Loft parameters v_k in [0, 1] by chord length averaged over the pole
58
+ * columns (the standard skinning parameterization), plus the average
59
+ * flow-line length used to scale condition magnitudes.
60
+ */
61
+ static loftParameters(sections: CompatibleSection[]): {
62
+ params: number[];
63
+ averageLength: number;
64
+ };
65
+ /**
66
+ * The takeoff derivative per pole column at an end section. `normal` uses
67
+ * the section's plane normal (constant across the section — the surface
68
+ * leaves perpendicular everywhere); `tangent` uses the in-plane outward
69
+ * direction per column (winding tangent × section normal).
70
+ *
71
+ * At the last section the surface *arrives* rather than departs: a normal
72
+ * arrival still travels along the loft (no flip), but an outward-bulging
73
+ * tangent arrival must descend back from the bulge — the in-plane
74
+ * derivative flips inward so a positive magnitude bulges both ends outward.
75
+ */
76
+ static derivativeField(section: CompatibleSection, condition: LoftEndCondition, averageLength: number, isEnd: boolean): number[][];
77
+ /**
78
+ * Builds the side surface from the pole grid, caps it with the exact
79
+ * boundary sections (the first and last v-columns of the grid), and sews
80
+ * everything into one correctly-oriented solid.
81
+ */
82
+ static buildLoftSolid(uBasis: LoftSurfaceBasis, grid: number[][][], vBasis: BSplineCurveData): Solid;
83
+ /**
84
+ * The wall faces of a skinned grid, split at profile corners: a corner
85
+ * buried inside one face has no edge to render, select or fillet, and its
86
+ * mesh normals smear. Each u-range between creases (the seam is always a
87
+ * boundary) becomes its own face; smooth profiles keep the single closed
88
+ * face.
89
+ */
90
+ static sideFaces(uBasis: LoftSurfaceBasis, grid: number[][][], vBasis: BSplineCurveData): TopoDS_Shape[];
91
+ /** Sews faces into a watertight shell and wraps it into a solid. */
92
+ static sewSolid(faces: TopoDS_Shape[]): Solid;
93
+ /**
94
+ * The section's exact boundary as a wire, segmented at the same crease
95
+ * points as the wall faces so sewing pairs edges exactly.
96
+ */
97
+ static capWire(uBasis: LoftSurfaceBasis, poles: number[][]): TopoDS_Wire;
98
+ /** Planar cap built from the section's exact boundary curve. */
99
+ private static capFace;
100
+ /** Consecutive u-ranges between profile corners; one full range when the profile is smooth. */
101
+ private static uRanges;
102
+ /** Wraps the sewn shell into a correctly-oriented solid. */
103
+ private static solidFromShell;
104
+ }
@@ -0,0 +1,299 @@
1
+ import { getOC } from "../init.js";
2
+ import { Explorer } from "../explorer.js";
3
+ import { ShapeProps } from "../props.js";
4
+ import { NCollections } from "../ncollection.js";
5
+ import { Vector3d } from "../../math/vector3d.js";
6
+ import { interpolateWithDerivatives } from "../../math/bspline-interpolation.js";
7
+ import { Solid } from "../../common/solid.js";
8
+ import { CurveData } from "./curve-data.js";
9
+ /**
10
+ * The shared loft-skinning pipeline: interpolates matching pole columns of
11
+ * compatible sections along the loft (optionally with end-derivative
12
+ * constraints) and assembles the resulting `Geom_BSplineSurface` plus exact
13
+ * boundary caps into a sewn solid. Used by `ConstrainedLoft` (conditions)
14
+ * and `GuidedLoft` (virtual sections along rails).
15
+ */
16
+ export class Skinning {
17
+ static SEWING_TOLERANCE = 1e-6;
18
+ /**
19
+ * Interpolates each pole column along the loft. Every column shares the
20
+ * same parameters and constraint pattern, so every column yields the same
21
+ * v-basis.
22
+ */
23
+ static skinSections(compatible, startCondition, endCondition) {
24
+ const { sections } = compatible;
25
+ const { params, averageLength } = Skinning.loftParameters(sections);
26
+ const startField = startCondition
27
+ ? Skinning.derivativeField(sections[0], startCondition, averageLength, false)
28
+ : null;
29
+ const endField = endCondition
30
+ ? Skinning.derivativeField(sections[sections.length - 1], endCondition, averageLength, true)
31
+ : null;
32
+ const columns = sections.map(section => section.poles);
33
+ const { grid, vBasis } = Skinning.interpolateColumns(columns, params, startField, endField);
34
+ return { grid, vBasis, params, averageLength };
35
+ }
36
+ /**
37
+ * Interpolates pole columns across a stack of sections (sections[k] is the
38
+ * full pole set of section k) at the given parameters, with optional
39
+ * per-column end derivatives.
40
+ */
41
+ static interpolateColumns(sections, params, startField = null, endField = null) {
42
+ const poleCount = sections[0].length;
43
+ const grid = [];
44
+ let vBasis = null;
45
+ for (let i = 0; i < poleCount; i++) {
46
+ const column = sections.map(section => section[i]);
47
+ const interpolated = interpolateWithDerivatives(column, params, startField ? startField[i] : undefined, endField ? endField[i] : undefined);
48
+ grid.push(interpolated.poles);
49
+ vBasis = interpolated;
50
+ }
51
+ return { grid, vBasis: vBasis };
52
+ }
53
+ /**
54
+ * Loft parameters v_k in [0, 1] by chord length averaged over the pole
55
+ * columns (the standard skinning parameterization), plus the average
56
+ * flow-line length used to scale condition magnitudes.
57
+ */
58
+ static loftParameters(sections) {
59
+ const sectionCount = sections.length;
60
+ const poleCount = sections[0].poles.length;
61
+ const sums = new Array(sectionCount).fill(0);
62
+ let usableColumns = 0;
63
+ let totalLength = 0;
64
+ for (let i = 0; i < poleCount; i++) {
65
+ const cumulative = new Array(sectionCount).fill(0);
66
+ for (let k = 1; k < sectionCount; k++) {
67
+ const a = sections[k - 1].poles[i];
68
+ const b = sections[k].poles[i];
69
+ cumulative[k] = cumulative[k - 1] + Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
70
+ }
71
+ const total = cumulative[sectionCount - 1];
72
+ if (total > 1e-12) {
73
+ usableColumns++;
74
+ totalLength += total;
75
+ for (let k = 1; k < sectionCount; k++) {
76
+ sums[k] += cumulative[k] / total;
77
+ }
78
+ }
79
+ }
80
+ if (usableColumns === 0) {
81
+ throw new Error("Loft profiles are coincident — nothing to loft between.");
82
+ }
83
+ const params = sums.map(sum => sum / usableColumns);
84
+ params[0] = 0;
85
+ params[sectionCount - 1] = 1;
86
+ for (let k = 1; k < sectionCount; k++) {
87
+ if (params[k] <= params[k - 1]) {
88
+ throw new Error("Loft has coincident consecutive profiles.");
89
+ }
90
+ }
91
+ return { params, averageLength: totalLength / usableColumns };
92
+ }
93
+ /**
94
+ * The takeoff derivative per pole column at an end section. `normal` uses
95
+ * the section's plane normal (constant across the section — the surface
96
+ * leaves perpendicular everywhere); `tangent` uses the in-plane outward
97
+ * direction per column (winding tangent × section normal).
98
+ *
99
+ * At the last section the surface *arrives* rather than departs: a normal
100
+ * arrival still travels along the loft (no flip), but an outward-bulging
101
+ * tangent arrival must descend back from the bulge — the in-plane
102
+ * derivative flips inward so a positive magnitude bulges both ends outward.
103
+ */
104
+ static derivativeField(section, condition, averageLength, isEnd) {
105
+ const poleCount = section.poles.length;
106
+ let scale = condition.magnitude * averageLength;
107
+ if (condition.kind === "normal") {
108
+ const direction = section.normal.multiply(scale);
109
+ const vector = [direction.x, direction.y, direction.z];
110
+ return new Array(poleCount).fill(vector);
111
+ }
112
+ if (isEnd) {
113
+ scale = -scale;
114
+ }
115
+ // Closed sections repeat the first pole at the end; wrap around it so
116
+ // the seam column gets the same direction from both sides.
117
+ const field = [];
118
+ for (let i = 0; i < poleCount; i++) {
119
+ const previous = section.poles[i === 0 ? poleCount - 2 : i - 1];
120
+ const next = section.poles[i === poleCount - 1 ? 1 : i + 1];
121
+ const tangent = new Vector3d(next[0] - previous[0], next[1] - previous[1], next[2] - previous[2]);
122
+ let outward = tangent.cross(section.normal);
123
+ if (outward.length() < 1e-12) {
124
+ throw new Error("Loft tangent condition: profile winding is degenerate at a pole.");
125
+ }
126
+ outward = outward.normalize().multiply(scale);
127
+ field.push([outward.x, outward.y, outward.z]);
128
+ }
129
+ return field;
130
+ }
131
+ /**
132
+ * Builds the side surface from the pole grid, caps it with the exact
133
+ * boundary sections (the first and last v-columns of the grid), and sews
134
+ * everything into one correctly-oriented solid.
135
+ */
136
+ static buildLoftSolid(uBasis, grid, vBasis) {
137
+ const faces = Skinning.sideFaces(uBasis, grid, vBasis);
138
+ faces.push(Skinning.capFace(uBasis, grid.map(row => row[0])));
139
+ faces.push(Skinning.capFace(uBasis, grid.map(row => row[row.length - 1])));
140
+ return Skinning.sewSolid(faces);
141
+ }
142
+ /**
143
+ * The wall faces of a skinned grid, split at profile corners: a corner
144
+ * buried inside one face has no edge to render, select or fillet, and its
145
+ * mesh normals smear. Each u-range between creases (the seam is always a
146
+ * boundary) becomes its own face; smooth profiles keep the single closed
147
+ * face.
148
+ */
149
+ static sideFaces(uBasis, grid, vBasis) {
150
+ const oc = getOC();
151
+ const [poles, disposePoles] = NCollections.toArray2Pnt(grid);
152
+ const [uKnots, disposeUKnots] = NCollections.toArray1Double(uBasis.knots);
153
+ const [uMults, disposeUMults] = NCollections.toArray1Int(uBasis.multiplicities);
154
+ const [vKnots, disposeVKnots] = NCollections.toArray1Double(vBasis.knots);
155
+ const [vMults, disposeVMults] = NCollections.toArray1Int(vBasis.multiplicities);
156
+ let surface;
157
+ try {
158
+ if (uBasis.weights) {
159
+ const weightGrid = grid.map((row, uIndex) => row.map(() => uBasis.weights[uIndex]));
160
+ const [weights, disposeWeights] = NCollections.toArray2Double(weightGrid);
161
+ try {
162
+ surface = new oc.Geom_BSplineSurface(poles, weights, uKnots, vKnots, uMults, vMults, uBasis.degree, vBasis.degree, false, false);
163
+ }
164
+ finally {
165
+ disposeWeights();
166
+ }
167
+ }
168
+ else {
169
+ surface = new oc.Geom_BSplineSurface(poles, uKnots, vKnots, uMults, vMults, uBasis.degree, vBasis.degree, false, false);
170
+ }
171
+ }
172
+ finally {
173
+ disposePoles();
174
+ disposeUKnots();
175
+ disposeUMults();
176
+ disposeVKnots();
177
+ disposeVMults();
178
+ }
179
+ const ranges = Skinning.uRanges(uBasis);
180
+ const faces = [];
181
+ for (const [from, to] of ranges) {
182
+ let piece = surface;
183
+ if (ranges.length > 1) {
184
+ piece = oc.GeomConvert.SplitBSplineSurface(surface, from, to, true, 1e-9, true);
185
+ }
186
+ const faceMaker = new oc.BRepBuilderAPI_MakeFace(piece, Skinning.SEWING_TOLERANCE);
187
+ const isDone = faceMaker.IsDone();
188
+ if (isDone) {
189
+ faces.push(faceMaker.Face());
190
+ }
191
+ faceMaker.delete();
192
+ if (piece !== surface) {
193
+ piece.delete();
194
+ }
195
+ if (!isDone) {
196
+ surface.delete();
197
+ throw new Error("Loft failed to build its side surface.");
198
+ }
199
+ }
200
+ surface.delete();
201
+ return faces;
202
+ }
203
+ /** Sews faces into a watertight shell and wraps it into a solid. */
204
+ static sewSolid(faces) {
205
+ const oc = getOC();
206
+ const sewing = new oc.BRepBuilderAPI_Sewing(Skinning.SEWING_TOLERANCE, true, true, true, false);
207
+ for (const face of faces) {
208
+ sewing.Add(face);
209
+ }
210
+ const progress = new oc.Message_ProgressRange();
211
+ sewing.Perform(progress);
212
+ progress.delete();
213
+ if (sewing.NbFreeEdges() > 0) {
214
+ sewing.delete();
215
+ throw new Error("Loft surface and caps did not close into a watertight shell.");
216
+ }
217
+ const sewn = sewing.SewedShape();
218
+ sewing.delete();
219
+ return Skinning.solidFromShell(sewn);
220
+ }
221
+ /**
222
+ * The section's exact boundary as a wire, segmented at the same crease
223
+ * points as the wall faces so sewing pairs edges exactly.
224
+ */
225
+ static capWire(uBasis, poles) {
226
+ const oc = getOC();
227
+ const boundary = CurveData.build({
228
+ poles,
229
+ weights: uBasis.weights,
230
+ knots: uBasis.knots,
231
+ multiplicities: uBasis.multiplicities,
232
+ degree: uBasis.degree,
233
+ });
234
+ const ranges = Skinning.uRanges(uBasis);
235
+ const wireMaker = new oc.BRepBuilderAPI_MakeWire();
236
+ for (const [from, to] of ranges) {
237
+ let segment = boundary;
238
+ if (ranges.length > 1) {
239
+ segment = oc.GeomConvert.SplitBSplineCurve(boundary, from, to, 1e-9, true);
240
+ }
241
+ const edgeMaker = new oc.BRepBuilderAPI_MakeEdge(segment);
242
+ wireMaker.Add(edgeMaker.Edge());
243
+ edgeMaker.delete();
244
+ if (segment !== boundary) {
245
+ segment.delete();
246
+ }
247
+ }
248
+ boundary.delete();
249
+ const wire = wireMaker.Wire();
250
+ wireMaker.delete();
251
+ return wire;
252
+ }
253
+ /** Planar cap built from the section's exact boundary curve. */
254
+ static capFace(uBasis, poles) {
255
+ const oc = getOC();
256
+ const faceMaker = new oc.BRepBuilderAPI_MakeFace(Skinning.capWire(uBasis, poles), true);
257
+ if (!faceMaker.IsDone()) {
258
+ faceMaker.delete();
259
+ throw new Error("Loft could not cap a profile — guided and conditioned lofts require planar profiles.");
260
+ }
261
+ const face = faceMaker.Face();
262
+ faceMaker.delete();
263
+ return face;
264
+ }
265
+ /** Consecutive u-ranges between profile corners; one full range when the profile is smooth. */
266
+ static uRanges(uBasis) {
267
+ const bounds = [
268
+ uBasis.knots[0],
269
+ ...(uBasis.creases ?? []),
270
+ uBasis.knots[uBasis.knots.length - 1],
271
+ ];
272
+ const ranges = [];
273
+ for (let i = 0; i + 1 < bounds.length; i++) {
274
+ ranges.push([bounds[i], bounds[i + 1]]);
275
+ }
276
+ return ranges;
277
+ }
278
+ /** Wraps the sewn shell into a correctly-oriented solid. */
279
+ static solidFromShell(sewn) {
280
+ const oc = getOC();
281
+ let shellShape = sewn;
282
+ if (sewn.ShapeType() !== oc.TopAbs_ShapeEnum.TopAbs_SHELL) {
283
+ const shells = Explorer.findShapes(sewn, oc.TopAbs_ShapeEnum.TopAbs_SHELL);
284
+ if (shells.length !== 1) {
285
+ throw new Error("Loft sewing did not produce a single shell.");
286
+ }
287
+ shellShape = shells[0];
288
+ }
289
+ const builder = new oc.BRep_Builder();
290
+ let solid = new oc.TopoDS_Solid();
291
+ builder.MakeSolid(solid);
292
+ builder.Add(solid, oc.TopoDS.Shell(shellShape));
293
+ builder.delete();
294
+ if (ShapeProps.getProperties(solid).volumeMm3 < 0) {
295
+ solid = oc.TopoDS.Solid(solid.Reversed());
296
+ }
297
+ return Solid.fromTopoDSSolid(solid);
298
+ }
299
+ }