@code3d/core 0.0.1-alpha.0 → 0.0.1-alpha.10
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/README.md +43 -807
- package/THIRD_PARTY.md +40 -0
- package/bld/THIRD_PARTY_NOTICES.txt +182 -0
- package/bld/chunks/chunk-5IP37Z5U.js +30 -0
- package/bld/chunks/chunk-5IP37Z5U.js.map +7 -0
- package/bld/chunks/chunk-7FIFPADJ.js +1 -0
- package/bld/chunks/chunk-7FIFPADJ.js.map +7 -0
- package/bld/chunks/chunk-HBRLIGQN.js +31 -0
- package/bld/chunks/chunk-HBRLIGQN.js.map +7 -0
- package/bld/chunks/chunk-IJHQ57VL.js +19 -0
- package/bld/chunks/chunk-IJHQ57VL.js.map +7 -0
- package/bld/chunks/chunk-TTJOM4DJ.js +26458 -0
- package/bld/chunks/chunk-TTJOM4DJ.js.map +7 -0
- package/bld/library/bound-solver.d.ts +29 -5
- package/bld/library/bound-solver.d.ts.map +1 -1
- package/bld/library/cached.d.ts +35 -0
- package/bld/library/cached.d.ts.map +1 -0
- package/bld/library/font.d.ts +39 -0
- package/bld/library/font.d.ts.map +1 -0
- package/bld/library/google-font.d.ts +14 -0
- package/bld/library/google-font.d.ts.map +1 -0
- package/bld/library/index.d.ts +10 -2
- package/bld/library/index.d.ts.map +1 -1
- package/bld/library/index.js +94 -3
- package/bld/library/index.js.map +7 -1
- package/bld/library/inspect.d.ts +98 -0
- package/bld/library/inspect.d.ts.map +1 -0
- package/bld/library/kernel-artifact-codec.d.ts.map +1 -1
- package/bld/library/kernel-cache.d.ts +28 -7
- package/bld/library/kernel-cache.d.ts.map +1 -1
- package/bld/library/loft.d.ts.map +1 -1
- package/bld/library/material.d.ts +1 -1
- package/bld/library/material.d.ts.map +1 -1
- package/bld/library/open-cascade.d.ts +4 -0
- package/bld/library/open-cascade.d.ts.map +1 -0
- package/bld/library/relation-solver.d.ts +19 -8
- package/bld/library/relation-solver.d.ts.map +1 -1
- package/bld/library/replicad.d.ts +3 -2
- package/bld/library/replicad.d.ts.map +1 -1
- package/bld/library/replicad.js +10 -24
- package/bld/library/replicad.js.map +7 -1
- package/bld/library/retained-memory.d.ts.map +1 -1
- package/bld/library/runtime.d.ts +518 -155
- package/bld/library/runtime.d.ts.map +1 -1
- package/bld/library/sketch-solver.d.ts +1 -1
- package/bld/library/sketch-solver.d.ts.map +1 -1
- package/bld/library/sketch.d.ts +21 -1
- package/bld/library/sketch.d.ts.map +1 -1
- package/bld/library/text.d.ts +53 -0
- package/bld/library/text.d.ts.map +1 -0
- package/bld/library/three.js +3 -3
- package/bld/library/three.js.map +7 -1
- package/bld/library/topology.d.ts +6 -2
- package/bld/library/topology.d.ts.map +1 -1
- package/bld/node/index.d.ts.map +1 -1
- package/bld/node/index.js +96 -14
- package/bld/node/index.js.map +7 -1
- package/bld/node/replicad.js +13 -3
- package/bld/node/replicad.js.map +7 -1
- package/bld/tooling/index.d.ts +18 -15
- package/bld/tooling/index.d.ts.map +1 -1
- package/bld/tooling/index.js +165 -19
- package/bld/tooling/index.js.map +7 -1
- package/docs/api.md +764 -0
- package/docs/custom-primitives.mdx +58 -0
- package/docs/local-coordinates.md +187 -0
- package/docs/origins-and-rotation.mdx +105 -0
- package/docs/relations.mdx +450 -0
- package/docs/runtime.md +297 -0
- package/docs/shells.mdx +70 -0
- package/docs/sketches.md +83 -0
- package/docs/text.md +23 -0
- package/docs/topology.md +152 -0
- package/docs/values.md +119 -0
- package/package.json +20 -7
- package/src/library/bound-solver.ts +103 -73
- package/src/library/cached.ts +153 -0
- package/src/library/font.ts +186 -0
- package/src/library/google-font.ts +97 -0
- package/src/library/index.ts +53 -5
- package/src/library/inspect.ts +176 -0
- package/src/library/kernel-artifact-codec.ts +129 -20
- package/src/library/kernel-cache.ts +175 -39
- package/src/library/loft.ts +9 -1
- package/src/library/material.ts +3 -4
- package/src/library/open-cascade.ts +13 -0
- package/src/library/relation-solver.ts +230 -113
- package/src/library/replicad.ts +5 -5
- package/src/library/retained-memory.ts +6 -1
- package/src/library/runtime.ts +3753 -1181
- package/src/library/shell.ts +3 -3
- package/src/library/sketch-solver.ts +3 -1
- package/src/library/sketch.ts +112 -13
- package/src/library/text.ts +477 -0
- package/src/library/topology.ts +61 -17
- package/src/library/tsconfig.json +2 -1
- package/src/node/index.ts +14 -3
- package/src/node/tsconfig.json +2 -1
- package/src/tooling/index.ts +64 -51
- package/src/tooling/tsconfig.json +2 -1
- package/bld/library/alignment-geometry.js +0 -322
- package/bld/library/alignment-geometry.js.map +0 -1
- package/bld/library/bound-solver.js +0 -252
- package/bld/library/bound-solver.js.map +0 -1
- package/bld/library/extrude.js +0 -28
- package/bld/library/extrude.js.map +0 -1
- package/bld/library/kernel-artifact-codec.js +0 -148
- package/bld/library/kernel-artifact-codec.js.map +0 -1
- package/bld/library/kernel-cache.js +0 -245
- package/bld/library/kernel-cache.js.map +0 -1
- package/bld/library/kernel-shapes.js +0 -108
- package/bld/library/kernel-shapes.js.map +0 -1
- package/bld/library/loft.js +0 -161
- package/bld/library/loft.js.map +0 -1
- package/bld/library/material.js +0 -176
- package/bld/library/material.js.map +0 -1
- package/bld/library/model-color.js +0 -36
- package/bld/library/model-color.js.map +0 -1
- package/bld/library/open-cascade-error.js +0 -22
- package/bld/library/open-cascade-error.js.map +0 -1
- package/bld/library/relation-solver.js +0 -258
- package/bld/library/relation-solver.js.map +0 -1
- package/bld/library/retained-memory.js +0 -27
- package/bld/library/retained-memory.js.map +0 -1
- package/bld/library/runtime.js +0 -2954
- package/bld/library/runtime.js.map +0 -1
- package/bld/library/shell.js +0 -221
- package/bld/library/shell.js.map +0 -1
- package/bld/library/sketch-curve-intersections.js +0 -117
- package/bld/library/sketch-curve-intersections.js.map +0 -1
- package/bld/library/sketch-curves.js +0 -89
- package/bld/library/sketch-curves.js.map +0 -1
- package/bld/library/sketch-drag-rules.js +0 -522
- package/bld/library/sketch-drag-rules.js.map +0 -1
- package/bld/library/sketch-face.js +0 -72
- package/bld/library/sketch-face.js.map +0 -1
- package/bld/library/sketch-incidence.js +0 -114
- package/bld/library/sketch-incidence.js.map +0 -1
- package/bld/library/sketch-precision.js +0 -55
- package/bld/library/sketch-precision.js.map +0 -1
- package/bld/library/sketch-regions.js +0 -193
- package/bld/library/sketch-regions.js.map +0 -1
- package/bld/library/sketch-solver.js +0 -578
- package/bld/library/sketch-solver.js.map +0 -1
- package/bld/library/sketch.js +0 -650
- package/bld/library/sketch.js.map +0 -1
- package/bld/library/spatial.js +0 -195
- package/bld/library/spatial.js.map +0 -1
- package/bld/library/topology-id.js +0 -67
- package/bld/library/topology-id.js.map +0 -1
- package/bld/library/topology-inspection.js +0 -267
- package/bld/library/topology-inspection.js.map +0 -1
- package/bld/library/topology.js +0 -455
- package/bld/library/topology.js.map +0 -1
|
@@ -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
|
+
}
|
package/src/library/topology.ts
CHANGED
|
@@ -107,6 +107,8 @@ function topologyShapes(shape: AnyShape, kind: TopologyKind): AnyShape[] {
|
|
|
107
107
|
type StableTopology = Readonly<{
|
|
108
108
|
/** IDs aligned with the corresponding shape traversal order. */
|
|
109
109
|
ids: readonly TopologyId[];
|
|
110
|
+
/** Next local ID; retained across edits so retired IDs cannot be reused. */
|
|
111
|
+
nextId: number;
|
|
110
112
|
}>;
|
|
111
113
|
|
|
112
114
|
export type VertexTopology = StableTopology;
|
|
@@ -172,6 +174,7 @@ type StableEdgeGroup = Readonly<{
|
|
|
172
174
|
start: number;
|
|
173
175
|
count: number;
|
|
174
176
|
edgeId: EdgeId;
|
|
177
|
+
linear: boolean;
|
|
175
178
|
}>;
|
|
176
179
|
|
|
177
180
|
type StableSurfaceGroup = Readonly<{
|
|
@@ -254,7 +257,31 @@ export function booleanWithTopology(
|
|
|
254
257
|
let result: Shape3D | undefined;
|
|
255
258
|
try {
|
|
256
259
|
builder.Build();
|
|
260
|
+
if (!builder.IsDone())
|
|
261
|
+
throw new Error(
|
|
262
|
+
`Could not complete the ${operation === 'fuse' ? 'union' : operation} operation.`,
|
|
263
|
+
);
|
|
257
264
|
builder.SimplifyResult(true, true, 0.001);
|
|
265
|
+
if (operation === 'intersect') {
|
|
266
|
+
const shape = builder.Shape();
|
|
267
|
+
try {
|
|
268
|
+
const solids = new oc.TopExp_Explorer(
|
|
269
|
+
shape,
|
|
270
|
+
oc.TopAbs_ShapeEnum.TopAbs_SOLID,
|
|
271
|
+
oc.TopAbs_ShapeEnum.TopAbs_SHAPE,
|
|
272
|
+
);
|
|
273
|
+
try {
|
|
274
|
+
if (!solids.More())
|
|
275
|
+
throw new Error(
|
|
276
|
+
'The inputs have no common solid volume. Adjust their positions or dimensions so they overlap.',
|
|
277
|
+
);
|
|
278
|
+
} finally {
|
|
279
|
+
solids.delete();
|
|
280
|
+
}
|
|
281
|
+
} finally {
|
|
282
|
+
shape.delete();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
258
285
|
result = castOwnedShape3D(builder.Shape());
|
|
259
286
|
return {
|
|
260
287
|
shape: result,
|
|
@@ -281,7 +308,17 @@ export function stableEdgeGroups(
|
|
|
281
308
|
topology,
|
|
282
309
|
groups.map(group => group.edgeId),
|
|
283
310
|
);
|
|
284
|
-
|
|
311
|
+
const linear = new Map(
|
|
312
|
+
edges.map(edge => [
|
|
313
|
+
edge.hashCode,
|
|
314
|
+
(edge as ReplicadEdge).geomType === 'LINE',
|
|
315
|
+
]),
|
|
316
|
+
);
|
|
317
|
+
return groups.map((group, index) => ({
|
|
318
|
+
...group,
|
|
319
|
+
edgeId: ids[index],
|
|
320
|
+
linear: linear.get(group.edgeId) ?? false,
|
|
321
|
+
}));
|
|
285
322
|
} finally {
|
|
286
323
|
deleteShapes(edges);
|
|
287
324
|
}
|
|
@@ -484,7 +521,7 @@ function modifyEdges(
|
|
|
484
521
|
return {
|
|
485
522
|
shape: result,
|
|
486
523
|
topology: transferShapeTopology(
|
|
487
|
-
[{shape, topology: source,
|
|
524
|
+
[{shape, topology: source, namespace: 'preserve'}],
|
|
488
525
|
result,
|
|
489
526
|
builder,
|
|
490
527
|
),
|
|
@@ -570,7 +607,7 @@ function resolveTopologyIndex(
|
|
|
570
607
|
return index;
|
|
571
608
|
}
|
|
572
609
|
|
|
573
|
-
function assertTopologyId(kind: TopologyKind, id: TopologyId): void {
|
|
610
|
+
export function assertTopologyId(kind: TopologyKind, id: TopologyId): void {
|
|
574
611
|
if (!isTopologyId(id)) {
|
|
575
612
|
throw new Error(
|
|
576
613
|
`${kind[0].toUpperCase()}${kind.slice(1)} IDs must be positive integers or paths of at least two positive integers; received ${JSON.stringify(id)}.`,
|
|
@@ -581,8 +618,8 @@ function assertTopologyId(kind: TopologyKind, id: TopologyId): void {
|
|
|
581
618
|
export type TopologyInput = Readonly<{
|
|
582
619
|
shape: AnyShape;
|
|
583
620
|
topology: ShapeTopology;
|
|
584
|
-
/**
|
|
585
|
-
|
|
621
|
+
/** Input prefix, unchanged namespace for edits, or an internal operation step. */
|
|
622
|
+
namespace: number | 'preserve' | 'intermediate';
|
|
586
623
|
}>;
|
|
587
624
|
|
|
588
625
|
/** Returns owned handles, released by the transfer after matching. */
|
|
@@ -601,23 +638,26 @@ export function transferShapeTopology(
|
|
|
601
638
|
const transfer = (kind: TopologyKind): StableTopology => {
|
|
602
639
|
const originals: AnyShape[][] = [];
|
|
603
640
|
const results = topologyShapes(output, kind);
|
|
641
|
+
let nextId = 1;
|
|
604
642
|
try {
|
|
605
643
|
const candidates = inputs.flatMap((input, inputIndex) => {
|
|
606
644
|
const shapes = topologyShapes(input.shape, kind);
|
|
607
645
|
originals.push(shapes);
|
|
608
646
|
const source = input.topology[topologyMetadata[kind].plural];
|
|
609
647
|
assertTopologyLength(kind, shapes, source);
|
|
648
|
+
if (input.namespace === 'preserve')
|
|
649
|
+
nextId = Math.max(nextId, source.nextId);
|
|
610
650
|
return shapes.map((shape, index) => {
|
|
611
651
|
const id = source.ids[index];
|
|
612
652
|
// Numeric elements in an internal prefix are still new to this
|
|
613
653
|
// operation. Reallocate them with the final traversal, without exposing
|
|
614
654
|
// internal creation steps or reserving numbers for discarded geometry.
|
|
615
655
|
const inherited =
|
|
616
|
-
input.
|
|
617
|
-
? inheritedTopologyId(input.
|
|
618
|
-
: typeof id
|
|
619
|
-
?
|
|
620
|
-
:
|
|
656
|
+
typeof input.namespace === 'number'
|
|
657
|
+
? inheritedTopologyId(input.namespace, id)
|
|
658
|
+
: input.namespace === 'preserve' || typeof id !== 'number'
|
|
659
|
+
? id
|
|
660
|
+
: undefined;
|
|
621
661
|
const unchanged = matchingOutputShapes(shape.wrapped, results);
|
|
622
662
|
if (unchanged.length) return {id: inherited, matches: unchanged};
|
|
623
663
|
if (typeof history !== 'function') {
|
|
@@ -658,10 +698,8 @@ export function transferShapeTopology(
|
|
|
658
698
|
inherited.set(index, candidate.id);
|
|
659
699
|
}
|
|
660
700
|
}
|
|
661
|
-
|
|
662
|
-
return stableTopology(
|
|
663
|
-
results.map((_, index) => inherited.get(index) ?? nextId++),
|
|
664
|
-
);
|
|
701
|
+
const ids = results.map((_, index) => inherited.get(index) ?? nextId++);
|
|
702
|
+
return stableTopology(ids, nextId);
|
|
665
703
|
} finally {
|
|
666
704
|
originals.forEach(deleteShapes);
|
|
667
705
|
deleteShapes(results);
|
|
@@ -734,11 +772,17 @@ function stableGroupIds(
|
|
|
734
772
|
function initialTopology(
|
|
735
773
|
shapes: readonly (ReplicadVertex | ReplicadEdge | ReplicadFace)[],
|
|
736
774
|
): StableTopology {
|
|
737
|
-
return stableTopology(
|
|
775
|
+
return stableTopology(
|
|
776
|
+
shapes.map((_, index) => index + 1),
|
|
777
|
+
shapes.length + 1,
|
|
778
|
+
);
|
|
738
779
|
}
|
|
739
780
|
|
|
740
|
-
function stableTopology(
|
|
741
|
-
|
|
781
|
+
function stableTopology(
|
|
782
|
+
ids: readonly TopologyId[],
|
|
783
|
+
nextId: number,
|
|
784
|
+
): StableTopology {
|
|
785
|
+
return {ids, nextId};
|
|
742
786
|
}
|
|
743
787
|
|
|
744
788
|
function shapeTopology(
|
package/src/node/index.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import {fileURLToPath} from 'node:url';
|
|
2
1
|
import initOpenCascade from '@code3d/opencascade';
|
|
3
|
-
import {setOC} from 'replicad';
|
|
4
2
|
import {init_planegcs_module as initializeSketchSolver} from '@salusoft89/planegcs';
|
|
3
|
+
import * as fontEngine from 'harfbuzzjs';
|
|
4
|
+
import {readFileSync} from 'node:fs';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
import {
|
|
7
|
+
installFontEngine,
|
|
8
|
+
installModelResourceReader,
|
|
9
|
+
} from '../library/font.js';
|
|
10
|
+
import {installOpenCascade} from '../library/open-cascade.js';
|
|
5
11
|
import {installSketchSolver} from '../library/sketch-solver.js';
|
|
6
12
|
|
|
13
|
+
installModelResourceReader(url =>
|
|
14
|
+
url.protocol === 'file:' ? readFileSync(url) : undefined,
|
|
15
|
+
);
|
|
16
|
+
installFontEngine(fontEngine);
|
|
17
|
+
|
|
7
18
|
const wasmPath = fileURLToPath(import.meta.resolve('@code3d/opencascade/wasm'));
|
|
8
19
|
const openCascade = await initOpenCascade({locateFile: () => wasmPath});
|
|
9
|
-
|
|
20
|
+
installOpenCascade(openCascade);
|
|
10
21
|
installSketchSolver(
|
|
11
22
|
await initializeSketchSolver({
|
|
12
23
|
locateFile: () =>
|