@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,767 @@
1
+ import {
2
+ solveSketchProblem,
3
+ type SketchSolveObjective,
4
+ type SketchSolveProblem,
5
+ type SketchSolveTarget,
6
+ type SketchSolveConstraint,
7
+ type SketchSolveResult,
8
+ } from './sketch-solver.js';
9
+ import type {SketchPosition} from './sketch.js';
10
+ import {
11
+ sketchIncidences,
12
+ sketchIncidenceGeometry,
13
+ sketchIncidencePoints,
14
+ sketchIncidenceCurve,
15
+ sketchIncidenceBoundary,
16
+ type SketchIncidence,
17
+ type SketchIncidenceGeometry,
18
+ } from './sketch-incidence.js';
19
+ import {
20
+ sketchCurveClosestParameter,
21
+ sketchCurvePosition,
22
+ } from './sketch-curves.js';
23
+
24
+ export type SketchDragContext = Readonly<{
25
+ reference: SketchSolveProblem;
26
+ target: SketchSolveTarget;
27
+ /** Displayed gesture-start geometry, before applying AST coordinate locks. */
28
+ geometry?: SketchIncidenceGeometry;
29
+ }>;
30
+ export type SketchDragPlan = Readonly<{
31
+ problem: SketchSolveProblem;
32
+ stages: readonly [SketchDragStage, ...SketchDragStage[]];
33
+ incidences?: readonly SketchIncidence[];
34
+ }>;
35
+ /** A rule may prepare a stage's seed from the preceding feasible geometry.
36
+ * The seed is only an initial guess; all accumulated locks still apply. */
37
+ export type SketchDragStage = Readonly<{
38
+ objectives: (current: SketchSolveProblem) => readonly SketchSolveObjective[];
39
+ seed?: (
40
+ current: SketchSolveProblem,
41
+ objectives: readonly SketchSolveObjective[],
42
+ ) => SketchSolveProblem;
43
+ }>;
44
+ export type SketchDragSession = (
45
+ current: SketchSolveProblem,
46
+ target: SketchSolveTarget,
47
+ ) => SketchDragPlan;
48
+ export type SketchDragRule = (
49
+ context: SketchDragContext,
50
+ ) => SketchDragSession | undefined;
51
+
52
+ /** Dispatch knows neither point roles nor connected components. */
53
+ export function createSketchDragSession(
54
+ context: SketchDragContext,
55
+ rules: readonly SketchDragRule[] = dragRules,
56
+ ): SketchDragSession {
57
+ for (const rule of rules) {
58
+ const session = rule(context);
59
+ if (session) return session;
60
+ }
61
+ throw new Error('No sketch drag rule matched the gesture.');
62
+ }
63
+
64
+ export function solveSketchDrag(
65
+ current: SketchSolveProblem,
66
+ target: SketchSolveTarget,
67
+ reference: SketchSolveProblem,
68
+ geometry?: SketchDragContext['geometry'],
69
+ ) {
70
+ const plan = createSketchDragSession({reference, target, geometry})(
71
+ current,
72
+ target,
73
+ );
74
+ // Resolve remaining freedom only after every gesture-specific preference.
75
+ // Earlier stages' achieved positions stay fixed; references never follow
76
+ // the iterative seed. Solver points are canonical, so aliases add no weight.
77
+ const stages: [SketchDragStage, ...SketchDragStage[]] = [
78
+ ...plan.stages,
79
+ {
80
+ objectives: reached =>
81
+ reference.points.flatMap((_, point) =>
82
+ (target.kind === 'point' && point === target.point) ||
83
+ reached.points[point].locked.every(Boolean)
84
+ ? []
85
+ : [anchor(reference, point)],
86
+ ),
87
+ },
88
+ ];
89
+ const bounds = new Map<SketchIncidence, number>();
90
+ for (;;) {
91
+ const problem = {
92
+ ...plan.problem,
93
+ constraints: [
94
+ ...plan.problem.constraints,
95
+ ...[...bounds].map(([contact, endpoint]) => ({
96
+ kind: 'coincident' as const,
97
+ points: [contact.point, endpoint] as const,
98
+ })),
99
+ ],
100
+ };
101
+ const result = solveSketchDragPlan({...plan, problem, stages});
102
+ const solved = sketchIncidenceGeometry(problem, result);
103
+ const outside = plan.incidences?.flatMap(contact => {
104
+ const endpoint = bounds.has(contact)
105
+ ? undefined
106
+ : sketchIncidenceBoundary(solved, contact);
107
+ return endpoint === undefined ? [] : [{contact, endpoint}];
108
+ })[0];
109
+ if (!outside) return result;
110
+ bounds.set(outside.contact, outside.endpoint);
111
+ }
112
+ }
113
+
114
+ /** Each stage keeps the preceding stage's chosen parameter values, not all
115
+ * equivalent optima. These locks belong to this solve, never the next frame. */
116
+ export function solveSketchDragPlan(plan: SketchDragPlan): SketchSolveResult {
117
+ let problem = plan.problem;
118
+ let result: SketchSolveResult | undefined;
119
+ for (const stage of plan.stages) {
120
+ const objectives = stage.objectives(problem);
121
+ if (result && !objectives.length) continue;
122
+ // Stage output is an iterative seed, not a new authored exact value.
123
+ // Otherwise cleanup can undo a later hard solve to restore an earlier tail.
124
+ const solved = solveSketchProblem(
125
+ stage.seed?.(problem, objectives) ?? problem,
126
+ objectives,
127
+ plan.problem,
128
+ );
129
+ result = solved;
130
+ const points = new Set(
131
+ objectives.filter(o => o.kind === 'point').map(o => o.point),
132
+ );
133
+ const radii = objectives.filter(o => o.kind === 'radius');
134
+ problem = {
135
+ ...problem,
136
+ points: problem.points.map((point, index) => ({
137
+ position: solved.positions[index],
138
+ locked: points.has(index) ? [true, true] : point.locked,
139
+ })),
140
+ circles: problem.circles.map((circle, index) => ({
141
+ ...circle,
142
+ radius: solved.radii[index],
143
+ locked:
144
+ circle.locked ||
145
+ radii.some(o => o.curve === 'circle' && o.index === index),
146
+ })),
147
+ arcs: problem.arcs.map((arc, index) => ({
148
+ ...arc,
149
+ radius: solved.arcRadii[index],
150
+ locked:
151
+ arc.locked || radii.some(o => o.curve === 'arc' && o.index === index),
152
+ })),
153
+ };
154
+ }
155
+ return result!;
156
+ }
157
+
158
+ type PointPolicy = Readonly<
159
+ | {
160
+ kind: 'translation';
161
+ points: readonly number[];
162
+ radii: readonly SketchSolveObjective[];
163
+ exterior: readonly SketchSolveObjective[];
164
+ }
165
+ | {
166
+ kind: 'endpoint';
167
+ anchors: readonly number[];
168
+ }
169
+ >;
170
+
171
+ const radiusRule: SketchDragRule = ({reference, target}) => {
172
+ if (target.kind !== 'radius') return;
173
+ const curve = (target.curve === 'arc' ? reference.arcs : reference.circles)[
174
+ target.index
175
+ ];
176
+ return (current, updated) => {
177
+ const next = updated as typeof target;
178
+ const suggestions = new Map<number, SketchPosition[]>();
179
+ const dimension = reference.constraints.find(
180
+ c =>
181
+ c.kind === 'radius' && c.curve === next.curve && c.index === next.index,
182
+ );
183
+ const radius = curve.locked
184
+ ? curve.radius
185
+ : dimension?.kind === 'radius'
186
+ ? dimension.value
187
+ : next.value;
188
+ const center = reference.points[curve.center].position;
189
+ if (next.curve === 'arc') {
190
+ const arc = reference.arcs[next.index];
191
+ for (const point of arc.points)
192
+ suggest(
193
+ suggestions,
194
+ point,
195
+ radialPosition(center, reference.points[point].position, radius),
196
+ );
197
+ }
198
+ for (const c of reference.constraints)
199
+ if (
200
+ c.kind === 'pointOnCircle' &&
201
+ c.curve === next.curve &&
202
+ c.index === next.index
203
+ )
204
+ suggest(
205
+ suggestions,
206
+ c.points[0],
207
+ radialPosition(
208
+ center,
209
+ reference.points[c.points[0]].position,
210
+ radius,
211
+ ),
212
+ );
213
+ return {
214
+ problem: seed(
215
+ current,
216
+ suggestions,
217
+ next.curve === 'arc' ? new Map([[next.index, radius]]) : new Map(),
218
+ ),
219
+ stages: [
220
+ {objectives: () => [{...next, weight: 1}]},
221
+ {
222
+ objectives: () => [
223
+ anchor(reference, curve.center),
224
+ ...[...suggestions].map(([point, positions]) => ({
225
+ ...anchor(reference, point),
226
+ position: positions[0],
227
+ })),
228
+ ],
229
+ },
230
+ ],
231
+ };
232
+ };
233
+ };
234
+
235
+ const centerRule: SketchDragRule = context => {
236
+ if (context.target.kind !== 'point') return;
237
+ const scope = connected(neighbors(context.reference), context.target.point);
238
+ const policy = translationPolicy(
239
+ context.reference,
240
+ context.target.point,
241
+ scope,
242
+ );
243
+ if (policy) return pointSession(context, [policy]);
244
+ return;
245
+ };
246
+
247
+ const junctionRule: SketchDragRule = context => {
248
+ const {reference, target} = context;
249
+ if (
250
+ target.kind !== 'point' ||
251
+ reference.points[target.point].locked.some(Boolean)
252
+ )
253
+ return;
254
+ // This rule, not the dispatcher, defines an unconstrained articulation point.
255
+ if (
256
+ reference.constraints.some(
257
+ c =>
258
+ !(c.kind === 'pointOnCircle' && c.points[1] === target.point) &&
259
+ constraintPoints(reference, c).includes(target.point),
260
+ )
261
+ )
262
+ return;
263
+ const scope = connected(neighbors(reference), target.point);
264
+ const remaining = new Set([...scope].filter(p => p !== target.point));
265
+ const graph = neighbors(reference, target.point);
266
+ const parts: Set<number>[] = [];
267
+ for (const point of remaining) {
268
+ const part = connected(graph, point);
269
+ for (const member of part) remaining.delete(member);
270
+ part.add(target.point);
271
+ parts.push(part);
272
+ }
273
+ if (parts.length < 2) return;
274
+ return pointSession(
275
+ context,
276
+ parts.map(
277
+ part =>
278
+ translationPolicy(reference, target.point, part) ??
279
+ endpointPolicy(reference, target.point, part),
280
+ ),
281
+ );
282
+ };
283
+
284
+ const endpointRule: SketchDragRule = context => {
285
+ if (context.target.kind !== 'point') return;
286
+ const scope = connected(neighbors(context.reference), context.target.point);
287
+ return pointSession(context, [
288
+ endpointPolicy(context.reference, context.target.point, scope),
289
+ ]);
290
+ };
291
+
292
+ const motionRules: readonly SketchDragRule[] = [
293
+ radiusRule,
294
+ centerRule,
295
+ junctionRule,
296
+ endpointRule,
297
+ ];
298
+
299
+ /** This rule supplies geometric connections; the dispatcher still knows no roles. */
300
+ const incidenceRule: SketchDragRule = ({reference, target, geometry}) => {
301
+ const original = geometry ?? sketchIncidenceGeometry(reference);
302
+ const contacts = sketchIncidences(original);
303
+ if (!contacts.length) return;
304
+ const constraints = contacts.map((contact): SketchSolveConstraint =>
305
+ contact.kind === 'line'
306
+ ? {
307
+ kind: 'pointOnLine',
308
+ points: [contact.point, ...original.lines[contact.index]],
309
+ }
310
+ : {
311
+ kind: 'pointOnCircle',
312
+ points: [
313
+ contact.point,
314
+ (contact.kind === 'circle' ? original.circles : original.arcs)[
315
+ contact.index
316
+ ].center,
317
+ ],
318
+ curve: contact.kind,
319
+ index: contact.index,
320
+ },
321
+ );
322
+ const session = createSketchDragSession(
323
+ {
324
+ reference: {
325
+ ...reference,
326
+ constraints: [...reference.constraints, ...constraints],
327
+ },
328
+ target,
329
+ },
330
+ motionRules,
331
+ );
332
+ return (current, updated) => {
333
+ // A polar seed follows the requested angle, including an exact half-turn,
334
+ // where a distance equation's local derivative alone cannot pick a branch.
335
+ const suggestions = new Map<number, SketchPosition[]>();
336
+ const currentGeometry = sketchIncidenceGeometry(current);
337
+ for (const contact of contacts) {
338
+ if (
339
+ contact.kind === 'line' ||
340
+ updated.kind !== 'point' ||
341
+ contact.point !== updated.point
342
+ )
343
+ continue;
344
+ const curve = sketchIncidenceCurve(currentGeometry, contact);
345
+ if (
346
+ curve.kind !== 'line' &&
347
+ updated.position.every((v, i) => v === curve.center[i])
348
+ )
349
+ continue;
350
+ const t = sketchCurveClosestParameter(curve, updated.position);
351
+ // Finite endpoints already have authoritative coordinates. Re-evaluating
352
+ // sin/cos would introduce a new seed such as cos(pi/2) instead of exact 0.
353
+ const position =
354
+ contact.kind === 'arc' && (t === 0 || t === 1)
355
+ ? currentGeometry.points[
356
+ currentGeometry.arcs[contact.index].points[t]
357
+ ]
358
+ : sketchCurvePosition(curve, t);
359
+ suggest(suggestions, contact.point, position);
360
+ }
361
+ const plan = session(
362
+ {
363
+ ...seed(current, suggestions, new Map()),
364
+ constraints: [...current.constraints, ...constraints],
365
+ },
366
+ updated,
367
+ );
368
+ // An interior point can slide without moving either end. Both endpoints
369
+ // are soft references, so they can yield when the point moves off the line.
370
+ const preferences =
371
+ updated.kind === 'point'
372
+ ? new Set(
373
+ contacts
374
+ .filter(c => c.point === updated.point)
375
+ .flatMap(c => sketchIncidencePoints(original, c).slice(1)),
376
+ )
377
+ : new Set<number>();
378
+ // Incidence leaves a tangential freedom. Keep followers near their
379
+ // gesture-start positions instead of accepting an arbitrary slide.
380
+ for (const {point} of contacts) preferences.add(point);
381
+ const stages: [SketchDragStage, ...SketchDragStage[]] = [...plan.stages];
382
+ const last = stages.at(-1)!;
383
+ stages[stages.length - 1] = {
384
+ ...last,
385
+ objectives: current => {
386
+ const objectives = last.objectives(current);
387
+ const present = new Set(
388
+ objectives.filter(o => o.kind === 'point').map(o => o.point),
389
+ );
390
+ if (updated.kind === 'point') present.add(updated.point);
391
+ return [
392
+ ...objectives,
393
+ ...[...preferences]
394
+ .filter(
395
+ p => !present.has(p) && !current.points[p].locked.every(Boolean),
396
+ )
397
+ .map(p => anchor(reference, p)),
398
+ ];
399
+ },
400
+ };
401
+ return {
402
+ ...plan,
403
+ incidences: contacts,
404
+ stages,
405
+ };
406
+ };
407
+ };
408
+
409
+ const dragRules: readonly SketchDragRule[] = [incidenceRule, ...motionRules];
410
+
411
+ function pointSession(
412
+ context: SketchDragContext,
413
+ policies: readonly PointPolicy[],
414
+ ): SketchDragSession {
415
+ const {reference} = context;
416
+ const translations = policies.filter(p => p.kind === 'translation');
417
+ const endpoints = policies.filter(p => p.kind === 'endpoint');
418
+ // An endpoint can also be another curve's center. Its incident arcs retain
419
+ // their center preference regardless of the point's other motion roles.
420
+ const arcs = reference.arcs.flatMap((arc, index) =>
421
+ context.target.kind === 'point' && arc.points.includes(context.target.point)
422
+ ? [index]
423
+ : [],
424
+ );
425
+ const centers = new Set(arcs.map(index => reference.arcs[index].center));
426
+ const anchors = [...new Set(endpoints.flatMap(p => p.anchors))].filter(
427
+ point => !centers.has(point),
428
+ );
429
+ const translated = [...new Set(translations.flatMap(p => p.points))];
430
+ const preservedRadii = translations.flatMap(p => p.radii);
431
+ return (current, updated) => {
432
+ const target = updated as Extract<SketchSolveTarget, {kind: 'point'}>;
433
+ const suggestions = new Map<number, SketchPosition[]>();
434
+ const radii = new Map<number, number>();
435
+ const from = reference.points[target.point].position;
436
+ for (const index of arcs) {
437
+ const arc = reference.arcs[index];
438
+ const center = reference.points[arc.center].position;
439
+ const dimension = reference.constraints.find(
440
+ c => c.kind === 'radius' && c.curve === 'arc' && c.index === index,
441
+ );
442
+ const radius = arc.locked
443
+ ? arc.radius
444
+ : dimension?.kind === 'radius'
445
+ ? dimension.value
446
+ : Math.hypot(
447
+ target.position[0] - center[0],
448
+ target.position[1] - center[1],
449
+ );
450
+ if (radius === 0) continue;
451
+ radii.set(index, radius);
452
+ const sweep = reference.constraints.some(
453
+ c => c.kind === 'sweep' && c.index === index,
454
+ );
455
+ // A mouse target at the center has no radial direction. For a fixed
456
+ // radius keep the current endpoint direction as the seed; the mouse
457
+ // objective still participates in the constrained solve.
458
+ const direction = target.position.every((v, axis) => v === center[axis])
459
+ ? current.points[target.point].position
460
+ : target.position;
461
+ const turn =
462
+ Math.atan2(direction[1] - center[1], direction[0] - center[0]) -
463
+ Math.atan2(from[1] - center[1], from[0] - center[0]);
464
+ for (const point of arc.points) {
465
+ let position =
466
+ point === target.point ? direction : reference.points[point].position;
467
+ if (sweep && point !== target.point) {
468
+ const [x, y] = [position[0] - center[0], position[1] - center[1]];
469
+ position = [
470
+ center[0] + x * Math.cos(turn) - y * Math.sin(turn),
471
+ center[1] + x * Math.sin(turn) + y * Math.cos(turn),
472
+ ];
473
+ }
474
+ suggest(suggestions, point, radialPosition(center, position, radius));
475
+ }
476
+ }
477
+ // Seed a center's followers from the same proposed position as the center.
478
+ // Mixing a projected arc endpoint with a raw mouse translation distorts
479
+ // the attached arc before its translation stage can preserve its shape.
480
+ const endpoint = suggestions.get(target.point);
481
+ const destination = endpoint ? averagePosition(endpoint) : target.position;
482
+ for (const point of translated) {
483
+ if (point === target.point && endpoint) continue;
484
+ suggest(
485
+ suggestions,
486
+ point,
487
+ reference.points[point].position.map(
488
+ (v, axis) => v + destination[axis] - from[axis],
489
+ ) as [number, number],
490
+ );
491
+ }
492
+ const stages: [SketchDragStage, ...SketchDragStage[]] = [
493
+ {objectives: () => [{...target, weight: 1}]},
494
+ {
495
+ objectives: reached => [
496
+ ...anchors.map(point => anchor(reference, point)),
497
+ ...translated
498
+ .filter(p => p !== target.point)
499
+ .map(p => ({
500
+ ...anchor(reference, p),
501
+ position: reference.points[p].position.map(
502
+ (v, axis) =>
503
+ v - from[axis] + reached.points[target.point].position[axis],
504
+ ) as [number, number],
505
+ })),
506
+ ],
507
+ // Earlier radius/mouse stages may leave arbitrary follower angles.
508
+ // Start local translation at its actual, now-known destination rather
509
+ // than asking a near-singular soft solve to remove that inherited pose.
510
+ seed: (reached, objectives) =>
511
+ seed(
512
+ reached,
513
+ new Map(
514
+ objectives.flatMap(o =>
515
+ o.kind === 'point' && translated.includes(o.point)
516
+ ? [[o.point, [o.position]] as const]
517
+ : [],
518
+ ),
519
+ ),
520
+ new Map(),
521
+ ),
522
+ },
523
+ ...(translations.length
524
+ ? [{objectives: () => translations.flatMap(p => p.exterior)}]
525
+ : []),
526
+ ];
527
+ // A center's radii take precedence over following the mouse. A shared
528
+ // endpoint still retains its incident centers before these radius goals.
529
+ if (preservedRadii.length)
530
+ stages.unshift({objectives: () => preservedRadii});
531
+ if (centers.size)
532
+ stages.unshift({
533
+ objectives: () => [...centers].map(point => anchor(reference, point)),
534
+ });
535
+ return {problem: seed(current, suggestions, radii), stages};
536
+ };
537
+ }
538
+
539
+ function translationPolicy(
540
+ problem: SketchSolveProblem,
541
+ point: number,
542
+ scope: ReadonlySet<number>,
543
+ ): PointPolicy | undefined {
544
+ const curves = problem.arcs.filter(
545
+ a => a.center === point && a.points.every(p => scope.has(p)),
546
+ );
547
+ const owned = new Set([point, ...curves.flatMap(a => [...a.points])]);
548
+ for (const c of problem.constraints)
549
+ if (c.kind === 'pointOnCircle' && c.points[1] === point)
550
+ owned.add(c.points[0]);
551
+ const rectangle = rectanglePoints(problem, point, scope);
552
+ const isCenter =
553
+ curves.length > 0 ||
554
+ problem.circles.some(c => c.center === point) ||
555
+ !!rectangle;
556
+ if (!isCenter) return;
557
+ if (rectangle) for (const p of rectangle) owned.add(p);
558
+ const radii = (['circle', 'arc'] as const).flatMap(curve =>
559
+ (curve === 'circle' ? problem.circles : problem.arcs).flatMap((c, index) =>
560
+ scope.has(c.center)
561
+ ? [{kind: 'radius' as const, curve, index, value: c.radius, weight: 1}]
562
+ : [],
563
+ ),
564
+ );
565
+ const ownRadius = (o: Extract<SketchSolveObjective, {kind: 'radius'}>) =>
566
+ (o.curve === 'circle' ? problem.circles : problem.arcs)[o.index].center ===
567
+ point;
568
+ return {
569
+ kind: 'translation',
570
+ points: [...owned],
571
+ radii: radii.filter(ownRadius),
572
+ exterior: [
573
+ ...[...scope].filter(p => !owned.has(p)).map(p => anchor(problem, p)),
574
+ ...radii.filter(o => !ownRadius(o)),
575
+ ],
576
+ };
577
+ }
578
+
579
+ function endpointPolicy(
580
+ problem: SketchSolveProblem,
581
+ point: number,
582
+ scope: ReadonlySet<number>,
583
+ ): PointPolicy {
584
+ const arcs = problem.arcs.flatMap((a, index) =>
585
+ a.points.includes(point) &&
586
+ scope.has(a.center) &&
587
+ a.points.every(p => scope.has(p))
588
+ ? [index]
589
+ : [],
590
+ );
591
+ const controls = new Set(arcs.map(i => problem.arcs[i].center));
592
+ for (const c of problem.constraints)
593
+ if (c.kind === 'pointOnCircle' && c.points[0] === point)
594
+ controls.add(c.points[1]);
595
+ for (const c of problem.constraints)
596
+ if (
597
+ c.kind === 'midpoint' &&
598
+ c.points[0] !== point &&
599
+ rectanglePoints(problem, c.points[0], scope)
600
+ )
601
+ controls.add(c.points[0]);
602
+ controls.delete(point);
603
+ if (controls.size) return {kind: 'endpoint', anchors: [...controls]};
604
+ const graph = neighbors(problem);
605
+ const distance = new Map([[point, 0]]);
606
+ for (const [p, d] of distance)
607
+ for (const next of graph[p])
608
+ if (scope.has(next) && !distance.has(next)) distance.set(next, d + 1);
609
+ let farthest = -1,
610
+ max = 0;
611
+ for (const p of scope)
612
+ if ((distance.get(p) ?? 0) > max) {
613
+ farthest = p;
614
+ max = distance.get(p)!;
615
+ }
616
+ return {kind: 'endpoint', anchors: farthest < 0 ? [] : [farthest]};
617
+ }
618
+
619
+ /** Recognize the actual four-edge/midpoint structure, not tool creation history. */
620
+ function rectanglePoints(
621
+ problem: SketchSolveProblem,
622
+ center: number,
623
+ scope: ReadonlySet<number>,
624
+ ): readonly number[] | undefined {
625
+ const midpoint = problem.constraints.find(
626
+ c => c.kind === 'midpoint' && c.points[0] === center,
627
+ );
628
+ if (midpoint?.kind !== 'midpoint') return;
629
+ const lines = problem.lines.filter(
630
+ line => line.every(p => scope.has(p)) && !line.includes(center),
631
+ );
632
+ const corners = new Set(lines.flatMap(line => [...line]));
633
+ if (
634
+ lines.length !== 4 ||
635
+ corners.size !== 4 ||
636
+ !midpoint.points.slice(1).every(p => corners.has(p))
637
+ )
638
+ return;
639
+ if (
640
+ [...corners].some(p => lines.filter(line => line.includes(p)).length !== 2)
641
+ )
642
+ return;
643
+ if (
644
+ !lines.every(line =>
645
+ problem.constraints.some(
646
+ c =>
647
+ (c.kind === 'horizontal' || c.kind === 'vertical') &&
648
+ c.points.every(p => line.includes(p)),
649
+ ),
650
+ )
651
+ )
652
+ return;
653
+ return [...corners];
654
+ }
655
+
656
+ function constraintPoints(
657
+ problem: SketchSolveProblem,
658
+ c: SketchSolveProblem['constraints'][number],
659
+ ): readonly number[] {
660
+ if ('points' in c) return c.points;
661
+ if ('point' in c) return [c.point];
662
+ if (c.kind === 'sweep' || c.curve === 'arc') {
663
+ const arc = problem.arcs[c.index];
664
+ return [arc.center, ...arc.points];
665
+ }
666
+ return [problem.circles[c.index].center];
667
+ }
668
+
669
+ function neighbors(problem: SketchSolveProblem, excluded = -1): Set<number>[] {
670
+ const graph = problem.points.map(() => new Set<number>());
671
+ const connect = (vertices: readonly number[]) => {
672
+ const included = vertices.filter(p => p !== excluded);
673
+ for (const a of included)
674
+ for (const b of included) if (a !== b) graph[a].add(b);
675
+ };
676
+ problem.lines.forEach(connect);
677
+ problem.arcs.forEach(a => connect([a.center, ...a.points]));
678
+ problem.constraints.forEach(c => connect(constraintPoints(problem, c)));
679
+ return graph;
680
+ }
681
+
682
+ function connected(
683
+ graph: readonly ReadonlySet<number>[],
684
+ start: number,
685
+ ): Set<number> {
686
+ const result = new Set([start]);
687
+ for (const point of result) for (const next of graph[point]) result.add(next);
688
+ return result;
689
+ }
690
+
691
+ function anchor(
692
+ problem: SketchSolveProblem,
693
+ point: number,
694
+ ): SketchSolveObjective {
695
+ return {
696
+ kind: 'point',
697
+ point,
698
+ position: problem.points[point].position,
699
+ weight: 1,
700
+ };
701
+ }
702
+
703
+ function suggest(
704
+ suggestions: Map<number, SketchPosition[]>,
705
+ point: number,
706
+ position: SketchPosition,
707
+ ): void {
708
+ const values = suggestions.get(point) ?? [];
709
+ values.push(position);
710
+ suggestions.set(point, values);
711
+ }
712
+
713
+ function radialPosition(
714
+ center: SketchPosition,
715
+ position: SketchPosition,
716
+ radius: number,
717
+ ): SketchPosition {
718
+ // Rescale the actual direction: atan2 -> sin/cos would turn an exact
719
+ // horizontal/vertical coordinate into a tail that is then kept as a seed.
720
+ const dx = position[0] - center[0],
721
+ dy = position[1] - center[1];
722
+ const length = Math.hypot(dx, dy);
723
+ return [
724
+ center[0] + (dx / length) * radius,
725
+ center[1] + (dy / length) * radius,
726
+ ];
727
+ }
728
+
729
+ function seed(
730
+ problem: SketchSolveProblem,
731
+ suggestions: ReadonlyMap<number, readonly SketchPosition[]>,
732
+ radii: ReadonlyMap<number, number>,
733
+ ): SketchSolveProblem {
734
+ return {
735
+ ...problem,
736
+ points: problem.points.map((p, index) => {
737
+ const values = suggestions.get(index);
738
+ if (!values) return p;
739
+ const position = averagePosition(values);
740
+ return {
741
+ ...p,
742
+ position: p.position.map((v, axis) =>
743
+ p.locked[axis] ||
744
+ problem.constraints.some(
745
+ c =>
746
+ 'point' in c &&
747
+ c.point === index &&
748
+ (c.kind === 'fixed' || c.kind === (axis === 0 ? 'x' : 'y')),
749
+ )
750
+ ? v
751
+ : position[axis],
752
+ ) as [number, number],
753
+ };
754
+ }),
755
+ arcs: problem.arcs.map((arc, index) =>
756
+ arc.locked || !radii.has(index)
757
+ ? arc
758
+ : {...arc, radius: radii.get(index)!},
759
+ ),
760
+ };
761
+ }
762
+
763
+ function averagePosition(values: readonly SketchPosition[]): SketchPosition {
764
+ return [0, 1].map(
765
+ axis => values.reduce((sum, value) => sum + value[axis], 0) / values.length,
766
+ ) as [number, number];
767
+ }