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,250 @@
1
+ /**
2
+ * Bridge to the occt.ts kernel (dist/wasm/occtjs.js + occtjs.wasm — OCCT 7.9
3
+ * with a hand-bound surface that, unlike the stock opencascade.js build, DOES
4
+ * expose hidden-line removal, plus STEP/BRep byte import and built-in edge
5
+ * extraction on tessellation).
6
+ *
7
+ * The modeling kernel stays on opencascade.js (full raw surface); this bridge
8
+ * serves one purpose: true-HLR drawing views. Geometry crosses kernels as
9
+ * STEP bytes (adapter's proven MEMFS export → occt.ts readStep), and the
10
+ * projected segments are remapped into the caller's screen frame (u = right,
11
+ * v = up) before chaining into polylines, so the sheet layout consumes the
12
+ * exact same view data the mesh-projected fallback produces.
13
+ *
14
+ * Resolution order for the dist directory (first hit wins):
15
+ * 1. explicit `distDir` argument
16
+ * 2. `DSH_OCCTJS_DIST` environment variable
17
+ * 3. `<repo>/node_modules/occt.ts/dist` — the npm package (default)
18
+ * 4. `<repo>/../opencascade-ts/dist` — sibling checkout (dev machines)
19
+ * 5. `<repo>/vendor/opencascade-ts/dist`
20
+ * 6. `<repo>/node_modules/opencascade-ts/dist`
21
+ *
22
+ * `createOcctBridge()` resolves to null when no dist is found or init
23
+ * fails — the drawing op then throws (engineering drawings require this
24
+ * kernel; there is no fallback engine).
25
+ */
26
+ 'use strict'
27
+ const fs = require('node:fs')
28
+ const path = require('node:path')
29
+ const { pathToFileURL } = require('node:url')
30
+
31
+ const norm = (v) => {
32
+ const len = Math.hypot(v[0], v[1], v[2]) || 1
33
+ return [v[0] / len, v[1] / len, v[2] / len]
34
+ }
35
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
36
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
37
+
38
+ function resolveDistDir(explicit) {
39
+ const repoRoot = path.resolve(__dirname, '..', '..')
40
+ const candidates = [
41
+ explicit,
42
+ process.env.DSH_OCCTJS_DIST,
43
+ path.join(repoRoot, 'node_modules', 'occt.ts', 'dist'),
44
+ path.join(repoRoot, '..', 'opencascade-ts', 'dist'),
45
+ path.join(repoRoot, 'vendor', 'opencascade-ts', 'dist'),
46
+ path.join(repoRoot, 'node_modules', 'opencascade-ts', 'dist'),
47
+ ]
48
+ for (const dir of candidates) {
49
+ if (!dir) continue
50
+ if (fs.existsSync(path.join(dir, 'wasm', 'occtjs.js')) && fs.existsSync(path.join(dir, 'wasm', 'occtjs.wasm'))) {
51
+ return dir
52
+ }
53
+ }
54
+ return null
55
+ }
56
+
57
+ async function loadModule(distDir) {
58
+ const imported = await import(pathToFileURL(path.join(distDir, 'wasm', 'occtjs.js')).href)
59
+ const factory = imported.default
60
+ if (typeof factory !== 'function') throw new Error(`occt.ts module has no default factory: ${distDir}`)
61
+ const wasmBinary = fs.readFileSync(path.join(distDir, 'wasm', 'occtjs.wasm'))
62
+ return factory({ wasmBinary })
63
+ }
64
+
65
+ /** Canonical form for coincidence filtering: sorted quantized point keys. */
66
+ function polylineKey(points) {
67
+ const keys = []
68
+ for (let i = 0; i + 1 < points.length; i += 2) {
69
+ keys.push(`${Math.round(points[i] * 100)}|${Math.round(points[i + 1] * 100)}`)
70
+ }
71
+ keys.sort()
72
+ return keys.join(';')
73
+ }
74
+
75
+ /**
76
+ * Project one shape (as STEP bytes) into the requested views with the
77
+ * occt.ts hidden-line engine. views: [{ name, dir, xDir }] — the same spec
78
+ * the mesh HLR takes; returns { views: [{ name, visible, hidden }], version }.
79
+ */
80
+ async function hiddenLineViews(mod, stepBytes, views) {
81
+ const ptr = mod._malloc(stepBytes.length)
82
+ try {
83
+ mod.HEAPU8.set(stepBytes, ptr)
84
+ const shape = mod.readStep(ptr, stepBytes.length)
85
+ if (shape.isNull()) throw new Error(`occt.ts readStep: ${mod.lastError()}`)
86
+ try {
87
+ return {
88
+ hlr: 'occt.ts',
89
+ views: views.map((view) => {
90
+ const w = norm(view.dir)
91
+ const u = norm(view.xDir)
92
+ const v = norm(cross(w, u)) // screen-up in model space
93
+ // occt.ts frame rule (mirrors its occ.js wrapper): x = up projected
94
+ // onto the view plane, y = dir × x. We pass v as the up hint.
95
+ const dv = dot(w, v)
96
+ let x = [v[0] - w[0] * dv, v[1] - w[1] * dv, v[2] - w[2] * dv]
97
+ if (Math.hypot(x[0], x[1], x[2]) < 1e-9) x = Math.abs(w[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0]
98
+ x = norm(x)
99
+ const y = norm(cross(w, x))
100
+ const raw = mod.hiddenLines(shape, w[0], w[1], w[2], x[0], x[1], x[2], 0.1)
101
+ const read = (ptr2, count) => new Float32Array(mod.HEAPU8.buffer, ptr2, count * 3).slice()
102
+ let visible
103
+ let hidden
104
+ try {
105
+ visible = read(raw.visiblePtr(), raw.visiblePointCount())
106
+ hidden = read(raw.hiddenPtr(), raw.hiddenPointCount())
107
+ } finally {
108
+ raw.delete()
109
+ }
110
+ // Remap occt.ts frame (x, y) → drawing frame (u, v): the 2×2 basis dot products.
111
+ const a = dot(x, u)
112
+ const b = dot(y, u)
113
+ const c = dot(x, v)
114
+ const d = dot(y, v)
115
+ const remap = (arr) => {
116
+ const xyz = []
117
+ for (let i = 0; i + 2 < arr.length; i += 3) {
118
+ xyz.push(arr[i] * a + arr[i + 1] * b, arr[i] * c + arr[i + 1] * d, 0)
119
+ }
120
+ return xyz
121
+ }
122
+ const toPolylines = (xyz) =>
123
+ chainSegments(
124
+ Array.from({ length: xyz.length / 6 }, (_, s) => [
125
+ xyz[s * 6], xyz[s * 6 + 1], xyz[s * 6 + 3], xyz[s * 6 + 4],
126
+ ]),
127
+ ).map((chain) => simplify(chain, 0.02)).filter((chain) => chain.length >= 4)
128
+ const visiblePolylines = toPolylines(remap(visible))
129
+ const visibleKeys = new Set(visiblePolylines.map(polylineKey))
130
+ // An outline edge hidden behind a face whose projection coincides
131
+ // with its visible twin (e.g. a box's back rectangle) is already
132
+ // drawn — drop the dashed duplicate.
133
+ const hiddenPolylines = toPolylines(remap(hidden))
134
+ .filter((chain) => !visibleKeys.has(polylineKey(chain)))
135
+ return { name: view.name, visible: visiblePolylines, hidden: hiddenPolylines }
136
+ }),
137
+ }
138
+ } finally {
139
+ shape.delete()
140
+ }
141
+ } finally {
142
+ mod._free(ptr)
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Create the bridge, or null when the dist is absent/unloadable (the caller
148
+ * falls back to the mesh HLR). The heavy wasm load is lazy and cached.
149
+ */
150
+ async function createOcctBridge(options = {}) {
151
+ const distDir = resolveDistDir(options.distDir)
152
+ if (distDir === null) return null
153
+ const mod = await loadModule(distDir)
154
+ if (typeof mod.hiddenLines !== 'function' || typeof mod.readStep !== 'function') {
155
+ throw new Error(`occt.ts dist at ${distDir} lacks hiddenLines/readStep`)
156
+ }
157
+ return {
158
+ kernel: 'occt.ts',
159
+ distDir,
160
+ occtVersion: mod.occtVersion(),
161
+ hiddenLineViews: (stepBytes, views) => hiddenLineViews(mod, stepBytes, views),
162
+ }
163
+ }
164
+
165
+ // ── polyline post-processing (shared formatting for projected segments) ─────
166
+
167
+ /**
168
+ * Chain collinear-adjacent segments ([x1,y1,x2,y2] tuples) into polylines by
169
+ * quantized shared endpoints — turns the kernel's independent segment pairs
170
+ * into continuous strokes for the sheet renderer.
171
+ */
172
+ function chainSegments(segments) {
173
+ const key = (x, y) => `${Math.round(x * 1000)}|${Math.round(y * 1000)}`
174
+ const map = new Map()
175
+ segments.forEach((seg, s) => {
176
+ const [x1, y1, x2, y2] = seg
177
+ for (const [k, end] of [[key(x1, y1), 0], [key(x2, y2), 1]]) {
178
+ let list = map.get(k)
179
+ if (list === undefined) { list = []; map.set(k, list) }
180
+ list.push({ s, end })
181
+ }
182
+ })
183
+ const used = new Uint8Array(segments.length)
184
+ const chains = []
185
+ for (let s = 0; s < segments.length; s++) {
186
+ if (used[s]) continue
187
+ used[s] = 1
188
+ const chain = [segments[s][0], segments[s][1], segments[s][2], segments[s][3]]
189
+ // Extend forward from the tail, then backward from the head.
190
+ for (const direction of [1, -1]) {
191
+ for (;;) {
192
+ const tailX = direction === 1 ? chain[chain.length - 2] : chain[0]
193
+ const tailY = direction === 1 ? chain[chain.length - 1] : chain[1]
194
+ const candidates = map.get(key(tailX, tailY)) ?? []
195
+ let found = null
196
+ for (const candidate of candidates) {
197
+ if (used[candidate.s]) continue
198
+ found = candidate
199
+ break
200
+ }
201
+ if (found === null) break
202
+ used[found.s] = 1
203
+ const [ex1, ey1, ex2, ey2] = segments[found.s]
204
+ // Append the far end oriented away from the join point.
205
+ const joinIsStart = key(ex1, ey1) === key(tailX, tailY)
206
+ const px = joinIsStart ? ex2 : ex1
207
+ const py = joinIsStart ? ey2 : ey1
208
+ if (direction === 1) chain.push(px, py)
209
+ else chain.unshift(px, py)
210
+ }
211
+ }
212
+ chains.push(chain)
213
+ }
214
+ return chains
215
+ }
216
+
217
+ /** Ramer–Douglas–Peucker on a flat [x,y,…] polyline. */
218
+ function simplify(points, epsilon) {
219
+ const n = points.length / 2
220
+ if (n < 3) return points
221
+ const keep = new Uint8Array(n)
222
+ keep[0] = 1
223
+ keep[n - 1] = 1
224
+ const stack = [[0, n - 1]]
225
+ while (stack.length > 0) {
226
+ const [first, last] = stack.pop()
227
+ const x1 = points[first * 2], y1 = points[first * 2 + 1]
228
+ const x2 = points[last * 2], y2 = points[last * 2 + 1]
229
+ const dx = x2 - x1, dy = y2 - y1
230
+ const len = Math.hypot(dx, dy)
231
+ let maxDist = 0
232
+ let index = -1
233
+ for (let i = first + 1; i < last; i++) {
234
+ const px = points[i * 2], py = points[i * 2 + 1]
235
+ const dist = len === 0 ? Math.hypot(px - x1, py - y1) : Math.abs(dy * px - dx * py + x2 * y1 - y2 * x1) / len
236
+ if (dist > maxDist) { maxDist = dist; index = i }
237
+ }
238
+ if (maxDist > epsilon && index > 0) {
239
+ keep[index] = 1
240
+ stack.push([first, index], [index, last])
241
+ }
242
+ }
243
+ const out = []
244
+ for (let i = 0; i < n; i++) {
245
+ if (keep[i]) { out.push(points[i * 2], points[i * 2 + 1]) }
246
+ }
247
+ return out
248
+ }
249
+
250
+ module.exports = { createOcctBridge, resolveDistDir }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Document registry: the workspace's file space of named modeling documents
3
+ * (`.dsh-cad/docs/<docId>.json`) plus the session→document bindings that give
4
+ * every chat session its own active document. Sessions see all documents (the
5
+ * file space is workspace-scoped) but new sessions start on a fresh document
6
+ * instead of inheriting leftovers from earlier conversations.
7
+ *
8
+ * `index.json` is the write-through manifest: document metadata and bindings
9
+ * persist across service restarts alongside the documents themselves.
10
+ */
11
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { randomUUID } from 'node:crypto';
14
+ import { ModelDocument } from './document.js';
15
+ const nowIso = () => new Date().toISOString();
16
+ export class DocumentRegistry {
17
+ root;
18
+ state = { version: 1, docs: [], sessionBindings: {} };
19
+ loaded = false;
20
+ constructor(root) {
21
+ this.root = root;
22
+ }
23
+ get base() {
24
+ return path.join(this.root, '.dsh-cad');
25
+ }
26
+ get docsDir() {
27
+ return path.join(this.base, 'docs');
28
+ }
29
+ get file() {
30
+ return path.join(this.base, 'index.json');
31
+ }
32
+ docFile(id) {
33
+ return path.join(this.docsDir, `${id}.json`);
34
+ }
35
+ /** Load the manifest once; migrates the legacy single document on first run. */
36
+ async ensureLoaded() {
37
+ if (this.loaded)
38
+ return;
39
+ this.loaded = true;
40
+ try {
41
+ const text = await readFile(this.file, 'utf8');
42
+ const parsed = JSON.parse(text);
43
+ if (Array.isArray(parsed.docs)) {
44
+ this.state = { version: 1, docs: parsed.docs, sessionBindings: parsed.sessionBindings ?? {}, legacyDocId: parsed.legacyDocId ?? null };
45
+ }
46
+ return;
47
+ }
48
+ catch {
49
+ /* no manifest yet — try the legacy migration below */
50
+ }
51
+ await this.migrateLegacy();
52
+ }
53
+ /**
54
+ * Adopt the pre-registry single document (`.dsh-cad/model.json`) as a named
55
+ * document so existing work stays openable from the file list.
56
+ */
57
+ async migrateLegacy() {
58
+ const legacyPath = path.join(this.base, 'model.json');
59
+ try {
60
+ const text = await readFile(legacyPath, 'utf8');
61
+ const parsed = JSON.parse(text);
62
+ if (typeof parsed.docId !== 'string' || parsed.docId === '')
63
+ return;
64
+ await mkdir(this.docsDir, { recursive: true });
65
+ await rename(legacyPath, this.docFile(parsed.docId));
66
+ const stamped = nowIso();
67
+ this.state.docs.push({
68
+ id: parsed.docId,
69
+ name: '导入的模型',
70
+ createdAt: stamped,
71
+ updatedAt: stamped,
72
+ opCount: parsed.ops?.length ?? 0,
73
+ bodyCount: Object.keys(parsed.bodyNames ?? {}).length,
74
+ });
75
+ // Upgrade continuity: the first session that models after the upgrade
76
+ // inherits the pre-registry document instead of a fresh empty one.
77
+ this.state.legacyDocId = parsed.docId;
78
+ await this.persist();
79
+ }
80
+ catch {
81
+ /* nothing to migrate — a fresh workspace */
82
+ }
83
+ }
84
+ async persist() {
85
+ await mkdir(this.base, { recursive: true });
86
+ await writeFile(this.file, JSON.stringify(this.state));
87
+ }
88
+ /** All document metas, most recently updated first. */
89
+ async list() {
90
+ await this.ensureLoaded();
91
+ return [...this.state.docs].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
92
+ }
93
+ /** Create a named document (empty op log, persisted immediately). */
94
+ async create(name) {
95
+ await this.ensureLoaded();
96
+ const document = new ModelDocument(this.root, randomUUID());
97
+ await document.save();
98
+ const stamped = nowIso();
99
+ const trimmed = typeof name === 'string' ? name.trim() : '';
100
+ const resolved = trimmed !== '' ? trimmed : this.nextUntitledName();
101
+ this.state.docs.push({ id: document.doc.docId, name: resolved, createdAt: stamped, updatedAt: stamped, opCount: 0, bodyCount: 0 });
102
+ await this.persist();
103
+ return document;
104
+ }
105
+ nextUntitledName() {
106
+ let n = 1;
107
+ const names = new Set(this.state.docs.map((doc) => doc.name));
108
+ while (names.has(`未命名 ${n}`))
109
+ n += 1;
110
+ return `未命名 ${n}`;
111
+ }
112
+ /** Open a document by id (null when the manifest has no such entry). */
113
+ async open(id) {
114
+ await this.ensureLoaded();
115
+ if (!this.state.docs.some((doc) => doc.id === id))
116
+ return null;
117
+ return new ModelDocument(this.root, id);
118
+ }
119
+ /** Resolve by document id or (case-insensitive) exact name. */
120
+ async resolve(ref) {
121
+ await this.ensureLoaded();
122
+ const trimmed = ref.trim();
123
+ const byId = this.state.docs.find((doc) => doc.id === trimmed);
124
+ if (byId !== undefined)
125
+ return byId;
126
+ const lowered = trimmed.toLowerCase();
127
+ return this.state.docs.find((doc) => doc.name.toLowerCase() === lowered) ?? null;
128
+ }
129
+ /** Bind a session to its active document. */
130
+ async bind(sessionId, docId) {
131
+ await this.ensureLoaded();
132
+ this.state.sessionBindings[sessionId] = docId;
133
+ await this.persist();
134
+ }
135
+ /** The session's active document id (null: unbound — create on first use). */
136
+ async bindingOf(sessionId) {
137
+ await this.ensureLoaded();
138
+ const bound = this.state.sessionBindings[sessionId];
139
+ if (bound === undefined)
140
+ return null;
141
+ // Drop bindings whose document vanished (deleted out of band).
142
+ return this.state.docs.some((doc) => doc.id === bound) ? bound : null;
143
+ }
144
+ /**
145
+ * One-shot upgrade continuity: hand the migrated legacy document to the
146
+ * first unbound session (so continuing an old conversation keeps its
147
+ * bodies) and clear the marker. Returns null once claimed or absent.
148
+ */
149
+ async claimLegacyFor(sessionId) {
150
+ await this.ensureLoaded();
151
+ const legacyId = this.state.legacyDocId ?? null;
152
+ if (legacyId === null)
153
+ return null;
154
+ if (!this.state.docs.some((doc) => doc.id === legacyId)) {
155
+ this.state.legacyDocId = null;
156
+ await this.persist();
157
+ return null;
158
+ }
159
+ this.state.legacyDocId = null;
160
+ this.state.sessionBindings[sessionId] = legacyId;
161
+ await this.persist();
162
+ return legacyId;
163
+ }
164
+ /** Update a document's meta after recorded ops (write-through). */
165
+ async touch(docId, patch) {
166
+ await this.ensureLoaded();
167
+ const meta = this.state.docs.find((doc) => doc.id === docId);
168
+ if (meta === undefined)
169
+ return;
170
+ if (patch.opCount !== undefined)
171
+ meta.opCount = patch.opCount;
172
+ if (patch.bodyCount !== undefined)
173
+ meta.bodyCount = patch.bodyCount;
174
+ if (patch.name !== undefined && patch.name.trim() !== '')
175
+ meta.name = patch.name.trim();
176
+ meta.updatedAt = nowIso();
177
+ await this.persist();
178
+ }
179
+ /** Rename a document. */
180
+ async rename(id, name) {
181
+ await this.ensureLoaded();
182
+ const meta = this.state.docs.find((doc) => doc.id === id);
183
+ if (meta === undefined)
184
+ return null;
185
+ const trimmed = name.trim();
186
+ if (trimmed === '')
187
+ return meta;
188
+ meta.name = trimmed;
189
+ meta.updatedAt = nowIso();
190
+ await this.persist();
191
+ return meta;
192
+ }
193
+ /**
194
+ * Delete a document: remove its op log, drop every session binding to it.
195
+ * Scene caches (bin mirrors) are inert without the manifest entry and are
196
+ * left for the store's own lifecycle.
197
+ */
198
+ async remove(id) {
199
+ await this.ensureLoaded();
200
+ const index = this.state.docs.findIndex((doc) => doc.id === id);
201
+ if (index === -1)
202
+ return false;
203
+ this.state.docs.splice(index, 1);
204
+ for (const [sessionId, docId] of Object.entries(this.state.sessionBindings)) {
205
+ if (docId === id)
206
+ delete this.state.sessionBindings[sessionId];
207
+ }
208
+ if (this.state.legacyDocId === id)
209
+ this.state.legacyDocId = null;
210
+ await this.persist();
211
+ try {
212
+ await rm(this.docFile(id), { force: true });
213
+ }
214
+ catch {
215
+ /* the manifest no longer references it either way */
216
+ }
217
+ return true;
218
+ }
219
+ }
package/lib/routes.js CHANGED
@@ -5,6 +5,8 @@
5
5
  * - GET /dsh-cad/demo-scene — the built-in demo example, parsed from the
6
6
  * packaged demo-bracket.brep by OCCT (local
7
7
  * file ↔ editor display correspondence)
8
+ * - GET /dsh-cad/docs — the workspace document file space (list)
9
+ * - POST /dsh-cad/docs/delete — remove a document (panel delete button)
8
10
  */
9
11
  import { createHash } from 'node:crypto';
10
12
  import { readFile } from 'node:fs/promises';
@@ -12,6 +14,8 @@ import { convert } from './convert/index.js';
12
14
  export const SCENE_ROUTE_PATH = '/dsh-cad/scene';
13
15
  export const BIN_ROUTE_PATH = '/dsh-cad/bin';
14
16
  export const DEMO_SCENE_ROUTE_PATH = '/dsh-cad/demo-scene';
17
+ export const DOCS_ROUTE_PATH = '/dsh-cad/docs';
18
+ export const DOCS_DELETE_ROUTE_PATH = '/dsh-cad/docs/delete';
15
19
  /** The built-in demo examples (packaged as lib/demo-<part>.brep). */
16
20
  export const DEMO_PARTS = ['bracket', 'flange', 'shaft'];
17
21
  /** Register the scene route on the shared HTTP server. Returns a disposer. */
@@ -140,3 +144,69 @@ export function registerDemoRoute(server) {
140
144
  },
141
145
  });
142
146
  }
