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,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
|
+
}
|