dsh-cad 0.13.2 → 0.14.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.
@@ -166,8 +166,8 @@ async function applyOp(op) {
166
166
  }
167
167
  case 'loft': {
168
168
  const bodyId = op.bodyId
169
- const shape = adapter.makeLoft(op.sections, { solid: op.solid, ruled: op.ruled })
170
- assertUsable(shape, 'loft', '检查各截面是否闭合、点数是否合理')
169
+ const shape = adapter.makeLoft(op.sections, { solid: op.solid, ruled: op.ruled, smooth: op.smooth })
170
+ assertUsable(shape, 'loft', '检查各截面是否闭合、点数是否合理;平滑截面要求截面点共面且不重复')
171
171
  bodies.set(bodyId, { shape, name: op.name ?? bodyId })
172
172
  return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
173
173
  }
@@ -319,18 +319,105 @@ function createAdapter(occt) {
319
319
  return face
320
320
  }
321
321
 
322
+ /**
323
+ * Closed Catmull-Rom polyline through the given flat [x,y,z, …] points,
324
+ * sampled at `samples` chords per span. This build has no curve→edge
325
+ * binding, so the occt.ts backend's periodic B-spline section is carried
326
+ * here as a dense closed polyline (the same approximation the bspline
327
+ * profile segment uses) — the loft still smooths across sections.
328
+ */
329
+ function smoothLoop(points, samples = 16) {
330
+ if (!Array.isArray(points) || points.length < 9 || points.length % 3 !== 0) {
331
+ throw new Error('a section needs at least 3 [x,y,z] triplets (≥9 numbers)')
332
+ }
333
+ const n = points.length / 3
334
+ const at = (i) => {
335
+ const k = ((i % n) + n) % n
336
+ return [points[k * 3], points[k * 3 + 1], points[k * 3 + 2]]
337
+ }
338
+ const out = []
339
+ for (let i = 0; i < n; i++) {
340
+ const p0 = at(i - 1)
341
+ const p1 = at(i)
342
+ const p2 = at(i + 1)
343
+ const p3 = at(i + 2)
344
+ for (let s = 0; s < samples; s++) {
345
+ const t = s / samples
346
+ const t2 = t * t
347
+ const t3 = t2 * t
348
+ for (let axis = 0; axis < 3; axis++) {
349
+ out.push(0.5 * (
350
+ 2 * p1[axis]
351
+ + (-p0[axis] + p2[axis]) * t
352
+ + (2 * p0[axis] - 5 * p1[axis] + 4 * p2[axis] - p3[axis]) * t2
353
+ + (-p0[axis] + 3 * p1[axis] - 3 * p2[axis] + p3[axis]) * t3
354
+ ))
355
+ }
356
+ }
357
+ }
358
+ return closedWire(out)
359
+ }
360
+
361
+ const sub3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
362
+ const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
363
+ const cross3 = (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]]
364
+ const norm3 = (a) => {
365
+ const len = Math.hypot(a[0], a[1], a[2]) || 1
366
+ return [a[0] / len, a[1] / len, a[2] / len]
367
+ }
368
+
369
+ /** Plane frame (origin, in-plane X, in-plane Y) for a planar curve section. */
370
+ function sectionFrame(section) {
371
+ const at = section.at ?? [0, 0, 0]
372
+ if (!Array.isArray(at) || at.length !== 3) throw new Error('section.at must be [x,y,z]')
373
+ const n = norm3(Array.isArray(section.normal) ? section.normal : [0, 0, 1])
374
+ const seed = Array.isArray(section.xAxis) ? section.xAxis : [1, 0, 0]
375
+ let u = sub3(seed, n.map((c) => c * dot3(seed, n)))
376
+ if (Math.hypot(u[0], u[1], u[2]) < 1e-9) {
377
+ const alt = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
378
+ u = sub3(alt, n.map((c) => c * dot3(alt, n)))
379
+ }
380
+ u = norm3(u)
381
+ return [at, u, cross3(n, u)]
382
+ }
383
+
384
+ /**
385
+ * One loft section → a closed wire.
386
+ * [x,y,z, …] flat point loop (polygon, or smooth spline)
387
+ * { points: […], smooth?: bool } same, with a per-section override
388
+ * { through: [x,y,z, …] } smooth curve through the points
389
+ * { profile, at?, normal?, xAxis? } 2D curve profile placed in a plane
390
+ */
391
+ function sectionWire(section, smoothDefault) {
392
+ if (Array.isArray(section)) return smoothDefault ? smoothLoop(section) : closedWire(section)
393
+ if (section === null || typeof section !== 'object') {
394
+ throw new Error('a loft section must be a flat [x,y,z,…] loop or a {points|through|profile, …} object')
395
+ }
396
+ if (section.profile !== undefined) {
397
+ const [o3, u3, v3] = sectionFrame(section)
398
+ return profileWire(section.profile, o3, u3, v3)
399
+ }
400
+ if (section.through !== undefined) return smoothLoop(section.through)
401
+ if (section.points !== undefined) {
402
+ return (section.smooth ?? smoothDefault) ? smoothLoop(section.points) : closedWire(section.points)
403
+ }
404
+ throw new Error('a section object needs `points`, `through` or `profile`')
405
+ }
406
+
322
407
  /**
323
408
  * Loft: skin a solid through successive closed sections (OCCT ThruSections).
324
- * `sections` is a list of flat [x,y,z, …] loops, in order along the loft.
325
- * `solid` caps the ends, `ruled` keeps the sides straight (no smoothing).
409
+ * Sections are flat [x,y,z, …] loops or section objects (see sectionWire);
410
+ * `smooth` fits a periodic B-spline through plain point loops on the occt.ts
411
+ * backend, `solid` caps the ends, `ruled` keeps the sides straight.
326
412
  */