147
+ /** Register the docs file-space route: GET /dsh-cad/docs (list documents). */
148
+ export function registerDocsRoute(server, registry, binStore) {
149
+ return server.register({
150
+ kind: 'exact',
151
+ path: DOCS_ROUTE_PATH,
152
+ handler: async (req, res) => {
153
+ if (req.method !== 'GET') {
154
+ res.writeHead(404, { 'content-type': 'application/json' });
155
+ res.end(JSON.stringify({ error: 'not found' }));
156
+ return;
157
+ }
158
+ try {
159
+ const docs = await registry.list();
160
+ const entries = await Promise.all(docs.map(async (doc) => ({
161
+ id: doc.id,
162
+ name: doc.name,
163
+ bodies: doc.bodyCount,
164
+ updatedAt: doc.updatedAt,
165
+ // Preview URL only when a published scene exists (memory or mirror).
166
+ ...(await binStore.has(doc.id) ? { sceneUrl: `${BIN_ROUTE_PATH}/${doc.id}` } : {}),
167
+ })));
168
+ const body = Buffer.from(JSON.stringify({ docs: entries }));
169
+ res.writeHead(200, {
170
+ 'content-type': 'application/json',
171
+ 'content-length': body.length,
172
+ 'cache-control': 'no-store',
173
+ });
174
+ res.end(body);
175
+ }
176
+ catch (cause) {
177
+ res.writeHead(500, { 'content-type': 'application/json' });
178
+ res.end(JSON.stringify({ error: cause instanceof Error ? cause.message : String(cause) }));
179
+ }
180
+ },
181
+ });
182
+ }
183
+ /** Register the docs delete route: POST /dsh-cad/docs/delete?id=<docId>. */
184
+ export function registerDocsDeleteRoute(server, registry) {
185
+ return server.register({
186
+ kind: 'exact',
187
+ path: DOCS_DELETE_ROUTE_PATH,
188
+ handler: async (req, res) => {
189
+ const url = new URL(req.url ?? '/', 'http://localhost');
190
+ const id = url.searchParams.get('id');
191
+ if (req.method !== 'POST' || id === null || id === '') {
192
+ res.writeHead(404, { 'content-type': 'application/json' });
193
+ res.end(JSON.stringify({ error: 'not found' }));
194
+ return;
195
+ }
196
+ try {
197
+ const removed = await registry.remove(id);
198
+ if (!removed) {
199
+ res.writeHead(404, { 'content-type': 'application/json' });
200
+ res.end(JSON.stringify({ error: `unknown document: ${id}` }));
201
+ return;
202
+ }
203
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
204
+ res.end(JSON.stringify({ deleted: id }));
205
+ }
206
+ catch (cause) {
207
+ res.writeHead(500, { 'content-type': 'application/json' });
208
+ res.end(JSON.stringify({ error: cause instanceof Error ? cause.message : String(cause) }));
209
+ }
210
+ },
211
+ });
212
+ }