dsh-cad 0.2.0 → 0.7.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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * The constraint tool family over the Ansatz solver:
3
+ * - `cad_constraint` — declare the constraint model (entities + constraints,
4
+ * persisted in the document op log); instance-bound entities track assembly
5
+ * placements live at solve time;
6
+ * - `cad_solve` — solve, report the solver's diagnostics (DOF,
7
+ * residuals, suggestions — verbatim for the LLM), and write solved rigid3
8
+ * poses back onto assembly instances (scene refreshes);
9
+ * - `cad_motion` — kinematic sweep: drive one constraint's value from
10
+ * `from` to `to` over N frames, solve every frame, apply the last
11
+ * successful placement, and return the motion table.
12
+ *
13
+ * Solver capability note: Ansatz is staged — today it implements
14
+ * distance-to-origin (2D point) and rejects other kinds with a precise
15
+ * "capability not implemented" tool error. The plumbing here is the finished
16
+ * contract; as the solver's constraint coverage grows (Stage 1–3: general
17
+ * numerics, rank analysis, full 2D/3D sets), these tools need no changes.
18
+ */
19
+ import { defineTool } from '@deepseek-ai/dsh-tools';
20
+ import { runModelOp } from '../modeling/client.js';
21
+ import { solveModel } from '../modeling/ansatz-bridge.js';
22
+ import { buildSolverModel, rigid3ToInstance, solvedRigid3s, } from '../modeling/constraints.js';
23
+ /** Body/triangle counts shared by every tool's output. */
24
+ function docCounts(ctx) {
25
+ const meshes = [...ctx.meshCache.values()];
26
+ return {
27
+ bodies: meshes.length,
28
+ triangles: meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0),
29
+ };
30
+ }
31
+ function sceneUrl(ctx) {
32
+ const base = ctx.deps.ensureSceneRoute();
33
+ if (base === null)
34
+ return {};
35
+ return { sceneUrl: `${base.replace('/scene', '/bin')}/asm-${ctx.getDocument().doc.docId}?v=${ctx.getDocument().doc.version}` };
36
+ }
37
+ /**
38
+ * Solve the stored model and apply rigid3 results to instance-bound entities.
39
+ * Each applied placement goes through ctx.syncAssembly so the transform is
40
+ * recorded in the op log (replay restores every instance) and the assembly
41
+ * scene refreshes.
42
+ */
43
+ async function solveAndApply(ctx) {
44
+ const model = ctx.constraintState.model;
45
+ if (model === null)
46
+ throw new Error('no constraint model yet — declare one with cad_constraint first');
47
+ const list = await runModelOp({ kind: 'assembly_list' });
48
+ const solverInput = buildSolverModel(model, list.instances ?? []);
49
+ const report = await solveModel(solverInput);
50
+ let applied = 0;
51
+ const solved = solvedRigid3s(report.entities);
52
+ for (const entity of model.entities) {
53
+ if (entity.instance === undefined)
54
+ continue;
55
+ const pose = solved.get(entity.id);
56
+ if (pose === undefined)
57
+ continue;
58
+ const placement = rigid3ToInstance(pose);
59
+ const op = {
60
+ kind: 'assembly_transform',
61
+ instanceId: entity.instance,
62
+ translate: placement.translate,
63
+ rotate: placement.rotate,
64
+ };
65
+ const result = await runModelOp(op);
66
+ await ctx.syncAssembly(op, result);
67
+ applied++;
68
+ }
69
+ return { report, applied, instances: list.instances?.length ?? 0 };
70
+ }
71
+ /** Apply one frame's solved poses without re-solving (motion playback path). */
72
+ async function applyPoses(ctx, poses) {
73
+ let applied = 0;
74
+ for (const pose of poses) {
75
+ const op = { kind: 'assembly_transform', instanceId: pose.instance, translate: pose.translate, rotate: pose.rotate };
76
+ const result = await runModelOp(op);
77
+ await ctx.syncAssembly(op, result);
78
+ applied++;
79
+ }
80
+ return applied;
81
+ }
82
+ export function createConstraintTools(ctx) {
83
+ const cadConstraint = defineTool({
84
+ name: 'cad_constraint',
85
+ description: 'Declare the constraint model for the Ansatz solver: `entities` (2D geometry, or `instance` refs to assembly instances — their rigid3 poses sync live at solve time) + `constraints` ' +
86
+ '(coincident/collinear/parallel/perpendicular/tangent/distance/angle/symmetric/fixed/mate/coaxial with a/b entity ids and value params, mm & radians). ' +
87
+ 'Persisted in the document op log; solved with cad_solve. Solver capability is staged — unsupported kinds fail with a precise diagnostic.',
88
+ parameters: {
89
+ entities: {
90
+ type: 'array',
91
+ description: 'Entities: {id, instance? (assembly instanceId) | geometry: {type: "point2"|"line2"|"circle2"|"arc2"|"rigid3", …}}.',
92
+ items: { type: 'json', description: 'One entity object.' },
93
+ },
94
+ constraints: {
95
+ type: 'array',
96
+ description: 'Constraints: {type, a, b?, value?, label?} — e.g. {type: "distance", a: 0, b: null, value: 10}.',
97
+ items: { type: 'json', description: 'One constraint object.' },
98
+ },
99
+ clear: { type: 'boolean', description: 'Clear the stored constraint model.' },
100
+ },
101
+ output: {
102
+ schema: {
103
+ type: 'object',
104
+ additionalProperties: false,
105
+ properties: {
106
+ entities: { type: 'number', required: true, description: 'Stored entity count.' },
107
+ constraints: { type: 'number', required: true, description: 'Stored constraint count.' },
108
+ viewId: { type: 'string', description: 'Assembly scene viewId.' },
109
+ instances: { type: 'number', description: 'Assembly instance count.' },
110
+ cleared: { type: 'boolean', description: 'True when the model was cleared.' },
111
+ bodies: { type: 'number', required: true, description: 'Bodies in the document.' },
112
+ triangles: { type: 'number', required: true, description: 'Document triangle count.' },
113
+ version: { type: 'number', required: true, description: 'Document version.' },
114
+ sceneUrl: { type: 'string', description: 'Versioned assembly scene URL (web compositions).' },
115
+ },
116
+ },
117
+ render: (_args, value) => [{ type: 'text', text: `约束模型: ${String(value.entities)} 实体 · ${String(value.constraints)} 约束 (version ${String(value.version)})` }],
118
+ presentationMeta: (_args, value) => ctx.assemblyMetaOf(value),
119
+ },
120
+ isConcurrencySafe: () => false,
121
+ async execute(args, exec) {
122
+ await ctx.resolveDoc(exec);
123
+ let model = null;
124
+ let cleared = false;
125
+ if (args.clear === true) {
126
+ model = { entities: [], constraints: [] };
127
+ cleared = true;
128
+ }
129
+ else {
130
+ const entities = (args.entities ?? []);
131
+ const constraints = (args.constraints ?? []);
132
+ if (entities.length === 0)
133
+ throw new Error('entities must list at least one entity (or pass clear: true)');
134
+ model = {
135
+ entities: entities.map((entity, index) => ({
136
+ id: Number(entity.id ?? index),
137
+ ...(typeof entity.instance === 'string' ? { instance: entity.instance } : {}),
138
+ ...(typeof entity.geometry === 'object' && entity.geometry !== null ? { geometry: entity.geometry } : {}),
139
+ })),
140
+ constraints: constraints.map((constraint, index) => {
141
+ const { id, type, label, ...params } = constraint;
142
+ return {
143
+ id: Number(id ?? index),
144
+ kind: { type: String(type), ...params },
145
+ ...(typeof label === 'string' ? { label } : {}),
146
+ };
147
+ }),
148
+ };
149
+ }
150
+ const op = { kind: 'constraints', model: model };
151
+ const result = await runModelOp(op);
152
+ ctx.constraintState.model = model;
153
+ const syncValue = await ctx.syncAssembly(op, result);
154
+ const value = {
155
+ entities: model.entities.length,
156
+ constraints: model.constraints.length,
157
+ viewId: `asm-${ctx.getDocument().doc.docId}`,
158
+ instances: Number(syncValue.instances ?? 0),
159
+ ...docCounts(ctx),
160
+ version: ctx.getDocument().doc.version,
161
+ ...(cleared ? { cleared } : {}),
162
+ ...sceneUrl(ctx),
163
+ };
164
+ return value;
165
+ },
166
+ presentCall: () => ({ card: 'generic', title: '约束模型', kind: 'other' }),
167
+ presentResult: () => ({ card: 'generic', title: '约束模型' }),
168
+ });
169
+ const cadSolve = defineTool({
170
+ name: 'cad_solve',
171
+ description: 'Solve the stored constraint model with the Ansatz solver, write solved rigid3 poses back onto the assembly instances (the Assembly tab refreshes), ' +
172
+ 'and return the full diagnostics: outcome, DOF remaining, max residual, redundant constraints, and the solver\'s own suggestions (Chinese, agent-oriented).',
173
+ parameters: {},
174
+ output: {
175
+ schema: {
176
+ type: 'object',
177
+ additionalProperties: false,
178
+ properties: {
179
+ outcome: { type: 'string', required: true, description: 'converged | underconstrained | inconsistent.' },
180
+ viewId: { type: 'string', description: 'Assembly scene viewId.' },
181
+ instances: { type: 'number', description: 'Assembly instance count.' },
182
+ dofRemaining: { type: 'number', description: 'Degrees of freedom left unsolved.' },
183
+ dofTotal: { type: 'number', description: 'Total degrees of freedom.' },
184
+ maxResidual: { type: 'number', description: 'Largest constraint residual.' },
185
+ redundant: { type: 'number', description: 'Redundant constraint count.' },
186
+ suggestions: { type: 'number', description: 'Suggestion count (full text in the render).' },
187
+ applied: { type: 'number', required: true, description: 'Instance placements applied.' },
188
+ bodies: { type: 'number', required: true, description: 'Bodies in the document.' },
189
+ triangles: { type: 'number', required: true, description: 'Document triangle count.' },
190
+ version: { type: 'number', required: true, description: 'Document version.' },
191
+ sceneUrl: { type: 'string', description: 'Versioned assembly scene URL (web compositions).' },
192
+ },
193
+ },
194
+ render: (_args, value) => [{ type: 'text', text: renderSolve(value, ctx.constraintState.model, ctx.constraintState.lastSuggestions) }],
195
+ presentationMeta: (_args, value) => ctx.assemblyMetaOf(value),
196
+ },
197
+ isConcurrencySafe: () => false,
198
+ async execute(_args, exec) {
199
+ await ctx.resolveDoc(exec);
200
+ const { report, applied, instances } = await solveAndApply(ctx);
201
+ const diagnostics = report.diagnostics ?? {};
202
+ // The solver's own suggestion texts — the load-bearing LLM diagnostics.
203
+ const suggestionText = (diagnostics.suggestions ?? [])
204
+ .map((suggestion) => suggestion.human_message ?? suggestion.action ?? '')
205
+ .filter((text) => text !== '');
206
+ const value = {
207
+ outcome: report.outcome,
208
+ applied,
209
+ viewId: `asm-${ctx.getDocument().doc.docId}`,
210
+ instances,
211
+ ...docCounts(ctx),
212
+ version: ctx.getDocument().doc.version,
213
+ ...(diagnostics.dof_remaining !== undefined ? { dofRemaining: diagnostics.dof_remaining } : {}),
214
+ ...(diagnostics.dof_total !== undefined ? { dofTotal: diagnostics.dof_total } : {}),
215
+ ...(diagnostics.max_residual?.residual !== undefined ? { maxResidual: diagnostics.max_residual.residual } : {}),
216
+ ...(Array.isArray(diagnostics.redundant_constraints) ? { redundant: diagnostics.redundant_constraints.length } : {}),
217
+ ...(suggestionText.length > 0 ? { suggestions: suggestionText.length } : {}),
218
+ ...sceneUrl(ctx),
219
+ };
220
+ ctx.constraintState.lastSuggestions = suggestionText;
221
+ return value;
222
+ },
223
+ presentCall: () => ({ card: 'generic', title: '约束求解', kind: 'other' }),
224
+ presentResult: () => ({ card: 'generic', title: '约束求解' }),
225
+ });
226
+ const cadMotion = defineTool({
227
+ name: 'cad_motion',
228
+ description: 'Kinematic sweep: drive one numeric constraint from `from` to `to` across N frames, solving each frame; writes the last successful placement to the assembly ' +
229
+ 'and returns the per-frame motion table (value, outcome, placements). Use for animating mechanisms (e.g. crank angle or joint distance over time).',
230
+ parameters: {
231
+ constraintId: { type: 'number', required: true, description: 'Id of the driving constraint (must have a numeric value).' },
232
+ from: { type: 'number', required: true, description: 'Driver start value (mm or radians).' },
233
+ to: { type: 'number', required: true, description: 'Driver end value.' },
234
+ frames: { type: 'number', description: 'Frame count (default 8, min 2).' },
235
+ },
236
+ output: {
237
+ schema: {
238
+ type: 'object',
239
+ additionalProperties: false,
240
+ properties: {
241
+ frames: { type: 'number', required: true, description: 'Frames solved.' },
242
+ viewId: { type: 'string', description: 'Assembly scene viewId.' },
243
+ instances: { type: 'number', description: 'Assembly instance count.' },
244
+ converged: { type: 'number', required: true, description: 'Frames that converged.' },
245
+ applied: { type: 'number', required: true, description: 'Placements applied from the last successful frame.' },
246
+ bodies: { type: 'number', required: true, description: 'Bodies in the document.' },
247
+ triangles: { type: 'number', required: true, description: 'Document triangle count.' },
248
+ version: { type: 'number', required: true, description: 'Document version.' },
249
+ table: { type: 'string', description: 'Per-frame motion table (value, outcome, poses).' },
250
+ sceneUrl: { type: 'string', description: 'Versioned assembly scene URL (web compositions).' },
251
+ },
252
+ },
253
+ render: (_args, value) => [{ type: 'text', text: String(value.table ?? '') }],
254
+ presentationMeta: (_args, value) => ctx.assemblyMetaOf(value),
255
+ },
256
+ isConcurrencySafe: () => false,
257
+ async execute(args, exec) {
258
+ await ctx.resolveDoc(exec);
259
+ const model = ctx.constraintState.model;
260
+ if (model === null)
261
+ throw new Error('no constraint model yet — declare one with cad_constraint first');
262
+ const driver = model.constraints.find((constraint) => constraint.id === args.constraintId);
263
+ if (driver === undefined)
264
+ throw new Error(`unknown constraint id: ${args.constraintId}`);
265
+ if (typeof driver.kind.value !== 'number')
266
+ throw new Error(`constraint ${args.constraintId} has no numeric value to drive`);
267
+ const frameCount = Math.max(2, Math.trunc(args.frames ?? 8));
268
+ const list = await runModelOp({ kind: 'assembly_list' });
269
+ const solverInput = buildSolverModel(model, list.instances ?? []);
270
+ const byEntity = new Map(model.entities.map((entity) => [entity.id, entity]));
271
+ const table = [];
272
+ let converged = 0;
273
+ let lastGood = null;
274
+ for (let frame = 0; frame < frameCount; frame++) {
275
+ const value = args.from + ((args.to - args.from) * frame) / (frameCount - 1);
276
+ const frameModel = structuredClone(solverInput);
277
+ for (const constraint of frameModel.constraints) {
278
+ if (constraint.id === args.constraintId)
279
+ constraint.kind.value = value;
280
+ }
281
+ let line;
282
+ try {
283
+ const report = await solveModel(frameModel);
284
+ const solved = solvedRigid3s(report.entities);
285
+ const placements = [];
286
+ for (const [entityId, pose] of solved) {
287
+ const entity = byEntity.get(entityId);
288
+ if (entity?.instance === undefined)
289
+ continue;
290
+ placements.push({ instance: entity.instance, ...rigid3ToInstance(pose) });
291
+ }
292
+ if (report.outcome !== 'inconsistent') {
293
+ converged++;
294
+ lastGood = placements;
295
+ }
296
+ line = `frame ${frame}: value=${value.toFixed(3)} ${report.outcome} · ${placements.length} poses`;
297
+ }
298
+ catch (error) {
299
+ line = `frame ${frame}: value=${value.toFixed(3)} ✗ ${error instanceof Error ? error.message.slice(0, 120) : String(error)}`;
300
+ }
301
+ table.push(line);
302
+ }
303
+ let applied = 0;
304
+ if (lastGood !== null && lastGood.length > 0) {
305
+ applied = await applyPoses(ctx, lastGood);
306
+ }
307
+ const value = {
308
+ frames: frameCount,
309
+ converged,
310
+ applied,
311
+ viewId: `asm-${ctx.getDocument().doc.docId}`,
312
+ instances: list.instances?.length ?? 0,
313
+ ...docCounts(ctx),
314
+ version: ctx.getDocument().doc.version,
315
+ table: [`运动扫描: driver=约束${args.constraintId} ${args.from} → ${args.to} (${frameCount} 帧)`, ...table].join('\n'),
316
+ ...sceneUrl(ctx),
317
+ };
318
+ return value;
319
+ },
320
+ presentCall: (args) => ({ card: 'generic', title: `运动扫描 ${String(args.constraintId ?? '')}`.trim(), kind: 'other' }),
321
+ presentResult: () => ({ card: 'generic', title: '运动扫描' }),
322
+ });
323
+ return [cadConstraint, cadSolve, cadMotion];
324
+ }
325
+ function renderSolve(value, model, suggestions) {
326
+ const lines = [
327
+ `求解: ${String(value.outcome)} · DOF 剩余 ${String(value.dofRemaining ?? '?')}/${String(value.dofTotal ?? '?')} · 最大残差 ${String(value.maxResidual ?? '?')} · 应用 ${String(value.applied)} 个位姿`,
328
+ ];
329
+ if (model !== null && model.constraints.length > 0) {
330
+ lines.push(`模型: ${model.entities.length} 实体 · ${model.constraints.length} 约束 (${model.constraints.map((c) => `#${c.id} ${String(c.kind.type)}`).join(', ')})`);
331
+ }
332
+ for (const suggestion of suggestions.slice(0, 4))
333
+ lines.push(`建议: ${suggestion}`);
334
+ return lines.join('\n');
335
+ }