@code3d/core 0.0.1-alpha.0

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 (172) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +824 -0
  3. package/THIRD_PARTY.md +28 -0
  4. package/bld/library/alignment-geometry.d.ts +50 -0
  5. package/bld/library/alignment-geometry.d.ts.map +1 -0
  6. package/bld/library/alignment-geometry.js +322 -0
  7. package/bld/library/alignment-geometry.js.map +1 -0
  8. package/bld/library/bound-solver.d.ts +33 -0
  9. package/bld/library/bound-solver.d.ts.map +1 -0
  10. package/bld/library/bound-solver.js +252 -0
  11. package/bld/library/bound-solver.js.map +1 -0
  12. package/bld/library/extrude.d.ts +8 -0
  13. package/bld/library/extrude.d.ts.map +1 -0
  14. package/bld/library/extrude.js +28 -0
  15. package/bld/library/extrude.js.map +1 -0
  16. package/bld/library/index.d.ts +7 -0
  17. package/bld/library/index.d.ts.map +1 -0
  18. package/bld/library/index.js +3 -0
  19. package/bld/library/index.js.map +1 -0
  20. package/bld/library/kernel-artifact-codec.d.ts +3 -0
  21. package/bld/library/kernel-artifact-codec.d.ts.map +1 -0
  22. package/bld/library/kernel-artifact-codec.js +148 -0
  23. package/bld/library/kernel-artifact-codec.js.map +1 -0
  24. package/bld/library/kernel-cache.d.ts +63 -0
  25. package/bld/library/kernel-cache.d.ts.map +1 -0
  26. package/bld/library/kernel-cache.js +245 -0
  27. package/bld/library/kernel-cache.js.map +1 -0
  28. package/bld/library/kernel-shapes.d.ts +20 -0
  29. package/bld/library/kernel-shapes.d.ts.map +1 -0
  30. package/bld/library/kernel-shapes.js +108 -0
  31. package/bld/library/kernel-shapes.js.map +1 -0
  32. package/bld/library/loft.d.ts +7 -0
  33. package/bld/library/loft.d.ts.map +1 -0
  34. package/bld/library/loft.js +161 -0
  35. package/bld/library/loft.js.map +1 -0
  36. package/bld/library/material.d.ts +12 -0
  37. package/bld/library/material.d.ts.map +1 -0
  38. package/bld/library/material.js +176 -0
  39. package/bld/library/material.js.map +1 -0
  40. package/bld/library/model-color.d.ts +8 -0
  41. package/bld/library/model-color.d.ts.map +1 -0
  42. package/bld/library/model-color.js +36 -0
  43. package/bld/library/model-color.js.map +1 -0
  44. package/bld/library/open-cascade-error.d.ts +2 -0
  45. package/bld/library/open-cascade-error.d.ts.map +1 -0
  46. package/bld/library/open-cascade-error.js +22 -0
  47. package/bld/library/open-cascade-error.js.map +1 -0
  48. package/bld/library/relation-solver.d.ts +29 -0
  49. package/bld/library/relation-solver.d.ts.map +1 -0
  50. package/bld/library/relation-solver.js +258 -0
  51. package/bld/library/relation-solver.js.map +1 -0
  52. package/bld/library/replicad.d.ts +17 -0
  53. package/bld/library/replicad.d.ts.map +1 -0
  54. package/bld/library/replicad.js +24 -0
  55. package/bld/library/replicad.js.map +1 -0
  56. package/bld/library/retained-memory.d.ts +7 -0
  57. package/bld/library/retained-memory.d.ts.map +1 -0
  58. package/bld/library/retained-memory.js +27 -0
  59. package/bld/library/retained-memory.js.map +1 -0
  60. package/bld/library/runtime.d.ts +775 -0
  61. package/bld/library/runtime.d.ts.map +1 -0
  62. package/bld/library/runtime.js +2954 -0
  63. package/bld/library/runtime.js.map +1 -0
  64. package/bld/library/shell.d.ts +7 -0
  65. package/bld/library/shell.d.ts.map +1 -0
  66. package/bld/library/shell.js +221 -0
  67. package/bld/library/shell.js.map +1 -0
  68. package/bld/library/sketch-curve-intersections.d.ts +13 -0
  69. package/bld/library/sketch-curve-intersections.d.ts.map +1 -0
  70. package/bld/library/sketch-curve-intersections.js +117 -0
  71. package/bld/library/sketch-curve-intersections.js.map +1 -0
  72. package/bld/library/sketch-curves.d.ts +31 -0
  73. package/bld/library/sketch-curves.d.ts.map +1 -0
  74. package/bld/library/sketch-curves.js +89 -0
  75. package/bld/library/sketch-curves.js.map +1 -0
  76. package/bld/library/sketch-drag-rules.d.ts +35 -0
  77. package/bld/library/sketch-drag-rules.d.ts.map +1 -0
  78. package/bld/library/sketch-drag-rules.js +522 -0
  79. package/bld/library/sketch-drag-rules.js.map +1 -0
  80. package/bld/library/sketch-face.d.ts +4 -0
  81. package/bld/library/sketch-face.d.ts.map +1 -0
  82. package/bld/library/sketch-face.js +72 -0
  83. package/bld/library/sketch-face.js.map +1 -0
  84. package/bld/library/sketch-incidence.d.ts +26 -0
  85. package/bld/library/sketch-incidence.d.ts.map +1 -0
  86. package/bld/library/sketch-incidence.js +114 -0
  87. package/bld/library/sketch-incidence.js.map +1 -0
  88. package/bld/library/sketch-precision.d.ts +19 -0
  89. package/bld/library/sketch-precision.d.ts.map +1 -0
  90. package/bld/library/sketch-precision.js +55 -0
  91. package/bld/library/sketch-precision.js.map +1 -0
  92. package/bld/library/sketch-regions.d.ts +12 -0
  93. package/bld/library/sketch-regions.d.ts.map +1 -0
  94. package/bld/library/sketch-regions.js +193 -0
  95. package/bld/library/sketch-regions.js.map +1 -0
  96. package/bld/library/sketch-solver.d.ts +97 -0
  97. package/bld/library/sketch-solver.d.ts.map +1 -0
  98. package/bld/library/sketch-solver.js +578 -0
  99. package/bld/library/sketch-solver.js.map +1 -0
  100. package/bld/library/sketch.d.ts +142 -0
  101. package/bld/library/sketch.d.ts.map +1 -0
  102. package/bld/library/sketch.js +650 -0
  103. package/bld/library/sketch.js.map +1 -0
  104. package/bld/library/spatial.d.ts +28 -0
  105. package/bld/library/spatial.d.ts.map +1 -0
  106. package/bld/library/spatial.js +195 -0
  107. package/bld/library/spatial.js.map +1 -0
  108. package/bld/library/three.d.ts +3 -0
  109. package/bld/library/three.d.ts.map +1 -0
  110. package/bld/library/three.js +3 -0
  111. package/bld/library/three.js.map +1 -0
  112. package/bld/library/topology-id.d.ts +24 -0
  113. package/bld/library/topology-id.d.ts.map +1 -0
  114. package/bld/library/topology-id.js +67 -0
  115. package/bld/library/topology-id.js.map +1 -0
  116. package/bld/library/topology-inspection.d.ts +64 -0
  117. package/bld/library/topology-inspection.d.ts.map +1 -0
  118. package/bld/library/topology-inspection.js +267 -0
  119. package/bld/library/topology-inspection.js.map +1 -0
  120. package/bld/library/topology.d.ts +94 -0
  121. package/bld/library/topology.d.ts.map +1 -0
  122. package/bld/library/topology.js +455 -0
  123. package/bld/library/topology.js.map +1 -0
  124. package/bld/node/index.d.ts +2 -0
  125. package/bld/node/index.d.ts.map +1 -0
  126. package/bld/node/index.js +14 -0
  127. package/bld/node/index.js.map +1 -0
  128. package/bld/node/replicad.d.ts +3 -0
  129. package/bld/node/replicad.d.ts.map +1 -0
  130. package/bld/node/replicad.js +3 -0
  131. package/bld/node/replicad.js.map +1 -0
  132. package/bld/tooling/index.d.ts +26 -0
  133. package/bld/tooling/index.d.ts.map +1 -0
  134. package/bld/tooling/index.js +19 -0
  135. package/bld/tooling/index.js.map +1 -0
  136. package/package.json +53 -0
  137. package/src/library/alignment-geometry.ts +431 -0
  138. package/src/library/bound-solver.ts +410 -0
  139. package/src/library/extrude.ts +38 -0
  140. package/src/library/index.ts +88 -0
  141. package/src/library/kernel-artifact-codec.ts +166 -0
  142. package/src/library/kernel-cache.ts +337 -0
  143. package/src/library/kernel-shapes.ts +135 -0
  144. package/src/library/loft.ts +196 -0
  145. package/src/library/material.ts +228 -0
  146. package/src/library/model-color.ts +47 -0
  147. package/src/library/open-cascade-error.ts +26 -0
  148. package/src/library/relation-solver.ts +479 -0
  149. package/src/library/replicad.ts +40 -0
  150. package/src/library/retained-memory.ts +23 -0
  151. package/src/library/runtime.ts +5146 -0
  152. package/src/library/shell.ts +281 -0
  153. package/src/library/sketch-curve-intersections.ts +154 -0
  154. package/src/library/sketch-curves.ts +161 -0
  155. package/src/library/sketch-drag-rules.ts +767 -0
  156. package/src/library/sketch-face.ts +89 -0
  157. package/src/library/sketch-incidence.ts +204 -0
  158. package/src/library/sketch-precision.ts +81 -0
  159. package/src/library/sketch-regions.ts +271 -0
  160. package/src/library/sketch-solver.ts +973 -0
  161. package/src/library/sketch.ts +977 -0
  162. package/src/library/spatial.ts +254 -0
  163. package/src/library/three.ts +2 -0
  164. package/src/library/topology-id.ts +81 -0
  165. package/src/library/topology-inspection.ts +368 -0
  166. package/src/library/topology.ts +776 -0
  167. package/src/library/tsconfig.json +11 -0
  168. package/src/node/index.ts +20 -0
  169. package/src/node/replicad.ts +3 -0
  170. package/src/node/tsconfig.json +16 -0
  171. package/src/tooling/index.ts +160 -0
  172. package/src/tooling/tsconfig.json +15 -0
