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/README.md +235 -190
- package/README.zh-CN.md +212 -173
- package/lib/client.js +174 -174
- package/lib/client.js.map +3 -3
- package/lib/index.js +6 -2
- package/lib/modeling/ansatz-bridge.js +108 -0
- package/lib/modeling/bin-store.js +15 -1
- package/lib/modeling/constraints.js +85 -0
- package/lib/modeling/document.js +22 -5
- package/lib/modeling/modeling-worker.cjs +350 -235
- package/lib/modeling/occt-adapter.cjs +617 -271
- package/lib/modeling/occt-bridge.cjs +250 -0
- package/lib/modeling/registry.js +219 -0
- package/lib/routes.js +70 -0
- package/lib/tools/cad-constraint.js +335 -0
- package/lib/tools/cad-model.js +595 -58
- package/lib/types/modeling/ansatz-bridge.d.ts +24 -0
- package/lib/types/modeling/bin-store.d.ts +2 -0
- package/lib/types/modeling/client.d.ts +50 -1
- package/lib/types/modeling/constraints.d.ts +63 -0
- package/lib/types/modeling/document.d.ts +11 -1
- package/lib/types/modeling/registry.d.ts +61 -0
- package/lib/types/routes.d.ts +11 -0
- package/lib/types/tools/cad-constraint.d.ts +25 -0
- package/lib/types/tools/cad-model.d.ts +5 -2
- package/package.json +4 -3
- package/cordis.patch.yml +0 -3
- package/lib/modeling/hlr.cjs +0 -328
|
@@ -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
|
+
}
|