327
413
  function makeLoft(sections, options = {}) {
328
414
  if (!Array.isArray(sections) || sections.length < 2) throw new Error('a loft needs at least 2 sections')
329
415
  const solid = options.solid ?? true
330
416
  const ruled = options.ruled ?? false
417
+ const smooth = options.smooth === true
331
418
  // Verified spelling: this build binds only the 3-argument constructor.
332
419
  const thru = new occt.BRepOffsetAPI_ThruSections(solid, ruled, 1e-6)
333
- for (const section of sections) thru.AddWire(closedWire(section))
420
+ for (const section of sections) thru.AddWire(sectionWire(section, smooth))
334
421
  thru.Build()
335
422
  if (!thru.IsDone()) throw new Error('loft failed (check that the sections are closed and non-degenerate)')
336
423
  return thru.Shape()
@@ -174,9 +174,11 @@ function createOcctTsAdapter(mod) {
174
174
  const parts = new mod.VectorShape()
175
175
  try {
176
176
  let cur = [profile.start[0], profile.start[1]]
177
+ const pushed = []
177
178
  const push = (shape) => {
178
179
  if (shape.isNull()) throw wrapError('segment edge', new Error('null edge'))
179
180
  parts.push_back(shape)
181
+ pushed.push(shape)
180
182
  }
181
183
  for (const seg of segs) {
182
184
  if (seg === null || typeof seg !== 'object') throw new Error('each profile segment must be an object')
@@ -222,6 +224,12 @@ function createOcctTsAdapter(mod) {
222
224
  if (Math.hypot(cur[0] - profile.start[0], cur[1] - profile.start[1]) > 1e-9) {
223
225
  push(mod.makeLine(cur[0], cur[1], 0, profile.start[0], profile.start[1], 0))
224
226
  }
227
+ if (pushed.length === 1) {
228
+ // A single closed B-spline already IS a wire; makeWire needs ≥2 parts.
229
+ // (A lone edge cannot close a profile — say so instead of a kernel error.)
230
+ if (typeof pushed[0].shapeType === 'function' && pushed[0].shapeType() === 'Wire') return pushed[0]
231
+ throw new Error('a segment profile needs at least 2 segments (a single open edge cannot close a loop)')
232
+ }
225
233
  const wire = mod.makeWire(parts)
226
234
  if (wire.isNull()) throw wrapError('makeWire', new Error('null wire'))
227
235
  return wire
@@ -241,17 +249,82 @@ function createOcctTsAdapter(mod) {
241
249
  return solid
242
250
  }
243
251
 
244
- /** Loft through closed 3D section wires. */
252
+ // ── loft sections: point loops, smooth curves, planar profiles ─────────────
253
+ /**
254
+ * Closed wire through a flat [x,y,z, …] loop: a polygon (the historical
255
+ * section) or — with `smooth` — a PERIODIC B-spline interpolating every
256
+ * point. The periodic spline has no start/end vertex, so a loft skinned over
257
+ * it carries no crease lines: the lateral face is a single smooth B-spline
258
+ * surface instead of one planar strip per polygon edge.
259
+ */
260
+ function loopWire(xyz, smooth) {
261
+ if (!Array.isArray(xyz) || xyz.length < 9 || xyz.length % 3 !== 0) {
262
+ throw new Error('a section needs at least 3 [x,y,z] triplets (≥9 numbers)')
263
+ }
264
+ if (!smooth) return polygonWireXY(xyz)
265
+ const { ptr, free } = mallocF64(xyz)
266
+ try {
267
+ const wire = mod.makeBsplineThrough(ptr, xyz.length / 3, true, 1e-6)
268
+ if (wire.isNull()) throw wrapError('smooth section', new Error('periodic B-spline construction failed'))
269
+ return wire
270
+ } finally {
271
+ free()
272
+ }
273
+ }
274
+
275
+ /** Plane frame (origin, in-plane X, in-plane Y) for a planar curve section. */
276
+ function sectionFrame(section) {
277
+ const at = section.at ?? [0, 0, 0]
278
+ if (!Array.isArray(at) || at.length !== 3) throw new Error('section.at must be [x,y,z]')
279
+ const n = vec3.norm(Array.isArray(section.normal) ? section.normal : [0, 0, 1])
280
+ const seed = Array.isArray(section.xAxis) ? section.xAxis : [1, 0, 0]
281
+ const d = vec3.dot(seed, n)
282
+ let u = [seed[0] - n[0] * d, seed[1] - n[1] * d, seed[2] - n[2] * d]
283
+ if (Math.hypot(u[0], u[1], u[2]) < 1e-9) {
284
+ // xAxis parallel to the normal — fall back to a stable perpendicular.
285
+ const alt = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
286
+ const d2 = vec3.dot(alt, n)
287
+ u = [alt[0] - n[0] * d2, alt[1] - n[1] * d2, alt[2] - n[2] * d2]
288
+ }
289
+ u = vec3.norm(u)
290
+ // v = n × u so that u × v = n — profileWire's (u,v) → plane mapping stays
291
+ // right-handed and profile +Y points along the plane normal cross X.
292
+ return [at, u, vec3.cross(n, u)]
293
+ }
294
+
295
+ /**
296
+ * One loft section → a closed wire.
297
+ * [x,y,z, …] flat point loop (polygon, or smooth B-spline)
298
+ * { points: […], smooth?: bool } same, with a per-section override
299
+ * { through: [x,y,z, …] } periodic B-spline through the points
300
+ * { profile, at?, normal?, xAxis? } 2D curve profile placed in a plane
301
+ */
302
+ function sectionWire(section, smoothDefault) {
303
+ if (Array.isArray(section)) return loopWire(section, smoothDefault)
304
+ if (section === null || typeof section !== 'object') {
305
+ throw new Error('a loft section must be a flat [x,y,z,…] loop or a {points|through|profile, …} object')
306
+ }
307
+ if (section.profile !== undefined) {
308
+ const [o3, u3, v3] = sectionFrame(section)
309
+ return profileWire(section.profile, o3, u3, v3)
310
+ }
311
+ if (section.through !== undefined) return loopWire(section.through, true)
312
+ if (section.points !== undefined) return loopWire(section.points, section.smooth ?? smoothDefault)
313
+ throw new Error('a section object needs `points`, `through` or `profile`')
314
+ }
315
+
316
+ /**
317
+ * Loft through closed 3D sections. `smooth: true` turns plain point loops
318
+ * into periodic B-splines, which is what makes a crease-free skin: OCCT
319
+ * skins a B-spline surface through continuous sections, while polygon
320
+ * sections leave a G0 crease along every polygon edge.
321
+ */
245
322
  function makeLoft(sections, options = {}) {
246
323
  if (!Array.isArray(sections) || sections.length < 2) throw new Error('a loft needs at least 2 sections')
324
+ const smooth = options.smooth === true
247
325
  const v = new mod.VectorShape()
248
326
  try {
249
- for (const section of sections) {
250
- if (!Array.isArray(section) || section.length < 9 || section.length % 3 !== 0) {
251
- throw new Error('each section must be a flat [x,y,z,…] loop (≥9 numbers)')
252
- }
253
- v.push_back(polygonWireXY(section))
254
- }
327
+ for (const section of sections) v.push_back(sectionWire(section, smooth))
255
328
  const solid = mod.loft(v, options.ruled ?? false, options.solid ?? true)
256
329
  if (solid.isNull()) throw wrapError('loft', new Error('null shape'))
257
330
  if (Math.abs(volume(solid)) < 1e-9) {
package/lib/routes.js CHANGED
@@ -166,6 +166,11 @@ export function registerDocsRoute(server, registry, binStore) {
166
166
  name: doc.name,
167
167
  bodies: doc.bodyCount,
168
168
  updatedAt: doc.updatedAt,
169
+ // Op count: the panel's liveness signal. It polls this list and
170
+ // re-fetches the scene when the tracked document's version moves,
171
+ // so the viewport follows modeling ops even when the conversation
172
+ // meta pipeline is stale (host UI drift).
173
+ version: doc.opCount,
169
174
  // Preview URL only when a published scene exists (memory or mirror).
170
175
  ...(await binStore.has(doc.id) ? { sceneUrl: `${BIN_ROUTE_PATH}/${doc.id}` } : {}),
171
176
  })));
@@ -792,16 +792,18 @@ export function createModelTools(deps) {
792
792
  });
793
793
  const cadLoft = defineTool({
794
794
  name: 'cad_loft',
795
- description: 'Loft (放样): skin a solid through successive closed sections. `sections` is a list of ≥2 loops, each a flat [x0,y0,z0, x1,y1,z1, …] triplet list in its own plane (≥3 points, auto-closed), ordered along the loft. ' +
796
- 'Sections may differ in shape and point count (e.g. a square lofted to a hexagon). `ruled` keeps the sides straight instead of smoothing. ' +
795
+ description: 'Loft (放样): skin a solid through successive closed sections (≥2), ordered along the loft. Each section is either a flat [x0,y0,z0, x1,y1,z1, …] point loop, or an object: {points:[…], smooth?:true}, {through:[…]} (closed B-spline interpolating the points), or {profile, at?, normal?, xAxis?} — a 2D curve profile (flat [x,y], {start,segments:[line|arc|bspline]}, or {circle:{center,radius}}) laid in its own plane. ' +
796
+ 'For a CREASE-FREE surface pass `smooth: true` (or a curve section): a polygon-section loft is planar strips meeting at G0 creases, while a periodic B-spline / NURBS section skims ONE continuous B-spline lateral face. ' +
797
+ 'Sections may differ in shape and point count (e.g. a square lofted to a hexagon); `ruled` keeps the sides straight instead of smoothing. ' +
797
798
  'Each result passes a BRepCheck validity gate: a self-intersecting or degenerate loft is rejected with a clear message instead of storing an unusable body.',
798
799
  parameters: {
799
800
  sections: {
800
801
  type: 'array',
801
802
  required: true,
802
- description: 'Ordered sections, each a flat [x,y,z,…] loop (≥9 numbers).',
803
- items: { type: 'array', items: { type: 'number' }, description: 'One closed section loop.' },
803
+ description: 'Ordered sections (≥2): each a flat [x,y,z,…] loop (≥9 numbers) or a section object {points|through|profile, at?, normal?, xAxis?, smooth?}.',
804
+ items: { type: 'json', description: 'One closed section loop or section object.' },
804
805
  },
806
+ smooth: { type: 'boolean', description: 'Fit a periodic B-spline through each plain point loop — the crease-free "NURBS section" loft (default false).' },
805
807
  ruled: { type: 'boolean', description: 'Straight (ruled) sides instead of a smoothed surface (default false).' },
806
808
  solid: { type: 'boolean', description: 'Cap the ends into a solid (default true).' },
807
809
  name: { type: 'string', description: 'Optional display name.' },
@@ -824,8 +826,17 @@ export function createModelTools(deps) {
824
826
  await resolveDoc(exec);
825
827
  if (!Array.isArray(args.sections) || args.sections.length < 2)
826
828
  throw new Error('sections must list at least 2 closed loops');
829
+ for (const section of args.sections) {
830
+ if (Array.isArray(section)) {
831
+ if (section.length < 9 || section.length % 3 !== 0)
832
+ throw new Error('a point loop section needs ≥3 [x,y,z] triplets (≥9 numbers)');
833
+ }
834
+ else if (section === null || typeof section !== 'object') {
835
+ throw new Error('each section must be a flat [x,y,z,…] loop or a {points|through|profile, …} object');
836
+ }
837
+ }
827
838
  const bodyId = nextBodyId();
828
- const op = { kind: 'loft', bodyId, sections: args.sections, solid: args.solid, ruled: args.ruled, name: args.name };
839
+ const op = { kind: 'loft', bodyId, sections: args.sections, solid: args.solid, ruled: args.ruled, smooth: args.smooth, name: args.name };
829
840
  const result = await runModelOp(op);
830
841
  return syncScene(op, result);
831
842
  },
@@ -61,6 +61,23 @@ export interface DrawingViewSpec {
61
61
  dir: [number, number, number];
62
62
  xDir: [number, number, number];
63
63
  }
64
+ /** One loft section: a flat world-space loop, or a curve/profile section. */
65
+ export type LoftSection = number[] | {
66
+ /** Flat [x,y,z, …] loop (polygon, or a smooth B-spline when `smooth`). */
67
+ points?: number[];
68
+ /** Flat [x,y,z, …] points interpolated by a closed B-spline. */
69
+ through?: number[];
70
+ /** 2D profile (flat [x,y,…], {start,segments}, or {circle}) in the section plane. */
71
+ profile?: unknown;
72
+ /** Plane origin for `profile` (default world origin). */
73
+ at?: [number, number, number];
74
+ /** Plane normal for `profile` (default +Z). */
75
+ normal?: [number, number, number];
76
+ /** In-plane X axis for `profile` (default world +X projected). */
77
+ xAxis?: [number, number, number];
78
+ /** Override the loft-level `smooth` flag for this section. */
79
+ smooth?: boolean;
80
+ };
64
81
  export type ModelOp = {
65
82
  kind: 'create_prim';
66
83
  bodyId: string;
@@ -79,9 +96,10 @@ export type ModelOp = {
79
96
  } | {
80
97
  kind: 'loft';
81
98
  bodyId: string;
82
- sections: number[][];
99
+ sections: LoftSection[];
83
100
  solid?: boolean;
84
101
  ruled?: boolean;
102
+ smooth?: boolean;
85
103
  name?: string;
86
104
  } | {
87
105
  kind: 'revolve';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-cad",
3
3
  "description": "CAD visualization plugin for DeepSeek Harness: cad_view / cad_info tools with an embedded 3D (STL/OBJ/STEP/IGES) and 2D (DXF/SVG) viewer card in the Web UI",
4
- "version": "0.13.2",
4
+ "version": "0.14.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "LAU-MARS",