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.
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SceneStore } from './store.js';
2
2
  import { BinarySceneStore } from './modeling/bin-store.js';
3
- import { registerSceneRoute, registerBinRoute, registerDemoRoute } from './routes.js';
3
+ import { DocumentRegistry } from './modeling/registry.js';
4
+ import { registerSceneRoute, registerBinRoute, registerDemoRoute, registerDocsRoute, registerDocsDeleteRoute } from './routes.js';
4
5
  import { createCadViewTool } from './tools/cad-view.js';
5
6
  import { createCadInfoTool } from './tools/cad-info.js';
6
7
  import { createModelTools } from './tools/cad-model.js';
@@ -14,6 +15,7 @@ export function apply(ctx, config = {}) {
14
15
  const root = config.root ?? process.cwd();
15
16
  const store = new SceneStore(root);
16
17
  const binStore = new BinarySceneStore(root);
18
+ const registry = new DocumentRegistry(process.cwd());
17
19
  const workspaceRoot = process.cwd();
18
20
  let routeRegistered = false;
19
21
  /** Idempotently register the scene routes; returns the JSON base or null. */
@@ -27,6 +29,8 @@ export function apply(ctx, config = {}) {
27
29
  registerSceneRoute(server, store);
28
30
  registerBinRoute(server, binStore);
29
31
  registerDemoRoute(server);
32
+ registerDocsRoute(server, registry, binStore);
33
+ registerDocsDeleteRoute(server, registry);
30
34
  routeRegistered = true;
31
35
  return '/dsh-cad/scene';
32
36
  };
@@ -55,7 +59,7 @@ export function apply(ctx, config = {}) {
55
59
  }
56
60
  const cadView = createCadViewTool({ store, workspaceRoot, ensureSceneRoute });
57
61
  const cadInfo = createCadInfoTool({ workspaceRoot });
58
- const modelTools = createModelTools({ store: binStore, sceneStore: store, workspaceRoot, ensureSceneRoute });
62
+ const modelTools = createModelTools({ store: binStore, sceneStore: store, workspaceRoot, ensureSceneRoute, registry });
59
63
  const cadFreeCad = createFreeCadTool({ store: binStore, workspaceRoot, ensureSceneRoute });
60
64
  const cadFusion = createFusionTool({ store: binStore, workspaceRoot, ensureSceneRoute });
61
65
  const cadImage = createCadImageTool({ store, workspaceRoot, ensureSceneRoute });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Bridge to the Ansatz geometric constraint solver — the `ansatz-wasm` npm
3
+ * dependency (a wasm-bindgen build: no Rust toolchain, no native binary,
4
+ * platform independent, synchronous once loaded).
5
+ *
6
+ * Resolution for the wasm package directory, first hit wins:
7
+ * 1. an explicit `wasmDir` option
8
+ * 2. `DSH_ANSATZ_WASM` (any built pkg-node / package directory)
9
+ * 3. the `ansatz-wasm` npm dependency in node_modules
10
+ * 4. a sibling Ansatz checkout's wasm-pack output (solver development)
11
+ * 5. Node's own resolution (hoisted/nested installs)
12
+ *
13
+ * The solver answers through a JSON envelope
14
+ * `{ok:true,result} | {ok:false,error:{kind,message}}`. A tool-layer error
15
+ * (e.g. `unsupported_constraint`) is thrown as an Error carrying the solver's
16
+ * own message, so the LLM sees its "never just says failed" diagnostics
17
+ * verbatim.
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { createRequire } from 'node:module';
22
+ import { fileURLToPath } from 'node:url';
23
+ const require_ = createRequire(import.meta.url);
24
+ /** Repo root, derived from this module's URL (__dirname is absent in ESM). */
25
+ function repoRoot() {
26
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
27
+ }
28
+ /** A directory holding the built wasm package (ansatz_wasm.js + _bg.wasm). */
29
+ function isWasmDir(dir) {
30
+ if (!dir)
31
+ return false;
32
+ return fs.existsSync(path.join(dir, 'ansatz_wasm.js')) && fs.existsSync(path.join(dir, 'ansatz_wasm_bg.wasm'));
33
+ }
34
+ /**
35
+ * Resolve the wasm solver package directory, or null when none is present.
36
+ * The npm dependency wins, so `npm install ansatz-wasm@latest` takes effect.
37
+ */
38
+ export function resolveAnsatzWasmDir(explicit) {
39
+ const root = repoRoot();
40
+ const candidates = [
41
+ explicit,
42
+ process.env.DSH_ANSATZ_WASM,
43
+ // The declared npm dependency — the primary, version-respecting path.
44
+ path.join(root, 'node_modules', 'ansatz-wasm'),
45
+ // Solver development: a sibling checkout's wasm-pack output.
46
+ path.join(root, '..', 'Ansatz', 'crates', 'ansatz-wasm', 'pkg-node'),
47
+ ];
48
+ for (const dir of candidates) {
49
+ if (dir !== undefined && isWasmDir(dir))
50
+ return dir;
51
+ }
52
+ // Fall back to Node's own resolution (hoisted/nested installs).
53
+ try {
54
+ return path.dirname(require_.resolve('ansatz-wasm/package.json'));
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ /** Whether the solver is available (diagnostics/tests). */
61
+ export function ansatzAvailable() {
62
+ return resolveAnsatzWasmDir() !== null;
63
+ }
64
+ let cachedWasm = null;
65
+ function loadWasm(dir) {
66
+ if (cachedWasm !== null && cachedWasm.dir === dir)
67
+ return cachedWasm.module;
68
+ // The wasm-pack nodejs target is CJS and reads its .wasm sibling from
69
+ // __dirname, so it must be required (not imported) with the dir intact.
70
+ const module = require_(path.join(dir, 'ansatz_wasm.js'));
71
+ cachedWasm = { dir, module };
72
+ return module;
73
+ }
74
+ /** Unwrap the envelope: report on ok, Error carrying the solver text otherwise. */
75
+ function unwrapEnvelope(envelopeJson) {
76
+ let envelope;
77
+ try {
78
+ envelope = JSON.parse(envelopeJson);
79
+ }
80
+ catch {
81
+ throw new Error(`ansatz returned a non-JSON envelope: ${envelopeJson.slice(0, 200)}`);
82
+ }
83
+ if (envelope.ok === true && envelope.result !== undefined)
84
+ return envelope.result;
85
+ const kind = envelope.error?.kind ?? 'solver_error';
86
+ const message = envelope.error?.message ?? 'the solver returned an unspecified error';
87
+ throw new Error(`ansatz ${kind}: ${message}`);
88
+ }
89
+ /** Solve one model with the wasm solver. */
90
+ export async function solveModel(model, options = {}) {
91
+ const wasmDir = options.wasmDir ?? resolveAnsatzWasmDir();
92
+ if (wasmDir === null) {
93
+ throw new Error('the Ansatz solver is unavailable — install it (npm install ansatz-wasm) or point DSH_ANSATZ_WASM at a built wasm package');
94
+ }
95
+ return unwrapEnvelope(loadWasm(wasmDir).solveJson(JSON.stringify(model)));
96
+ }
97
+ /** Solver version string, or null when the solver is unavailable. */
98
+ export function ansatzVersion(options = {}) {
99
+ const wasmDir = options.wasmDir ?? resolveAnsatzWasmDir();
100
+ if (wasmDir === null)
101
+ return null;
102
+ try {
103
+ return loadWasm(wasmDir).version();
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
@@ -3,7 +3,7 @@
3
3
  * route serves straight from memory; a debounced disk mirror keeps restart
4
4
  * replay working without paying a file write on every modeling step.
5
5
  */
6
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
6
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
  import { packBinaryScene } from './bin-format.js';
9
9
  const DISK_MIRROR_DELAY_MS = 1500;
@@ -45,6 +45,20 @@ export class BinarySceneStore {
45
45
  return null;
46
46
  }
47
47
  }
48
+ /** Existence check without loading the buffer into memory. */
49
+ async has(viewId) {
50
+ if (!/^[0-9a-zA-Z_-]{1,64}$/.test(viewId))
51
+ return false;
52
+ if (this.memory.has(viewId))
53
+ return true;
54
+ try {
55
+ await access(path.join(this.directory, `${viewId}.bin`));
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
48
62
  /** Debounced disk mirror: one write after modeling quiesces, not per step. */
49
63
  scheduleDiskMirror(viewId, buffer) {
50
64
  const existing = this.diskTimers.get(viewId);
@@ -0,0 +1,85 @@
1
+ // ── Euler degrees ↔ axis-angle radians (via quaternion, XYZ order) ───────────
2
+ const DEG = Math.PI / 180;
3
+ function eulerToQuaternion(rx, ry, rz) {
4
+ const cx = Math.cos((rx * DEG) / 2), sx = Math.sin((rx * DEG) / 2);
5
+ const cy = Math.cos((ry * DEG) / 2), sy = Math.sin((ry * DEG) / 2);
6
+ const cz = Math.cos((rz * DEG) / 2), sz = Math.sin((rz * DEG) / 2);
7
+ // Rx·Ry·Rz composition — the same convention as cad_transform / assembly.ts
8
+ // (coefficient signs matrix-verified; see the round-trip tests).
9
+ return [
10
+ sx * cy * cz + cx * sy * sz,
11
+ cx * sy * cz - sx * cy * sz,
12
+ cx * cy * sz + sx * sy * cz,
13
+ cx * cy * cz - sx * sy * sz,
14
+ ];
15
+ }
16
+ function quaternionToEuler(qw, qx, qy, qz) {
17
+ // Exact inverse of eulerToQuaternion, extracted from the Rx·Ry·Rz rotation
18
+ // matrix written in quaternion terms: ry = asin(m02), rx = atan2(−m12, m22),
19
+ // rz = atan2(−m01, m00). ry is gimbal-limited to ±90°; rx/rz span ±180°.
20
+ const rx = Math.atan2(2 * (qw * qx - qy * qz), 1 - 2 * (qx * qx + qy * qy)) / DEG;
21
+ const s2 = Math.min(1, Math.max(-1, 2 * (qw * qy + qx * qz)));
22
+ const ry = Math.asin(s2) / DEG;
23
+ const rz = Math.atan2(2 * (qw * qz - qx * qy), 1 - 2 * (qy * qy + qz * qz)) / DEG;
24
+ return [rx, ry, rz];
25
+ }
26
+ /** Instance placement → solver rigid3 pose. */
27
+ export function instanceToRigid3(instance) {
28
+ const [rx, ry, rz] = instance.rotate;
29
+ const [qx, qy, qz, qw] = eulerToQuaternion(rx, ry, rz);
30
+ const angle = 2 * Math.acos(Math.min(1, Math.max(-1, qw)));
31
+ let vector = [0, 0, 0];
32
+ if (angle > 1e-9) {
33
+ const s = Math.sin(angle / 2);
34
+ vector = [(qx / s) * angle, (qy / s) * angle, (qz / s) * angle];
35
+ }
36
+ return {
37
+ translation: { x: instance.translate[0], y: instance.translate[1], z: instance.translate[2] },
38
+ rotation: { vector },
39
+ };
40
+ }
41
+ /** Solver rigid3 pose → instance placement (translate tuple + Euler degrees). */
42
+ export function rigid3ToInstance(pose) {
43
+ const [ax, ay, az] = pose.rotation.vector;
44
+ const angle = Math.hypot(ax, ay, az);
45
+ let qx = 0, qy = 0, qz = 0, qw = 1;
46
+ if (angle > 1e-12) {
47
+ const h = angle / 2;
48
+ const s = Math.sin(h) / angle;
49
+ qx = ax * s;
50
+ qy = ay * s;
51
+ qz = az * s;
52
+ qw = Math.cos(h);
53
+ }
54
+ const [rx, ry, rz] = quaternionToEuler(qw, qx, qy, qz);
55
+ return {
56
+ translate: [pose.translation.x, pose.translation.y, pose.translation.z],
57
+ rotate: [rx, ry, rz],
58
+ };
59
+ }
60
+ /** Build the solver-input model: instance-bound entities get live rigid3 poses. */
61
+ export function buildSolverModel(model, instances) {
62
+ const byInstance = new Map(instances.map((instance) => [instance.instanceId, instance]));
63
+ const entities = model.entities.map((entity) => {
64
+ if (entity.instance !== undefined) {
65
+ const live = byInstance.get(entity.instance);
66
+ if (live === undefined)
67
+ throw new Error(`constraint entity ${entity.id} references unknown assembly instance: ${entity.instance}`);
68
+ return { id: entity.id, geometry: { type: 'rigid3', pose: instanceToRigid3(live) } };
69
+ }
70
+ if (entity.geometry === undefined)
71
+ throw new Error(`constraint entity ${entity.id} has neither an instance binding nor literal geometry`);
72
+ return { id: entity.id, geometry: entity.geometry };
73
+ });
74
+ return { entities, constraints: model.constraints };
75
+ }
76
+ /** Extract solved rigid3 poses keyed by entity id (absent for 2D geometry). */
77
+ export function solvedRigid3s(reportEntities) {
78
+ const out = new Map();
79
+ for (const entity of reportEntities) {
80
+ if (entity.geometry?.type === 'rigid3' && entity.geometry.pose !== undefined) {
81
+ out.set(entity.id, entity.geometry.pose);
82
+ }
83
+ }
84
+ return out;
85
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Modeling document: an operation log (JSON) per workspace that both persists
2
+ * Modeling document: an operation log (JSON) that both persists
3
3
  * across restarts and is the unit of replay — restart recovery re-applies the
4
4
  * log to a fresh worker, which is what makes the worker's in-memory shapes
5
5
  * disposable.
@@ -9,15 +9,27 @@ import path from 'node:path';
9
9
  import { randomUUID } from 'node:crypto';
10
10
  export class ModelDocument {
11
11
  root;
12
- doc = { docId: randomUUID(), version: 0, ops: [], bodyNames: {} };
13
- constructor(root) {
12
+ doc;
13
+ /**
14
+ * @param docId Registry-assigned document id. With an id the document lives
15
+ * at `<root>/.dsh-cad/docs/<docId>.json`; without one it keeps the legacy
16
+ * single-document path `<root>/.dsh-cad/model.json` (tests, migration).
17
+ */
18
+ constructor(root, docId) {
14
19
  this.root = root;
20
+ this.legacy = docId === undefined;
21
+ this.doc = { docId: docId ?? randomUUID(), version: 0, ops: [], bodyNames: {} };
15
22
  }
16
- get directory() {
23
+ /** True when this instance owns the legacy single-document path. */
24
+ legacy;
25
+ get base() {
17
26
  return path.join(this.root, '.dsh-cad');
18
27
  }
28
+ get directory() {
29
+ return this.legacy ? this.base : path.join(this.base, 'docs');
30
+ }
19
31
  get file() {
20
- return path.join(this.directory, 'model.json');
32
+ return this.legacy ? path.join(this.base, 'model.json') : path.join(this.base, 'docs', `${this.doc.docId}.json`);
21
33
  }
22
34
  /** Load the persisted document if one exists. */
23
35
  async restore() {
@@ -46,6 +58,11 @@ export class ModelDocument {
46
58
  await mkdir(this.directory, { recursive: true });
47
59
  await writeFile(this.file, JSON.stringify(this.doc));
48
60
  }
61
+ /** Persist the current state without appending an op (registry creation). */
62
+ async save() {
63
+ await mkdir(this.directory, { recursive: true });
64
+ await writeFile(this.file, JSON.stringify(this.doc));
65
+ }
49
66
  /** Clear the document (cad_new / tests). */
50
67
  async clear() {
51
68
  this.doc = { docId: randomUUID(), version: 0, ops: [], bodyNames: {} };