dsh-cad 0.2.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.
Files changed (113) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +190 -0
  3. package/README.zh-CN.md +173 -0
  4. package/cordis.patch.yml +3 -0
  5. package/lib/b64.js +12 -0
  6. package/lib/cad_connector/builtin-executor.js +55 -0
  7. package/lib/cad_connector/builtin.js +8 -0
  8. package/lib/cad_connector/executor.js +76 -0
  9. package/lib/cad_connector/freecad-executor.js +398 -0
  10. package/lib/cad_connector/freecad.js +8 -0
  11. package/lib/cad_connector/fusion360-executor.js +398 -0
  12. package/lib/cad_connector/fusion360.js +8 -0
  13. package/lib/cad_connector/gstarcad3d.js +8 -0
  14. package/lib/cad_connector/index.js +20 -0
  15. package/lib/cad_connector/onshape.js +8 -0
  16. package/lib/cad_connector/solidworks.js +8 -0
  17. package/lib/cad_connector/types.js +6 -0
  18. package/lib/cad_connector/zw3d.js +8 -0
  19. package/lib/client.js +4117 -0
  20. package/lib/client.js.map +7 -0
  21. package/lib/convert/dcprt.js +38 -0
  22. package/lib/convert/detect.js +22 -0
  23. package/lib/convert/dxf.js +124 -0
  24. package/lib/convert/index.js +70 -0
  25. package/lib/convert/obj.js +106 -0
  26. package/lib/convert/png.js +114 -0
  27. package/lib/convert/step-worker.mjs +61 -0
  28. package/lib/convert/step.js +66 -0
  29. package/lib/convert/stl.js +110 -0
  30. package/lib/convert/svg.js +101 -0
  31. package/lib/demo-bracket.brep +668 -0
  32. package/lib/demo-flange.brep +709 -0
  33. package/lib/demo-shaft.brep +516 -0
  34. package/lib/feature_script/dc_asm.js +17 -0
  35. package/lib/feature_script/dc_engineering.js +19 -0
  36. package/lib/feature_script/dc_prt.js +21 -0
  37. package/lib/feature_script/index.js +7 -0
  38. package/lib/image/profile.js +218 -0
  39. package/lib/index.js +77 -0
  40. package/lib/modeling/assembly.js +63 -0
  41. package/lib/modeling/bin-format.js +80 -0
  42. package/lib/modeling/bin-store.js +67 -0
  43. package/lib/modeling/client.js +117 -0
  44. package/lib/modeling/document.js +55 -0
  45. package/lib/modeling/drawing.js +324 -0
  46. package/lib/modeling/hlr.cjs +328 -0
  47. package/lib/modeling/modeling-worker.cjs +235 -0
  48. package/lib/modeling/occt-adapter.cjs +271 -0
  49. package/lib/routes.js +142 -0
  50. package/lib/store.js +68 -0
  51. package/lib/tools/cad-freecad.js +160 -0
  52. package/lib/tools/cad-fusion.js +138 -0
  53. package/lib/tools/cad-image.js +121 -0
  54. package/lib/tools/cad-info.js +92 -0
  55. package/lib/tools/cad-model.js +816 -0
  56. package/lib/tools/cad-view.js +112 -0
  57. package/lib/tools/util.js +25 -0
  58. package/lib/types/b64.d.ts +8 -0
  59. package/lib/types/cad/builtin.d.ts +3 -0
  60. package/lib/types/cad/freecad.d.ts +3 -0
  61. package/lib/types/cad/fusion360.d.ts +3 -0
  62. package/lib/types/cad/gstarcad3d.d.ts +3 -0
  63. package/lib/types/cad/index.d.ts +8 -0
  64. package/lib/types/cad/onshape.d.ts +3 -0
  65. package/lib/types/cad/solidworks.d.ts +3 -0
  66. package/lib/types/cad/types.d.ts +21 -0
  67. package/lib/types/cad/zw3d.d.ts +3 -0
  68. package/lib/types/cad_connector/builtin-executor.d.ts +9 -0
  69. package/lib/types/cad_connector/builtin.d.ts +3 -0
  70. package/lib/types/cad_connector/executor.d.ts +69 -0
  71. package/lib/types/cad_connector/freecad-executor.d.ts +55 -0
  72. package/lib/types/cad_connector/freecad.d.ts +3 -0
  73. package/lib/types/cad_connector/fusion360-executor.d.ts +11 -0
  74. package/lib/types/cad_connector/fusion360.d.ts +3 -0
  75. package/lib/types/cad_connector/gstarcad3d.d.ts +3 -0
  76. package/lib/types/cad_connector/index.d.ts +8 -0
  77. package/lib/types/cad_connector/onshape.d.ts +3 -0
  78. package/lib/types/cad_connector/solidworks.d.ts +3 -0
  79. package/lib/types/cad_connector/types.d.ts +21 -0
  80. package/lib/types/cad_connector/zw3d.d.ts +3 -0
  81. package/lib/types/convert/dcprt.d.ts +14 -0
  82. package/lib/types/convert/detect.d.ts +7 -0
  83. package/lib/types/convert/dxf.d.ts +3 -0
  84. package/lib/types/convert/index.d.ts +9 -0
  85. package/lib/types/convert/obj.d.ts +7 -0
  86. package/lib/types/convert/png.d.ts +8 -0
  87. package/lib/types/convert/step.d.ts +4 -0
  88. package/lib/types/convert/stl.d.ts +7 -0
  89. package/lib/types/convert/svg.d.ts +8 -0
  90. package/lib/types/feature_script/dc_asm.d.ts +40 -0
  91. package/lib/types/feature_script/dc_engineering.d.ts +55 -0
  92. package/lib/types/feature_script/dc_prt.d.ts +37 -0
  93. package/lib/types/feature_script/index.d.ts +7 -0
  94. package/lib/types/image/profile.d.ts +28 -0
  95. package/lib/types/index.d.ts +18 -0
  96. package/lib/types/modeling/assembly.d.ts +17 -0
  97. package/lib/types/modeling/bin-format.d.ts +42 -0
  98. package/lib/types/modeling/bin-store.d.ts +20 -0
  99. package/lib/types/modeling/client.d.ts +125 -0
  100. package/lib/types/modeling/document.d.ts +26 -0
  101. package/lib/types/modeling/drawing.d.ts +38 -0
  102. package/lib/types/routes.d.ts +27 -0
  103. package/lib/types/store.d.ts +18 -0
  104. package/lib/types/tools/cad-freecad.d.ts +10 -0
  105. package/lib/types/tools/cad-fusion.d.ts +8 -0
  106. package/lib/types/tools/cad-image.d.ts +8 -0
  107. package/lib/types/tools/cad-info.d.ts +6 -0
  108. package/lib/types/tools/cad-model.d.ts +13 -0
  109. package/lib/types/tools/cad-view.d.ts +10 -0
  110. package/lib/types/tools/util.d.ts +4 -0
  111. package/lib/types/types.d.ts +142 -0
  112. package/lib/types.js +25 -0
  113. package/package.json +91 -0
