fluidcad 0.0.32 → 0.0.33

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 (52) hide show
  1. package/lib/dist/common/scene-object.d.ts +2 -0
  2. package/lib/dist/common/scene-object.js +6 -0
  3. package/lib/dist/common/solid.d.ts +4 -1
  4. package/lib/dist/common/solid.js +13 -0
  5. package/lib/dist/core/index.d.ts +2 -1
  6. package/lib/dist/core/index.js +1 -0
  7. package/lib/dist/core/interfaces.d.ts +76 -0
  8. package/lib/dist/core/load.d.ts +2 -2
  9. package/lib/dist/core/rib.d.ts +18 -0
  10. package/lib/dist/core/rib.js +37 -0
  11. package/lib/dist/features/load.d.ts +6 -0
  12. package/lib/dist/features/load.js +53 -1
  13. package/lib/dist/features/rib.d.ts +31 -0
  14. package/lib/dist/features/rib.js +321 -0
  15. package/lib/dist/features/select.d.ts +1 -0
  16. package/lib/dist/features/select.js +81 -10
  17. package/lib/dist/filters/edge/belongs-to-face.d.ts +12 -9
  18. package/lib/dist/filters/edge/belongs-to-face.js +64 -15
  19. package/lib/dist/filters/filter-builder-base.d.ts +25 -0
  20. package/lib/dist/filters/filter-builder-base.js +47 -0
  21. package/lib/dist/filters/filter.js +39 -14
  22. package/lib/dist/filters/from-object.d.ts +4 -0
  23. package/lib/dist/filters/from-object.js +10 -0
  24. package/lib/dist/helpers/scene-helpers.d.ts +1 -1
  25. package/lib/dist/helpers/scene-helpers.js +146 -12
  26. package/lib/dist/io/file-import.d.ts +5 -1
  27. package/lib/dist/io/file-import.js +29 -18
  28. package/lib/dist/oc/color-transfer.d.ts +19 -8
  29. package/lib/dist/oc/color-transfer.js +70 -12
  30. package/lib/dist/oc/extrude-ops.d.ts +2 -1
  31. package/lib/dist/oc/extrude-ops.js +51 -2
  32. package/lib/dist/oc/rib-ops.d.ts +35 -0
  33. package/lib/dist/oc/rib-ops.js +619 -0
  34. package/lib/dist/oc/topology-index.d.ts +6 -0
  35. package/lib/dist/oc/topology-index.js +36 -0
  36. package/lib/dist/rendering/render-solid.js +1 -3
  37. package/lib/dist/rendering/render.d.ts +1 -0
  38. package/lib/dist/rendering/render.js +44 -1
  39. package/lib/dist/rendering/scene-compare.js +3 -0
  40. package/lib/dist/rendering/scene.d.ts +1 -0
  41. package/lib/dist/rendering/scene.js +4 -0
  42. package/lib/dist/tests/features/color-lineage.test.js +18 -0
  43. package/lib/dist/tests/features/filter-positional.test.d.ts +1 -0
  44. package/lib/dist/tests/features/filter-positional.test.js +129 -0
  45. package/lib/dist/tests/features/rib.test.d.ts +1 -0
  46. package/lib/dist/tests/features/rib.test.js +598 -0
  47. package/lib/dist/tests/scene-compare.test.d.ts +1 -0
  48. package/lib/dist/tests/scene-compare.test.js +77 -0
  49. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +1 -1
  51. package/ui/dist/assets/{index-DMw0OYCF.js → index-CFi9p7wR.js} +9 -9
  52. package/ui/dist/index.html +1 -1
