@code3d/core 0.0.1-alpha.0 → 0.0.1-alpha.3

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 (131) hide show
  1. package/README.md +223 -786
  2. package/THIRD_PARTY.md +40 -0
  3. package/bld/THIRD_PARTY_NOTICES.txt +182 -0
  4. package/bld/chunks/chunk-BF55AIEQ.js +31 -0
  5. package/bld/chunks/chunk-BF55AIEQ.js.map +7 -0
  6. package/bld/chunks/chunk-P5VGKAEM.js +30 -0
  7. package/bld/chunks/chunk-P5VGKAEM.js.map +7 -0
  8. package/bld/chunks/chunk-RPY2WVCB.js +24834 -0
  9. package/bld/chunks/chunk-RPY2WVCB.js.map +7 -0
  10. package/bld/chunks/chunk-TJIN6S4R.js +1 -0
  11. package/bld/chunks/chunk-TJIN6S4R.js.map +7 -0
  12. package/bld/chunks/chunk-U5G2VU2I.js +19 -0
  13. package/bld/chunks/chunk-U5G2VU2I.js.map +7 -0
  14. package/bld/library/cached.d.ts +33 -0
  15. package/bld/library/cached.d.ts.map +1 -0
  16. package/bld/library/font.d.ts +39 -0
  17. package/bld/library/font.d.ts.map +1 -0
  18. package/bld/library/google-font.d.ts +14 -0
  19. package/bld/library/google-font.d.ts.map +1 -0
  20. package/bld/library/index.d.ts +8 -2
  21. package/bld/library/index.d.ts.map +1 -1
  22. package/bld/library/index.js +60 -3
  23. package/bld/library/index.js.map +7 -1
  24. package/bld/library/kernel-artifact-codec.d.ts.map +1 -1
  25. package/bld/library/kernel-cache.d.ts +24 -6
  26. package/bld/library/kernel-cache.d.ts.map +1 -1
  27. package/bld/library/material.d.ts +1 -1
  28. package/bld/library/material.d.ts.map +1 -1
  29. package/bld/library/open-cascade.d.ts +4 -0
  30. package/bld/library/open-cascade.d.ts.map +1 -0
  31. package/bld/library/replicad.d.ts +3 -2
  32. package/bld/library/replicad.d.ts.map +1 -1
  33. package/bld/library/replicad.js +10 -24
  34. package/bld/library/replicad.js.map +7 -1
  35. package/bld/library/retained-memory.d.ts.map +1 -1
  36. package/bld/library/runtime.d.ts +146 -80
  37. package/bld/library/runtime.d.ts.map +1 -1
  38. package/bld/library/sketch-solver.d.ts +1 -1
  39. package/bld/library/sketch-solver.d.ts.map +1 -1
  40. package/bld/library/sketch.d.ts +9 -1
  41. package/bld/library/sketch.d.ts.map +1 -1
  42. package/bld/library/text.d.ts +53 -0
  43. package/bld/library/text.d.ts.map +1 -0
  44. package/bld/library/three.js +3 -3
  45. package/bld/library/three.js.map +7 -1
  46. package/bld/library/topology.d.ts.map +1 -1
  47. package/bld/node/index.d.ts.map +1 -1
  48. package/bld/node/index.js +62 -14
  49. package/bld/node/index.js.map +7 -1
  50. package/bld/node/replicad.js +13 -3
  51. package/bld/node/replicad.js.map +7 -1
  52. package/bld/tooling/index.d.ts +17 -15
  53. package/bld/tooling/index.d.ts.map +1 -1
  54. package/bld/tooling/index.js +147 -19
  55. package/bld/tooling/index.js.map +7 -1
  56. package/package.json +18 -6
  57. package/src/library/cached.ts +132 -0
  58. package/src/library/font.ts +186 -0
  59. package/src/library/google-font.ts +97 -0
  60. package/src/library/index.ts +8 -3
  61. package/src/library/kernel-artifact-codec.ts +129 -20
  62. package/src/library/kernel-cache.ts +128 -34
  63. package/src/library/loft.ts +4 -0
  64. package/src/library/material.ts +3 -4
  65. package/src/library/open-cascade.ts +13 -0
  66. package/src/library/replicad.ts +5 -5
  67. package/src/library/retained-memory.ts +6 -1
  68. package/src/library/runtime.ts +2064 -1730
  69. package/src/library/sketch-solver.ts +3 -1
  70. package/src/library/sketch.ts +81 -13
  71. package/src/library/text.ts +477 -0
  72. package/src/library/topology.ts +24 -0
  73. package/src/library/tsconfig.json +2 -1
  74. package/src/node/index.ts +14 -3
  75. package/src/node/tsconfig.json +2 -1
  76. package/src/tooling/index.ts +48 -46
  77. package/src/tooling/tsconfig.json +2 -1
  78. package/bld/library/alignment-geometry.js +0 -322
  79. package/bld/library/alignment-geometry.js.map +0 -1
  80. package/bld/library/bound-solver.js +0 -252
  81. package/bld/library/bound-solver.js.map +0 -1
  82. package/bld/library/extrude.js +0 -28
  83. package/bld/library/extrude.js.map +0 -1
  84. package/bld/library/kernel-artifact-codec.js +0 -148
  85. package/bld/library/kernel-artifact-codec.js.map +0 -1
  86. package/bld/library/kernel-cache.js +0 -245
  87. package/bld/library/kernel-cache.js.map +0 -1
  88. package/bld/library/kernel-shapes.js +0 -108
  89. package/bld/library/kernel-shapes.js.map +0 -1
  90. package/bld/library/loft.js +0 -161
  91. package/bld/library/loft.js.map +0 -1
  92. package/bld/library/material.js +0 -176
  93. package/bld/library/material.js.map +0 -1
  94. package/bld/library/model-color.js +0 -36
  95. package/bld/library/model-color.js.map +0 -1
  96. package/bld/library/open-cascade-error.js +0 -22
  97. package/bld/library/open-cascade-error.js.map +0 -1
  98. package/bld/library/relation-solver.js +0 -258
  99. package/bld/library/relation-solver.js.map +0 -1
  100. package/bld/library/retained-memory.js +0 -27
  101. package/bld/library/retained-memory.js.map +0 -1
  102. package/bld/library/runtime.js +0 -2954
  103. package/bld/library/runtime.js.map +0 -1
  104. package/bld/library/shell.js +0 -221
  105. package/bld/library/shell.js.map +0 -1
  106. package/bld/library/sketch-curve-intersections.js +0 -117
  107. package/bld/library/sketch-curve-intersections.js.map +0 -1
  108. package/bld/library/sketch-curves.js +0 -89
  109. package/bld/library/sketch-curves.js.map +0 -1
  110. package/bld/library/sketch-drag-rules.js +0 -522
  111. package/bld/library/sketch-drag-rules.js.map +0 -1
  112. package/bld/library/sketch-face.js +0 -72
  113. package/bld/library/sketch-face.js.map +0 -1
  114. package/bld/library/sketch-incidence.js +0 -114
  115. package/bld/library/sketch-incidence.js.map +0 -1
  116. package/bld/library/sketch-precision.js +0 -55
  117. package/bld/library/sketch-precision.js.map +0 -1
  118. package/bld/library/sketch-regions.js +0 -193
  119. package/bld/library/sketch-regions.js.map +0 -1
  120. package/bld/library/sketch-solver.js +0 -578
  121. package/bld/library/sketch-solver.js.map +0 -1
  122. package/bld/library/sketch.js +0 -650
  123. package/bld/library/sketch.js.map +0 -1
  124. package/bld/library/spatial.js +0 -195
  125. package/bld/library/spatial.js.map +0 -1
  126. package/bld/library/topology-id.js +0 -67
  127. package/bld/library/topology-id.js.map +0 -1
  128. package/bld/library/topology-inspection.js +0 -267
  129. package/bld/library/topology-inspection.js.map +0 -1
  130. package/bld/library/topology.js +0 -455
  131. package/bld/library/topology.js.map +0 -1