@@ -0,0 +1,218 @@
1
+ /** Otsu's threshold for a luma image. */
2
+ export function otsuThreshold(gray) {
3
+ const histogram = new Array(256).fill(0);
4
+ for (const value of gray)
5
+ histogram[value]++;
6
+ const total = gray.length;
7
+ let sum = 0;
8
+ for (let t = 0; t < 256; t++)
9
+ sum += t * histogram[t];
10
+ let sumB = 0;
11
+ let countB = 0;
12
+ let best = 0;
13
+ let bestThreshold = 127;
14
+ for (let t = 0; t < 256; t++) {
15
+ countB += histogram[t];
16
+ if (countB === 0)
17
+ continue;
18
+ const countF = total - countB;
19
+ if (countF === 0)
20
+ break;
21
+ sumB += t * histogram[t];
22
+ const meanB = sumB / countB;
23
+ const meanF = (sum - sumB) / countF;
24
+ const between = countB * countF * (meanB - meanF) * (meanB - meanF);
25
+ if (between > best) {
26
+ best = between;
27
+ bestThreshold = t;
28
+ }
29
+ }
30
+ return bestThreshold;
31
+ }
32
+ /** Connected components over a binary mask (4-connectivity, iterative flood). */
33
+ function components(mask, width, height) {
34
+ const labels = new Int32Array(mask.length).fill(-1);
35
+ const groups = [];
36
+ for (let start = 0; start < mask.length; start++) {
37
+ if (mask[start] !== 1 || labels[start] !== -1)
38
+ continue;
39
+ const group = [];
40
+ const stack = [start];
41
+ labels[start] = groups.length;
42
+ while (stack.length > 0) {
43
+ const index = stack.pop();
44
+ group.push(index);
45
+ const x = index % width;
46
+ const y = (index - x) / width;
47
+ if (x > 0 && mask[index - 1] === 1 && labels[index - 1] === -1) {
48
+ labels[index - 1] = groups.length;
49
+ stack.push(index - 1);
50
+ }
51
+ if (x < width - 1 && mask[index + 1] === 1 && labels[index + 1] === -1) {
52
+ labels[index + 1] = groups.length;
53
+ stack.push(index + 1);
54
+ }
55
+ if (y > 0 && mask[index - width] === 1 && labels[index - width] === -1) {
56
+ labels[index - width] = groups.length;
57
+ stack.push(index - width);
58
+ }
59
+ if (y < height - 1 && mask[index + width] === 1 && labels[index + width] === -1) {
60
+ labels[index + width] = groups.length;
61
+ stack.push(index + width);
62
+ }
63
+ }
64
+ groups.push(group);
65
+ }
66
+ return groups;
67
+ }
68
+ /** Moore-neighbor boundary trace of one labeled component (outer contour). */
69
+ function traceBoundary(labels, width, height, label, start) {
70
+ // 8 neighbors clockwise starting from east; backtrack is tracked as pixel
71
+ // coordinates and re-expressed relative to the current pixel each step.
72
+ const dirs = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]];
73
+ const at = (x, y) => {
74
+ if (x < 0 || y < 0 || x >= width || y >= height)
75
+ return -1;
76
+ return labels[y * width + x];
77
+ };
78
+ const dirIndex = (dx, dy) => {
79
+ for (let i = 0; i < 8; i++)
80
+ if (dirs[i][0] === dx && dirs[i][1] === dy)
81
+ return i;
82
+ return 0;
83
+ };
84
+ const sy = Math.floor(start / width);
85
+ const sx = start - sy * width;
86
+ // The scan found the leftmost foreground pixel of its row, so west is background.
87
+ let bx = sx - 1;
88
+ let by = sy;
89
+ const boundary = [sx, sy];
90
+ let px = sx;
91
+ let py = sy;
92
+ for (let step = 0, maxSteps = 8 * width * height + 16; step < maxSteps; step++) {
93
+ const origin = dirIndex(bx - px, by - py);
94
+ let found = -1;
95
+ for (let k = 1; k <= 8; k++) {
96
+ const d = (origin + k) % 8;
97
+ if (at(px + dirs[d][0], py + dirs[d][1]) === label) {
98
+ found = d;
99
+ break;
100
+ }
101
+ }
102
+ if (found === -1)
103
+ break; // isolated single pixel
104
+ const previous = (found + 7) % 8;
105
+ bx = px + dirs[previous][0];
106
+ by = py + dirs[previous][1];
107
+ px += dirs[found][0];
108
+ py += dirs[found][1];
109
+ if (px === sx && py === sy)
110
+ break;
111
+ boundary.push(px, py);
112
+ }
113
+ return boundary;
114
+ }
115
+ function perpendicularDistance(point, index, ax, ay, bx, by) {
116
+ const px = point[index];
117
+ const py = point[index + 1];
118
+ const dx = bx - ax;
119
+ const dy = by - ay;
120
+ const lengthSquared = dx * dx + dy * dy;
121
+ if (lengthSquared === 0)
122
+ return Math.hypot(px - ax, py - ay);
123
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
124
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
125
+ }
126
+ /** RDP over [from, to]; returns kept indices (inclusive). */
127
+ function rdpRange(boundary, from, to, tolerance) {
128
+ const keep = [];
129
+ const stack = [[from, to]];
130
+ keep.push(from, to);
131
+ while (stack.length > 0) {
132
+ const [start, end] = stack.pop();
133
+ let maxDistance = -1;
134
+ let split = -1;
135
+ const ax = boundary[start * 2];
136
+ const ay = boundary[start * 2 + 1];
137
+ const bx = boundary[end * 2];
138
+ const by = boundary[end * 2 + 1];
139
+ for (let i = start + 1; i < end; i++) {
140
+ const distance = perpendicularDistance(boundary, i * 2, ax, ay, bx, by);
141
+ if (distance > maxDistance) {
142
+ maxDistance = distance;
143
+ split = i;
144
+ }
145
+ }
146
+ if (maxDistance > tolerance && split !== -1) {
147
+ keep.push(split);
148
+ stack.push([start, split], [split, end]);
149
+ }
150
+ }
151
+ return keep.sort((a, b) => a - b);
152
+ }
153
+ /**
154
+ * Simplify a closed boundary: anchor RDP at the start and its farthest point
155
+ * (a mid-edge start anchor alone would let a nearby corner slip through).
156
+ */
157
+ function simplify(boundary, tolerance) {
158
+ // Close the ring explicitly so the second RDP range ends on the start point.
159
+ const closed = boundary.concat([boundary[0], boundary[1]]);
160
+ const count = closed.length / 2;
161
+ let far = 0;
162
+ let maxDistance = -1;
163
+ for (let i = 1; i < count - 1; i++) {
164
+ const distance = Math.hypot(closed[i * 2] - closed[0], closed[i * 2 + 1] - closed[1]);
165
+ if (distance > maxDistance) {
166
+ maxDistance = distance;
167
+ far = i;
168
+ }
169
+ }
170
+ const first = rdpRange(closed, 0, far, tolerance);
171
+ const second = rdpRange(closed, far, count - 1, tolerance);
172
+ const indices = [...first, ...second.filter((index) => index !== far && index !== count - 1)];
173
+ const out = [];
174
+ for (const index of indices)
175
+ out.push(closed[index * 2], closed[index * 2 + 1]);
176
+ return out;
177
+ }
178
+ /** Extract simplified outer contours from a luma image, largest first. */
179
+ export function extractProfiles(image, options = {}) {
180
+ const { width, height, gray } = image;
181
+ const threshold = options.threshold ?? otsuThreshold(gray);
182
+ const invert = options.invert ?? false;
183
+ const tolerance = options.tolerance ?? 1.5;
184
+ const scale = options.scale ?? 1;
185
+ const minArea = options.minArea ?? 24;
186
+ const mask = new Uint8Array(gray.length);
187
+ for (let i = 0; i < gray.length; i++) {
188
+ // Otsu's class split is "≤ T vs > T", so dark uses <=.
189
+ const dark = gray[i] <= threshold;
190
+ mask[i] = (invert ? !dark : dark) ? 1 : 0;
191
+ }
192
+ const labels = new Int32Array(mask.length).fill(-1);
193
+ const profiles = [];
194
+ for (const group of components(mask, width, height)) {
195
+ if (group.length < minArea)
196
+ continue;
197
+ const label = profiles.length + 1;
198
+ for (const index of group)
199
+ labels[index] = label;
200
+ const boundary = traceBoundary(labels, width, height, label, group[0]);
201
+ for (const index of group)
202
+ labels[index] = -1;
203
+ if (boundary.length < 6)
204
+ continue;
205
+ const points = simplify(boundary, tolerance);
206
+ if (points.length < 6)
207
+ continue;
208
+ // px (y down) → mm (y up)
209
+ const mm = new Array(points.length);
210
+ for (let i = 0; i < points.length; i += 2) {
211
+ mm[i] = points[i] * scale;
212
+ mm[i + 1] = (height - 1 - points[i + 1]) * scale;
213
+ }
214
+ profiles.push({ points: mm, area: group.length });
215
+ }
216
+ profiles.sort((a, b) => b.area - a.area);
217
+ return profiles;
218
+ }
package/lib/index.js ADDED
@@ -0,0 +1,77 @@
1
+ import { SceneStore } from './store.js';
2
+ import { BinarySceneStore } from './modeling/bin-store.js';
3
+ import { registerSceneRoute, registerBinRoute, registerDemoRoute } from './routes.js';
4
+ import { createCadViewTool } from './tools/cad-view.js';
5
+ import { createCadInfoTool } from './tools/cad-info.js';
6
+ import { createModelTools } from './tools/cad-model.js';
7
+ import { createFreeCadTool } from './tools/cad-freecad.js';
8
+ import { createFusionTool } from './tools/cad-fusion.js';
9
+ import { createCadImageTool } from './tools/cad-image.js';
10
+ export const name = 'dsh-cad';
11
+ /** Hard dependencies: the tool registry. */
12
+ export const inject = ['tools'];
13
+ export function apply(ctx, config = {}) {
14
+ const root = config.root ?? process.cwd();
15
+ const store = new SceneStore(root);
16
+ const binStore = new BinarySceneStore(root);
17
+ const workspaceRoot = process.cwd();
18
+ let routeRegistered = false;
19
+ /** Idempotently register the scene routes; returns the JSON base or null. */
20
+ const ensureSceneRoute = () => {
21
+ if (routeRegistered)
22
+ return '/dsh-cad/scene';
23
+ const scope = ctx;
24
+ const server = (scope.get?.('webServer') ?? scope.get?.('httpServer'));
25
+ if (server === undefined)
26
+ return null;
27
+ registerSceneRoute(server, store);
28
+ registerBinRoute(server, binStore);
29
+ registerDemoRoute(server);
30
+ routeRegistered = true;
31
+ return '/dsh-cad/scene';
32
+ };
33
+ // The demo route backs the CAD editor's startup fetch (before any tool
34
+ // call), so registration must not wait for the first tool use. The web
35
+ // composition may provide the HTTP server after our activation, so retry
36
+ // briefly; fiber ordering decides the exact moment.
37
+ let attempts = 0;
38
+ const retry = setInterval(() => {
39
+ if (routeRegistered || ++attempts > 200) {
40
+ clearInterval(retry);
41
+ return;
42
+ }
43
+ try {
44
+ ensureSceneRoute();
45
+ }
46
+ catch {
47
+ /* the lazy path (first tool use) still covers late availability */
48
+ }
49
+ }, 100);
50
+ try {
51
+ ensureSceneRoute();
52
+ }
53
+ catch {
54
+ /* registers lazily at first tool use */
55
+ }
56
+ const cadView = createCadViewTool({ store, workspaceRoot, ensureSceneRoute });
57
+ const cadInfo = createCadInfoTool({ workspaceRoot });
58
+ const modelTools = createModelTools({ store: binStore, sceneStore: store, workspaceRoot, ensureSceneRoute });
59
+ const cadFreeCad = createFreeCadTool({ store: binStore, workspaceRoot, ensureSceneRoute });
60
+ const cadFusion = createFusionTool({ store: binStore, workspaceRoot, ensureSceneRoute });
61
+ const cadImage = createCadImageTool({ store, workspaceRoot, ensureSceneRoute });
62
+ const disposers = [
63
+ ctx.tools.register(cadView),
64
+ ctx.tools.register(cadInfo),
65
+ ...modelTools.map((tool) => ctx.tools.register(tool)),
66
+ ctx.tools.register(cadFreeCad),
67
+ ctx.tools.register(cadFusion),
68
+ ctx.tools.register(cadImage),
69
+ ];
70
+ ctx.effect(() => {
71
+ return () => {
72
+ clearInterval(retry);
73
+ for (const dispose of disposers)
74
+ dispose();
75
+ };
76
+ });
77
+ }
@@ -0,0 +1,63 @@
1
+ function rotationMatrix(rotate) {
2
+ const toRad = (deg) => (deg * Math.PI) / 180;
3
+ const [rx, ry, rz] = rotate.map(toRad);
4
+ const cx = Math.cos(rx), sx = Math.sin(rx);
5
+ const cy = Math.cos(ry), sy = Math.sin(ry);
6
+ const cz = Math.cos(rz), sz = Math.sin(rz);
7
+ // Row-major 3×3: Rx·Ry·Rz
8
+ return new Float64Array([
9
+ cy * cz, -cy * sz, sy,
10
+ cx * sz + sx * sy * cz, cx * cz - sx * sy * sz, -sx * cy,
11
+ sx * sz - cx * sy * cz, sx * cz + cx * sy * sz, cx * cy,
12
+ ]);
13
+ }
14
+ function transformMesh(mesh, translate, rotate) {
15
+ const isIdentity = translate.every((v) => v === 0) && rotate.every((v) => v === 0);
16
+ if (isIdentity)
17
+ return mesh;
18
+ const m = rotationMatrix(rotate);
19
+ const [tx, ty, tz] = translate;
20
+ const positions = new Float32Array(mesh.positions.length);
21
+ for (let i = 0; i + 2 < mesh.positions.length; i += 3) {
22
+ const x = mesh.positions[i];
23
+ const y = mesh.positions[i + 1];
24
+ const z = mesh.positions[i + 2];
25
+ positions[i] = m[0] * x + m[1] * y + m[2] * z + tx;
26
+ positions[i + 1] = m[3] * x + m[4] * y + m[5] * z + ty;
27
+ positions[i + 2] = m[6] * x + m[7] * y + m[8] * z + tz;
28
+ }
29
+ // Normals rotate with the same linear part (translation never applies).
30
+ let normals;
31
+ if (mesh.normals !== undefined) {
32
+ normals = new Float32Array(mesh.normals.length);
33
+ for (let i = 0; i + 2 < mesh.normals.length; i += 3) {
34
+ const x = mesh.normals[i];
35
+ const y = mesh.normals[i + 1];
36
+ const z = mesh.normals[i + 2];
37
+ normals[i] = m[0] * x + m[1] * y + m[2] * z;
38
+ normals[i + 1] = m[3] * x + m[4] * y + m[5] * z;
39
+ normals[i + 2] = m[6] * x + m[7] * y + m[8] * z;
40
+ }
41
+ }
42
+ return { ...mesh, positions, normals };
43
+ }
44
+ /**
45
+ * Compose the assembly scene: one transformed mesh copy per instance.
46
+ * Instances referencing bodies missing from `bodies` (consumed by a later
47
+ * boolean) are skipped — the worker's live list already filters them, this is
48
+ * the replay-side double guard. Duplicate names get a ·N suffix.
49
+ */
50
+ export function composeAssemblyMeshes(bodies, instances) {
51
+ const meshes = [];
52
+ const nameUse = new Map();
53
+ for (const instance of instances) {
54
+ const body = bodies.get(instance.bodyId);
55
+ if (body === undefined)
56
+ continue;
57
+ const seen = nameUse.get(instance.name) ?? 0;
58
+ nameUse.set(instance.name, seen + 1);
59
+ const name = seen === 0 ? instance.name : `${instance.name}·${seen + 1}`;
60
+ meshes.push(transformMesh({ ...body, name }, instance.translate, instance.rotate));
61
+ }
62
+ return meshes;
63
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Binary scene format: the direct worker→three.js transport. One packed
3
+ * buffer (JSON header + raw f32/u32 payloads) that the browser decodes with
4
+ * typed-array views — no base64, no JSON number arrays, no intermediate file
5
+ * unless the debounced disk mirror or an explicit cad_export happens.
6
+ *
7
+ * Layout (little-endian):
8
+ * [4B magic "DCB1"][4B headerByteLength][header JSON utf8][payload bytes]
9
+ * Header: { meshes: [{ name, color?, vertexCount, triangleCount,
10
+ * posOffset, posBytes, nrmOffset?, nrmBytes, idxOffset, idxBytes }],
11
+ * bounds: { min: [x,y,z], max: [x,y,z] }, units }
12
+ * Offsets/lengths are byte counts relative to the payload start.
13
+ */
14
+ const MAGIC = 0x31424344; // "DCB1" little-endian
15
+ /** Pack meshes into the binary transport buffer. */
16
+ export function packBinaryScene(meshes) {
17
+ const header = {
18
+ meshes: [],
19
+ bounds: { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] },
20
+ units: 'mm',
21
+ };
22
+ let payloadBytes = 0;
23
+ for (const mesh of meshes) {
24
+ const posBytes = mesh.positions.byteLength;
25
+ const nrmBytes = mesh.normals === undefined ? 0 : mesh.normals.byteLength;
26
+ const idxBytes = mesh.indices.byteLength;
27
+ const desc = {
28
+ name: mesh.name,
29
+ ...(mesh.color === undefined ? {} : { color: mesh.color }),
30
+ vertexCount: mesh.positions.length / 3,
31
+ triangleCount: mesh.indices.length / 3,
32
+ posOffset: payloadBytes,
33
+ posBytes,
34
+ ...(mesh.normals === undefined ? {} : { nrmOffset: payloadBytes + posBytes, nrmBytes }),
35
+ idxOffset: payloadBytes + posBytes + nrmBytes,
36
+ idxBytes,
37
+ };
38
+ header.meshes.push(desc);
39
+ payloadBytes += posBytes + nrmBytes + idxBytes;
40
+ for (let i = 0; i < mesh.positions.length; i += 3) {
41
+ const x = mesh.positions[i] ?? 0;
42
+ const y = mesh.positions[i + 1] ?? 0;
43
+ const z = mesh.positions[i + 2] ?? 0;
44
+ if (x < header.bounds.min[0])
45
+ header.bounds.min[0] = x;
46
+ if (y < header.bounds.min[1])
47
+ header.bounds.min[1] = y;
48
+ if (z < header.bounds.min[2])
49
+ header.bounds.min[2] = z;
50
+ if (x > header.bounds.max[0])
51
+ header.bounds.max[0] = x;
52
+ if (y > header.bounds.max[1])
53
+ header.bounds.max[1] = y;
54
+ if (z > header.bounds.max[2])
55
+ header.bounds.max[2] = z;
56
+ }
57
+ }
58
+ if (!Number.isFinite(header.bounds.min[0])) {
59
+ header.bounds = { min: [0, 0, 0], max: [1, 1, 1] };
60
+ }
61
+ const headerBuffer = Buffer.from(JSON.stringify(header), 'utf8');
62
+ // Pad the header so the payload starts 8-byte aligned (typed-array views).
63
+ const paddedHeaderLength = Math.ceil(headerBuffer.length / 8) * 8;
64
+ const out = Buffer.alloc(8 + paddedHeaderLength + payloadBytes);
65
+ out.writeUInt32LE(MAGIC, 0);
66
+ out.writeUInt32LE(paddedHeaderLength, 4);
67
+ headerBuffer.copy(out, 8);
68
+ let cursor = 8 + paddedHeaderLength;
69
+ for (const mesh of meshes) {
70
+ Buffer.from(mesh.positions.buffer, mesh.positions.byteOffset, mesh.positions.byteLength).copy(out, cursor);
71
+ cursor += mesh.positions.byteLength;
72
+ if (mesh.normals !== undefined) {
73
+ Buffer.from(mesh.normals.buffer, mesh.normals.byteOffset, mesh.normals.byteLength).copy(out, cursor);
74
+ cursor += mesh.normals.byteLength;
75
+ }
76
+ Buffer.from(mesh.indices.buffer, mesh.indices.byteOffset, mesh.indices.byteLength).copy(out, cursor);
77
+ cursor += mesh.indices.byteLength;
78
+ }
79
+ return out;
80
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * In-memory binary scene store: viewId → packed buffer + version. The binary
3
+ * route serves straight from memory; a debounced disk mirror keeps restart
4
+ * replay working without paying a file write on every modeling step.
5
+ */
6
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+ import { packBinaryScene } from './bin-format.js';
9
+ const DISK_MIRROR_DELAY_MS = 1500;
10
+ export class BinarySceneStore {
11
+ root;
12
+ memory = new Map();
13
+ diskTimers = new Map();
14
+ constructor(root) {
15
+ this.root = root;
16
+ }
17
+ get directory() {
18
+ return path.join(this.root, '.dsh-cad', 'bins');
19
+ }
20
+ /** Publish a new version under a stable viewId; returns the version. */
21
+ async publish(viewId, meshes) {
22
+ const previous = this.memory.get(viewId);
23
+ const version = (previous?.version ?? 0) + 1;
24
+ const buffer = packBinaryScene(meshes);
25
+ const etag = `"v${version}-${buffer.length}"`;
26
+ this.memory.set(viewId, { buffer, version, etag });
27
+ this.scheduleDiskMirror(viewId, buffer);
28
+ return version;
29
+ }
30
+ /** Fetch for serving: memory first, disk mirror as the restart fallback. */
31
+ async get(viewId) {
32
+ if (!/^[0-9a-zA-Z_-]{1,64}$/.test(viewId))
33
+ return null;
34
+ const memoryHit = this.memory.get(viewId);
35
+ if (memoryHit !== undefined)
36
+ return memoryHit;
37
+ try {
38
+ const buffer = await readFile(path.join(this.directory, `${viewId}.bin`));
39
+ // Mirror entries serve replay; the version rides in the etag only.
40
+ const entry = { buffer, version: 1, etag: `"d${buffer.length}"` };
41
+ this.memory.set(viewId, entry);
42
+ return entry;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** Debounced disk mirror: one write after modeling quiesces, not per step. */
49
+ scheduleDiskMirror(viewId, buffer) {
50
+ const existing = this.diskTimers.get(viewId);
51
+ if (existing !== undefined)
52
+ clearTimeout(existing);
53
+ const timer = setTimeout(() => {
54
+ this.diskTimers.delete(viewId);
55
+ void (async () => {
56
+ try {
57
+ await mkdir(this.directory, { recursive: true });
58
+ await writeFile(path.join(this.directory, `${viewId}.bin`), buffer);
59
+ }
60
+ catch {
61
+ // The mirror is best-effort; the in-memory copy keeps serving.
62
+ }
63
+ })();
64
+ }, DISK_MIRROR_DELAY_MS);
65
+ this.diskTimers.set(viewId, timer);
66
+ }
67
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Main-thread modeling client: one lazily spawned OCCT worker per client,
3
+ * job correlation, and transferable buffers — the same shape as
4
+ * convert/step.ts. The module default is a shared singleton (the live
5
+ * modeling session); isolated conversions (e.g. .dcprt replay) spawn their
6
+ * own client via createModelClient() and dispose() it when done, so they
7
+ * never clobber the session state.
8
+ */
9
+ import { Worker } from 'node:worker_threads';
10
+ import { fileURLToPath } from 'node:url';
11
+ export function createModelClient() {
12
+ let worker = null;
13
+ let ready = null;
14
+ let disposed = false;
15
+ let nextJobId = 1;
16
+ const pending = new Map();
17
+ function spawn() {
18
+ const spawned = new Worker(new URL('./modeling-worker.cjs', import.meta.url));
19
+ const readyPromise = new Promise((resolve, reject) => {
20
+ const timer = setTimeout(() => reject(new Error('modeling kernel startup timed out')), 60_000);
21
+ const onReady = (message) => {
22
+ clearTimeout(timer);
23
+ if (message.ok)
24
+ resolve();
25
+ else
26
+ reject(new Error(message.error ?? 'modeling kernel init failed'));
27
+ };
28
+ spawned.once('message', onReady);
29
+ });
30
+ spawned.on('message', (response) => {
31
+ const job = pending.get(response.jobId);
32
+ if (job === undefined)
33
+ return;
34
+ pending.delete(response.jobId);
35
+ clearTimeout(job.timer);
36
+ if (response.ok && response.result !== undefined)
37
+ job.resolve(response.result);
38
+ else
39
+ job.reject(new Error(response.error ?? 'modeling operation failed'));
40
+ });
41
+ spawned.on('error', (error) => failAll(error));
42
+ spawned.on('exit', (code) => {
43
+ if (code !== 0)
44
+ failAll(new Error(`the modeling worker exited unexpectedly (code ${code})`));
45
+ worker = null;
46
+ ready = null;
47
+ });
48
+ return { worker: spawned, ready: readyPromise };
49
+ }
50
+ function failAll(error) {
51
+ for (const [jobId, job] of pending) {
52
+ pending.delete(jobId);
53
+ clearTimeout(job.timer);
54
+ job.reject(error);
55
+ }
56
+ }
57
+ async function ensureWorker() {
58
+ if (worker !== null && ready !== null) {
59
+ await ready;
60
+ return worker;
61
+ }
62
+ const spawned = spawn();
63
+ worker = spawned.worker;
64
+ ready = spawned.ready;
65
+ await ready;
66
+ return worker;
67
+ }
68
+ return {
69
+ async run(op, timeoutMs = 180_000) {
70
+ if (disposed)
71
+ throw new Error('modeling client has been disposed');
72
+ const active = await ensureWorker();
73
+ const jobId = nextJobId++;
74
+ return new Promise((resolve, reject) => {
75
+ const timer = setTimeout(() => {
76
+ pending.delete(jobId);
77
+ reject(new Error(`modeling operation timed out after ${timeoutMs}ms`));
78
+ }, timeoutMs);
79
+ pending.set(jobId, { resolve, reject, timer });
80
+ active.postMessage({ jobId, op });
81
+ });
82
+ },
83
+ dispose() {
84
+ if (disposed)
85
+ return;
86
+ disposed = true;
87
+ failAll(new Error('modeling client disposed'));
88
+ if (worker !== null)
89
+ void worker.terminate();
90
+ worker = null;
91
+ ready = null;
92
+ },
93
+ };
94
+ }
95
+ const sharedClient = createModelClient();
96
+ /**
97
+ * Monotonic count of `reset` ops posted through the shared worker. Any
98
+ * out-of-band replay (e.g. opening a .dcprt) resets the worker and bumps the
99
+ * epoch, so the modeling session owner can detect staleness and re-replay
100
+ * its document before the next op.
101
+ */
102
+ let resetEpoch = 0;
103
+ /** Run one modeling operation on the shared session worker. */
104
+ export async function runModelOp(op, timeoutMs = 180_000) {
105
+ const result = await sharedClient.run(op, timeoutMs);
106
+ if (op.kind === 'reset')
107
+ resetEpoch += 1;
108
+ return result;
109
+ }
110
+ /** Current reset epoch of the shared worker (see above). */
111
+ export function workerResetEpoch() {
112
+ return resetEpoch;
113
+ }
114
+ /** Test/Dev helper: hard worker file path (used by the vitest suite). */
115
+ export function workerEntryPath() {
116
+ return fileURLToPath(new URL('./modeling-worker.cjs', import.meta.url));
117
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Modeling document: an operation log (JSON) per workspace that both persists
3
+ * across restarts and is the unit of replay — restart recovery re-applies the
4
+ * log to a fresh worker, which is what makes the worker's in-memory shapes
5
+ * disposable.
6
+ */
7
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { randomUUID } from 'node:crypto';
10
+ export class ModelDocument {
11
+ root;
12
+ doc = { docId: randomUUID(), version: 0, ops: [], bodyNames: {} };
13
+ constructor(root) {
14
+ this.root = root;
15
+ }
16
+ get directory() {
17
+ return path.join(this.root, '.dsh-cad');
18
+ }
19
+ get file() {
20
+ return path.join(this.directory, 'model.json');
21
+ }
22
+ /** Load the persisted document if one exists. */
23
+ async restore() {
24
+ try {
25
+ const text = await readFile(this.file, 'utf8');
26
+ const parsed = JSON.parse(text);
27
+ if (typeof parsed.docId === 'string' && Array.isArray(parsed.ops)) {
28
+ this.doc = { ...parsed, version: parsed.version ?? 0, bodyNames: parsed.bodyNames ?? {} };
29
+ }
30
+ }
31
+ catch {
32
+ // No document yet — a fresh one stays in memory until the first op.
33
+ }
34
+ }
35
+ /** Append an applied operation and persist. */
36
+ async record(op, bodyName) {
37
+ this.doc.ops.push(op);
38
+ this.doc.version += 1;
39
+ if (bodyName !== null)
40
+ this.doc.bodyNames[bodyName.bodyId] = bodyName.name;
41
+ if (op.kind === 'delete' || op.kind === 'boolean') {
42
+ const removed = op.kind === 'delete' ? [op.target] : op.tools;
43
+ for (const id of removed)
44
+ delete this.doc.bodyNames[id];
45
+ }
46
+ await mkdir(this.directory, { recursive: true });
47
+ await writeFile(this.file, JSON.stringify(this.doc));
48
+ }
49
+ /** Clear the document (cad_new / tests). */
50
+ async clear() {
51
+ this.doc = { docId: randomUUID(), version: 0, ops: [], bodyNames: {} };
52
+ await mkdir(this.directory, { recursive: true });
53
+ await writeFile(this.file, JSON.stringify(this.doc));
54
+ }
55
+ }