@constructorfleet/sketchforge-engine 1.26.0 → 1.28.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 (200) hide show
  1. package/dist/cadAnalysis.d.ts +94 -0
  2. package/dist/cadAnalysis.d.ts.map +1 -0
  3. package/dist/cadAnalysis.js +157 -0
  4. package/dist/cadAnalysis.js.map +1 -0
  5. package/dist/cadBakeMetadata.d.ts +11 -2
  6. package/dist/cadBakeMetadata.d.ts.map +1 -1
  7. package/dist/cadBakeMetadata.js +9 -0
  8. package/dist/cadBakeMetadata.js.map +1 -1
  9. package/dist/cadProjection.d.ts.map +1 -1
  10. package/dist/cadProjection.js.map +1 -1
  11. package/dist/cadSection.d.ts.map +1 -1
  12. package/dist/cadShell.d.ts +143 -0
  13. package/dist/cadShell.d.ts.map +1 -0
  14. package/dist/cadShell.js +313 -0
  15. package/dist/cadShell.js.map +1 -0
  16. package/dist/cadSolids.d.ts.map +1 -1
  17. package/dist/cadSolids.js +18 -1
  18. package/dist/cadSolids.js.map +1 -1
  19. package/dist/cutBrepFrameMigration.d.ts +46 -0
  20. package/dist/cutBrepFrameMigration.d.ts.map +1 -0
  21. package/dist/cutBrepFrameMigration.js +105 -0
  22. package/dist/cutBrepFrameMigration.js.map +1 -0
  23. package/dist/cutterPadding.d.ts.map +1 -1
  24. package/dist/domain/cameraProjection.js.map +1 -1
  25. package/dist/domain/sketchforge.d.ts +31 -1
  26. package/dist/domain/sketchforge.d.ts.map +1 -1
  27. package/dist/editorHistory.d.ts.map +1 -1
  28. package/dist/editorHistory.js.map +1 -1
  29. package/dist/engine.d.ts +3 -3
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +156 -78
  32. package/dist/engine.js.map +1 -1
  33. package/dist/exactBooleanParts.d.ts +4 -2
  34. package/dist/exactBooleanParts.d.ts.map +1 -1
  35. package/dist/exactBooleanParts.js +7 -3
  36. package/dist/exactBooleanParts.js.map +1 -1
  37. package/dist/exactBooleanSolids.d.ts.map +1 -1
  38. package/dist/gearProfile.d.ts.map +1 -1
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/loftProfile.d.ts +1 -1
  44. package/dist/loftProfile.d.ts.map +1 -1
  45. package/dist/loftProfile.js.map +1 -1
  46. package/dist/meshEdgeProjection.d.ts +104 -0
  47. package/dist/meshEdgeProjection.d.ts.map +1 -0
  48. package/dist/meshEdgeProjection.js +439 -0
  49. package/dist/meshEdgeProjection.js.map +1 -0
  50. package/dist/meshImport.d.ts.map +1 -1
  51. package/dist/meshImport.js +2 -20
  52. package/dist/meshImport.js.map +1 -1
  53. package/dist/meshMassProperties.d.ts +92 -0
  54. package/dist/meshMassProperties.d.ts.map +1 -0
  55. package/dist/meshMassProperties.js +570 -0
  56. package/dist/meshMassProperties.js.map +1 -0
  57. package/dist/meshSection.d.ts +44 -0
  58. package/dist/meshSection.d.ts.map +1 -0
  59. package/dist/meshSection.js +154 -0
  60. package/dist/meshSection.js.map +1 -0
  61. package/dist/meshShellOverlap.d.ts +75 -0
  62. package/dist/meshShellOverlap.d.ts.map +1 -0
  63. package/dist/meshShellOverlap.js +797 -0
  64. package/dist/meshShellOverlap.js.map +1 -0
  65. package/dist/meshShellSolids.d.ts +44 -0
  66. package/dist/meshShellSolids.d.ts.map +1 -0
  67. package/dist/meshShellSolids.js +116 -0
  68. package/dist/meshShellSolids.js.map +1 -0
  69. package/dist/nodeSvgDomParser.d.ts +3 -0
  70. package/dist/nodeSvgDomParser.d.ts.map +1 -0
  71. package/dist/nodeSvgDomParser.js +53 -0
  72. package/dist/nodeSvgDomParser.js.map +1 -0
  73. package/dist/parametricRecompute.d.ts.map +1 -1
  74. package/dist/parametricRecompute.js +63 -10
  75. package/dist/parametricRecompute.js.map +1 -1
  76. package/dist/placementWorkplane.d.ts.map +1 -1
  77. package/dist/placementWorkplane.js.map +1 -1
  78. package/dist/projectAssets.d.ts +1 -1
  79. package/dist/projectStore.d.ts +1 -1
  80. package/dist/projectStore.d.ts.map +1 -1
  81. package/dist/projectStore.js +23 -2
  82. package/dist/projectStore.js.map +1 -1
  83. package/dist/revolveRequests.d.ts +57 -1
  84. package/dist/revolveRequests.d.ts.map +1 -1
  85. package/dist/revolveRequests.js +103 -4
  86. package/dist/revolveRequests.js.map +1 -1
  87. package/dist/sceneExport.d.ts +6 -0
  88. package/dist/sceneExport.d.ts.map +1 -1
  89. package/dist/sceneExport.js +6 -2
  90. package/dist/sceneExport.js.map +1 -1
  91. package/dist/serverCadInterference.d.ts +8 -45
  92. package/dist/serverCadInterference.d.ts.map +1 -1
  93. package/dist/serverCadInterference.js +21 -43
  94. package/dist/serverCadInterference.js.map +1 -1
  95. package/dist/serverCadOperations.d.ts +73 -4
  96. package/dist/serverCadOperations.d.ts.map +1 -1
  97. package/dist/serverCadOperations.js +356 -71
  98. package/dist/serverCadOperations.js.map +1 -1
  99. package/dist/serverCadProjection.d.ts +14 -1
  100. package/dist/serverCadProjection.d.ts.map +1 -1
  101. package/dist/serverCadProjection.js +135 -13
  102. package/dist/serverCadProjection.js.map +1 -1
  103. package/dist/serverCadProperties.d.ts +14 -7
  104. package/dist/serverCadProperties.d.ts.map +1 -1
  105. package/dist/serverCadProperties.js +60 -24
  106. package/dist/serverCadProperties.js.map +1 -1
  107. package/dist/serverCadSection.d.ts +21 -1
  108. package/dist/serverCadSection.d.ts.map +1 -1
  109. package/dist/serverCadSection.js +157 -43
  110. package/dist/serverCadSection.js.map +1 -1
  111. package/dist/serverCadShell.d.ts +7 -94
  112. package/dist/serverCadShell.d.ts.map +1 -1
  113. package/dist/serverCadShell.js +25 -272
  114. package/dist/serverCadShell.js.map +1 -1
  115. package/dist/serverCadThickness.d.ts +8 -8
  116. package/dist/serverCadThickness.d.ts.map +1 -1
  117. package/dist/serverCadThickness.js +15 -42
  118. package/dist/serverCadThickness.js.map +1 -1
  119. package/dist/serverShapeBooleans.d.ts +75 -0
  120. package/dist/serverShapeBooleans.d.ts.map +1 -1
  121. package/dist/serverShapeBooleans.js +251 -9
  122. package/dist/serverShapeBooleans.js.map +1 -1
  123. package/dist/serverShapeGroups.js.map +1 -1
  124. package/dist/serverStepExplode.d.ts +1 -2
  125. package/dist/serverStepExplode.d.ts.map +1 -1
  126. package/dist/serverStepExplode.js +15 -148
  127. package/dist/serverStepExplode.js.map +1 -1
  128. package/dist/shapeBounds.d.ts.map +1 -1
  129. package/dist/shapeCatalog.d.ts.map +1 -1
  130. package/dist/shapeCatalog.js +1 -0
  131. package/dist/shapeCatalog.js.map +1 -1
  132. package/dist/shapeRequests.d.ts +13 -0
  133. package/dist/shapeRequests.d.ts.map +1 -1
  134. package/dist/shapeRequests.js +14 -0
  135. package/dist/shapeRequests.js.map +1 -1
  136. package/dist/sketchCadProfile.d.ts +13 -0
  137. package/dist/sketchCadProfile.d.ts.map +1 -1
  138. package/dist/sketchCadProfile.js +108 -0
  139. package/dist/sketchCadProfile.js.map +1 -1
  140. package/dist/sketchCadSweep.js.map +1 -1
  141. package/dist/sketchEdgeSegmentLineage.d.ts +32 -0
  142. package/dist/sketchEdgeSegmentLineage.d.ts.map +1 -0
  143. package/dist/sketchEdgeSegmentLineage.js +49 -0
  144. package/dist/sketchEdgeSegmentLineage.js.map +1 -0
  145. package/dist/sketchEdgeSegmentLineageKernel.d.ts +83 -0
  146. package/dist/sketchEdgeSegmentLineageKernel.d.ts.map +1 -0
  147. package/dist/sketchEdgeSegmentLineageKernel.js +125 -0
  148. package/dist/sketchEdgeSegmentLineageKernel.js.map +1 -0
  149. package/dist/sketchFaceDraftKernel.d.ts +16 -9
  150. package/dist/sketchFaceDraftKernel.d.ts.map +1 -1
  151. package/dist/sketchFaceDraftKernel.js +19 -11
  152. package/dist/sketchFaceDraftKernel.js.map +1 -1
  153. package/dist/sketchRevolve.d.ts +13 -0
  154. package/dist/sketchRevolve.d.ts.map +1 -1
  155. package/dist/sketchRevolve.js +4 -1
  156. package/dist/sketchRevolve.js.map +1 -1
  157. package/dist/skfProject.d.ts +1 -1
  158. package/dist/skfProject.d.ts.map +1 -1
  159. package/dist/skfProject.js +63 -4
  160. package/dist/skfProject.js.map +1 -1
  161. package/dist/sphereTessellation.d.ts.map +1 -1
  162. package/dist/stepAssemblyExplode.d.ts +45 -0
  163. package/dist/stepAssemblyExplode.d.ts.map +1 -0
  164. package/dist/stepAssemblyExplode.js +195 -0
  165. package/dist/stepAssemblyExplode.js.map +1 -0
  166. package/dist/stepAssemblyHierarchy.js.map +1 -1
  167. package/dist/stepComponentMetadata.d.ts +1 -1
  168. package/dist/stepComponentMetadata.d.ts.map +1 -1
  169. package/dist/stepComponentMetadata.js +93 -39
  170. package/dist/stepComponentMetadata.js.map +1 -1
  171. package/dist/stepExport.d.ts +14 -1
  172. package/dist/stepExport.d.ts.map +1 -1
  173. package/dist/stepExport.js +81 -32
  174. package/dist/stepExport.js.map +1 -1
  175. package/dist/stepImportCommon.d.ts +4 -1
  176. package/dist/stepImportCommon.d.ts.map +1 -1
  177. package/dist/stepImportCommon.js +7 -6
  178. package/dist/stepImportCommon.js.map +1 -1
  179. package/dist/stlImport.js.map +1 -1
  180. package/dist/support/cadModifierTypes.d.ts +100 -1
  181. package/dist/support/cadModifierTypes.d.ts.map +1 -1
  182. package/dist/support/i18n/LocalizedError.d.ts.map +1 -1
  183. package/dist/support/i18n/locales/en/messages.json +42 -2
  184. package/dist/support/i18n/locales.d.ts.map +1 -1
  185. package/dist/support/lib/appTheme.d.ts.map +1 -1
  186. package/dist/support/lib/materialPresets.js.map +1 -1
  187. package/dist/support/lib/measurementUnits.d.ts.map +1 -1
  188. package/dist/support/lib/operator-configuration.json +13 -1
  189. package/dist/support/lib/operatorConfiguration.d.ts +5 -5
  190. package/dist/support/lib/workplaneGrid.js.map +1 -1
  191. package/dist/svgImport.d.ts +2 -0
  192. package/dist/svgImport.d.ts.map +1 -1
  193. package/dist/svgImport.js +7 -0
  194. package/dist/svgImport.js.map +1 -1
  195. package/dist/threadCatalog.js.map +1 -1
  196. package/dist/workplaneSettings.js.map +1 -1
  197. package/dist/workplaneShapes.d.ts +2 -2
  198. package/dist/workplaneShapes.d.ts.map +1 -1
  199. package/dist/workplaneShapes.js.map +1 -1
  200. package/package.json +7 -6