@@ -0,0 +1,321 @@
1
+ import { Face } from "../common/face.js";
2
+ import { ExtrudeBase } from "./extrude-base.js";
3
+ import { ExtrudeOps } from "../oc/extrude-ops.js";
4
+ import { Explorer } from "../oc/explorer.js";
5
+ import { FaceQuery } from "../oc/face-query.js";
6
+ import { RibOps } from "../oc/rib-ops.js";
7
+ import { WireOps } from "../oc/wire-ops.js";
8
+ import { requireShapes } from "../common/operand-check.js";
9
+ export class Rib extends ExtrudeBase {
10
+ _thickness;
11
+ _spine;
12
+ _parallel = false;
13
+ _extend = false;
14
+ constructor(thickness, spine, extrudable) {
15
+ super(extrudable);
16
+ this._thickness = thickness;
17
+ this._spine = spine;
18
+ }
19
+ get thickness() {
20
+ return this._thickness;
21
+ }
22
+ get spine() {
23
+ return this._spine;
24
+ }
25
+ parallel() {
26
+ this._parallel = true;
27
+ return this;
28
+ }
29
+ extend() {
30
+ this._extend = true;
31
+ return this;
32
+ }
33
+ validate() {
34
+ requireShapes(this._spine, "spine", "rib");
35
+ }
36
+ build(context) {
37
+ const p = context.getProfiler();
38
+ const plane = this.extrudable.getPlane();
39
+ const originalSpineWire = p.record('Get spine wire', () => this.getSpineWire(this._spine));
40
+ let spineWire = originalSpineWire;
41
+ const scopeObjects = this.resolveFusionScope(context.getSceneObjects());
42
+ const scopeShapes = scopeObjects.flatMap(o => o.getShapes({}, 'solid'));
43
+ if (scopeShapes.length === 0) {
44
+ throw new Error("Rib requires target solids in the scene or via .scope()");
45
+ }
46
+ if (this._extend) {
47
+ spineWire = p.record('Extend spine', () => RibOps.extendSpineWire(spineWire, scopeShapes, plane));
48
+ }
49
+ // Sign convention:
50
+ // thickness > 0 → extrude OPPOSITE to the sketch normal (typical
51
+ // "into the cavity" direction when the sketch sits
52
+ // on top of a parent face).
53
+ // thickness < 0 → extrude IN the sketch normal direction.
54
+ // Same applies for parallel mode (the perpendicular-in-plane direction
55
+ // gets the same sign flip).
56
+ const dirSign = -Math.sign(this._thickness);
57
+ let direction;
58
+ let distance;
59
+ if (this._parallel) {
60
+ const perpDir = RibOps.computeSpinePerpendicularDirection(spineWire, plane);
61
+ direction = perpDir.multiply(dirSign);
62
+ distance = p.record('Compute extrude distance', () => RibOps.computeExtrudeDistanceAlongDirection(direction, plane.origin, scopeShapes));
63
+ }
64
+ else {
65
+ direction = plane.normal.multiply(dirSign);
66
+ distance = p.record('Compute extrude distance', () => RibOps.computeExtrudeDistance(plane, scopeShapes));
67
+ }
68
+ // Parallel + draft uses a tapered loft (firstFace lies on the spine
69
+ // plane by construction, exact thickness preserved). Everything else
70
+ // builds a plain prism from the rib profile face.
71
+ const useTaperedLoft = this._parallel && this.getDraft() !== null;
72
+ const profileFace = useTaperedLoft
73
+ ? null
74
+ : p.record('Make rib profile', () => this._parallel
75
+ ? RibOps.makeRibProfileParallel(spineWire, this._thickness, plane)
76
+ : RibOps.makeRibProfile(spineWire, this._thickness, plane));
77
+ const vec = direction.multiply(distance);
78
+ const { solid, firstFace, lastFace } = p.record('Extrude rib', () => {
79
+ if (useTaperedLoft) {
80
+ const draft = this.getDraft();
81
+ const angleRad = (draft[0] * Math.PI) / 180;
82
+ return RibOps.makeTaperedRibPrism(spineWire, this._thickness, plane, direction, distance, angleRad);
83
+ }
84
+ return ExtrudeOps.makePrismFromVec(profileFace, vec);
85
+ });
86
+ const ribSolid = solid;
87
+ const ribFirstFace = firstFace;
88
+ const ribLastFace = lastFace;
89
+ this.extrudable.removeShapes(this);
90
+ if (this._spine !== this.extrudable) {
91
+ this._spine.removeShapes(this);
92
+ }
93
+ const conformed = p.record('Conform rib', () => RibOps.conformRibToScope(ribSolid, scopeShapes, originalSpineWire, ribFirstFace, ribLastFace, direction));
94
+ let classified = {
95
+ startFaces: conformed.startFaces,
96
+ endFaces: conformed.endFaces,
97
+ sideFaces: conformed.sideFaces,
98
+ internalFaces: conformed.internalFaces,
99
+ capFaces: [],
100
+ };
101
+ let conformedSolids = conformed.solids;
102
+ // Draft is applied AFTER conformance so the prism walls are already
103
+ // bounded by the cavity. Drafting the over-extended pre-conform
104
+ // prism caused OCC to fail (walls would cross within the over-
105
+ // extension); the conformed rib is finite, so OCC handles strong
106
+ // drafts cleanly.
107
+ //
108
+ // Parallel mode skips this entirely — its prism was built tapered
109
+ // by `RibOps.makeTaperedRibPrism`, so the conformed result is
110
+ // already drafted with an exact (no-drift) start-face thickness.
111
+ // Normal mode uses OCC's draft with the sketch plane as the neutral
112
+ // plane (which already coincides with the prism base, so no shift
113
+ // is required).
114
+ if (!this._parallel && this.getDraft() && conformedSolids.length === 1 && classified.startFaces.length > 0) {
115
+ const draft = this.getDraft();
116
+ let angle = draft[0];
117
+ const draftPlane = plane;
118
+ // Mirrors the dirSign reversal above: in normal mode the OCC
119
+ // pull direction is the unsigned plane.normal, which now matches
120
+ // the extrude direction only when thickness < 0 (since extrude
121
+ // direction = plane.normal * -sign(thickness)). Negate the angle
122
+ // to flip OCC's outward bias to the user-facing "+ draft narrows
123
+ // the tip" convention.
124
+ if (this._thickness < 0) {
125
+ angle = -angle;
126
+ }
127
+ const rad = (deg) => deg * Math.PI / 180;
128
+ const draftResult = p.record('Apply draft', () => {
129
+ // Use the first start face as the OCC "firstFace" param (pivot
130
+ // anchor). When the conformance trimmed the original end face
131
+ // into the cavity (so endFaces is empty), the tip is already
132
+ // captured as an internal face — we pass startFaces[0] as the
133
+ // "lastFace" placeholder so its IsSame check is a no-op (it'll
134
+ // also match firstFace and so be excluded once). The actual tip
135
+ // surface stays excluded via the internalFaces argument.
136
+ const startRep = classified.startFaces[0];
137
+ const endRep = classified.endFaces[0] ?? startRep;
138
+ // Faces of the rib that sit flush with a scope face (the rib's
139
+ // mounting face — typically a cap that meets the cavity wall)
140
+ // must not be tilted. Drafting them either tears the rib away
141
+ // from the parent (negative draft) or makes OCC fail outright
142
+ // (positive draft) because there's no material outside the wall
143
+ // for the tilt to extend into.
144
+ const wallTouchingFaces = findScopeCoincidentFaces(classified.sideFaces, scopeShapes);
145
+ // The rib's "cap" faces (perpendicular to the spine direction)
146
+ // close the slab at the spine endpoints. Drafting them tilts
147
+ // them inward and shrinks the rib's length along the spine,
148
+ // which the user doesn't expect — only the long wall faces
149
+ // (perpendicular to spine direction) should taper.
150
+ const spineStart = originalSpineWire.getFirstVertex().toPoint();
151
+ const spineEnd = originalSpineWire.getLastVertex().toPoint();
152
+ const spineDir = spineStart.vectorTo(spineEnd).normalize();
153
+ const capFaces = findFacesAlignedWith(classified.sideFaces, spineDir);
154
+ const excludes = [
155
+ ...classified.startFaces.slice(1),
156
+ ...classified.endFaces.slice(classified.endFaces[0] === endRep ? 1 : 0),
157
+ ...classified.internalFaces,
158
+ ...wallTouchingFaces,
159
+ ...capFaces,
160
+ ];
161
+ return ExtrudeOps.applyDraftOnSideFaces(conformedSolids[0], startRep, endRep, draftPlane, rad(angle), excludes);
162
+ });
163
+ const remap = (faces) => {
164
+ const out = [];
165
+ for (const f of faces) {
166
+ for (const r of draftResult.remapFace(f)) {
167
+ out.push(r);
168
+ }
169
+ }
170
+ return out;
171
+ };
172
+ classified = {
173
+ startFaces: remap(classified.startFaces),
174
+ endFaces: remap(classified.endFaces),
175
+ sideFaces: remap(classified.sideFaces),
176
+ internalFaces: remap(classified.internalFaces),
177
+ capFaces: [],
178
+ };
179
+ conformedSolids = [draftResult.solid];
180
+ }
181
+ if (this._operationMode === 'new') {
182
+ this.setState('start-faces', classified.startFaces);
183
+ this.setState('end-faces', classified.endFaces);
184
+ this.setState('side-faces', classified.sideFaces);
185
+ this.setState('internal-faces', classified.internalFaces);
186
+ this.setState('cap-faces', classified.capFaces);
187
+ this.addShapes(conformedSolids);
188
+ this.recordShapeFacesAndEdgesAsAdditions(conformedSolids);
189
+ this.classifyExtrudeEdges();
190
+ return;
191
+ }
192
+ this.finalizeAndFuse(conformedSolids, classified, context);
193
+ }
194
+ getSpineWire(pathObj) {
195
+ const shapes = pathObj.getShapes({ excludeMeta: false });
196
+ const edges = shapes.flatMap(s => s.getSubShapes('edge'));
197
+ return WireOps.makeWireFromEdges(edges);
198
+ }
199
+ getDependencies() {
200
+ const deps = [];
201
+ if (this.extrudable) {
202
+ deps.push(this.extrudable);
203
+ }
204
+ if (this._spine) {
205
+ deps.push(this._spine);
206
+ }
207
+ return deps;
208
+ }
209
+ createCopy(remap) {
210
+ const extrudable = this.extrudable
211
+ ? (remap.get(this.extrudable) || this.extrudable)
212
+ : undefined;
213
+ const spine = remap.get(this._spine) || this._spine;
214
+ const copy = new Rib(this._thickness, spine, extrudable).syncWith(this);
215
+ copy._parallel = this._parallel;
216
+ copy._extend = this._extend;
217
+ return copy;
218
+ }
219
+ compareTo(other) {
220
+ if (!(other instanceof Rib)) {
221
+ return false;
222
+ }
223
+ if (!super.compareTo(other)) {
224
+ return false;
225
+ }
226
+ if (this._thickness !== other._thickness) {
227
+ return false;
228
+ }
229
+ if (!this._spine.compareTo(other._spine)) {
230
+ return false;
231
+ }
232
+ if (this.extrudable && other.extrudable && !this.extrudable.compareTo(other.extrudable)) {
233
+ return false;
234
+ }
235
+ if (this._parallel !== other._parallel) {
236
+ return false;
237
+ }
238
+ if (this._extend !== other._extend) {
239
+ return false;
240
+ }
241
+ return true;
242
+ }
243
+ getType() {
244
+ return "rib";
245
+ }
246
+ serialize() {
247
+ return {
248
+ thickness: this._thickness,
249
+ spine: this._spine.serialize(),
250
+ extrudable: this.extrudable?.serialize(),
251
+ operationMode: this._operationMode !== 'add' ? this._operationMode : undefined,
252
+ parallel: this._parallel || undefined,
253
+ extend: this._extend || undefined,
254
+ draft: this._draft,
255
+ };
256
+ }
257
+ }
258
+ // Faces of the rib whose surface sits flush with a face of any scope
259
+ // shape (planar coincidence). These are the rib's mounting faces — they
260
+ // must not be tilted by draft; tilting them either tears the rib away
261
+ // from the parent (negative draft) or makes OCC's draft fail (positive
262
+ // draft), since there's no material outside the parent wall for the
263
+ // tilt to extend into.
264
+ // Rib faces whose surface normal is parallel to `direction` — for the
265
+ // rib's draft step this picks out the "cap" faces at the spine endpoints
266
+ // (perpendicular to the spine direction). They cap the slab length and
267
+ // shouldn't tilt under draft; only the wall faces, whose normals are
268
+ // perpendicular to spineDir, take the taper.
269
+ function findFacesAlignedWith(faces, direction) {
270
+ const out = [];
271
+ for (const f of faces) {
272
+ if (FaceQuery.getSurfaceType(f) !== "plane") {
273
+ continue;
274
+ }
275
+ const pl = FaceQuery.getSurfacePlane(f);
276
+ if (1 - Math.abs(pl.normal.dot(direction)) < 1e-4) {
277
+ out.push(f);
278
+ }
279
+ }
280
+ return out;
281
+ }
282
+ function findScopeCoincidentFaces(ribSideFaces, scopeShapes) {
283
+ const out = [];
284
+ const scopePlanarFaces = [];
285
+ for (const scope of scopeShapes) {
286
+ const rawFaces = Explorer.findShapes(scope.getShape(), Explorer.getOcShapeType("face"));
287
+ for (const rf of rawFaces) {
288
+ const wrapped = Face.fromTopoDSFace(Explorer.toFace(rf));
289
+ if (FaceQuery.getSurfaceType(wrapped) !== "plane") {
290
+ continue;
291
+ }
292
+ const pl = FaceQuery.getSurfacePlane(wrapped);
293
+ scopePlanarFaces.push({ face: wrapped, origin: pl.origin, normal: pl.normal });
294
+ }
295
+ }
296
+ const tol = 1e-4;
297
+ for (const rf of ribSideFaces) {
298
+ if (FaceQuery.getSurfaceType(rf) !== "plane") {
299
+ continue;
300
+ }
301
+ const rPl = FaceQuery.getSurfacePlane(rf);
302
+ for (const { origin: sOrigin, normal: sNormal } of scopePlanarFaces) {
303
+ // Parallel test: |normal · normal'| ≈ 1
304
+ if (1 - Math.abs(rPl.normal.dot(sNormal)) > 1e-6) {
305
+ continue;
306
+ }
307
+ // Coincidence test: perpendicular distance from one plane's origin
308
+ // to the other plane is below tolerance. (FaceQuery.getSignedPlaneDistance
309
+ // routes through gp_Pln.Distance, which returns 0 for parallel-but-
310
+ // separated planes — useless for coincidence — so we compute it
311
+ // directly here.)
312
+ const offset = sOrigin.vectorTo(rPl.origin);
313
+ const d = Math.abs(offset.dot(sNormal));
314
+ if (d <= tol) {
315
+ out.push(rf);
316
+ break;
317
+ }
318
+ }
319
+ }
320
+ return out;
321
+ }
@@ -11,6 +11,7 @@ export declare class SelectSceneObject extends SceneObject implements ISelect {
11
11
  private shapes;
12
12
  constructor(filters: FilterBuilderBase<Shape>[], constraintObject?: SceneObject);
13
13
  build(context: BuildSceneObjectContext): void;
14
+ private injectFromMembershipSets;
14
15
  private collectFromSceneObjects;
15
16
  private getAllShapes;
16
17
  getDependencies(): SceneObject[];
@@ -3,6 +3,7 @@ import { ShapeFilter } from "../filters/filter.js";
3
3
  import { SceneObject } from "../common/scene-object.js";
4
4
  import { BelongsToFaceFilter, NotBelongsToFaceFilter } from "../filters/edge/belongs-to-face.js";
5
5
  import { FromSceneObjectFilter } from "../filters/from-object.js";
6
+ import { TopologyIndex } from "../oc/topology-index.js";
6
7
  export class SelectSceneObject extends SceneObject {
7
8
  filters;
8
9
  constraintObject;
@@ -50,8 +51,39 @@ export class SelectSceneObject extends SceneObject {
50
51
  if (this.type === "edge") {
51
52
  this.injectScopeFaces(filters, sceneObjects);
52
53
  }
53
- const filteredShapes = this.applyFilters(allShapes, filters);
54
- this.addShapes(filteredShapes);
54
+ const fromFilters = this.injectFromMembershipSets(filters);
55
+ try {
56
+ const filteredShapes = this.applyFilters(allShapes, filters);
57
+ this.addShapes(filteredShapes);
58
+ }
59
+ finally {
60
+ for (const { filter, set } of fromFilters) {
61
+ filter.setMembershipSet(null);
62
+ set.delete();
63
+ }
64
+ }
65
+ }
66
+ injectFromMembershipSets(filters) {
67
+ const allocated = [];
68
+ for (const builder of filters) {
69
+ for (const filter of builder.getFilters()) {
70
+ if (filter instanceof FromSceneObjectFilter) {
71
+ const shapeType = filter.getShapeType();
72
+ const rawShapes = [];
73
+ for (const obj of filter.getSceneObjects()) {
74
+ for (const owner of obj.getShapes()) {
75
+ for (const sub of owner.getSubShapes(shapeType)) {
76
+ rawShapes.push(sub.getShape());
77
+ }
78
+ }
79
+ }
80
+ const set = TopologyIndex.buildShapeSet(rawShapes);
81
+ filter.setMembershipSet(set);
82
+ allocated.push({ filter, set });
83
+ }
84
+ }
85
+ }
86
+ return allocated;
55
87
  }
56
88
  collectFromSceneObjects(filters) {
57
89
  const objects = [];
@@ -70,8 +102,17 @@ export class SelectSceneObject extends SceneObject {
70
102
  }
71
103
  getAllShapes(scope, exludedShapes) {
72
104
  const scopeShapes = scope.flatMap(obj => obj.getShapes({}, 'solid').map(s => s.getSubShapes(this.type)).flat());
73
- exludedShapes = exludedShapes.flatMap(s => s.getSubShapes(this.type));
74
- return scopeShapes.filter(shape => !exludedShapes.some(exShape => exShape.isSame(shape)));
105
+ const flatExcluded = exludedShapes.flatMap(s => s.getSubShapes(this.type));
106
+ if (flatExcluded.length === 0) {
107
+ return scopeShapes;
108
+ }
109
+ const excludedSet = TopologyIndex.buildShapeSet(flatExcluded.map(s => s.getShape()));
110
+ try {
111
+ return scopeShapes.filter(shape => !excludedSet.Contains(shape.getShape()));
112
+ }
113
+ finally {
114
+ excludedSet.delete();
115
+ }
75
116
  }
76
117
  getDependencies() {
77
118
  const deps = [];
@@ -97,16 +138,37 @@ export class SelectSceneObject extends SceneObject {
97
138
  return new SelectSceneObject(mirroredFilters, this.constraintObject);
98
139
  }
99
140
  injectScopeFaces(filters, sceneObjects) {
100
- let scopeFaces = null;
141
+ let scopeSolids = null;
142
+ let extraFaces = null;
143
+ let faceByHash = null;
101
144
  for (const builder of filters) {
102
145
  for (const filter of builder.getFilters()) {
103
146
  if (filter instanceof BelongsToFaceFilter || filter instanceof NotBelongsToFaceFilter) {
104
- if (!scopeFaces) {
105
- scopeFaces = this.constraintObject
106
- ? this.constraintObject.getShapes().flatMap(s => s.getSubShapes("face"))
107
- : sceneObjects.flatMap(obj => obj.getShapes({}, 'solid').flatMap(s => s.getSubShapes("face")));
147
+ if (!scopeSolids) {
148
+ if (this.constraintObject) {
149
+ const constraintShapes = this.constraintObject.getShapes();
150
+ scopeSolids = constraintShapes.filter(s => s.isSolid());
151
+ // Faces directly in the constraint (not part of a solid) need the
152
+ // legacy linear-scan path since they don't have a cached index.
153
+ extraFaces = constraintShapes
154
+ .filter(s => !s.isSolid())
155
+ .flatMap(s => s.getSubShapes("face"));
156
+ }
157
+ else {
158
+ scopeSolids = sceneObjects.flatMap(obj => obj.getShapes({}, 'solid'));
159
+ extraFaces = [];
160
+ }
161
+ faceByHash = new Map();
162
+ for (const solid of scopeSolids) {
163
+ for (const face of solid.getFaces()) {
164
+ addToBucket(faceByHash, face);
165
+ }
166
+ }
167
+ for (const face of extraFaces) {
168
+ addToBucket(faceByHash, face);
169
+ }
108
170
  }
109
- filter.setScopeFaces(scopeFaces);
171
+ filter.setScopeIndex(scopeSolids, extraFaces, faceByHash);
110
172
  }
111
173
  }
112
174
  }
@@ -158,3 +220,12 @@ export class SelectSceneObject extends SceneObject {
158
220
  };
159
221
  }
160
222
  }
223
+ function addToBucket(faceByHash, face) {
224
+ const hash = face.getShape().HashCode(2147483647);
225
+ let bucket = faceByHash.get(hash);
226
+ if (!bucket) {
227
+ bucket = [];
228
+ faceByHash.set(hash, bucket);
229
+ }
230
+ bucket.push(face);
231
+ }
@@ -1,22 +1,25 @@
1
1
  import { Matrix4 } from "../../math/matrix4.js";
2
2
  import { Edge, Face } from "../../common/shapes.js";
3
+ import { Solid } from "../../common/solid.js";
3
4
  import { FilterBase } from "../filter-base.js";
4
5
  import { FilterBuilderBase } from "../filter-builder-base.js";
5
- export declare class BelongsToFaceFilter extends FilterBase<Edge> {
6
- private faceFilterBuilders;
7
- private scopeFaces;
6
+ declare abstract class BelongsToFaceFilterBase extends FilterBase<Edge> {
7
+ protected faceFilterBuilders: FilterBuilderBase<Face>[];
8
+ protected scopeSolids: Solid[];
9
+ protected scopeFaces: Face[];
10
+ protected faceByHash: Map<number, Face[]>;
8
11
  constructor(faceFilterBuilders: FilterBuilderBase<Face>[]);
9
- setScopeFaces(faces: Face[]): void;
12
+ setScopeIndex(solids: Solid[], extraFaces: Face[], faceByHash: Map<number, Face[]>): void;
13
+ protected findContainingFaces(edge: Edge): Face[];
14
+ }
15
+ export declare class BelongsToFaceFilter extends BelongsToFaceFilterBase {
10
16
  match(shape: Edge): boolean;
11
17
  compareTo(other: BelongsToFaceFilter): boolean;
12
18
  transform(matrix: Matrix4): BelongsToFaceFilter;
13
19
  }
14
- export declare class NotBelongsToFaceFilter extends FilterBase<Edge> {
15
- private faceFilterBuilders;
16
- private scopeFaces;
17
- constructor(faceFilterBuilders: FilterBuilderBase<Face>[]);
18
- setScopeFaces(faces: Face[]): void;
20
+ export declare class NotBelongsToFaceFilter extends BelongsToFaceFilterBase {
19
21
  match(shape: Edge): boolean;
20
22
  compareTo(other: NotBelongsToFaceFilter): boolean;
21
23
  transform(matrix: Matrix4): NotBelongsToFaceFilter;
22
24
  }
25
+ export {};
@@ -1,16 +1,53 @@
1
+ import { Face } from "../../common/shapes.js";
2
+ import { Explorer } from "../../oc/explorer.js";
3
+ import { TopologyIndex } from "../../oc/topology-index.js";
1
4
  import { FilterBase } from "../filter-base.js";
2
- export class BelongsToFaceFilter extends FilterBase {
5
+ class BelongsToFaceFilterBase extends FilterBase {
3
6
  faceFilterBuilders;
7
+ scopeSolids = [];
4
8
  scopeFaces = [];
9
+ faceByHash = new Map();
5
10
  constructor(faceFilterBuilders) {
6
11
  super();
7
12
  this.faceFilterBuilders = faceFilterBuilders;
8
13
  }
9
- setScopeFaces(faces) {
10
- this.scopeFaces = faces;
14
+ setScopeIndex(solids, extraFaces, faceByHash) {
15
+ this.scopeSolids = solids;
16
+ this.scopeFaces = extraFaces;
17
+ this.faceByHash = faceByHash;
11
18
  }
19
+ findContainingFaces(edge) {
20
+ const edgeShape = edge.getShape();
21
+ const seen = new Set();
22
+ const result = [];
23
+ for (const solid of this.scopeSolids) {
24
+ const index = solid.getEdgeToFacesIndex();
25
+ const rawFaces = TopologyIndex.seekShapes(index, edgeShape);
26
+ for (const raw of rawFaces) {
27
+ const wrapper = resolveFaceWrapper(raw, this.faceByHash);
28
+ if (wrapper && !seen.has(wrapper)) {
29
+ seen.add(wrapper);
30
+ result.push(wrapper);
31
+ }
32
+ }
33
+ }
34
+ if (this.scopeFaces.length > 0) {
35
+ for (const face of this.scopeFaces) {
36
+ if (seen.has(face)) {
37
+ continue;
38
+ }
39
+ if (face.hasEdge(edgeShape) !== null) {
40
+ seen.add(face);
41
+ result.push(face);
42
+ }
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+ }
48
+ export class BelongsToFaceFilter extends BelongsToFaceFilterBase {
12
49
  match(shape) {
13
- const containingFaces = this.scopeFaces.filter(face => face.hasEdge(shape.getShape()) !== null);
50
+ const containingFaces = this.findContainingFaces(shape);
14
51
  return this.faceFilterBuilders.every(builder => {
15
52
  const filters = builder.getFilters();
16
53
  return containingFaces.some(face => filters.every(f => f.match(face)));
@@ -32,18 +69,9 @@ export class BelongsToFaceFilter extends FilterBase {
32
69
  return new BelongsToFaceFilter(transformed);
33
70
  }
34
71
  }
35
- export class NotBelongsToFaceFilter extends FilterBase {
36
- faceFilterBuilders;
37
- scopeFaces = [];
38
- constructor(faceFilterBuilders) {
39
- super();
40
- this.faceFilterBuilders = faceFilterBuilders;
41
- }
42
- setScopeFaces(faces) {
43
- this.scopeFaces = faces;
44
- }
72
+ export class NotBelongsToFaceFilter extends BelongsToFaceFilterBase {
45
73
  match(shape) {
46
- const containingFaces = this.scopeFaces.filter(face => face.hasEdge(shape.getShape()) !== null);
74
+ const containingFaces = this.findContainingFaces(shape);
47
75
  return !this.faceFilterBuilders.every(builder => {
48
76
  const filters = builder.getFilters();
49
77
  return containingFaces.some(face => filters.every(f => f.match(face)));
@@ -65,3 +93,24 @@ export class NotBelongsToFaceFilter extends FilterBase {
65
93
  return new NotBelongsToFaceFilter(transformed);
66
94
  }
67
95
  }
96
+ function resolveFaceWrapper(rawFace, faceByHash) {
97
+ const hash = rawFace.HashCode(2147483647);
98
+ const bucket = faceByHash.get(hash);
99
+ if (bucket) {
100
+ for (const candidate of bucket) {
101
+ if (candidate.getShape().IsSame(rawFace)) {
102
+ return candidate;
103
+ }
104
+ }
105
+ }
106
+ // Not in scope (e.g. neighbor face from another part / out-of-scope solid).
107
+ // Wrap on the fly so the face filters can still evaluate it.
108
+ const wrapped = Face.fromTopoDSFace(Explorer.toFace(rawFace));
109
+ if (!bucket) {
110
+ faceByHash.set(hash, [wrapped]);
111
+ }
112
+ else {
113
+ bucket.push(wrapped);
114
+ }
115
+ return wrapped;
116
+ }
@@ -1,12 +1,37 @@
1
1
  import { Shape } from "../common/shapes.js";
2
2
  import { FilterBase } from "./filter-base.js";
3
+ export type IndexSelector = {
4
+ type: 'first';
5
+ } | {
6
+ type: 'last';
7
+ } | {
8
+ type: 'at';
9
+ index: number;
10
+ };
3
11
  export declare class FilterBuilderBase<TShape extends Shape = Shape> {
4
12
  protected filters: FilterBase<TShape>[];
5
13
  protected _withTangents: boolean;
14
+ protected _indexSelector?: IndexSelector;
6
15
  filter(filter: FilterBase<TShape>): this;
7
16
  /**
8
17
  * Expands the selection to include all shapes transitively connected
9
18
  * by tangency (G1 continuity) to the initially matched shapes.
10
19
  */
11
20
  withTangents(): this;
21
+ /**
22
+ * Selects the first matching shape (in iteration order). If called multiple
23
+ * times on the same builder, the last positional call wins.
24
+ */
25
+ first(): this;
26
+ /**
27
+ * Selects the last matching shape (in iteration order). If called multiple
28
+ * times on the same builder, the last positional call wins.
29
+ */
30
+ last(): this;
31
+ /**
32
+ * Selects the matching shape at the given zero-based index.
33
+ * Out-of-range indices yield no match. Negative indices are not supported;
34
+ * use {@link last} for the final element.
35
+ */
36
+ at(index: number): this;
12
37
  }