dsh-cad 0.2.0 → 0.8.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 +245 -190
- package/README.zh-CN.md +220 -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 +389 -235
- package/lib/modeling/occt-adapter.cjs +617 -271
- package/lib/modeling/occt-bridge.cjs +407 -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 +663 -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 +62 -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,407 @@
|
|
|
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
|
+
// ── occt.ts-hosted solid operations (shell / draft / boolean / transform) ────
|
|
148
|
+
//
|
|
149
|
+
// opencascade.js cannot read geometry BACK (STEPControl_Reader and BRepTools
|
|
150
|
+
// readers are opaque no-method shells in that build), so a solid produced by
|
|
151
|
+
// an occt.ts-only op cannot return to the opencascade.js modeling session.
|
|
152
|
+
// Instead the result stays hosted as STEP bytes on the occt.ts side: every
|
|
153
|
+
// hosted op re-reads the STEP, operates, and writes it back, while meshes for
|
|
154
|
+
// display/volume come from the occt.ts tessellator. opencascade.js-exclusive
|
|
155
|
+
// ops (fillet/chamfer/loft/sweep/revolve) refuse hosted bodies with a clear
|
|
156
|
+
// error instead of silently operating on stale geometry.
|
|
157
|
+
|
|
158
|
+
/** Read STEP bytes into an occt.ts shape (caller deletes). */
|
|
159
|
+
function readShape(mod, stepBytes) {
|
|
160
|
+
const ptr = mod._malloc(stepBytes.length)
|
|
161
|
+
try {
|
|
162
|
+
mod.HEAPU8.set(stepBytes, ptr)
|
|
163
|
+
const shape = mod.readStep(ptr, stepBytes.length, 'mm')
|
|
164
|
+
if (shape.isNull()) throw new Error(`occt.ts readStep: ${mod.lastError()}`)
|
|
165
|
+
return shape
|
|
166
|
+
} finally {
|
|
167
|
+
mod._free(ptr)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Write an occt.ts shape to STEP bytes. */
|
|
172
|
+
function toStepBytes(mod, shape) {
|
|
173
|
+
const text = mod.writeStep(shape, 'mm')
|
|
174
|
+
return Buffer.from(text, 'utf8')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Tessellate an occt.ts shape into the worker mesh format. */
|
|
178
|
+
function tessellateMesh(mod, shape, name, linearDeflection = 0.3) {
|
|
179
|
+
const data = mod.tessellate(shape, linearDeflection, 20, false)
|
|
180
|
+
try {
|
|
181
|
+
const positions = new Float32Array(mod.HEAPU8.buffer, data.positionsPtr(), data.positionCount() * 3).slice()
|
|
182
|
+
const indices = new Uint32Array(mod.HEAPU8.buffer, data.indicesPtr(), data.indexCount()).slice()
|
|
183
|
+
const triangles = indices.length / 3
|
|
184
|
+
// Flat per-triangle normals (the mechanical-CAD look the viewer expects).
|
|
185
|
+
const normals = new Float32Array(positions.length)
|
|
186
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
187
|
+
const a = indices[i] * 3, b = indices[i + 1] * 3, c = indices[i + 2] * 3
|
|
188
|
+
const ux = positions[c] - positions[a], uy = positions[c + 1] - positions[a + 1], uz = positions[c + 2] - positions[a + 2]
|
|
189
|
+
const vx = positions[b] - positions[a], vy = positions[b + 1] - positions[a + 1], vz = positions[b + 2] - positions[a + 2]
|
|
190
|
+
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
191
|
+
const len = Math.hypot(nx, ny, nz) || 1
|
|
192
|
+
nx /= len; ny /= len; nz /= len
|
|
193
|
+
for (const corner of [a, b, c]) {
|
|
194
|
+
normals[corner] = nx; normals[corner + 1] = ny; normals[corner + 2] = nz
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { name, positions, normals, indices, vertexCount: positions.length / 3, triangleCount: triangles }
|
|
198
|
+
} finally {
|
|
199
|
+
data.delete()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Faces (1-based indices) whose outward plane-normal matches a target list. */
|
|
204
|
+
function matchFaces(mod, shape, openNormals) {
|
|
205
|
+
if (!Array.isArray(openNormals) || openNormals.length === 0) return []
|
|
206
|
+
const desc = JSON.parse(mod.describe(shape))
|
|
207
|
+
const faces = Array.isArray(desc.faces) ? desc.faces : []
|
|
208
|
+
const picked = []
|
|
209
|
+
for (const target of openNormals) {
|
|
210
|
+
const tlen = Math.hypot(target[0], target[1], target[2])
|
|
211
|
+
if (tlen < 1e-12) continue
|
|
212
|
+
const tn = [target[0] / tlen, target[1] / tlen, target[2] / tlen]
|
|
213
|
+
let best = null
|
|
214
|
+
let bestScore = 0.9
|
|
215
|
+
for (const face of faces) {
|
|
216
|
+
if (face.surface !== 'plane' || !Array.isArray(face.normal)) continue
|
|
217
|
+
const dot = face.normal[0] * tn[0] + face.normal[1] * tn[1] + face.normal[2] * tn[2]
|
|
218
|
+
// |dot| ≈ 1 means parallel; the OUTER face along ±target has the larger
|
|
219
|
+
// origin·target projection (a box carries both ±Z faces).
|
|
220
|
+
if (Math.abs(dot) < 0.9) continue
|
|
221
|
+
const outward = Math.sign(dot) >= 0 ? 1 : -1
|
|
222
|
+
const proj = (face.origin[0] * tn[0] + face.origin[1] * tn[1] + face.origin[2] * tn[2]) * outward
|
|
223
|
+
const score = Math.abs(dot) + proj * 1e-3
|
|
224
|
+
if (score > bestScore) { bestScore = score; best = face.index }
|
|
225
|
+
}
|
|
226
|
+
if (best !== null) picked.push(best)
|
|
227
|
+
}
|
|
228
|
+
return picked
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** int32 face-index array into wasm memory; returns {ptr, count} or null. */
|
|
232
|
+
function faceListPtr(mod, faces) {
|
|
233
|
+
if (!Array.isArray(faces) || faces.length === 0) return { ptr: 0, count: 0 }
|
|
234
|
+
const ptr = mod._malloc(faces.length * 4)
|
|
235
|
+
for (let i = 0; i < faces.length; i++) {
|
|
236
|
+
mod.HEAPU32[ptr / 4 + i] = faces[i]
|
|
237
|
+
}
|
|
238
|
+
return { ptr, count: faces.length }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Run one occt.ts-hosted solid operation on STEP bytes:
|
|
243
|
+
* shell(stepBytes, thickness, openNormals) → { step, mesh, volume, faces }
|
|
244
|
+
* draft(stepBytes, angleDegrees, direction, faces?) → { step, mesh, volume }
|
|
245
|
+
* The STEP bytes of the RESULT are the hosted body's new source of truth.
|
|
246
|
+
*/
|
|
247
|
+
function hostedSolidOp(mod, kind, stepBytes, params) {
|
|
248
|
+
const shape = readShape(mod, stepBytes)
|
|
249
|
+
try {
|
|
250
|
+
let result
|
|
251
|
+
if (kind === 'shell') {
|
|
252
|
+
const faces = Array.isArray(params.faces) && params.faces.length > 0
|
|
253
|
+
? params.faces
|
|
254
|
+
: matchFaces(mod, shape, params.openNormals)
|
|
255
|
+
const list = faceListPtr(mod, faces)
|
|
256
|
+
try {
|
|
257
|
+
result = mod.shell(shape, Math.abs(params.thickness), list.ptr, list.count)
|
|
258
|
+
} finally {
|
|
259
|
+
if (list.ptr !== 0) mod._free(list.ptr)
|
|
260
|
+
}
|
|
261
|
+
if (result.isNull()) throw new Error(`shell failed: ${mod.lastError()}`)
|
|
262
|
+
} else if (kind === 'draft') {
|
|
263
|
+
const faces = Array.isArray(params.faces) && params.faces.length > 0 ? params.faces : []
|
|
264
|
+
const list = faceListPtr(mod, faces)
|
|
265
|
+
const d = params.direction ?? [0, 0, 1]
|
|
266
|
+
try {
|
|
267
|
+
result = mod.draft(shape, (params.angle * Math.PI) / 180, d[0], d[1], d[2], 0, 0, 1, list.ptr, list.count, faces.length === 0)
|
|
268
|
+
} finally {
|
|
269
|
+
if (list.ptr !== 0) mod._free(list.ptr)
|
|
270
|
+
}
|
|
271
|
+
if (result.isNull()) throw new Error(`draft failed: ${mod.lastError()}`)
|
|
272
|
+
} else {
|
|
273
|
+
throw new Error(`unknown hosted op: ${kind}`)
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
return {
|
|
277
|
+
step: toStepBytes(mod, result),
|
|
278
|
+
mesh: tessellateMesh(mod, result, params.name ?? 'hosted'),
|
|
279
|
+
volume: Math.abs(result.volume()),
|
|
280
|
+
}
|
|
281
|
+
} finally {
|
|
282
|
+
result.delete()
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
shape.delete()
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Create the bridge, or null when the dist is absent/unloadable (the caller
|
|
291
|
+
* falls back to the mesh HLR). The heavy wasm load is lazy and cached.
|
|
292
|
+
*/
|
|
293
|
+
async function createOcctBridge(options = {}) {
|
|
294
|
+
const distDir = resolveDistDir(options.distDir)
|
|
295
|
+
if (distDir === null) return null
|
|
296
|
+
const mod = await loadModule(distDir)
|
|
297
|
+
if (typeof mod.hiddenLines !== 'function' || typeof mod.readStep !== 'function') {
|
|
298
|
+
throw new Error(`occt.ts dist at ${distDir} lacks hiddenLines/readStep`)
|
|
299
|
+
}
|
|
300
|
+
for (const required of ['shell', 'draft', 'describe', 'writeStep', 'tessellate']) {
|
|
301
|
+
if (typeof mod[required] !== 'function') {
|
|
302
|
+
throw new Error(`occt.ts dist at ${distDir} lacks ${required} (need occt.ts >= 0.3.0)`)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
kernel: 'occt.ts',
|
|
307
|
+
distDir,
|
|
308
|
+
occtVersion: mod.occtVersion(),
|
|
309
|
+
hiddenLineViews: (stepBytes, views) => hiddenLineViews(mod, stepBytes, views),
|
|
310
|
+
hostedSolidOp: (kind, stepBytes, params) => hostedSolidOp(mod, kind, stepBytes, params),
|
|
311
|
+
volumeOf: (stepBytes) => {
|
|
312
|
+
const shape = readShape(mod, stepBytes)
|
|
313
|
+
try {
|
|
314
|
+
return Math.abs(shape.volume())
|
|
315
|
+
} finally {
|
|
316
|
+
shape.delete()
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── polyline post-processing (shared formatting for projected segments) ─────
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Chain collinear-adjacent segments ([x1,y1,x2,y2] tuples) into polylines by
|
|
326
|
+
* quantized shared endpoints — turns the kernel's independent segment pairs
|
|
327
|
+
* into continuous strokes for the sheet renderer.
|
|
328
|
+
*/
|
|
329
|
+
function chainSegments(segments) {
|
|
330
|
+
const key = (x, y) => `${Math.round(x * 1000)}|${Math.round(y * 1000)}`
|
|
331
|
+
const map = new Map()
|
|
332
|
+
segments.forEach((seg, s) => {
|
|
333
|
+
const [x1, y1, x2, y2] = seg
|
|
334
|
+
for (const [k, end] of [[key(x1, y1), 0], [key(x2, y2), 1]]) {
|
|
335
|
+
let list = map.get(k)
|
|
336
|
+
if (list === undefined) { list = []; map.set(k, list) }
|
|
337
|
+
list.push({ s, end })
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
const used = new Uint8Array(segments.length)
|
|
341
|
+
const chains = []
|
|
342
|
+
for (let s = 0; s < segments.length; s++) {
|
|
343
|
+
if (used[s]) continue
|
|
344
|
+
used[s] = 1
|
|
345
|
+
const chain = [segments[s][0], segments[s][1], segments[s][2], segments[s][3]]
|
|
346
|
+
// Extend forward from the tail, then backward from the head.
|
|
347
|
+
for (const direction of [1, -1]) {
|
|
348
|
+
for (;;) {
|
|
349
|
+
const tailX = direction === 1 ? chain[chain.length - 2] : chain[0]
|
|
350
|
+
const tailY = direction === 1 ? chain[chain.length - 1] : chain[1]
|
|
351
|
+
const candidates = map.get(key(tailX, tailY)) ?? []
|
|
352
|
+
let found = null
|
|
353
|
+
for (const candidate of candidates) {
|
|
354
|
+
if (used[candidate.s]) continue
|
|
355
|
+
found = candidate
|
|
356
|
+
break
|
|
357
|
+
}
|
|
358
|
+
if (found === null) break
|
|
359
|
+
used[found.s] = 1
|
|
360
|
+
const [ex1, ey1, ex2, ey2] = segments[found.s]
|
|
361
|
+
// Append the far end oriented away from the join point.
|
|
362
|
+
const joinIsStart = key(ex1, ey1) === key(tailX, tailY)
|
|
363
|
+
const px = joinIsStart ? ex2 : ex1
|
|
364
|
+
const py = joinIsStart ? ey2 : ey1
|
|
365
|
+
if (direction === 1) chain.push(px, py)
|
|
366
|
+
else chain.unshift(px, py)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
chains.push(chain)
|
|
370
|
+
}
|
|
371
|
+
return chains
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Ramer–Douglas–Peucker on a flat [x,y,…] polyline. */
|
|
375
|
+
function simplify(points, epsilon) {
|
|
376
|
+
const n = points.length / 2
|
|
377
|
+
if (n < 3) return points
|
|
378
|
+
const keep = new Uint8Array(n)
|
|
379
|
+
keep[0] = 1
|
|
380
|
+
keep[n - 1] = 1
|
|
381
|
+
const stack = [[0, n - 1]]
|
|
382
|
+
while (stack.length > 0) {
|
|
383
|
+
const [first, last] = stack.pop()
|
|
384
|
+
const x1 = points[first * 2], y1 = points[first * 2 + 1]
|
|
385
|
+
const x2 = points[last * 2], y2 = points[last * 2 + 1]
|
|
386
|
+
const dx = x2 - x1, dy = y2 - y1
|
|
387
|
+
const len = Math.hypot(dx, dy)
|
|
388
|
+
let maxDist = 0
|
|
389
|
+
let index = -1
|
|
390
|
+
for (let i = first + 1; i < last; i++) {
|
|
391
|
+
const px = points[i * 2], py = points[i * 2 + 1]
|
|
392
|
+
const dist = len === 0 ? Math.hypot(px - x1, py - y1) : Math.abs(dy * px - dx * py + x2 * y1 - y2 * x1) / len
|
|
393
|
+
if (dist > maxDist) { maxDist = dist; index = i }
|
|
394
|
+
}
|
|
395
|
+
if (maxDist > epsilon && index > 0) {
|
|
396
|
+
keep[index] = 1
|
|
397
|
+
stack.push([first, index], [index, last])
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const out = []
|
|
401
|
+
for (let i = 0; i < n; i++) {
|
|
402
|
+
if (keep[i]) { out.push(points[i * 2], points[i * 2 + 1]) }
|
|
403
|
+
}
|
|
404
|
+
return out
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
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
|
+
}
|