@@ -12,16 +12,22 @@
12
12
  * talks to `self.postMessage` and is close to upstream's, and prising it apart
13
13
  * would conflict with every upstream change to it. What is shared instead is
14
14
  * the result format, so a shape filleted here is indistinguishable in the
15
- * editor from one filleted there.
15
+ * editor from one filleted there -- and, through `meshShellSolids`, how a mesh
16
+ * of several closed shells becomes one solid, so the two agree on which meshes
17
+ * with internal voids are solids (#527).
16
18
  */
17
19
  import { faceIdsFromEvolution } from "./faceLineage.js";
18
20
  import { withCadKernel } from "./serverCadKernel.js";
19
21
  import { exactSolidForShape, mirroredInShapeFrame, shapeIsTurned, unrotatedSolidForShape, UnbuildableShapeError, EXACT_SOLID_KINDS, } from "./cadSolids.js";
20
- import { canonicalizeShape } from "./workplaneShapes.js";
22
+ import { canonicalizeShape, shapeHasTaper } from "./workplaneShapes.js";
21
23
  import { createLocalId } from "./localIds.js";
22
- import { cadSketchRegions } from "./sketchCadProfile.js";
24
+ import { cadSketchRegions, draftPrismLoop } from "./sketchCadProfile.js";
25
+ import { sketchExtrusionSolid } from "./sketchExtrusionSolid.js";
23
26
  import { normalizeSketchRevolveSettings, sketchProfileToRevolvePolygons } from "./sketchRevolve.js";
24
27
  import { triangleFaceGroups } from "./cadFaceMesh.js";
28
+ import { meshDataFromPositions } from "./meshMassProperties.js";
29
+ import { MeshShellsOverlapError } from "./meshShellOverlap.js";
30
+ import { MeshShellSolidError, nestedMeshShellSolid } from "./meshShellSolids.js";
25
31
  import { draftSketchFace, draftPrismProfileOffset, faceIdsForCadResult } from "./sketchFaceDraftKernel.js";
26
32
  export class ServerCadError extends Error {
27
33
  status;
@@ -34,6 +40,71 @@ export class ServerCadError extends Error {
34
40
  const MESH_DEFLECTION = 0.1;
35
41
  const MIN_CAD_DIMENSION = 0.01;
36
42
  const FACE_HASH_UPPER_BOUND = 2_147_483_647;
43
+ /**
44
+ * How far a retained sketch output has been resized since it was swept: its
45
+ * drawn dimensions over the ones its source geometry was built at.
46
+ */
47
+ export function sketchResizeFactors(shape) {
48
+ const mesh = shape.importedMesh;
49
+ // A face-drafted output's bounds include its taper, so the resize recorded
50
+ // with the draft is the one to use. Once the draft is gone the bounds are
51
+ // the extrusion's again, and a recorded scale would hide any resize since.
52
+ if (shape.faceDraft && shape.faceDraftSourceScale)
53
+ return shape.faceDraftSourceScale;
54
+ const factor = (drawn, base) => base && base > 0 ? drawn / base : 1;
55
+ return {
56
+ width: factor(shape.width, mesh?.baseWidth),
57
+ depth: factor(shape.depth, mesh?.baseDepth),
58
+ height: factor(shape.height, mesh?.baseHeight),
59
+ };
60
+ }
61
+ function scaledSketchProfile(profile, width, depth) {
62
+ const xs = profile.points.map((point) => point.x);
63
+ const zs = profile.points.map((point) => point.z);
64
+ const centerX = (Math.min(...xs) + Math.max(...xs)) / 2;
65
+ const centerZ = (Math.min(...zs) + Math.max(...zs)) / 2;
66
+ const scalePoint = (point) => ({
67
+ ...point,
68
+ x: centerX + (point.x - centerX) * width,
69
+ z: centerZ + (point.z - centerZ) * depth,
70
+ ...(point.handleIn ? { handleIn: { ...point.handleIn, x: centerX + (point.handleIn.x - centerX) * width, z: centerZ + (point.handleIn.z - centerZ) * depth } } : {}),
71
+ ...(point.handleOut ? { handleOut: { ...point.handleOut, x: centerX + (point.handleOut.x - centerX) * width, z: centerZ + (point.handleOut.z - centerZ) * depth } } : {}),
72
+ });
73
+ return { ...profile, points: profile.points.map(scalePoint) };
74
+ }
75
+ const isResized = (factors) => [factors.width, factors.depth, factors.height].some((factor) => Math.abs(factor - 1) > 1e-8);
76
+ /**
77
+ * A sketch extrusion whose exact solid is still its bare sweep -- no draft,
78
+ * treatment or shell -- has its B-rep stored at the size it was drawn. A resize
79
+ * since then lives only in its dimensions, so its exact solid at the size it is
80
+ * shown is its profile scaled by that resize and extruded to its height, the
81
+ * rebuild `applySketchFaceDraft` makes (#452 for width and depth; #546 fixed
82
+ * the engine handing a height-resized extrusion to it at twice its height).
83
+ * Null when there is no resize to apply, or the solid is something else.
84
+ *
85
+ * Without this, a fillet, chamfer or shell of a resized extrusion was built
86
+ * from the B-rep as drawn and stored back at the original size (#539).
87
+ */
88
+ export function resizedSketchExtrusionProfile(shape) {
89
+ if (shape.sketchOperation !== "extrude" || !shape.sketchProfile || !shape.cadBrep)
90
+ return null;
91
+ if (shape.faceDraft !== undefined || shape.draftPrismAngle !== undefined || shape.shell
92
+ || (shape.edgeTreatments?.length ?? 0) > 0 || (shape.edgeTreatmentHistory?.length ?? 0) > 0)
93
+ return null;
94
+ const resize = sketchResizeFactors(shape);
95
+ return isResized(resize) ? scaledSketchProfile(shape.sketchProfile, resize.width, resize.depth) : null;
96
+ }
97
+ /** The bare extrusion's exact solid at its drawn size, or its stored solid when it has not been resized. */
98
+ function storedOrResizedSolid(cad, shape) {
99
+ const resizedProfile = resizedSketchExtrusionProfile(shape);
100
+ if (!resizedProfile)
101
+ return localSolidForShape(cad, shape);
102
+ const solid = sketchExtrusionSolid(cad, resizedProfile, shape.height);
103
+ if (!cadResultIsValid(cad, solid)) {
104
+ throw new ServerCadError("The resized sketch extrusion could not be rebuilt as exact CAD geometry.");
105
+ }
106
+ return solid;
107
+ }
37
108
  function meshPositionsOf(shape) {
38
109
  const positions = shape.importedMesh?.positions;
39
110
  return positions && positions.length >= 9 ? positions : null;
@@ -91,8 +162,39 @@ function solidFromBrep(cad, brep) {
91
162
  }
92
163
  throw new ServerCadError("The stored exact geometry for that shape could not be read.");
93
164
  }
94
- /** A solid sewn from a flat list of triangle corners, which is how a stored mesh arrives. */
165
+ /**
166
+ * A solid sewn from a flat list of triangle corners, which is how a stored mesh arrives.
167
+ *
168
+ * A mesh made of several closed shells -- a part with a sealed internal void,
169
+ * or separate bodies -- is built one shell at a time and put back together by
170
+ * nesting (#501). Sewn all at once, OCCT got a shell inside a shell, could make
171
+ * no one solid of it, and refused a watertight part outright.
172
+ */
95
173
  export function solidFromMeshPositions(cad, positions) {
174
+ let nested;
175
+ try {
176
+ nested = nestedMeshShellSolid(cad, meshDataFromPositions(positions), (shell) => sewnMeshSolid(cad, shell));
177
+ }
178
+ catch (error) {
179
+ if (!(error instanceof MeshShellSolidError))
180
+ throw error;
181
+ throw new ServerCadError(error.reason === "shells-overlap" ? MESH_SHELLS_OVERLAP : MESH_NOT_SOLID);
182
+ }
183
+ return nested ?? sewnMeshSolid(cad, positions);
184
+ }
185
+ /** The exact path's refusal for a mesh error the kernel-free measures throw; anything else unchanged. */
186
+ export function asServerCadError(error) {
187
+ return error instanceof MeshShellsOverlapError ? new ServerCadError(MESH_SHELLS_OVERLAP) : error;
188
+ }
189
+ const MESH_SHELLS_OVERLAP = "That shape's mesh has closed bodies that overlap without one enclosing the other, so the CAD kernel cannot tell which side of each is material. Union the bodies in the mesh before importing it.";
190
+ /**
191
+ * Why a mesh could not become a solid. Shared by every exact path that reads a
192
+ * mesh -- properties, booleans, interference, drawings -- so it names none of
193
+ * them; it used to say "cannot be filleted" to all of them.
194
+ */
195
+ const MESH_NOT_SOLID = "That shape's mesh is not a closed solid, so the CAD kernel cannot build exact geometry from it.";
196
+ /** One shell's triangles sewn into a solid, loosening the tolerance until it closes. */
197
+ function sewnMeshSolid(cad, positions) {
96
198
  const imported = cad.importStl(asciiStlFromPositions(positions));
97
199
  let shape = cad.fixShape(imported);
98
200
  if (isValidSolid(cad, shape))
@@ -113,7 +215,7 @@ export function solidFromMeshPositions(cad, positions) {
113
215
  // Try the next tolerance.
114
216
  }
115
217
  }
116
- throw new ServerCadError("That shape's mesh is not a closed solid, so it cannot be filleted.");
218
+ throw new ServerCadError(MESH_NOT_SOLID);
117
219
  }
118
220
  /**
119
221
  * Whether the shape's geometry has to be turned, or arrives already turned.
@@ -183,7 +285,9 @@ export function localSolidForShape(cad, shape) {
183
285
  * reads the same predicate.
184
286
  *
185
287
  * For stored geometry this is the local solid with only its mirror applied:
186
- * the rotation stays on the shape, which is where it already was.
288
+ * the rotation stays on the shape, which is where it already was. A resized
289
+ * bare sketch extrusion is the exception: it is rebuilt at its drawn size
290
+ * (`resizedSketchExtrusionProfile`), since its stored B-rep is not.
187
291
  *
188
292
  * Mirrored either way, because the result is stored with the shape's mirror
189
293
  * flags cleared. Building it unmirrored handed back the unmirrored shape and
@@ -191,7 +295,7 @@ export function localSolidForShape(cad, shape) {
191
295
  */
192
296
  export function orientedSolidForShape(cad, shape) {
193
297
  if (!rotationIsRebuilt(shape))
194
- return mirroredStoredSolid(cad, localSolidForShape(cad, shape), shape);
298
+ return mirroredStoredSolid(cad, storedOrResizedSolid(cad, shape), shape);
195
299
  try {
196
300
  return exactSolidForShape(cad, shape);
197
301
  }
@@ -391,11 +495,15 @@ export function applyEdgeTreatment(shape, treatment) {
391
495
  }
392
496
  }
393
497
  catch (error) {
394
- // OCCT fails when the radius does not fit the geometry, which is the
395
- // common mistake: too large a radius for a thin wall or a short edge.
396
- throw new ServerCadError(treatment.kind === "fillet"
397
- ? "That radius is too large for this shape's edges. Try a smaller one."
398
- : "That chamfer is too large for this shape's edges. Try a smaller one.");
498
+ const message = error instanceof Error ? error.message : String(error);
499
+ const wasmFault = typeof WebAssembly !== "undefined"
500
+ && (error instanceof WebAssembly.Exception || error instanceof WebAssembly.RuntimeError);
501
+ if (wasmFault || /out of memory|memory access out of bounds|allocation failed|wasm.*abort/i.test(message)) {
502
+ throw error;
503
+ }
504
+ // Keep geometric sizing failures actionable without disguising runtime
505
+ // faults as a user mistake.
506
+ throw new ServerCadError(`The requested ${treatment.kind} amount ${treatment.amount} could not be applied to ${edges.length} eligible edges. Try a smaller amount.`);
399
507
  }
400
508
  // Both fillet and chamfer hand back a compound wrapping the result, and
401
509
  // OCCT refuses a compound as the input to another treatment -- so storing
@@ -462,6 +570,13 @@ function revolveWithoutExactEdges(shape) {
462
570
  * viewport stretches the mesh by.
463
571
  */
464
572
  function revolveResizeFactors(shape) {
573
+ if (shape.faceDraftSourceScale) {
574
+ return {
575
+ radialX: shape.faceDraftSourceScale.width,
576
+ radialZ: shape.faceDraftSourceScale.depth,
577
+ axial: shape.faceDraftSourceScale.height,
578
+ };
579
+ }
465
580
  const mesh = shape.importedMesh;
466
581
  const ratio = (drawn, base) => (base && base > 0 && drawn > 0 ? drawn / base : 1);
467
582
  return {
@@ -491,6 +606,145 @@ function exactRevolveSolidForShape(cad, shape) {
491
606
  const { radialX, axial } = revolveResizeFactors(shape);
492
607
  return exactPlainSketchRevolveSolid(cad, shape.sketchProfile, normalizeSketchRevolveSettings(shape.sketchRevolve), undefined, { radial: radialX, axial });
493
608
  }
609
+ /**
610
+ * Where a stored revolve's axis sits in its mesh's natural frame, or null when
611
+ * that cannot be known without sweeping it again.
612
+ *
613
+ * The mesh is centred on its own box, so the axis is `-revolveAxisOffset`
614
+ * there (#503). A full turn saved before that offset was recorded is still
615
+ * symmetric about its axis under every one of its facet rotations, so the
616
+ * axis is the centroid of its triangles in X and Z -- which is not the box
617
+ * centre when the facet count is not a multiple of four. A partial sweep with
618
+ * no recorded axis has neither, and stays tessellated.
619
+ */
620
+ function storedRevolveAxis(shape, fullTurn) {
621
+ if (shape.revolveAxisOffset)
622
+ return { x: -shape.revolveAxisOffset.x, z: -shape.revolveAxisOffset.z };
623
+ if (!fullTurn)
624
+ return null;
625
+ const positions = shape.importedMesh?.positions ?? [];
626
+ let volume = 0;
627
+ let x = 0;
628
+ let z = 0;
629
+ for (let index = 0; index + 8 < positions.length; index += 9) {
630
+ const [ax, ay, az, bx, by, bz, cx, cy, cz] = positions.slice(index, index + 9);
631
+ const tetra = (ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)) / 6;
632
+ volume += tetra;
633
+ x += tetra * (ax + bx + cx) / 4;
634
+ z += tetra * (az + bz + cz) / 4;
635
+ }
636
+ return Math.abs(volume) > 1e-12 ? { x: x / volume, z: z / volume } : null;
637
+ }
638
+ /**
639
+ * Whether any profile segment is drawn as a curve.
640
+ *
641
+ * The rule `sketchProfileToRevolvePolygons` samples by: a segment that is not
642
+ * a line and has both its handles (the start point's outgoing one and the end
643
+ * point's incoming one) is a cubic, sampled into straight pieces; anything
644
+ * else is drawn straight. A revolve of the samples would be exact about the
645
+ * axis but still an approximation of the drawn curve, so it is not reported
646
+ * as exact.
647
+ */
648
+ function profileHasCurvedSegment(profile) {
649
+ const points = new Map(profile.points.map((point) => [point.id, point]));
650
+ return profile.segments.some((segment) => segment.kind !== "line"
651
+ && Boolean(points.get(segment.startId)?.handleOut) && Boolean(points.get(segment.endId)?.handleIn));
652
+ }
653
+ /**
654
+ * Why a revolve stored as a mesh is not measured, cut or exported as its exact
655
+ * solid, or null when it is.
656
+ *
657
+ * Wider than the edge listing's rule (`revolveWithoutExactEdges`): a partial
658
+ * sweep is exact here too, because nothing numbers its edges.
659
+ */
660
+ function revolveWithoutExactSolid(shape) {
661
+ if (!isUntreatedSketchRevolve(shape))
662
+ return "not an untreated sketch revolve";
663
+ if ((shape.importedMesh?.positions.length ?? 0) < 9)
664
+ return "no stored sweep to place the solid by";
665
+ const settings = normalizeSketchRevolveSettings(shape.sketchRevolve);
666
+ if (settings.pitch !== 0)
667
+ return "a helical revolve has only its tessellated sweep";
668
+ if (settings.coreRadius > 0)
669
+ return "a revolve fused to a core has no exact solid";
670
+ const { radialX, radialZ } = revolveResizeFactors(shape);
671
+ if (Math.abs(radialX - radialZ) > 1e-6 * Math.max(radialX, radialZ))
672
+ return "resized unevenly across its axis, so no revolve describes it";
673
+ if (shapeHasTaper(shape))
674
+ return "a taper is applied to the mesh, not to a revolve";
675
+ const fullTurn = Math.abs(Math.abs(settings.sweepAngle) - 360) <= 1e-6;
676
+ if (!storedRevolveAxis(shape, fullTurn))
677
+ return "a partial sweep saved without its axis";
678
+ if (profileHasCurvedSegment(shape.sketchProfile))
679
+ return "a curved profile edge is sampled into straight segments, so no exact solid describes it";
680
+ try {
681
+ if (cadSketchRegions(revolveSectionProfile(shape.sketchProfile, settings, { radial: 1, axial: 1 })).length !== 1) {
682
+ return "the profile has several closed regions";
683
+ }
684
+ }
685
+ catch {
686
+ return "the profile has no closed region";
687
+ }
688
+ return null;
689
+ }
690
+ /**
691
+ * Whether `placedSolidForShape` builds this shape as the exact solid of its
692
+ * revolve rather than sewing its stored mesh, which is what measuring it
693
+ * reports as its precision.
694
+ */
695
+ export function hasExactRevolveSolid(shape) {
696
+ return revolveWithoutExactSolid(shape) === null;
697
+ }
698
+ /**
699
+ * The angle a mesh of a result built from these shapes may turn through
700
+ * between facets, so that it is no coarser than the finest exact revolve
701
+ * among them, or undefined when none is one.
702
+ *
703
+ * Before a revolve was exact, a cut through it was made from its faceted
704
+ * sweep, so the stored result was exactly as fine as its `sides` asked. The
705
+ * exact result is meshed at a fixed chord height instead, which on a small
706
+ * ring is coarser than the sweep it replaced and ignores `sides` altogether.
707
+ * A group record is searched through, since it is cut as its members.
708
+ *
709
+ * Twice the sweep's facet angle, because OCCT's angular deflection bounds the
710
+ * turn between a facet's normal and the surface's, half a facet each way: at
711
+ * this value a full circle gets `sides` facets, the sweep's own count, and a
712
+ * 64-sided ring cut in half meshes to the 260 triangles its sweep gave.
713
+ */
714
+ export function exactRevolveAngularDeflection(shapes) {
715
+ let finest = 0;
716
+ const visit = (shape) => {
717
+ if (hasExactRevolveSolid(shape)) {
718
+ finest = Math.max(finest, normalizeSketchRevolveSettings(shape.sketchRevolve).sides);
719
+ }
720
+ else if (!shape.importedMesh && !shape.cadBrep) {
721
+ shape.groupedShapes?.forEach(visit);
722
+ }
723
+ };
724
+ shapes.forEach(visit);
725
+ return finest > 0 ? (4 * Math.PI) / finest : undefined;
726
+ }
727
+ /**
728
+ * The exact solid of a plain revolve, full turn or partial sweep, in the frame
729
+ * its stored mesh is drawn in -- centred on the mesh's box in X and Z, base at
730
+ * Y=0, at its drawn size -- or null when it has none (#523).
731
+ *
732
+ * That frame is what lets the caller place it exactly as the mesh is placed.
733
+ * The axis comes from the stored record rather than from the solid's own box,
734
+ * because a faceted partial sweep's box is not the exact sweep's box, and
735
+ * centring on the wrong one moves the solid off its axis.
736
+ */
737
+ export function exactRevolveLocalSolid(cad, shape) {
738
+ if (!hasExactRevolveSolid(shape))
739
+ return null;
740
+ const settings = normalizeSketchRevolveSettings(shape.sketchRevolve);
741
+ const axis = storedRevolveAxis(shape, Math.abs(Math.abs(settings.sweepAngle) - 360) <= 1e-6);
742
+ const { radialX, radialZ, axial } = revolveResizeFactors(shape);
743
+ const solid = revolvedSectionSolid(cad, shape.sketchProfile, settings, { radial: radialX, axial });
744
+ if (!cad.isValid(solid) || !cad.isSolid(solid))
745
+ throw new ServerCadError("The retained sketch revolve could not be rebuilt as exact CAD geometry.");
746
+ return cad.translate(solid, axis.x * radialX, 0, axis.z * radialZ);
747
+ }
494
748
  /**
495
749
  * The solid `GET /edges` numbers and a fillet or chamfer rounds.
496
750
  *
@@ -514,16 +768,16 @@ export function isPlainFullTurnSketchRevolve(shape) {
514
768
  && (settings.pitch ?? 0) === 0 && (settings.coreRadius ?? 0) === 0
515
769
  && Math.abs(Math.abs(settings.sweepAngle) - 360) <= 1e-6;
516
770
  }
517
- /** Build the analytical BREP for a plain, single-region full-turn revolve. */
518
- export function exactPlainSketchRevolveSolid(cad, sketchProfile, sketchRevolve, targetShape, sectionScale = { radial: 1, axial: 1 }) {
519
- if (!sketchRevolve || (sketchRevolve.pitch ?? 0) !== 0 || (sketchRevolve.coreRadius ?? 0) !== 0
520
- || Math.abs(Math.abs(sketchRevolve.sweepAngle) - 360) > 1e-6) {
521
- throw new ServerCadError("Exact revolve geometry requires a plain full-turn sketch revolve.");
522
- }
771
+ /**
772
+ * A revolve's section as a sketch profile in the OCCT build frame, at the given
773
+ * section scale: x is the distance from the axis and z runs up it from the
774
+ * revolve's base, the same polygons the mesh sweep turns.
775
+ */
776
+ function revolveSectionProfile(sketchProfile, sketchRevolve, sectionScale) {
523
777
  const polygons = sketchProfileToRevolvePolygons(sketchProfile, sketchRevolve);
524
778
  if (polygons.length === 0)
525
779
  throw new ServerCadError("The retained revolve profile has no closed region.");
526
- const profile = {
780
+ return {
527
781
  points: polygons.flatMap((polygon, polygonIndex) => polygon.map(([x, z], pointIndex) => ({
528
782
  id: `revolve-${polygonIndex}-point-${pointIndex}`,
529
783
  x: x * sectionScale.radial,
@@ -536,25 +790,44 @@ export function exactPlainSketchRevolveSolid(cad, sketchProfile, sketchRevolve,
536
790
  kind: "line",
537
791
  }))),
538
792
  };
793
+ }
794
+ /**
795
+ * The exact solid of a plain revolve's one section region, turned through its
796
+ * sweep about the Y axis, in the frame the mesh sweep builds before centring.
797
+ *
798
+ * Manifold sweeps from +X towards the editor's -Z and then turns the result by
799
+ * the start angle (or, for a negative sweep, by where the sweep ends), which
800
+ * is what a positive OCCT rotation about +Y does too, so the two agree for a
801
+ * partial sweep as well as for a full turn.
802
+ */
803
+ function revolvedSectionSolid(cad, sketchProfile, sketchRevolve, sectionScale) {
804
+ const profile = revolveSectionProfile(sketchProfile, sketchRevolve, sectionScale);
539
805
  const pathWire = (path) => cad.makeWire(path.steps.map(({ from, to }) => cad.makeLineEdge({ x: from.x, y: from.z, z: 0 }, { x: to.x, y: to.z, z: 0 })));
540
806
  const regions = cadSketchRegions(profile);
541
807
  if (regions.length !== 1) {
542
808
  throw new ServerCadError("Exact revolve geometry requires one closed profile region, and this revolve's profile has several.");
543
809
  }
544
- const solids = regions.map((region) => {
545
- let face = cad.makeFace(pathWire(region.outer));
546
- if (region.holes.length)
547
- face = cad.addHolesInFace(face, region.holes.map(pathWire));
548
- const solid = cad.revolve(face, {
549
- point: { x: 0, y: 0, z: 0 },
550
- direction: { x: 0, y: 1, z: 0 },
551
- }, Math.PI * 2);
552
- return Math.abs(sketchRevolve.startAngle) < 1e-8 ? solid : cad.rotate(solid, {
553
- point: { x: 0, y: 0, z: 0 },
554
- direction: { x: 0, y: 1, z: 0 },
555
- }, (sketchRevolve.startAngle * Math.PI) / 180);
556
- });
557
- let solid = solids[0];
810
+ const axis = { point: { x: 0, y: 0, z: 0 }, direction: { x: 0, y: 1, z: 0 } };
811
+ const region = regions[0];
812
+ let face = cad.makeFace(pathWire(region.outer));
813
+ if (region.holes.length)
814
+ face = cad.addHolesInFace(face, region.holes.map(pathWire));
815
+ const sweep = Math.abs(sketchRevolve.sweepAngle);
816
+ const solid = cad.revolve(face, axis, Math.abs(sweep - 360) <= 1e-6 ? Math.PI * 2 : (sweep * Math.PI) / 180);
817
+ const rotation = sketchRevolve.sweepAngle < 0 ? sketchRevolve.startAngle + sketchRevolve.sweepAngle : sketchRevolve.startAngle;
818
+ return Math.abs(rotation) < 1e-8 ? solid : cad.rotate(solid, axis, (rotation * Math.PI) / 180);
819
+ }
820
+ /** Build the analytical BREP for a plain, single-region full-turn revolve. */
821
+ export function exactPlainSketchRevolveSolid(cad, sketchProfile, sketchRevolve, targetShape, sectionScale = { radial: 1, axial: 1 }) {
822
+ if (!sketchRevolve || (sketchRevolve.pitch ?? 0) !== 0 || (sketchRevolve.coreRadius ?? 0) !== 0
823
+ || Math.abs(Math.abs(sketchRevolve.sweepAngle) - 360) > 1e-6) {
824
+ throw new ServerCadError("Exact revolve geometry requires a plain full-turn sketch revolve.");
825
+ }
826
+ // A full turn starts where its start angle says, whichever way it is swept.
827
+ let solid = revolvedSectionSolid(cad, sketchProfile, { ...sketchRevolve, sweepAngle: 360 }, sectionScale);
828
+ // Callers that cannot express a resized revolve analytically (for example,
829
+ // exact face listing of a radially elliptical legacy shape) use this bounds
830
+ // fit. Face drafting of a supported resize passes profile scales instead.
558
831
  if (targetShape?.importedMesh && targetShape.width > 0 && targetShape.depth > 0 && targetShape.height > 0) {
559
832
  const bounds = cad.getBoundingBox(solid, false);
560
833
  const sx = targetShape.width / Math.max(bounds.xmax - bounds.xmin, 1e-9);
@@ -570,25 +843,6 @@ export function exactPlainSketchRevolveSolid(cad, sketchProfile, sketchRevolve,
570
843
  throw new ServerCadError("The retained sketch revolve could not be rebuilt as exact CAD geometry.");
571
844
  return solid;
572
845
  }
573
- function scaleRevolveResult(cad, solid, shape) {
574
- const mesh = shape.importedMesh;
575
- if (!mesh)
576
- return solid;
577
- const factors = [
578
- shape.width / Math.max(mesh.baseWidth ?? shape.width, 1e-9),
579
- shape.height / Math.max(mesh.baseHeight ?? shape.height, 1e-9),
580
- shape.depth / Math.max(mesh.baseDepth ?? shape.depth, 1e-9),
581
- ];
582
- if (factors.every((factor) => Math.abs(factor - 1) < 1e-8))
583
- return solid;
584
- const bounds = cad.getBoundingBox(solid, false);
585
- const [sx, sy, sz] = factors;
586
- return cad.transform(solid, [
587
- sx, 0, 0, -(bounds.xmin + bounds.xmax) * sx / 2,
588
- 0, sy, 0, -bounds.ymin * sy,
589
- 0, 0, sz, -(bounds.zmin + bounds.zmax) * sz / 2,
590
- ]);
591
- }
592
846
  /** Rebuild the exact CAD representation of an eligible retained revolve. */
593
847
  export function exactPlainSketchRevolveResult(shape) {
594
848
  if (!shape.sketchProfile || !isPlainFullTurnSketchRevolve(shape)) {
@@ -620,18 +874,15 @@ export function applyDraftPrism(shape, request) {
620
874
  if (shape.sketchOperation !== "extrude" || !shape.sketchProfile) {
621
875
  return Promise.reject(new ServerCadError("Draft prism requires a sketch extrusion with its source profile retained.", 400));
622
876
  }
623
- const { points, segments } = shape.sketchProfile;
624
- if (points.length < 3 || segments.length !== points.length
625
- || segments.some((segment, index) => segment.kind && segment.kind !== "line"
626
- || segment.startId !== points[index].id
627
- || segment.endId !== points[(index + 1) % points.length].id)
628
- || points.some((point) => point.handleIn || point.handleOut || !Number.isFinite(point.x) || !Number.isFinite(point.z))) {
629
- return Promise.reject(new ServerCadError("Draft prism currently requires one closed polygonal sketch profile made from straight segments.", 400));
630
- }
877
+ const scale = sketchResizeFactors(shape);
878
+ const profile = scaledSketchProfile(shape.sketchProfile, scale.width, scale.depth);
879
+ const loop = draftPrismLoop(profile);
880
+ if (typeof loop === "string")
881
+ return Promise.reject(new ServerCadError(loop, 400));
631
882
  return withCadKernel((cad) => {
632
- const { x: centerX, z: centerZ } = draftPrismProfileOffset(shape.sketchProfile);
633
- const edges = points.map((point, index) => {
634
- const next = points[(index + 1) % points.length];
883
+ const { x: centerX, z: centerZ } = draftPrismProfileOffset(profile);
884
+ const edges = loop.map((point, index) => {
885
+ const next = loop[(index + 1) % loop.length];
635
886
  return cad.makeLineEdge({ x: point.x - centerX, y: 0, z: point.z - centerZ }, { x: next.x - centerX, y: 0, z: next.z - centerZ });
636
887
  });
637
888
  const face = cad.makeFace(cad.makeWire(edges));
@@ -657,17 +908,42 @@ export function applySketchFaceDraft(shape, request) {
657
908
  return Promise.reject(new ServerCadError("The face draft angle must be non-zero and between -45 and 45 degrees.", 400));
658
909
  }
659
910
  const exactRevolve = isPlainFullTurnSketchRevolve(shape);
911
+ if (exactRevolve) {
912
+ const resize = revolveResizeFactors(shape);
913
+ if (Math.abs(resize.radialX - resize.radialZ) > 1e-6 * Math.max(resize.radialX, resize.radialZ)) {
914
+ return Promise.reject(new ServerCadError("Face drafting requires an equal resize across the revolve's radial axes; unequal radial resizing makes an elliptical shape that cannot be drafted exactly.", 400));
915
+ }
916
+ }
660
917
  if ((!exactRevolve && shape.sketchOperation !== "extrude" && shape.sketchOperation !== "sweep")
661
918
  || !shape.sketchProfile || (!shape.cadBrep && !exactRevolve)) {
662
919
  return Promise.reject(new ServerCadError("Face drafts require an exact retained sketch extrusion, sweep, or plain full-turn revolve.", 400));
663
920
  }
664
921
  return withCadKernel((cad) => {
665
- const solid = exactRevolve
666
- ? exactPlainSketchRevolveSolid(cad, shape.sketchProfile, shape.sketchRevolve, shape)
667
- : solidFromBrep(cad, shape.cadBrep);
922
+ let solid;
923
+ if (exactRevolve) {
924
+ const resize = revolveResizeFactors(shape);
925
+ const hasResize = [resize.radialX, resize.radialZ, resize.axial]
926
+ .some((factor) => Math.abs(factor - 1) > 1e-8);
927
+ solid = hasResize
928
+ ? exactPlainSketchRevolveSolid(cad, shape.sketchProfile, shape.sketchRevolve, undefined, {
929
+ radial: resize.radialX,
930
+ axial: resize.axial,
931
+ })
932
+ : exactPlainSketchRevolveSolid(cad, shape.sketchProfile, shape.sketchRevolve, shape);
933
+ }
934
+ else {
935
+ solid = solidFromBrep(cad, shape.cadBrep);
936
+ }
937
+ const resize = sketchResizeFactors(shape);
938
+ if (!exactRevolve && shape.sketchOperation === "extrude" && isResized(resize)) {
939
+ solid = sketchExtrusionSolid(cad, scaledSketchProfile(shape.sketchProfile, resize.width, resize.depth), shape.height);
940
+ if (!cadResultIsValid(cad, solid) || !cad.isSolid(solid)) {
941
+ throw new ServerCadError("The resized sketch extrusion could not be rebuilt as exact CAD geometry.");
942
+ }
943
+ }
668
944
  const faces = cad.getSubShapes(solid, "face");
669
945
  const ids = shape.faceIds?.length === faces.length ? shape.faceIds
670
- : exactRevolve && !shape.faceColors ? faces.map((_, index) => `face_${encodeURIComponent(shape.id)}_${index}`)
946
+ : exactRevolve ? faces.map((_, index) => `face_${encodeURIComponent(shape.id)}_${index}`)
671
947
  : undefined;
672
948
  if (!ids || ids.length !== faces.length || new Set(ids).size !== faces.length) {
673
949
  throw new ServerCadError("Face drafting requires a complete, unique stable face ID list.");
@@ -684,12 +960,14 @@ export function applySketchFaceDraft(shape, request) {
684
960
  if (!cadResultIsValid(cad, drafted) || !cad.isSolid(drafted)) {
685
961
  throw new ServerCadError("The face draft did not produce a valid solid.");
686
962
  }
687
- return meshedCadResult(cad, exactRevolve ? scaleRevolveResult(cad, drafted, shape) : drafted, shape);
963
+ return meshedCadResult(cad, drafted, shape, undefined, solid);
688
964
  });
689
965
  }
690
966
  /** The exact geometry and a mesh of it, which is what a shape stores after any CAD operation. */
691
- export function meshedCadResult(cad, result, sourceShape, lineage, sourceSolid) {
692
- const mesh = cad.meshShape(result, { linearDeflection: MESH_DEFLECTION });
967
+ export function meshedCadResult(cad, result, sourceShape, lineage, sourceSolid, angularDeflection) {
968
+ const mesh = cad.meshShape(result, angularDeflection
969
+ ? { linearDeflection: MESH_DEFLECTION, angularDeflection }
970
+ : { linearDeflection: MESH_DEFLECTION });
693
971
  // The editor stores a flat triangle list, not an indexed mesh, so the
694
972
  // indices are expanded here rather than carried.
695
973
  const positions = [];
@@ -770,6 +1048,12 @@ export function shapeWithCadResult(shape, result) {
770
1048
  }));
771
1049
  return canonicalizeShape({
772
1050
  ...shape,
1051
+ // A baked rotation can move the bounds away from the source's pivot. The
1052
+ // mesh is rebased to those bounds, so move its project origin by the same
1053
+ // offset. Other results keep the source frame they were built in.
1054
+ x: shape.x + (bakedRotation ? centerX : 0),
1055
+ z: shape.z + (bakedRotation ? centerZ : 0),
1056
+ elevation: (shape.elevation ?? 0) + (bakedRotation ? minY : 0),
773
1057
  faceIds: result.faceIds,
774
1058
  faceColors: Object.keys(survivingColors).length ? survivingColors : undefined,
775
1059
  kind: "mesh",
@@ -816,7 +1100,8 @@ export function shapeWithCadResult(shape, result) {
816
1100
  // was created -- which is where a test shape sits -- subtracts zero.
817
1101
  //
818
1102
  // These are the result's own bounds, which is what the editor's worker
819
- // records for exactly the same reason.
1103
+ // records for exactly the same reason, and what both cut writers now
1104
+ // record for a cut's recentred B-rep (#548).
820
1105
  cadBrepFrame: { x: centerX, z: centerZ, elevation: minY, width, depth, height },
821
1106
  cadPrimitiveFrame: undefined,
822
1107
  cadDisplayEdges: undefined,