@@ -0,0 +1,281 @@
1
+ import {formatTopologyId, TopologyIdSet} from './topology-id.js';
2
+ import {getOC, type Shape3D} from 'replicad';
3
+ import {castOwnedShape3D, shapeSubshapes} from './kernel-shapes.js';
4
+ import {describeOpenCascadeException} from './open-cascade-error.js';
5
+ import {
6
+ transferShapeTopology,
7
+ type ShapeTopology,
8
+ type SurfaceId,
9
+ } from './topology.js';
10
+
11
+ const tolerance = 0.001;
12
+
13
+ export function shellWithTopology(
14
+ shape: Shape3D,
15
+ topology: ShapeTopology,
16
+ thickness: number,
17
+ removedSurfaceIds: readonly SurfaceId[],
18
+ ): {shape: Shape3D; topology: ShapeTopology} {
19
+ const selection = removedSurfaceIds.length
20
+ ? removedSurfaceIds.map(id => formatTopologyId('surface', id)).join(', ')
21
+ : 'no openings';
22
+ try {
23
+ if (removedSurfaceIds.length === topology.surfaces.ids.length) {
24
+ throw new Error('At least one surface must remain as a wall.');
25
+ }
26
+ const solid = singleSolid(shape);
27
+ try {
28
+ const volume = validVolume(solid);
29
+ return removedSurfaceIds.length
30
+ ? openShell(solid, topology, thickness, removedSurfaceIds, volume)
31
+ : closedShell(solid, topology, thickness, volume);
32
+ } finally {
33
+ solid.delete();
34
+ }
35
+ } catch (error) {
36
+ const detail =
37
+ describeOpenCascadeException(error) ??
38
+ (error instanceof Error ? error.message : String(error));
39
+ throw new Error(
40
+ `Could not construct shell with thickness ${thickness} and ${selection}.\n` +
41
+ `${detail} Try simplifying the geometry or changing the thickness or openings.`,
42
+ {cause: error},
43
+ );
44
+ }
45
+ }
46
+
47
+ function openShell(
48
+ shape: Shape3D,
49
+ topology: ShapeTopology,
50
+ thickness: number,
51
+ removedIds: readonly SurfaceId[],
52
+ sourceVolume: number,
53
+ ): {shape: Shape3D; topology: ShapeTopology} {
54
+ const oc = getOC();
55
+ const faces = shapeSubshapes(shape, 'face');
56
+ const selected = new TopologyIdSet(removedIds);
57
+ const closingFaces = new oc.NCollection_List_TopoDS_Shape();
58
+ const builder = new oc.BRepOffsetAPI_MakeThickSolid();
59
+ let result: Shape3D | undefined;
60
+ try {
61
+ faces.forEach((face, index) => {
62
+ if (selected.has(topology.surfaces.ids[index]))
63
+ closingFaces.Append(face.wrapped);
64
+ });
65
+ builder.MakeThickSolidByJoin(
66
+ shape.wrapped,
67
+ closingFaces,
68
+ -thickness,
69
+ tolerance,
70
+ oc.BRepOffset_Mode.BRepOffset_Skin,
71
+ false,
72
+ false,
73
+ oc.GeomAbs_JoinType.GeomAbs_Arc,
74
+ false,
75
+ );
76
+ if (!builder.IsDone())
77
+ throw new Error('OpenCascade could not build the offset walls.');
78
+ const raw = castOwnedShape3D(builder.Shape());
79
+ try {
80
+ result = singleSolid(raw);
81
+ } finally {
82
+ raw.delete();
83
+ }
84
+ const volume = validVolume(result);
85
+ const outputFaces = shapeSubshapes(result, 'face');
86
+ try {
87
+ // IsDone/IsValid can both succeed without offset walls, including on
88
+ // mixed-profile lofts. Diagnose that before comparing solid volumes.
89
+ const hasOffsetWall = faces.some((face, index) => {
90
+ if (selected.has(topology.surfaces.ids[index])) return false;
91
+ const generated = builder.Generated(face.wrapped);
92
+ try {
93
+ while (!generated.IsEmpty()) {
94
+ const candidate = generated.First();
95
+ generated.RemoveFirst();
96
+ try {
97
+ if (outputFaces.some(output => output.wrapped.IsSame(candidate)))
98
+ return true;
99
+ } finally {
100
+ candidate.delete();
101
+ }
102
+ }
103
+ return false;
104
+ } finally {
105
+ generated.delete();
106
+ }
107
+ });
108
+ if (!hasOffsetWall) {
109
+ throw new Error(
110
+ 'OpenCascade generated no offset walls for this geometry; the result is not hollow.',
111
+ );
112
+ }
113
+ } finally {
114
+ outputFaces.forEach(face => face.delete());
115
+ }
116
+ if (thickness > 0 && volume >= sourceVolume) {
117
+ throw new Error('The inward offset did not leave a cavity.');
118
+ }
119
+ return {
120
+ shape: result,
121
+ topology: transferShapeTopology(
122
+ [{shape, topology, index: 1}],
123
+ result,
124
+ builder,
125
+ ),
126
+ };
127
+ } catch (error) {
128
+ result?.delete();
129
+ throw error;
130
+ } finally {
131
+ builder.delete();
132
+ closingFaces.delete();
133
+ faces.forEach(face => face.delete());
134
+ }
135
+ }
136
+
137
+ function closedShell(
138
+ shape: Shape3D,
139
+ topology: ShapeTopology,
140
+ thickness: number,
141
+ sourceVolume: number,
142
+ ): {shape: Shape3D; topology: ShapeTopology} {
143
+ const oc = getOC();
144
+ const offset = new oc.BRepOffsetAPI_MakeOffsetShape();
145
+ let parallel: Shape3D | undefined;
146
+ try {
147
+ // MakeThickSolidByJoin with no closing faces returns only the offset solid.
148
+ offset.PerformByJoin(
149
+ shape.wrapped,
150
+ -thickness,
151
+ tolerance,
152
+ oc.BRepOffset_Mode.BRepOffset_Skin,
153
+ false,
154
+ false,
155
+ oc.GeomAbs_JoinType.GeomAbs_Arc,
156
+ false,
157
+ );
158
+ if (!offset.IsDone())
159
+ throw new Error('OpenCascade could not build the cavity offset.');
160
+ const raw = castOwnedShape3D(offset.Shape());
161
+ try {
162
+ parallel = singleSolid(raw);
163
+ } finally {
164
+ raw.delete();
165
+ }
166
+ const offsetVolume = validVolume(parallel);
167
+ if (
168
+ thickness > 0
169
+ ? offsetVolume >= sourceVolume
170
+ : offsetVolume <= sourceVolume
171
+ ) {
172
+ throw new Error('The offset did not produce a distinct cavity boundary.');
173
+ }
174
+ const cut = new oc.BRepAlgoAPI_Cut(
175
+ thickness > 0 ? shape.wrapped : parallel.wrapped,
176
+ thickness > 0 ? parallel.wrapped : shape.wrapped,
177
+ );
178
+ let result: Shape3D | undefined;
179
+ try {
180
+ cut.Build();
181
+ if (!cut.IsDone())
182
+ throw new Error('OpenCascade could not subtract the cavity.');
183
+ const rawResult = castOwnedShape3D(cut.Shape());
184
+ try {
185
+ result = singleSolid(rawResult);
186
+ } finally {
187
+ rawResult.delete();
188
+ }
189
+ validVolume(result);
190
+ const shells = new oc.TopExp_Explorer(
191
+ result.wrapped,
192
+ oc.TopAbs_ShapeEnum.TopAbs_SHELL,
193
+ );
194
+ try {
195
+ let count = 0;
196
+ while (shells.More()) {
197
+ count++;
198
+ shells.Next();
199
+ }
200
+ if (count < 2)
201
+ throw new Error('The offset did not form an enclosed cavity.');
202
+ } finally {
203
+ shells.delete();
204
+ }
205
+ // Original boundaries retain their input paths for either direction. Offset
206
+ // boundaries are new topology, even when OCCT has generation history.
207
+ return {
208
+ shape: result,
209
+ topology: transferShapeTopology(
210
+ [{shape, topology, index: 1}],
211
+ result,
212
+ cut,
213
+ ),
214
+ };
215
+ } catch (error) {
216
+ result?.delete();
217
+ throw error;
218
+ } finally {
219
+ cut.delete();
220
+ }
221
+ } finally {
222
+ parallel?.delete();
223
+ offset.delete();
224
+ }
225
+ }
226
+
227
+ /** Own one solid independently of its enclosing boolean compound or builder. */
228
+ function singleSolid(shape: Shape3D): Shape3D {
229
+ const oc = getOC();
230
+ const explorer = new oc.TopExp_Explorer(
231
+ shape.wrapped,
232
+ oc.TopAbs_ShapeEnum.TopAbs_SOLID,
233
+ );
234
+ let solid: Shape3D | undefined;
235
+ try {
236
+ if (!explorer.More())
237
+ throw new Error('Shell requires one connected solid.');
238
+ solid = castOwnedShape3D(explorer.Current());
239
+ explorer.Next();
240
+ if (explorer.More()) throw new Error('Shell requires one connected solid.');
241
+ return solid;
242
+ } catch (error) {
243
+ solid?.delete();
244
+ throw error;
245
+ } finally {
246
+ explorer.delete();
247
+ }
248
+ }
249
+
250
+ function validVolume(shape: Shape3D): number {
251
+ const check = new (getOC().BRepCheck_Analyzer)(
252
+ shape.wrapped,
253
+ true,
254
+ false,
255
+ false,
256
+ );
257
+ try {
258
+ if (!check.IsValid())
259
+ throw new Error('The offset geometry is invalid or self-intersecting.');
260
+ } finally {
261
+ check.delete();
262
+ }
263
+ const oc = getOC();
264
+ const properties = new oc.GProp_GProps();
265
+ try {
266
+ oc.BRepGProp.VolumeProperties(
267
+ shape.wrapped,
268
+ properties,
269
+ false,
270
+ false,
271
+ false,
272
+ );
273
+ const volume = properties.Mass();
274
+ if (!(Number.isFinite(volume) && volume > 0)) {
275
+ throw new Error('The offset geometry has no positive solid volume.');
276
+ }
277
+ return volume;
278
+ } finally {
279
+ properties.delete();
280
+ }
281
+ }
@@ -0,0 +1,154 @@
1
+ import type {SketchPosition} from './sketch.js';
2
+ import {
3
+ sketchCurveClosestParameter,
4
+ sketchCurvePosition,
5
+ sketchCurveTolerance,
6
+ type SketchCurve,
7
+ } from './sketch-curves.js';
8
+
9
+ export type SketchCurveIntersection = Readonly<{
10
+ position: SketchPosition;
11
+ parameters: readonly [first: number, second: number];
12
+ }>;
13
+
14
+ const subtract = (a: SketchPosition, b: SketchPosition): SketchPosition => [
15
+ a[0] - b[0],
16
+ a[1] - b[1],
17
+ ];
18
+ const cross = (a: SketchPosition, b: SketchPosition) =>
19
+ a[0] * b[1] - a[1] * b[0];
20
+ const distance = (a: SketchPosition, b: SketchPosition) =>
21
+ Math.hypot(...subtract(a, b));
22
+ const endpoints = (curve: SketchCurve): readonly SketchPosition[] =>
23
+ curve.kind === 'circle'
24
+ ? []
25
+ : [sketchCurvePosition(curve, 0), sketchCurvePosition(curve, 1)];
26
+
27
+ /**
28
+ * Isolated intersections and finite overlap boundaries, ordered along first.
29
+ * A shared continuous interior does not enumerate points: coincident full
30
+ * circles have no boundaries. Parameters are local to this evaluation, not IDs.
31
+ */
32
+ export function sketchCurveIntersections(
33
+ first: SketchCurve,
34
+ second: SketchCurve,
35
+ ): readonly SketchCurveIntersection[] {
36
+ const tolerances = [
37
+ sketchCurveTolerance(first),
38
+ sketchCurveTolerance(second),
39
+ ];
40
+ const tolerance = Math.min(...tolerances);
41
+ const contacts: SketchCurveIntersection[] = [];
42
+ for (const position of candidates(first, second, tolerance)) {
43
+ const parameters = [
44
+ sketchCurveClosestParameter(first, position),
45
+ sketchCurveClosestParameter(second, position),
46
+ ] as const;
47
+ if (
48
+ [first, second].some(
49
+ (curve, i) =>
50
+ distance(position, sketchCurvePosition(curve, parameters[i])) >
51
+ tolerances[i],
52
+ ) ||
53
+ contacts.some(
54
+ contact => distance(position, contact.position) <= tolerance,
55
+ )
56
+ )
57
+ continue;
58
+ contacts.push({position, parameters});
59
+ }
60
+ return contacts.sort((a, b) => a.parameters[0] - b.parameters[0]);
61
+ }
62
+
63
+ function candidates(
64
+ first: SketchCurve,
65
+ second: SketchCurve,
66
+ tolerance: number,
67
+ ): readonly SketchPosition[] {
68
+ if (first.kind === 'line') {
69
+ const [a, b] = first.points;
70
+ const length = distance(a, b);
71
+ if (!length) return [a];
72
+ const unit: SketchPosition = [
73
+ (b[0] - a[0]) / length,
74
+ (b[1] - a[1]) / length,
75
+ ];
76
+ if (second.kind === 'line') {
77
+ const [c, d] = second.points;
78
+ const otherLength = distance(c, d);
79
+ if (!otherLength) return [c];
80
+ // Collinear endpoints delimit overlap. Check them before dividing by
81
+ // the angle, which can be roundoff-sized for rotated/translated lines.
82
+ if (
83
+ second.points.every(
84
+ p => Math.abs(cross(unit, subtract(p, a))) <= tolerance,
85
+ )
86
+ )
87
+ return [a, b, c, d];
88
+ const otherUnit: SketchPosition = [
89
+ (d[0] - c[0]) / otherLength,
90
+ (d[1] - c[1]) / otherLength,
91
+ ];
92
+ const denominator = cross(unit, otherUnit);
93
+ if (Math.abs(denominator) <= Number.EPSILON * 16) return [];
94
+ const along = cross(subtract(c, a), otherUnit) / denominator;
95
+ return [[a[0] + along * unit[0], a[1] + along * unit[1]]];
96
+ }
97
+ const perpendicular = cross(unit, subtract(second.center, a));
98
+ const gap = Math.abs(perpendicular);
99
+ if (gap > second.radius + tolerance) return [];
100
+ const normalized = gap / second.radius;
101
+ // Classify tangency before sqrt magnifies coordinate roundoff into two
102
+ // artificial cuts. Use the same model-space tolerance as finite contacts.
103
+ const offset =
104
+ Math.abs(gap - second.radius) <= tolerance
105
+ ? 0
106
+ : second.radius *
107
+ Math.sqrt(Math.max(0, (1 - normalized) * (1 + normalized)));
108
+ // Start at the circular center's projection, not a far-away line endpoint.
109
+ const foot: SketchPosition = [
110
+ second.center[0] + perpendicular * unit[1],
111
+ second.center[1] - perpendicular * unit[0],
112
+ ];
113
+ return [-offset, offset].map(along => [
114
+ foot[0] + along * unit[0],
115
+ foot[1] + along * unit[1],
116
+ ]);
117
+ }
118
+ if (second.kind === 'line') return candidates(second, first, tolerance);
119
+ const delta = subtract(second.center, first.center);
120
+ const separation = Math.hypot(...delta);
121
+ if (
122
+ separation <= tolerance &&
123
+ Math.abs(first.radius - second.radius) <= tolerance
124
+ )
125
+ return [...endpoints(first), ...endpoints(second)];
126
+ if (
127
+ !separation ||
128
+ separation > first.radius + second.radius + tolerance ||
129
+ separation < Math.abs(first.radius - second.radius) - tolerance
130
+ )
131
+ return [];
132
+ // Normalize before squaring; the factored radius difference also avoids
133
+ // subtracting nearly equal squared radii.
134
+ const scale = Math.max(separation, first.radius, second.radius);
135
+ const d = separation / scale,
136
+ r = first.radius / scale,
137
+ s = second.radius / scale;
138
+ const along = (d * d + (r - s) * (r + s)) / (2 * d);
139
+ const tangent =
140
+ Math.abs(separation - first.radius - second.radius) <= tolerance ||
141
+ Math.abs(separation - Math.abs(first.radius - second.radius)) <= tolerance;
142
+ const height = tangent
143
+ ? 0
144
+ : Math.sqrt(Math.max(0, (r - along) * (r + along))) * scale;
145
+ const unit: SketchPosition = [delta[0] / separation, delta[1] / separation];
146
+ const foot: SketchPosition = [
147
+ first.center[0] + along * scale * unit[0],
148
+ first.center[1] + along * scale * unit[1],
149
+ ];
150
+ return [-height, height].map(offset => [
151
+ foot[0] - offset * unit[1],
152
+ foot[1] + offset * unit[0],
153
+ ]);
154
+ }
@@ -0,0 +1,161 @@
1
+ import {sketchRelativePrecision} from './sketch-precision.js';
2
+ import type {
3
+ SketchArcDirection,
4
+ SketchEntitySnapshot,
5
+ SketchPointAddress,
6
+ SketchPosition,
7
+ } from './sketch.js';
8
+
9
+ /** Analytic geometry only. Parameters are evaluation-local, never entity identity. */
10
+ export type SketchCurve =
11
+ | Readonly<{kind: 'line'; points: readonly [SketchPosition, SketchPosition]}>
12
+ | Readonly<{kind: 'circle'; center: SketchPosition; radius: number}>
13
+ | Readonly<{
14
+ kind: 'arc';
15
+ center: SketchPosition;
16
+ radius: number;
17
+ start: number;
18
+ sweep: number;
19
+ }>;
20
+
21
+ const tau = Math.PI * 2;
22
+ export const sketchPositiveAngle = (angle: number) =>
23
+ ((angle % tau) + tau) % tau;
24
+
25
+ export function sketchArcGeometry(
26
+ center: SketchPosition,
27
+ start: SketchPosition,
28
+ end: SketchPosition,
29
+ direction: SketchArcDirection,
30
+ ): Extract<SketchCurve, {kind: 'arc'}> {
31
+ const angle = Math.atan2(start[1] - center[1], start[0] - center[0]);
32
+ const endAngle = Math.atan2(end[1] - center[1], end[0] - center[0]);
33
+ const sign = direction === 'ccw' ? 1 : -1;
34
+ return {
35
+ kind: 'arc',
36
+ center,
37
+ radius: Math.hypot(start[0] - center[0], start[1] - center[1]),
38
+ start: angle,
39
+ sweep: sign * sketchPositiveAngle(sign * (endAngle - angle)),
40
+ };
41
+ }
42
+
43
+ export function sketchCurveGeometry<E extends SketchEntitySnapshot>(
44
+ entity: E,
45
+ point: (address: SketchPointAddress) => SketchPosition,
46
+ ): E extends {kind: 'point'}
47
+ ? undefined
48
+ : Extract<SketchCurve, {kind: E['kind']}>;
49
+ export function sketchCurveGeometry(
50
+ entity: SketchEntitySnapshot,
51
+ point: (address: SketchPointAddress) => SketchPosition,
52
+ ): SketchCurve | undefined {
53
+ switch (entity.kind) {
54
+ case 'point':
55
+ return undefined;
56
+ case 'line':
57
+ return {
58
+ kind: 'line',
59
+ points: [point(entity.points[0]), point(entity.points[1])],
60
+ };
61
+ case 'circle':
62
+ return {
63
+ kind: 'circle',
64
+ center: point(entity.center),
65
+ radius: entity.radius,
66
+ };
67
+ case 'arc':
68
+ return {
69
+ ...sketchArcGeometry(
70
+ point(entity.center),
71
+ point(entity.points[0]),
72
+ point(entity.points[1]),
73
+ entity.direction,
74
+ ),
75
+ radius: entity.radius,
76
+ };
77
+ }
78
+ }
79
+
80
+ export function sketchCurvePosition(
81
+ curve: SketchCurve,
82
+ t: number,
83
+ ): SketchPosition {
84
+ if (curve.kind === 'line') {
85
+ const [a, b] = curve.points;
86
+ return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])];
87
+ }
88
+ const angle =
89
+ curve.kind === 'circle' ? t * tau : curve.start + t * curve.sweep;
90
+ return [
91
+ curve.center[0] + curve.radius * Math.cos(angle),
92
+ curve.center[1] + curve.radius * Math.sin(angle),
93
+ ];
94
+ }
95
+
96
+ /** Nearest point on the finite curve, including arc endpoints (not its full circle). */
97
+ export function sketchCurveClosestParameter(
98
+ curve: SketchCurve,
99
+ position: SketchPosition,
100
+ ): number {
101
+ if (curve.kind === 'line') {
102
+ const [a, b] = curve.points,
103
+ dx = b[0] - a[0],
104
+ dy = b[1] - a[1];
105
+ return Math.max(
106
+ 0,
107
+ Math.min(
108
+ 1,
109
+ ((position[0] - a[0]) * dx + (position[1] - a[1]) * dy) /
110
+ (dx * dx + dy * dy) || 0,
111
+ ),
112
+ );
113
+ }
114
+ const angle = Math.atan2(
115
+ position[1] - curve.center[1],
116
+ position[0] - curve.center[0],
117
+ );
118
+ if (curve.kind === 'circle') return sketchPositiveAngle(angle) / tau;
119
+ const sign = Math.sign(curve.sweep),
120
+ sweep = Math.abs(curve.sweep);
121
+ const distance = sketchPositiveAngle(sign * (angle - curve.start));
122
+ if (distance <= sweep) return sweep ? distance / sweep : 0;
123
+ return distance - sweep < tau - distance ? 1 : 0;
124
+ }
125
+
126
+ export function sketchCurveBounds(
127
+ curve: SketchCurve,
128
+ ): readonly SketchPosition[] {
129
+ if (curve.kind === 'line') return curve.points;
130
+ const candidates = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2].filter(
131
+ angle =>
132
+ curve.kind === 'circle' ||
133
+ sketchPositiveAngle(Math.sign(curve.sweep) * (angle - curve.start)) <=
134
+ Math.abs(curve.sweep),
135
+ );
136
+ return [
137
+ ...(curve.kind === 'arc'
138
+ ? [sketchCurvePosition(curve, 0), sketchCurvePosition(curve, 1)]
139
+ : []),
140
+ ...candidates.map((angle): SketchPosition => [
141
+ curve.center[0] + curve.radius * Math.cos(angle),
142
+ curve.center[1] + curve.radius * Math.sin(angle),
143
+ ]),
144
+ ];
145
+ }
146
+
147
+ export function sketchCurveTolerance(curve: SketchCurve): number {
148
+ const size =
149
+ curve.kind === 'line'
150
+ ? Math.hypot(
151
+ curve.points[1][0] - curve.points[0][0],
152
+ curve.points[1][1] - curve.points[0][1],
153
+ )
154
+ : curve.radius;
155
+ return Math.max(
156
+ size * sketchRelativePrecision,
157
+ Number.EPSILON *
158
+ 16 *
159
+ Math.max(...sketchCurveBounds(curve).flatMap(p => p.map(Math.abs))),
160
+ );
161
+ }