@@ -1,4 +1,6 @@
1
- import type {GcsSystem, ModuleStatic} from '@salusoft89/planegcs';
1
+ import type {GcsSystem} from '@salusoft89/planegcs/dist/planegcs_dist/gcs_system.js';
2
+ // Upstream ships this declaration alongside sources, but omits it from dist.
3
+ import type {ModuleStatic} from '@salusoft89/planegcs/planegcs_dist/planegcs.js';
2
4
  import type {SketchArcDirection, SketchPosition} from './sketch.js';
3
5
  import {sketchArcGeometry} from './sketch-curves.js';
4
6
  import {pointLineDistance} from './sketch-incidence.js';
@@ -6,9 +6,11 @@ import {
6
6
  import {solveSketchDrag} from './sketch-drag-rules.js';
7
7
  import {sketchRegions} from './sketch-regions.js';
8
8
  import {
9
- sketchFaceModel,
9
+ SketchFrame,
10
10
  disposeModelObjects,
11
11
  isModelObject,
12
+ type Constraint,
13
+ type FaceAnchor,
12
14
  type FaceModel,
13
15
  } from './runtime.js';
14
16
  import {
@@ -85,6 +87,10 @@ export type SketchOptions = Readonly<{
85
87
 
86
88
  /** An immutable sketch definition, independent of B-Rep construction. */
87
89
  export interface Sketch {
90
+ /** The unbounded local XZ plane (+Y normal), independent of closed regions. */
91
+ readonly plane: FaceAnchor;
92
+ /** Relates this immutable sketch's frame without changing its two-dimensional data. */
93
+ relate(build: (self: Sketch) => Constraint | readonly Constraint[]): Sketch;
88
94
  /** References a point defined in this layer. */
89
95
  point(id: number): SketchPoint;
90
96
  /** Adds a local layer while retaining the upstream sketch as read-only input. */
@@ -112,11 +118,26 @@ const definitions = new WeakMap<Sketch, Definition>();
112
118
  const references = new WeakSet<SketchPoint>();
113
119
 
114
120
  class SketchValue implements Sketch {
121
+ readonly frame: SketchFrame;
122
+ readonly source?: Sketch;
123
+
115
124
  constructor(
116
- entries?: readonly SketchEntry[],
117
- options?: SketchOptions,
118
- base?: Sketch,
125
+ input:
126
+ | {
127
+ entries?: readonly SketchEntry[];
128
+ options?: SketchOptions;
129
+ base?: Sketch;
130
+ }
131
+ | {source: Sketch; frame: SketchFrame},
119
132
  ) {
133
+ if ('source' in input) {
134
+ this.frame = input.frame;
135
+ this.source = input.source;
136
+ definitions.set(this, definitions.get(input.source)!);
137
+ return;
138
+ }
139
+ const {entries, options, base} = input;
140
+ this.frame = new SketchFrame(base && (base as SketchValue).frame);
120
141
  const ids = new Set<number>();
121
142
  const points = new Map<number, SketchPosition>();
122
143
  const copied = (entries ?? []).map<SketchEntry>(entry => {
@@ -164,6 +185,10 @@ class SketchValue implements Sketch {
164
185
  ancestor = definitions.get(ancestor)?.base
165
186
  )
166
187
  ancestors.add(ancestor);
188
+ const ancestorFor = (value: Sketch) =>
189
+ [...ancestors].find(
190
+ ancestor => definitions.get(ancestor) === definitions.get(value),
191
+ );
167
192
  for (const [kind, id, data] of copied) {
168
193
  if (kind === 'point' && Array.isArray(data)) continue;
169
194
  const refs =
@@ -180,7 +205,7 @@ class SketchValue implements Sketch {
180
205
  throw new Error(
181
206
  `Sketch ${kind} ${id} references missing local point ${ref}.`,
182
207
  );
183
- } else if (!references.has(ref) || !ancestors.has(ref.sketch)) {
208
+ } else if (!references.has(ref) || !ancestorFor(ref.sketch)) {
184
209
  throw new Error(
185
210
  `Sketch ${kind} ${id} must reference a local or upstream point.`,
186
211
  );
@@ -209,7 +234,7 @@ class SketchValue implements Sketch {
209
234
  if (
210
235
  typeof ref === 'number'
211
236
  ? !points.has(ref)
212
- : !references.has(ref) || !ancestors.has(ref.sketch)
237
+ : !references.has(ref) || !ancestorFor(ref.sketch)
213
238
  )
214
239
  throw new Error(
215
240
  'Sketch constraints must reference a local or upstream point.',
@@ -304,6 +329,7 @@ class SketchValue implements Sketch {
304
329
  const local = 'local';
305
330
  const layers = new Map<Sketch, string>();
306
331
  const identity = (value: Sketch) => {
332
+ value = ancestorFor(value) ?? value;
307
333
  let id = layers.get(value);
308
334
  if (!id) layers.set(value, (id = `upstream:${layers.size}`));
309
335
  return id;
@@ -345,8 +371,21 @@ class SketchValue implements Sketch {
345
371
  return ref;
346
372
  }
347
373
 
374
+ get plane(): FaceAnchor {
375
+ return this.frame.plane;
376
+ }
377
+
378
+ relate(build: (self: Sketch) => Constraint | readonly Constraint[]): Sketch {
379
+ let related!: SketchValue;
380
+ this.frame.relate(frame => {
381
+ related = new SketchValue({source: this, frame});
382
+ return build(related);
383
+ });
384
+ return related;
385
+ }
386
+
348
387
  derive(entries?: readonly SketchEntry[], options?: SketchOptions): Sketch {
349
- return new SketchValue(entries, options, this);
388
+ return new SketchValue({entries, options, base: this});
350
389
  }
351
390
 
352
391
  face(): FaceModel {
@@ -355,14 +394,14 @@ class SketchValue implements Sketch {
355
394
  throw new Error(
356
395
  `sketch.face() requires exactly one closed region; found ${regions.length}. Use faces() for multiple regions.`,
357
396
  );
358
- return sketchFaceModel(regions[0]);
397
+ return this.frame.face(regions[0]);
359
398
  }
360
399
 
361
400
  faces(): readonly FaceModel[] {
362
401
  const faces: FaceModel[] = [];
363
402
  try {
364
403
  for (const region of sketchRegions(this.layers()))
365
- faces.push(sketchFaceModel(region));
404
+ faces.push(this.frame.face(region));
366
405
  return faces;
367
406
  } catch (error) {
368
407
  disposeModelObjects(
@@ -381,7 +420,13 @@ class SketchValue implements Sketch {
381
420
  )
382
421
  values.unshift(value);
383
422
  return values.map(value =>
384
- snapshotSketch(value, s => String(values.indexOf(s))),
423
+ snapshotSketch(value, s =>
424
+ String(
425
+ values.findIndex(
426
+ value => definitions.get(value) === definitions.get(s),
427
+ ),
428
+ ),
429
+ ),
385
430
  );
386
431
  }
387
432
  }
@@ -391,7 +436,7 @@ export function sketch(
391
436
  entries?: readonly SketchEntry[],
392
437
  options?: SketchOptions,
393
438
  ): Sketch {
394
- return new SketchValue(entries, options);
439
+ return new SketchValue({entries, options});
395
440
  }
396
441
 
397
442
  export function isSketch(value: unknown): value is Sketch {
@@ -402,6 +447,16 @@ export function sketchDefinition(value: Sketch): Definition {
402
447
  return definitions.get(value)!;
403
448
  }
404
449
 
450
+ /** The preceding spatial value; it shares a geometry definition, not a derived layer. */
451
+ export function sketchSource(value: Sketch): Sketch | undefined {
452
+ return (value as SketchValue).source;
453
+ }
454
+
455
+ /** The relation frame used by this sketch and inherited by its generated faces. */
456
+ export function sketchFrame(value: Sketch): SketchFrame {
457
+ return (value as SketchValue).frame;
458
+ }
459
+
405
460
  export type SketchPointSnapshot = Readonly<{
406
461
  kind: 'point';
407
462
  id: number;
@@ -497,17 +552,30 @@ export function snapshotSketch(
497
552
  redundant,
498
553
  } = sketchDefinition(value);
499
554
  const id = identity(value);
555
+ const ancestors: Sketch[] = [];
556
+ for (
557
+ let ancestor = base;
558
+ ancestor;
559
+ ancestor = sketchDefinition(ancestor).base
560
+ )
561
+ ancestors.push(ancestor);
562
+ const pointIdentity = (reference: Sketch) =>
563
+ identity(
564
+ ancestors.find(
565
+ ancestor => definitions.get(ancestor) === definitions.get(reference),
566
+ ) ?? reference,
567
+ );
500
568
  return {
501
569
  id,
502
570
  base: base && identity(base),
503
- entities: snapshotEntries(entries, id, identity, points).map(e =>
571
+ entities: snapshotEntries(entries, id, pointIdentity, points).map(e =>
504
572
  e.kind === 'point'
505
573
  ? {...e, position: points.get(e.id)!}
506
574
  : e.kind === 'circle' || e.kind === 'arc'
507
575
  ? {...e, radius: radii.get(e.id)!}
508
576
  : e,
509
577
  ),
510
- constraints: snapshotConstraints(constraints, id, identity),
578
+ constraints: snapshotConstraints(constraints, id, pointIdentity),
511
579
  degreesOfFreedom,
512
580
  redundant,
513
581
  };
@@ -0,0 +1,477 @@
1
+ import {
2
+ BlueprintSketcher,
3
+ getOC,
4
+ type Blueprint,
5
+ type Face,
6
+ type Wire,
7
+ type Point2D,
8
+ } from 'replicad';
9
+ import {castOwnedShape} from './kernel-shapes.js';
10
+ import type * as hb from 'harfbuzzjs';
11
+ import {boolean as combinePaths} from 'flo-boolean';
12
+ import {fontParts, shapeFontText, type Font, type FontPart} from './font.js';
13
+ import {cachedArtifact} from './cached.js';
14
+ import {kernelOperationKey, type KernelArtifact} from './kernel-cache.js';
15
+
16
+ export type TextOptions = Readonly<{
17
+ /** Extra model-unit spacing between glyphs, including spaces. Defaults to 0; may be negative. */
18
+ letterSpacing?: number;
19
+ /** Apply the font's kerning pairs. Defaults to true. */
20
+ kerning?: boolean;
21
+ }>;
22
+
23
+ export type PathCommand =
24
+ | {type: 'M'; x: number; y: number}
25
+ | {type: 'L'; x: number; y: number}
26
+ | {type: 'Q'; x1: number; y1: number; x: number; y: number}
27
+ | {
28
+ type: 'C';
29
+ x1: number;
30
+ y1: number;
31
+ x2: number;
32
+ y2: number;
33
+ x: number;
34
+ y: number;
35
+ }
36
+ | {type: 'Z'};
37
+ type Contour = readonly PathCommand[];
38
+ type Region = readonly Contour[];
39
+ type Glyph = Readonly<{
40
+ regions: KernelArtifact<readonly Region[]>;
41
+ x: number;
42
+ y: number;
43
+ }>;
44
+
45
+ /** Layout shares a baseline at (0, 0, 0); spaces advance without creating faces. */
46
+ export function textGlyphs(
47
+ content: string,
48
+ font: Font,
49
+ size: number,
50
+ options: TextOptions = {},
51
+ ): readonly Glyph[] {
52
+ if (!Number.isFinite(size) || size <= 0)
53
+ throw new Error('Text size must be finite and greater than zero.');
54
+ if (typeof content !== 'string')
55
+ throw new Error('Text content must be a string.');
56
+ const letterSpacing = options.letterSpacing ?? 0;
57
+ if (!Number.isFinite(letterSpacing))
58
+ throw new Error('Text letter spacing must be finite.');
59
+ if (/[\r\n\t]/u.test(content))
60
+ throw new Error(
61
+ 'text() supports one line; position separate text() calls for multiple lines.',
62
+ );
63
+ const parts = fontParts(font);
64
+ const runs: {part: FontPart; content: string}[] = [];
65
+ for (const character of content) {
66
+ const code = character.codePointAt(0)!;
67
+ const part = parts.find(
68
+ ({artifact, ranges}) =>
69
+ (!ranges.length ||
70
+ ranges.some(([start, end]) => code >= start && code <= end)) &&
71
+ artifact.value.font.nominalGlyph(code),
72
+ );
73
+ if (!part) {
74
+ throw new Error(
75
+ `Font ${font.family} has no glyph for ${JSON.stringify(character)} (U+${code.toString(16).toUpperCase()}).`,
76
+ );
77
+ }
78
+ const previous = runs.at(-1);
79
+ if (previous?.part === part) previous.content += character;
80
+ else runs.push({part, content: character});
81
+ }
82
+ const glyphs: Glyph[] = [];
83
+ let advanceX = 0;
84
+ let advanceY = 0;
85
+ for (const {part, content: run} of runs) {
86
+ const resource = part.artifact;
87
+ const parsed = resource.value;
88
+ const scale = size / parsed.face.upem;
89
+ const {infos, positions} = shapeFontText(
90
+ parsed.font,
91
+ run,
92
+ options.kerning ?? true,
93
+ );
94
+ infos.forEach((info, index) => {
95
+ const position = positions[index];
96
+ const regions = textGlyph(resource, info.codepoint, size);
97
+ glyphs.push({
98
+ regions,
99
+ x: advanceX + position.xOffset * scale + glyphs.length * letterSpacing,
100
+ y: -advanceY - position.yOffset * scale,
101
+ });
102
+ advanceX += position.xAdvance * scale;
103
+ advanceY += position.yAdvance * scale;
104
+ });
105
+ }
106
+ return glyphs;
107
+ }
108
+
109
+ const textGlyph = cachedArtifact(
110
+ (
111
+ resource: FontPart['artifact'],
112
+ codepoint: number,
113
+ size: number,
114
+ ): readonly Region[] =>
115
+ groupTextContours(
116
+ splitContours(
117
+ resource.value.font
118
+ .glyphToJson(codepoint)
119
+ .map(command =>
120
+ scaledCommand(command, size / resource.value.face.upem),
121
+ ),
122
+ ),
123
+ ),
124
+ {
125
+ key: (resource, codepoint, size) =>
126
+ kernelOperationKey('textGlyph', [codepoint, size], [resource]),
127
+ },
128
+ );
129
+
130
+ function scaledCommand(
131
+ {type, values}: hb.SvgPathCommand,
132
+ scale: number,
133
+ ): PathCommand {
134
+ const [x1, y1, x2, y2, x, y] = values.map(
135
+ (value, index) => value * scale * (index % 2 ? -1 : 1),
136
+ );
137
+ switch (type) {
138
+ case 'M':
139
+ case 'L':
140
+ return {type, x: x1, y: y1};
141
+ case 'Q':
142
+ return {type, x1, y1, x: x2, y: y2};
143
+ case 'C':
144
+ return {type, x1, y1, x2, y2, x, y};
145
+ case 'Z':
146
+ return {type};
147
+ default:
148
+ throw new Error(`Unsupported glyph path command: ${type}`);
149
+ }
150
+ }
151
+
152
+ function splitContours(commands: readonly PathCommand[]): readonly Contour[] {
153
+ const contours: PathCommand[][] = [];
154
+ let contour: PathCommand[] | undefined;
155
+ for (const command of commands) {
156
+ if (command.type === 'M') {
157
+ contour = [];
158
+ contours.push(contour);
159
+ }
160
+ if (!contour)
161
+ throw new Error('A font contour must start with a move command.');
162
+ contour.push(command);
163
+ if (command.type === 'Z') contour = undefined;
164
+ }
165
+ if (contour) throw new Error('A font contour must be closed.');
166
+ return contours.filter(contour => contour.length > 2);
167
+ }
168
+
169
+ function blueprint(contour: Contour, x = 0, y = 0): Blueprint {
170
+ const start = contour[0];
171
+ if (start?.type !== 'M')
172
+ throw new Error('A font contour must start with a move command.');
173
+ const sketcher = new BlueprintSketcher([start.x + x, -start.y - y]);
174
+ try {
175
+ let previous = start;
176
+ for (const command of contour.slice(1)) {
177
+ if (command.type === 'Z') return sketcher.close();
178
+ if (command.type === 'M')
179
+ throw new Error('Unexpected move inside a font contour.');
180
+ const end: [number, number] = [command.x + x, -command.y - y];
181
+ if (command.type === 'L') {
182
+ if (command.x !== previous.x || command.y !== previous.y)
183
+ sketcher.lineTo(end);
184
+ } else if (command.type === 'Q') {
185
+ sketcher.quadraticBezierCurveTo(end, [command.x1 + x, -command.y1 - y]);
186
+ } else {
187
+ sketcher.cubicBezierCurveTo(
188
+ end,
189
+ [command.x1 + x, -command.y1 - y],
190
+ [command.x2 + x, -command.y2 - y],
191
+ );
192
+ }
193
+ previous = {type: 'M', x: command.x, y: command.y};
194
+ }
195
+ throw new Error('A font contour must be closed.');
196
+ } catch (error) {
197
+ deleteBlueprint(sketcher.done());
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Work around Replicad #278 without relying on contour order. Every boundary
204
+ * belongs to its nearest containing boundary; even depths are filled islands.
205
+ * Overlapping outlines are first combined with the font non-zero fill rule.
206
+ */
207
+ export function groupTextContours(
208
+ contours: readonly Contour[],
209
+ ): readonly Region[] {
210
+ const blueprints: Blueprint[] = [];
211
+ try {
212
+ for (const contour of contours) blueprints.push(blueprint(contour));
213
+ if (
214
+ blueprints.some((a, i) =>
215
+ blueprints.slice(i + 1).some(b => contoursIntersect(a, b)),
216
+ )
217
+ ) {
218
+ blueprints.splice(0).forEach(deleteBlueprint);
219
+ contours = combineContours(contours);
220
+ for (const contour of contours) blueprints.push(blueprint(contour));
221
+ }
222
+ const contains = blueprints.map(() => new Set<number>());
223
+ for (let i = 0; i < blueprints.length; i++) {
224
+ for (let j = i + 1; j < blueprints.length; j++) {
225
+ const a = blueprints[i],
226
+ b = blueprints[j];
227
+ if (containsPoint(a, b.firstPoint)) contains[j].add(i);
228
+ if (containsPoint(b, a.firstPoint)) contains[i].add(j);
229
+ }
230
+ }
231
+ const parents = contains.map(
232
+ containers =>
233
+ [...containers].sort((a, b) => contains[b].size - contains[a].size)[0],
234
+ );
235
+ return contours.flatMap((outer, index) =>
236
+ contains[index].size % 2
237
+ ? []
238
+ : [[outer, ...contours.filter((_, hole) => parents[hole] === index)]],
239
+ );
240
+ } finally {
241
+ blueprints.forEach(deleteBlueprint);
242
+ }
243
+ }
244
+
245
+ /** Keep quadratic/cubic Beziers; no polygon flattening or SVG rounding. */
246
+ function combineContours(contours: readonly Contour[]): readonly Contour[] {
247
+ const paths = contours.map(contour => {
248
+ const curves: number[][][] = [];
249
+ let start: number[] = [];
250
+ let previous: number[] = [];
251
+ for (const command of contour) {
252
+ if (command.type === 'M') {
253
+ start = previous = [command.x, command.y];
254
+ continue;
255
+ }
256
+ if (command.type === 'Z') {
257
+ if (previous[0] !== start[0] || previous[1] !== start[1])
258
+ curves.push([previous, start]);
259
+ continue;
260
+ }
261
+ const end = [command.x, command.y];
262
+ if (command.type === 'L') curves.push([previous, end]);
263
+ else if (command.type === 'Q')
264
+ curves.push([previous, [command.x1, command.y1], end]);
265
+ else
266
+ curves.push([
267
+ previous,
268
+ [command.x1, command.y1],
269
+ [command.x2, command.y2],
270
+ end,
271
+ ]);
272
+ previous = end;
273
+ }
274
+ return curves;
275
+ });
276
+ return combinePaths('OR', paths, {minLoopArea: 0})
277
+ .filter(loop => loop.length)
278
+ .map(loop => {
279
+ const [x, y] = loop[0][0];
280
+ const commands: PathCommand[] = [{type: 'M', x, y}];
281
+ for (const curve of loop) {
282
+ const [x, y] = curve.at(-1)!;
283
+ if (curve.length === 2) commands.push({type: 'L', x, y});
284
+ else if (curve.length === 3)
285
+ commands.push({type: 'Q', x1: curve[1][0], y1: curve[1][1], x, y});
286
+ else
287
+ commands.push({
288
+ type: 'C',
289
+ x1: curve[1][0],
290
+ y1: curve[1][1],
291
+ x2: curve[2][0],
292
+ y2: curve[2][1],
293
+ x,
294
+ y,
295
+ });
296
+ }
297
+ commands.push({type: 'Z'});
298
+ return commands;
299
+ });
300
+ }
301
+
302
+ /** Blueprint.delete() does not release cached per-curve bounds. */
303
+ function deleteBlueprint(value: Blueprint): void {
304
+ for (const curve of value.curves) {
305
+ curve._boundingBox?.delete();
306
+ curve._boundingBox = null;
307
+ }
308
+ value.delete();
309
+ }
310
+
311
+ function contoursIntersect(a: Blueprint, b: Blueprint): boolean {
312
+ if (a.boundingBox.isOut(b.boundingBox)) return false;
313
+ const intersector = new (getOC().Geom2dAPI_InterCurveCurve)();
314
+ try {
315
+ for (const first of a.curves)
316
+ for (const second of b.curves) {
317
+ if (first.boundingBox.isOut(second.boundingBox)) continue;
318
+ intersector.Init(first.wrapped, second.wrapped, 1e-9);
319
+ if (intersector.NbPoints() || intersector.NbSegments()) return true;
320
+ }
321
+ return false;
322
+ } finally {
323
+ intersector.delete();
324
+ }
325
+ }
326
+
327
+ function containsPoint(contour: Blueprint, point: Point2D): boolean {
328
+ const [min, max] = contour.boundingBox.bounds;
329
+ if (
330
+ point[0] < min[0] ||
331
+ point[0] > max[0] ||
332
+ point[1] < min[1] ||
333
+ point[1] > max[1]
334
+ )
335
+ return false;
336
+ const span = Math.max(max[0] - min[0], max[1] - min[1]);
337
+ const intersector = new (getOC().Geom2dAPI_InterCurveCurve)();
338
+ try {
339
+ // A ray through a vertex or tangent cannot be counted by intersection parity.
340
+ // Retry a different direction in those cases, retaining exact native curves.
341
+ for (let attempt = 0; attempt < 16; attempt++) {
342
+ const end: Point2D = [
343
+ max[0] + span,
344
+ point[1] + span * Math.sin(attempt * 2.399963229728653),
345
+ ];
346
+ const ray = new BlueprintSketcher(point).lineTo(end).done();
347
+ try {
348
+ let crossings = 0,
349
+ ambiguous = false;
350
+ for (const curve of contour.curves) {
351
+ intersector.Init(ray.curves[0].wrapped, curve.wrapped, 1e-9);
352
+ if (intersector.NbSegments()) {
353
+ ambiguous = true;
354
+ break;
355
+ }
356
+ const endpoints = [curve.firstPoint, curve.lastPoint];
357
+ for (let index = 1; index <= intersector.NbPoints(); index++) {
358
+ const hit = intersector.Point(index);
359
+ try {
360
+ if (
361
+ endpoints.some(
362
+ p => Math.hypot(p[0] - hit.X(), p[1] - hit.Y()) < 1e-8,
363
+ )
364
+ )
365
+ ambiguous = true;
366
+ } finally {
367
+ hit.delete();
368
+ }
369
+ }
370
+ if (ambiguous) break;
371
+ crossings += intersector.NbPoints();
372
+ }
373
+ if (!ambiguous) return crossings % 2 === 1;
374
+ } finally {
375
+ deleteBlueprint(ray);
376
+ }
377
+ }
378
+ throw new Error('Could not determine containment of the font contours.');
379
+ } finally {
380
+ intersector.delete();
381
+ }
382
+ }
383
+
384
+ /** Own every native handle; Replicad's sketchOnPlane leaves edge builders alive. */
385
+ export function textRegionFace(region: Region, x: number, y: number): Face {
386
+ const oc = getOC();
387
+ const blueprints: Blueprint[] = [];
388
+ const wires: Wire[] = [];
389
+ const origin = new oc.gp_Pnt(0, 0, 0);
390
+ const normal = new oc.gp_Dir(0, 1, 0);
391
+ const xDirection = new oc.gp_Dir(1, 0, 0);
392
+ const axis = new oc.gp_Ax2(origin, normal, xDirection);
393
+ const plane = new oc.gp_Pln(origin, normal);
394
+ try {
395
+ for (const contour of region) {
396
+ const profile = blueprint(contour, x, y);
397
+ blueprints.push(profile);
398
+ const wireBuilder = new oc.BRepBuilderAPI_MakeWire();
399
+ try {
400
+ for (const curve of profile.curves) {
401
+ const nativeCurve = oc.GeomLib.To3d(axis, curve.wrapped);
402
+ try {
403
+ const edgeBuilder = new oc.BRepBuilderAPI_MakeEdge(nativeCurve);
404
+ try {
405
+ const edge = edgeBuilder.Edge();
406
+ try {
407
+ wireBuilder.Add(edge);
408
+ } finally {
409
+ edge.delete();
410
+ }
411
+ } finally {
412
+ edgeBuilder.delete();
413
+ }
414
+ } finally {
415
+ nativeCurve.delete();
416
+ }
417
+ }
418
+ if (!wireBuilder.IsDone())
419
+ throw new Error('Could not connect the font contour into a wire.');
420
+ wires.push(castOwnedShape(wireBuilder.Wire()) as Wire);
421
+ } finally {
422
+ wireBuilder.delete();
423
+ }
424
+ }
425
+ const faceBuilder = new oc.BRepBuilderAPI_MakeFace(
426
+ plane,
427
+ wires[0].wrapped,
428
+ true,
429
+ );
430
+ try {
431
+ for (const hole of wires.slice(1)) faceBuilder.Add(hole.wrapped);
432
+ if (!faceBuilder.IsDone())
433
+ throw new Error('Could not create a text face.');
434
+ const raw = faceBuilder.Face();
435
+ try {
436
+ const fixer = new oc.ShapeFix_Face(raw);
437
+ try {
438
+ fixer.FixOrientation();
439
+ const face = castOwnedShape(fixer.Face()) as Face;
440
+ try {
441
+ const analyzer = new oc.BRepCheck_Analyzer(
442
+ face.wrapped,
443
+ true,
444
+ false,
445
+ );
446
+ try {
447
+ if (!analyzer.IsValid())
448
+ throw new Error(
449
+ 'The font contours do not form a valid planar face.',
450
+ );
451
+ } finally {
452
+ analyzer.delete();
453
+ }
454
+ return face;
455
+ } catch (error) {
456
+ face.delete();
457
+ throw error;
458
+ }
459
+ } finally {
460
+ fixer.delete();
461
+ }
462
+ } finally {
463
+ raw.delete();
464
+ }
465
+ } finally {
466
+ faceBuilder.delete();
467
+ }
468
+ } finally {
469
+ wires.forEach(wire => wire.delete());
470
+ blueprints.forEach(deleteBlueprint);
471
+ plane.delete();
472
+ axis.delete();
473
+ xDirection.delete();
474
+ normal.delete();
475
+ origin.delete();
476
+ }
477
+ }