brep-io-kernel 1.0.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 (403) hide show
  1. package/LICENSE.md +32 -0
  2. package/README.md +144 -0
  3. package/dist-kernel/brep-kernel.js +74699 -0
  4. package/dist-kernel/help/CONTRIBUTING.html +248 -0
  5. package/dist-kernel/help/LICENSE.html +248 -0
  6. package/dist-kernel/help/MODELING.png +0 -0
  7. package/dist-kernel/help/PMI.png +0 -0
  8. package/dist-kernel/help/SKETCH.png +0 -0
  9. package/dist-kernel/help/assembly-constraints__Coincident_Constraint_dialog.png +0 -0
  10. package/dist-kernel/help/assembly-constraints___Angle_Constraint_dialog.png +0 -0
  11. package/dist-kernel/help/assembly-constraints___Distance_Constraint_dialog.png +0 -0
  12. package/dist-kernel/help/assembly-constraints___Fixed_Constraint_dialog.png +0 -0
  13. package/dist-kernel/help/assembly-constraints___Parallel_Constraint_dialog.png +0 -0
  14. package/dist-kernel/help/assembly-constraints___Touch_Align_Constraint_dialog.png +0 -0
  15. package/dist-kernel/help/assembly-constraints__angle-constraint.html +248 -0
  16. package/dist-kernel/help/assembly-constraints__coincident-constraint.html +248 -0
  17. package/dist-kernel/help/assembly-constraints__distance-constraint.html +248 -0
  18. package/dist-kernel/help/assembly-constraints__fixed-constraint.html +248 -0
  19. package/dist-kernel/help/assembly-constraints__parallel-constraint.html +248 -0
  20. package/dist-kernel/help/assembly-constraints__solver.html +248 -0
  21. package/dist-kernel/help/assembly-constraints__touch-align-constraint.html +248 -0
  22. package/dist-kernel/help/brep-api.html +263 -0
  23. package/dist-kernel/help/brep-kernel.html +258 -0
  24. package/dist-kernel/help/brep-model.html +248 -0
  25. package/dist-kernel/help/cylindrical-face-radius-embedding.html +290 -0
  26. package/dist-kernel/help/dialog-screenshots.html +248 -0
  27. package/dist-kernel/help/extruded-sketch-radius-embedding.html +336 -0
  28. package/dist-kernel/help/features__Assembly_Component_dialog.png +0 -0
  29. package/dist-kernel/help/features__Boolean_dialog.png +0 -0
  30. package/dist-kernel/help/features__Chamfer_dialog.png +0 -0
  31. package/dist-kernel/help/features__Datium_dialog.png +0 -0
  32. package/dist-kernel/help/features__Extrude_dialog.png +0 -0
  33. package/dist-kernel/help/features__Fillet_dialog.png +0 -0
  34. package/dist-kernel/help/features__Helix_dialog.png +0 -0
  35. package/dist-kernel/help/features__Hole_dialog.png +0 -0
  36. package/dist-kernel/help/features__Image_Heightmap_Solid_dialog.png +0 -0
  37. package/dist-kernel/help/features__Image_to_Face_dialog.png +0 -0
  38. package/dist-kernel/help/features__Import_3D_Model_dialog.png +0 -0
  39. package/dist-kernel/help/features__Loft_dialog.png +0 -0
  40. package/dist-kernel/help/features__Mirror_dialog.png +0 -0
  41. package/dist-kernel/help/features__Offset_Shell_dialog.png +0 -0
  42. package/dist-kernel/help/features__Overlap_Cleanup_dialog.png +0 -0
  43. package/dist-kernel/help/features__Pattern_Linear_dialog.png +0 -0
  44. package/dist-kernel/help/features__Pattern_Radial_dialog.png +0 -0
  45. package/dist-kernel/help/features__Pattern_dialog.png +0 -0
  46. package/dist-kernel/help/features__Plane_dialog.png +0 -0
  47. package/dist-kernel/help/features__Primitive_Cone_dialog.png +0 -0
  48. package/dist-kernel/help/features__Primitive_Cube_dialog.png +0 -0
  49. package/dist-kernel/help/features__Primitive_Cylinder_dialog.png +0 -0
  50. package/dist-kernel/help/features__Primitive_Pyramid_dialog.png +0 -0
  51. package/dist-kernel/help/features__Primitive_Sphere_dialog.png +0 -0
  52. package/dist-kernel/help/features__Primitive_Torus_dialog.png +0 -0
  53. package/dist-kernel/help/features__Remesh_dialog.png +0 -0
  54. package/dist-kernel/help/features__Revolve_dialog.png +0 -0
  55. package/dist-kernel/help/features__Sheet_Metal_Contour_Flange_dialog.png +0 -0
  56. package/dist-kernel/help/features__Sheet_Metal_Cutout_dialog.png +0 -0
  57. package/dist-kernel/help/features__Sheet_Metal_Flange_dialog.png +0 -0
  58. package/dist-kernel/help/features__Sheet_Metal_Tab_dialog.png +0 -0
  59. package/dist-kernel/help/features__Sketch_dialog.png +0 -0
  60. package/dist-kernel/help/features__Spline_dialog.png +0 -0
  61. package/dist-kernel/help/features__Sweep_dialog.png +0 -0
  62. package/dist-kernel/help/features__Transform_dialog.png +0 -0
  63. package/dist-kernel/help/features__Tube_dialog.png +0 -0
  64. package/dist-kernel/help/features__assembly-component.html +248 -0
  65. package/dist-kernel/help/features__boolean.html +248 -0
  66. package/dist-kernel/help/features__chamfer.html +248 -0
  67. package/dist-kernel/help/features__datium.html +248 -0
  68. package/dist-kernel/help/features__datum.html +248 -0
  69. package/dist-kernel/help/features__extrude.html +248 -0
  70. package/dist-kernel/help/features__fillet.html +248 -0
  71. package/dist-kernel/help/features__helix.html +248 -0
  72. package/dist-kernel/help/features__hole.html +248 -0
  73. package/dist-kernel/help/features__image-heightmap-solid.html +248 -0
  74. package/dist-kernel/help/features__image-to-face-2D_dialog.png +0 -0
  75. package/dist-kernel/help/features__image-to-face-3D_dialog.png +0 -0
  76. package/dist-kernel/help/features__image-to-face.html +248 -0
  77. package/dist-kernel/help/features__import-3d-model.html +248 -0
  78. package/dist-kernel/help/features__index.html +248 -0
  79. package/dist-kernel/help/features__loft.html +248 -0
  80. package/dist-kernel/help/features__mirror.html +248 -0
  81. package/dist-kernel/help/features__offset-shell.html +248 -0
  82. package/dist-kernel/help/features__pattern-linear.html +248 -0
  83. package/dist-kernel/help/features__pattern-radial.html +248 -0
  84. package/dist-kernel/help/features__pattern.html +248 -0
  85. package/dist-kernel/help/features__plane.html +248 -0
  86. package/dist-kernel/help/features__primitive-cone.html +248 -0
  87. package/dist-kernel/help/features__primitive-cube.html +248 -0
  88. package/dist-kernel/help/features__primitive-cylinder.html +248 -0
  89. package/dist-kernel/help/features__primitive-pyramid.html +248 -0
  90. package/dist-kernel/help/features__primitive-sphere.html +248 -0
  91. package/dist-kernel/help/features__primitive-torus.html +248 -0
  92. package/dist-kernel/help/features__remesh.html +248 -0
  93. package/dist-kernel/help/features__revolve.html +248 -0
  94. package/dist-kernel/help/features__sheet-metal-contour-flange.html +248 -0
  95. package/dist-kernel/help/features__sheet-metal-flange.html +248 -0
  96. package/dist-kernel/help/features__sheet-metal-tab.html +248 -0
  97. package/dist-kernel/help/features__sketch.html +248 -0
  98. package/dist-kernel/help/features__spline.html +248 -0
  99. package/dist-kernel/help/features__sweep.html +248 -0
  100. package/dist-kernel/help/features__transform.html +248 -0
  101. package/dist-kernel/help/features__tube.html +248 -0
  102. package/dist-kernel/help/file-formats.html +248 -0
  103. package/dist-kernel/help/getting-started.html +248 -0
  104. package/dist-kernel/help/highlights.html +248 -0
  105. package/dist-kernel/help/history-systems.html +248 -0
  106. package/dist-kernel/help/how-it-works.html +248 -0
  107. package/dist-kernel/help/index.html +862 -0
  108. package/dist-kernel/help/input-params-schema.html +363 -0
  109. package/dist-kernel/help/inspector-improvements.html +248 -0
  110. package/dist-kernel/help/inspector.html +248 -0
  111. package/dist-kernel/help/modes__modeling.html +248 -0
  112. package/dist-kernel/help/modes__pmi.html +248 -0
  113. package/dist-kernel/help/modes__sketch.html +248 -0
  114. package/dist-kernel/help/plugins.html +248 -0
  115. package/dist-kernel/help/pmi-annotations__Angle_Dimension_dialog.png +0 -0
  116. package/dist-kernel/help/pmi-annotations__Explode_Body_dialog.png +0 -0
  117. package/dist-kernel/help/pmi-annotations__Hole_Callout_dialog.png +0 -0
  118. package/dist-kernel/help/pmi-annotations__Leader_dialog.png +0 -0
  119. package/dist-kernel/help/pmi-annotations__Linear_Dimension_dialog.png +0 -0
  120. package/dist-kernel/help/pmi-annotations__Note_dialog.png +0 -0
  121. package/dist-kernel/help/pmi-annotations__Radial_Dimension_dialog.png +0 -0
  122. package/dist-kernel/help/pmi-annotations__angle-dimension.html +248 -0
  123. package/dist-kernel/help/pmi-annotations__explode-body.html +248 -0
  124. package/dist-kernel/help/pmi-annotations__hole-callout.html +248 -0
  125. package/dist-kernel/help/pmi-annotations__index.html +248 -0
  126. package/dist-kernel/help/pmi-annotations__leader.html +248 -0
  127. package/dist-kernel/help/pmi-annotations__linear-dimension.html +248 -0
  128. package/dist-kernel/help/pmi-annotations__note.html +248 -0
  129. package/dist-kernel/help/pmi-annotations__radial-dimension.html +248 -0
  130. package/dist-kernel/help/search-index.json +464 -0
  131. package/dist-kernel/help/simplified-radial-dimensions.html +298 -0
  132. package/dist-kernel/help/solid-methods.html +359 -0
  133. package/dist-kernel/help/table-of-contents.html +330 -0
  134. package/dist-kernel/help/ui-overview.html +248 -0
  135. package/dist-kernel/help/whats-new.html +248 -0
  136. package/package.json +54 -0
  137. package/src/BREP/AssemblyComponent.js +42 -0
  138. package/src/BREP/BREP.js +43 -0
  139. package/src/BREP/BetterSolid.js +805 -0
  140. package/src/BREP/Edge.js +103 -0
  141. package/src/BREP/Extrude.js +403 -0
  142. package/src/BREP/Face.js +187 -0
  143. package/src/BREP/MeshRepairer.js +634 -0
  144. package/src/BREP/OffsetShellSolid.js +614 -0
  145. package/src/BREP/PointCloudWrap.js +302 -0
  146. package/src/BREP/Revolve.js +345 -0
  147. package/src/BREP/SolidMethods/authoring.js +112 -0
  148. package/src/BREP/SolidMethods/booleanOps.js +230 -0
  149. package/src/BREP/SolidMethods/chamfer.js +122 -0
  150. package/src/BREP/SolidMethods/edgeResolution.js +25 -0
  151. package/src/BREP/SolidMethods/fillet.js +792 -0
  152. package/src/BREP/SolidMethods/index.js +72 -0
  153. package/src/BREP/SolidMethods/io.js +105 -0
  154. package/src/BREP/SolidMethods/lifecycle.js +103 -0
  155. package/src/BREP/SolidMethods/manifoldOps.js +375 -0
  156. package/src/BREP/SolidMethods/meshCleanup.js +2512 -0
  157. package/src/BREP/SolidMethods/meshQueries.js +264 -0
  158. package/src/BREP/SolidMethods/metadata.js +106 -0
  159. package/src/BREP/SolidMethods/metrics.js +51 -0
  160. package/src/BREP/SolidMethods/transforms.js +361 -0
  161. package/src/BREP/SolidMethods/visualize.js +508 -0
  162. package/src/BREP/SolidShared.js +26 -0
  163. package/src/BREP/Sweep.js +1596 -0
  164. package/src/BREP/Tube.js +857 -0
  165. package/src/BREP/Vertex.js +43 -0
  166. package/src/BREP/applyBooleanOperation.js +704 -0
  167. package/src/BREP/boundsUtils.js +48 -0
  168. package/src/BREP/chamfer.js +551 -0
  169. package/src/BREP/edgePolylineUtils.js +85 -0
  170. package/src/BREP/fillets/common.js +388 -0
  171. package/src/BREP/fillets/fillet.js +1422 -0
  172. package/src/BREP/fillets/filletGeometry.js +15 -0
  173. package/src/BREP/fillets/inset.js +389 -0
  174. package/src/BREP/fillets/offsetHelper.js +143 -0
  175. package/src/BREP/fillets/outset.js +88 -0
  176. package/src/BREP/helix.js +193 -0
  177. package/src/BREP/meshToBrep.js +234 -0
  178. package/src/BREP/primitives.js +279 -0
  179. package/src/BREP/setupManifold.js +71 -0
  180. package/src/BREP/threadGeometry.js +1120 -0
  181. package/src/BREP/triangleUtils.js +8 -0
  182. package/src/BREP/triangulate.js +608 -0
  183. package/src/FeatureRegistry.js +183 -0
  184. package/src/PartHistory.js +1132 -0
  185. package/src/UI/AccordionWidget.js +292 -0
  186. package/src/UI/CADmaterials.js +850 -0
  187. package/src/UI/EnvMonacoEditor.js +522 -0
  188. package/src/UI/FloatingWindow.js +396 -0
  189. package/src/UI/HistoryWidget.js +457 -0
  190. package/src/UI/MainToolbar.js +131 -0
  191. package/src/UI/ModelLibraryView.js +194 -0
  192. package/src/UI/OrthoCameraIdle.js +206 -0
  193. package/src/UI/PluginsWidget.js +280 -0
  194. package/src/UI/SceneListing.js +606 -0
  195. package/src/UI/SelectionFilter.js +629 -0
  196. package/src/UI/ViewCube.js +389 -0
  197. package/src/UI/assembly/AssemblyConstraintCollectionWidget.js +329 -0
  198. package/src/UI/assembly/AssemblyConstraintControlsWidget.js +282 -0
  199. package/src/UI/assembly/AssemblyConstraintsWidget.css +292 -0
  200. package/src/UI/assembly/AssemblyConstraintsWidget.js +1373 -0
  201. package/src/UI/assembly/constraintFaceUtils.js +115 -0
  202. package/src/UI/assembly/constraintHighlightUtils.js +70 -0
  203. package/src/UI/assembly/constraintLabelUtils.js +31 -0
  204. package/src/UI/assembly/constraintPointUtils.js +64 -0
  205. package/src/UI/assembly/constraintSelectionUtils.js +185 -0
  206. package/src/UI/assembly/constraintStatusUtils.js +142 -0
  207. package/src/UI/componentSelectorModal.js +240 -0
  208. package/src/UI/controls/CombinedTransformControls.js +386 -0
  209. package/src/UI/dialogs.js +351 -0
  210. package/src/UI/expressionsManager.js +100 -0
  211. package/src/UI/featureDialogWidgets/booleanField.js +25 -0
  212. package/src/UI/featureDialogWidgets/booleanOperationField.js +97 -0
  213. package/src/UI/featureDialogWidgets/buttonField.js +45 -0
  214. package/src/UI/featureDialogWidgets/componentSelectorField.js +102 -0
  215. package/src/UI/featureDialogWidgets/defaultField.js +23 -0
  216. package/src/UI/featureDialogWidgets/fileField.js +66 -0
  217. package/src/UI/featureDialogWidgets/index.js +34 -0
  218. package/src/UI/featureDialogWidgets/numberField.js +165 -0
  219. package/src/UI/featureDialogWidgets/optionsField.js +33 -0
  220. package/src/UI/featureDialogWidgets/referenceSelectionField.js +208 -0
  221. package/src/UI/featureDialogWidgets/stringField.js +24 -0
  222. package/src/UI/featureDialogWidgets/textareaField.js +28 -0
  223. package/src/UI/featureDialogWidgets/threadDesignationField.js +160 -0
  224. package/src/UI/featureDialogWidgets/transformField.js +252 -0
  225. package/src/UI/featureDialogWidgets/utils.js +43 -0
  226. package/src/UI/featureDialogWidgets/vec3Field.js +133 -0
  227. package/src/UI/featureDialogs.js +1414 -0
  228. package/src/UI/fileManagerWidget.js +615 -0
  229. package/src/UI/history/HistoryCollectionWidget.js +1294 -0
  230. package/src/UI/history/historyCollectionWidget.css.js +257 -0
  231. package/src/UI/history/historyDisplayInfo.js +133 -0
  232. package/src/UI/mobile.js +28 -0
  233. package/src/UI/objectDump.js +442 -0
  234. package/src/UI/pmi/AnnotationCollectionWidget.js +120 -0
  235. package/src/UI/pmi/AnnotationHistory.js +353 -0
  236. package/src/UI/pmi/AnnotationRegistry.js +90 -0
  237. package/src/UI/pmi/BaseAnnotation.js +269 -0
  238. package/src/UI/pmi/LabelOverlay.css +102 -0
  239. package/src/UI/pmi/LabelOverlay.js +191 -0
  240. package/src/UI/pmi/PMIMode.js +1550 -0
  241. package/src/UI/pmi/PMIViewsWidget.js +1098 -0
  242. package/src/UI/pmi/annUtils.js +729 -0
  243. package/src/UI/pmi/dimensions/AngleDimensionAnnotation.js +647 -0
  244. package/src/UI/pmi/dimensions/ExplodeBodyAnnotation.js +507 -0
  245. package/src/UI/pmi/dimensions/HoleCalloutAnnotation.js +462 -0
  246. package/src/UI/pmi/dimensions/LeaderAnnotation.js +403 -0
  247. package/src/UI/pmi/dimensions/LinearDimensionAnnotation.js +532 -0
  248. package/src/UI/pmi/dimensions/NoteAnnotation.js +110 -0
  249. package/src/UI/pmi/dimensions/RadialDimensionAnnotation.js +659 -0
  250. package/src/UI/pmi/pmiStyle.js +44 -0
  251. package/src/UI/sketcher/SketchMode3D.js +4095 -0
  252. package/src/UI/sketcher/dimensions.js +674 -0
  253. package/src/UI/sketcher/glyphs.js +236 -0
  254. package/src/UI/sketcher/highlights.js +60 -0
  255. package/src/UI/toolbarButtons/aboutButton.js +5 -0
  256. package/src/UI/toolbarButtons/exportButton.js +609 -0
  257. package/src/UI/toolbarButtons/flatPatternButton.js +307 -0
  258. package/src/UI/toolbarButtons/importButton.js +160 -0
  259. package/src/UI/toolbarButtons/inspectorToggleButton.js +12 -0
  260. package/src/UI/toolbarButtons/metadataButton.js +1063 -0
  261. package/src/UI/toolbarButtons/orientToFaceButton.js +114 -0
  262. package/src/UI/toolbarButtons/registerDefaultButtons.js +46 -0
  263. package/src/UI/toolbarButtons/saveButton.js +99 -0
  264. package/src/UI/toolbarButtons/scriptRunnerButton.js +302 -0
  265. package/src/UI/toolbarButtons/testsButton.js +26 -0
  266. package/src/UI/toolbarButtons/undoRedoButtons.js +25 -0
  267. package/src/UI/toolbarButtons/wireframeToggleButton.js +5 -0
  268. package/src/UI/toolbarButtons/zoomToFitButton.js +5 -0
  269. package/src/UI/triangleDebuggerWindow.js +945 -0
  270. package/src/UI/viewer.js +4228 -0
  271. package/src/assemblyConstraints/AssemblyConstraintHistory.js +1576 -0
  272. package/src/assemblyConstraints/AssemblyConstraintRegistry.js +120 -0
  273. package/src/assemblyConstraints/BaseAssemblyConstraint.js +66 -0
  274. package/src/assemblyConstraints/constraintExpressionUtils.js +35 -0
  275. package/src/assemblyConstraints/constraintUtils/parallelAlignment.js +676 -0
  276. package/src/assemblyConstraints/constraints/AngleConstraint.js +485 -0
  277. package/src/assemblyConstraints/constraints/CoincidentConstraint.js +194 -0
  278. package/src/assemblyConstraints/constraints/DistanceConstraint.js +616 -0
  279. package/src/assemblyConstraints/constraints/FixedConstraint.js +78 -0
  280. package/src/assemblyConstraints/constraints/ParallelConstraint.js +252 -0
  281. package/src/assemblyConstraints/constraints/TouchAlignConstraint.js +961 -0
  282. package/src/core/entities/HistoryCollectionBase.js +72 -0
  283. package/src/core/entities/ListEntityBase.js +109 -0
  284. package/src/core/entities/schemaProcesser.js +121 -0
  285. package/src/exporters/sheetMetalFlatPattern.js +659 -0
  286. package/src/exporters/sheetMetalUnfold.js +862 -0
  287. package/src/exporters/step.js +1135 -0
  288. package/src/exporters/threeMF.js +575 -0
  289. package/src/features/assemblyComponent/AssemblyComponentFeature.js +780 -0
  290. package/src/features/boolean/BooleanFeature.js +94 -0
  291. package/src/features/chamfer/ChamferFeature.js +116 -0
  292. package/src/features/datium/DatiumFeature.js +80 -0
  293. package/src/features/edgeFeatureUtils.js +41 -0
  294. package/src/features/extrude/ExtrudeFeature.js +143 -0
  295. package/src/features/fillet/FilletFeature.js +197 -0
  296. package/src/features/helix/HelixFeature.js +405 -0
  297. package/src/features/hole/HoleFeature.js +1050 -0
  298. package/src/features/hole/screwClearance.js +86 -0
  299. package/src/features/hole/threadDesignationCatalog.js +149 -0
  300. package/src/features/imageHeightSolid/ImageHeightmapSolidFeature.js +463 -0
  301. package/src/features/imageToFace/ImageToFaceFeature.js +727 -0
  302. package/src/features/imageToFace/imageEditor.js +1270 -0
  303. package/src/features/imageToFace/traceUtils.js +971 -0
  304. package/src/features/import3dModel/Import3dModelFeature.js +151 -0
  305. package/src/features/loft/LoftFeature.js +605 -0
  306. package/src/features/mirror/MirrorFeature.js +151 -0
  307. package/src/features/offsetFace/OffsetFaceFeature.js +370 -0
  308. package/src/features/offsetShell/OffsetShellFeature.js +89 -0
  309. package/src/features/overlapCleanup/OverlapCleanupFeature.js +85 -0
  310. package/src/features/pattern/PatternFeature.js +275 -0
  311. package/src/features/patternLinear/PatternLinearFeature.js +120 -0
  312. package/src/features/patternRadial/PatternRadialFeature.js +186 -0
  313. package/src/features/plane/PlaneFeature.js +154 -0
  314. package/src/features/primitiveCone/primitiveConeFeature.js +99 -0
  315. package/src/features/primitiveCube/primitiveCubeFeature.js +70 -0
  316. package/src/features/primitiveCylinder/primitiveCylinderFeature.js +91 -0
  317. package/src/features/primitivePyramid/primitivePyramidFeature.js +72 -0
  318. package/src/features/primitiveSphere/primitiveSphereFeature.js +62 -0
  319. package/src/features/primitiveTorus/primitiveTorusFeature.js +109 -0
  320. package/src/features/remesh/RemeshFeature.js +97 -0
  321. package/src/features/revolve/RevolveFeature.js +111 -0
  322. package/src/features/selectionUtils.js +118 -0
  323. package/src/features/sheetMetal/SheetMetalContourFlangeFeature.js +1656 -0
  324. package/src/features/sheetMetal/SheetMetalCutoutFeature.js +1056 -0
  325. package/src/features/sheetMetal/SheetMetalFlangeFeature.js +1568 -0
  326. package/src/features/sheetMetal/SheetMetalHemFeature.js +43 -0
  327. package/src/features/sheetMetal/SheetMetalObject.js +141 -0
  328. package/src/features/sheetMetal/SheetMetalTabFeature.js +176 -0
  329. package/src/features/sheetMetal/UNFOLD_NEUTRAL_REQUIREMENTS.md +153 -0
  330. package/src/features/sheetMetal/contour-flange-rebuild-spec.md +261 -0
  331. package/src/features/sheetMetal/profileUtils.js +25 -0
  332. package/src/features/sheetMetal/sheetMetalCleanup.js +9 -0
  333. package/src/features/sheetMetal/sheetMetalFaceTypes.js +146 -0
  334. package/src/features/sheetMetal/sheetMetalMetadata.js +165 -0
  335. package/src/features/sheetMetal/sheetMetalPipeline.js +169 -0
  336. package/src/features/sheetMetal/sheetMetalProfileUtils.js +216 -0
  337. package/src/features/sheetMetal/sheetMetalTabUtils.js +29 -0
  338. package/src/features/sheetMetal/sheetMetalTree.js +210 -0
  339. package/src/features/sketch/SketchFeature.js +955 -0
  340. package/src/features/sketch/sketchSolver2D/ConstraintEngine.js +800 -0
  341. package/src/features/sketch/sketchSolver2D/constraintDefinitions.js +704 -0
  342. package/src/features/sketch/sketchSolver2D/mathHelpersMod.js +307 -0
  343. package/src/features/spline/SplineEditorSession.js +988 -0
  344. package/src/features/spline/SplineFeature.js +1388 -0
  345. package/src/features/spline/splineUtils.js +218 -0
  346. package/src/features/sweep/SweepFeature.js +110 -0
  347. package/src/features/transform/TransformFeature.js +152 -0
  348. package/src/features/tube/TubeFeature.js +635 -0
  349. package/src/fs.proxy.js +625 -0
  350. package/src/idbStorage.js +254 -0
  351. package/src/index.js +12 -0
  352. package/src/main.js +15 -0
  353. package/src/metadataManager.js +64 -0
  354. package/src/path.proxy.js +277 -0
  355. package/src/plugins/ghLoader.worker.js +151 -0
  356. package/src/plugins/pluginManager.js +286 -0
  357. package/src/pmi/PMIViewsManager.js +134 -0
  358. package/src/services/componentLibrary.js +198 -0
  359. package/src/tests/ConsoleCapture.js +189 -0
  360. package/src/tests/S7-diagnostics-2025-12-23T18-37-23-570Z.json +630 -0
  361. package/src/tests/browserTests.js +597 -0
  362. package/src/tests/debugBoolean.js +225 -0
  363. package/src/tests/partFiles/badBoolean.json +957 -0
  364. package/src/tests/partFiles/extrudeTest.json +88 -0
  365. package/src/tests/partFiles/filletFail.json +58 -0
  366. package/src/tests/partFiles/import_TEst.part.part.json +646 -0
  367. package/src/tests/partFiles/sheetMetalHem.BREP.json +734 -0
  368. package/src/tests/test_boolean_subtract.js +27 -0
  369. package/src/tests/test_chamfer.js +17 -0
  370. package/src/tests/test_extrudeFace.js +24 -0
  371. package/src/tests/test_fillet.js +17 -0
  372. package/src/tests/test_fillet_nonClosed.js +45 -0
  373. package/src/tests/test_filletsMoreDifficult.js +46 -0
  374. package/src/tests/test_history_features_basic.js +149 -0
  375. package/src/tests/test_hole.js +282 -0
  376. package/src/tests/test_mirror.js +16 -0
  377. package/src/tests/test_offsetShellGrouping.js +85 -0
  378. package/src/tests/test_plane.js +4 -0
  379. package/src/tests/test_primitiveCone.js +11 -0
  380. package/src/tests/test_primitiveCube.js +7 -0
  381. package/src/tests/test_primitiveCylinder.js +8 -0
  382. package/src/tests/test_primitivePyramid.js +9 -0
  383. package/src/tests/test_primitiveSphere.js +17 -0
  384. package/src/tests/test_primitiveTorus.js +21 -0
  385. package/src/tests/test_pushFace.js +126 -0
  386. package/src/tests/test_sheetMetalContourFlange.js +125 -0
  387. package/src/tests/test_sheetMetal_features.js +80 -0
  388. package/src/tests/test_sketch_openLoop.js +45 -0
  389. package/src/tests/test_solidMetrics.js +58 -0
  390. package/src/tests/test_stlLoader.js +1889 -0
  391. package/src/tests/test_sweepFace.js +55 -0
  392. package/src/tests/test_tube.js +45 -0
  393. package/src/tests/test_tube_closedLoop.js +67 -0
  394. package/src/tests/tests.js +493 -0
  395. package/src/tools/assemblyConstraintDialogCapturePage.js +56 -0
  396. package/src/tools/dialogCapturePageFactory.js +227 -0
  397. package/src/tools/featureDialogCapturePage.js +47 -0
  398. package/src/tools/pmiAnnotationDialogCapturePage.js +60 -0
  399. package/src/utils/axisHelpers.js +99 -0
  400. package/src/utils/deepClone.js +69 -0
  401. package/src/utils/geometryTolerance.js +37 -0
  402. package/src/utils/normalizeTypeString.js +8 -0
  403. package/src/utils/xformMath.js +51 -0
@@ -0,0 +1,4095 @@
1
+ // SketchMode3D: In-scene sketch editing overlay (no camera locking).
2
+
3
+ import * as THREE from "three";
4
+ import { ConstraintSolver } from "../../features/sketch/sketchSolver2D/ConstraintEngine.js";
5
+ import { updateListHighlights, applyHoverAndSelectionColors } from "./highlights.js";
6
+ import { renderDimensions as dimsRender } from "./dimensions.js";
7
+ import { AccordionWidget } from "../AccordionWidget.js";
8
+ import { deepClone } from "../../utils/deepClone.js";
9
+
10
+ export class SketchMode3D {
11
+ constructor(viewer, featureID) {
12
+ this.viewer = viewer;
13
+ this.featureID = featureID;
14
+ this._ui = null;
15
+ this._lock = null; // { basis:{x,y,z,origin} }
16
+ // Editing state
17
+ this._solver = null;
18
+ this._sketchGroup = null;
19
+ this._raycaster = new THREE.Raycaster();
20
+ this._drag = { active: false, pointId: null };
21
+ this._pendingDrag = { pointId: null, x: 0, y: 0, started: false };
22
+ // Geometry dragging (move all points of a curve)
23
+ this._dragGeo = { active: false, ids: [], startUV: { u: 0, v: 0 }, pointsStart: null };
24
+ this._pendingGeo = { ids: null, x: 0, y: 0, startUV: null, started: false };
25
+ // Track clicks on blank canvas area to clear selection on click (not drag)
26
+ this._blankDown = { active: false, x: 0, y: 0 };
27
+ this._selection = new Set();
28
+ this._hover = null; // current hovered item {type,id}
29
+ this._tool = "select";
30
+ this._ctxBar = null;
31
+ // Handle sizing helpers
32
+ this._handleGeom = new THREE.SphereGeometry(1, 12, 8); // unit sphere scaled per-frame
33
+ this._lastHandleScale = -1;
34
+ this._sizeRAF = 0;
35
+ // Dimension overlay state
36
+ this._dimRoot = null; // HTML overlay container for dimensions
37
+ this._dimOffsets = new Map(); // constraintId -> {du,dv} in plane space
38
+ this._dimSVG = null; // SVG element for leaders/arrows (deprecated for leaders)
39
+ this._dim3D = null; // THREE.Group for 3D leaders/arrows on plane
40
+ this._dragDim = {
41
+ active: false,
42
+ cid: null,
43
+ sx: 0,
44
+ sy: 0,
45
+ start: { dx: 0, dy: 0 },
46
+ };
47
+ // Track SKETCH groups we hide while editing so we can restore visibility
48
+ this._hiddenSketches = [];
49
+ // No clipping plane; orientation must do the work
50
+ // Reference object used for plane basis/orientation
51
+ this._refObj = null;
52
+ // Sketch undo/redo state
53
+ this._undoStack = [];
54
+ this._redoStack = [];
55
+ this._undoMax = 50;
56
+ this._undoTimer = null;
57
+ this._undoSignature = null;
58
+ this._undoReady = false;
59
+ this._undoApplying = false;
60
+ this._undoButtons = { undo: null, redo: null };
61
+ }
62
+
63
+ open() {
64
+ const v = this.viewer;
65
+ if (!v) return;
66
+
67
+ // Align camera to face/plane (look flat at the sketch reference)
68
+ // while preserving current camera distance.
69
+
70
+ // Find the sketch reference object
71
+ const ph = v.partHistory;
72
+ const feature = Array.isArray(ph?.features)
73
+ ? ph.features.find((f) => f?.inputParams?.featureID === this.featureID)
74
+ : null;
75
+ const refName = feature?.inputParams?.sketchPlane || null;
76
+ const refObj = refName ? ph.scene.getObjectByName(refName) : null;
77
+ this._refObj = refObj || null;
78
+
79
+ // Compute basis from reference (fallback to world XY), prefer persisted basis
80
+ let basis = null;
81
+ const saved = feature?.persistentData?.basis || null;
82
+ const savedMatchesRef = saved && (saved.refName === refName);
83
+ if (saved && savedMatchesRef) {
84
+ basis = {
85
+ x: new THREE.Vector3().fromArray(saved.x),
86
+ y: new THREE.Vector3().fromArray(saved.y),
87
+ z: new THREE.Vector3().fromArray(saved.z),
88
+ origin: new THREE.Vector3().fromArray(saved.origin),
89
+ };
90
+
91
+ } else {
92
+ basis = this.#basisFromReference(refObj);
93
+ // Persist freshly computed basis tagged with refName so future edits reuse it
94
+ try {
95
+ if (feature) {
96
+ feature.persistentData = feature.persistentData || {};
97
+ feature.persistentData.basis = {
98
+ origin: [basis.origin.x, basis.origin.y, basis.origin.z],
99
+ x: [basis.x.x, basis.x.y, basis.x.z],
100
+ y: [basis.y.x, basis.y.y, basis.y.z],
101
+ z: [basis.z.x, basis.z.y, basis.z.z],
102
+ refName: refName || undefined,
103
+ };
104
+ }
105
+ } catch { }
106
+ }
107
+
108
+ // Basis used for projecting points to/from world; also align camera now
109
+ const pivotBasis = basis.origin.clone();
110
+ // Compute a better visual pivot: world-space center of the reference object (face/plane)
111
+ let pivotLook = pivotBasis.clone();
112
+ try {
113
+ if (refObj) {
114
+ refObj.updateWorldMatrix(true, true);
115
+ // Prefer world-space bounding box center
116
+ const box = new THREE.Box3().setFromObject(refObj);
117
+ if (box && !box.isEmpty()) {
118
+ pivotLook.copy(box.getCenter(new THREE.Vector3()));
119
+ } else {
120
+ // Fallback to bounding sphere center in local -> world
121
+ const g = refObj.geometry;
122
+ const bs = g && (g.boundingSphere || (g.computeBoundingSphere(), g.boundingSphere));
123
+ if (bs) pivotLook.copy(refObj.localToWorld(bs.center.clone()));
124
+ else pivotLook.copy(refObj.getWorldPosition(new THREE.Vector3()));
125
+ }
126
+ }
127
+ } catch { }
128
+ const currentDist = v.camera.position.distanceTo(pivotLook);
129
+ this._lock = { basis, distance: currentDist || 20 };
130
+
131
+ // Reposition and orient camera to face the sketch plane head-on.
132
+ try {
133
+ const cam = v.camera;
134
+ const dist = Math.max(0.01, Math.abs(this._lock.distance || 20));
135
+ const z = basis.z.clone().normalize();
136
+ // Ensure we view the front side of the reference face/plane
137
+ let viewDir = z.clone();
138
+ try {
139
+ const faceBasis = basis.rawNormal
140
+ ? { z: basis.rawNormal }
141
+ : (refObj ? this.#basisFromReference(refObj) : null);
142
+ const faceNormal = faceBasis?.z?.clone()?.normalize();
143
+ if (faceNormal && viewDir.dot(faceNormal) < 0) {
144
+ viewDir.multiplyScalar(-1);
145
+ }
146
+ } catch { }
147
+ const y = basis.y.clone().normalize();
148
+ const pos = pivotLook.clone().add(viewDir.multiplyScalar(dist));
149
+ cam.position.copy(pos);
150
+ cam.up.copy(y);
151
+ cam.lookAt(pivotLook);
152
+ cam.updateMatrixWorld(true);
153
+ // Align Arcball target/pivot to the face center so first drag won't jump
154
+ try { if (v.controls) v.controls.target.copy(pivotLook); } catch { }
155
+ try { v.controls && v.controls._gizmos && v.controls._gizmos.position && v.controls._gizmos.position.copy(pivotLook); } catch { }
156
+ // Sync internal control matrices and gizmo size/state
157
+ try { v.controls && v.controls.update && v.controls.update(); } catch { }
158
+ // Ensure gizmo matrices are current before snapshotting state (prevents first-pan jump)
159
+ try { v.controls && v.controls._gizmos && v.controls._gizmos.updateMatrixWorld && v.controls._gizmos.updateMatrixWorld(true); } catch { }
160
+ try { v.controls && v.controls.updateMatrixState && v.controls.updateMatrixState(); } catch { }
161
+ try { v.render && v.render(); } catch { }
162
+ } catch { }
163
+
164
+ // Keep other sketch groups visible so they can be referenced while editing
165
+ this._hiddenSketches = [];
166
+
167
+ // Attach lightweight UI while reusing the app sidebar + toolbar layout.
168
+ this.#mountOverlayUI();
169
+ this.#mountSketchSidebar();
170
+ this.#mountTopToolbar();
171
+ this.#mountContextBar();
172
+
173
+ // Init solver with persisted sketch
174
+ const initialSketch = feature?.persistentData?.sketch || null;
175
+ this._solver = new ConstraintSolver({
176
+ sketch: initialSketch || undefined,
177
+ getSelectionItems: () => Array.from(this._selection),
178
+ updateCanvas: () => this.#rebuildSketchGraphics(),
179
+ notifyUser: (m) => {
180
+ try {
181
+ } catch { }
182
+ },
183
+ });
184
+
185
+ // Initialize solver settings
186
+ this._solverSettings = {
187
+ maxIterations: 500,
188
+ tolerance: 0.00001,
189
+ decimalPlaces: 6,
190
+ autoCleanupOrphans: true
191
+ };
192
+
193
+ // Load persisted dimension offsets (plane-space {du,dv}) if present
194
+ try {
195
+ const savedOffsets = feature?.persistentData?.sketchDimOffsets || null;
196
+ if (savedOffsets && typeof savedOffsets === "object") {
197
+ this._dimOffsets = new Map();
198
+ for (const [k, v] of Object.entries(savedOffsets)) {
199
+ const cid = isNaN(+k) ? k : +k;
200
+ if (v && typeof v === "object") {
201
+ if (v.d !== undefined) {
202
+ const d = Number(v.d) || 0;
203
+ this._dimOffsets.set(cid, { d });
204
+ } else if (v.dr !== undefined || v.dp !== undefined) {
205
+ const dr = Number(v.dr) || 0;
206
+ const dp = Number(v.dp) || 0;
207
+ this._dimOffsets.set(cid, { dr, dp });
208
+ } else {
209
+ const du = Number(v.du) || 0;
210
+ const dv = Number(v.dv) || 0;
211
+ this._dimOffsets.set(cid, { du, dv });
212
+ }
213
+ }
214
+ }
215
+ }
216
+ } catch { }
217
+
218
+ // Initialize undo stack after solver + dimension offsets are ready
219
+ this.#initSketchUndo();
220
+
221
+ // Build editing group
222
+ this._sketchGroup = new THREE.Group();
223
+ this._sketchGroup.renderOrder = 9999; // render last
224
+ this._sketchGroup.name = `__SKETCH_EDIT__:${this.featureID}`;
225
+ v.scene.add(this._sketchGroup);
226
+ // Dimension 3D group
227
+ this._dim3D = new THREE.Group();
228
+ this._dim3D.renderOrder = 9998; // just before sketch group
229
+ this._dim3D.name = `__SKETCH_DIMS__:${this.featureID}`;
230
+ v.scene.add(this._dim3D);
231
+
232
+ // No special camera layers needed
233
+ this.#rebuildSketchGraphics();
234
+
235
+ // Refresh external reference points to current model projection
236
+ try { this.#refreshExternalPointsPositions(true); } catch { }
237
+
238
+ // Removed debug vectors (camera ray + triangle normals)
239
+
240
+ // Mount label overlay root and initial render
241
+ this.#mountDimRoot();
242
+ this.#renderDimensions();
243
+
244
+ // Keep handles a constant screen size while zooming (no camera relock)
245
+ const tick = () => {
246
+ try {
247
+ this.#updateHandleSizes();
248
+ } catch { }
249
+ // Removed debug vector updates
250
+ // Light auto-refresh for external reference points (every ~300ms)
251
+ try {
252
+ const now = performance.now ? performance.now() : Date.now();
253
+ this._lastExtRefresh = this._lastExtRefresh || 0;
254
+ if (now - this._lastExtRefresh > 300) {
255
+ this._lastExtRefresh = now;
256
+ this.#refreshExternalPointsPositions(false);
257
+ }
258
+ } catch { }
259
+ this._sizeRAF = requestAnimationFrame(tick);
260
+ };
261
+ this._sizeRAF = requestAnimationFrame(tick);
262
+
263
+ // Pointer listeners for sketch interactions (no camera panning)
264
+ const el = v.renderer.domElement;
265
+ this._onMove = (e) => this.#onPointerMove(e);
266
+ this._onDown = (e) => this.#onPointerDown(e);
267
+ this._onUp = (e) => this.#onPointerUp(e);
268
+ el.addEventListener("pointermove", this._onMove, { passive: false });
269
+ // Use capture to prevent ArcballControls from starting spins on dimension/point/curve clicks
270
+ el.addEventListener("pointerdown", this._onDown, { passive: false, capture: true });
271
+ window.addEventListener("pointerup", this._onUp, {
272
+ passive: false,
273
+ capture: true,
274
+ });
275
+ // ESC key clears selection
276
+ this._onKeyDown = (ev) => {
277
+ const dialogOpen = (typeof window !== 'undefined') &&
278
+ (((typeof window.isDialogOpen === 'function') && window.isDialogOpen()) || window.__BREPDialogOpen);
279
+ if (dialogOpen) return; // Ignore shortcuts when a modal dialog is shown
280
+ const target = ev?.target || null;
281
+ const tag = target?.tagName ? String(target.tagName).toLowerCase() : '';
282
+ const isEditable = !!(
283
+ target
284
+ && (target.isContentEditable
285
+ || tag === 'input'
286
+ || tag === 'textarea'
287
+ || tag === 'select')
288
+ );
289
+ const key = (ev?.key || '').toLowerCase();
290
+ const isMod = !!(ev?.ctrlKey || ev?.metaKey);
291
+ const isUndo = isMod && !ev?.altKey && key === 'z' && !ev?.shiftKey;
292
+ const isRedo = isMod && !ev?.altKey && (key === 'y' || (ev?.shiftKey && key === 'z'));
293
+ if ((isUndo || isRedo) && !isEditable) {
294
+ try {
295
+ if (isUndo) this.undo();
296
+ else this.redo();
297
+ ev.preventDefault();
298
+ ev.stopImmediatePropagation();
299
+ } catch { }
300
+ return;
301
+ }
302
+ const k = ev.key || ev.code || '';
303
+ if (k === 'Escape' || k === 'Esc') {
304
+ if (this._selection.size) {
305
+ this._selection.clear();
306
+ try { this.#refreshContextBar(); } catch { }
307
+ try { this.#rebuildSketchGraphics(); } catch { }
308
+ try { ev.preventDefault(); ev.stopPropagation(); } catch { }
309
+ }
310
+ return;
311
+ }
312
+ if (k === 'Delete' || k === 'Backspace') {
313
+ if (this._selection.size) {
314
+ this.#deleteSelection();
315
+ try { ev.preventDefault(); ev.stopPropagation(); } catch { }
316
+ }
317
+ return;
318
+ }
319
+ };
320
+ window.addEventListener('keydown', this._onKeyDown, { passive: false });
321
+ }
322
+
323
+ close() {
324
+ const v = this.viewer;
325
+ if (this._ui && v?.container) {
326
+ try {
327
+ v.container.removeChild(this._ui);
328
+ } catch { }
329
+ this._ui = null;
330
+ }
331
+ if (this._left && this._sidebarHost) {
332
+ try {
333
+ this._sidebarHost.removeChild(this._left);
334
+ } catch { }
335
+ this._left = null;
336
+ }
337
+ if (Array.isArray(this._sidebarPrevChildren)) {
338
+ for (const entry of this._sidebarPrevChildren) {
339
+ try {
340
+ if (entry?.el) entry.el.style.display = entry.display || "";
341
+ } catch { }
342
+ }
343
+ this._sidebarPrevChildren = null;
344
+ }
345
+ if (this._sidebarPrevState && this._sidebarHost) {
346
+ try {
347
+ this._sidebarHost.hidden = !!this._sidebarPrevState.hidden;
348
+ this._sidebarHost.style.display = this._sidebarPrevState.display || "";
349
+ this._sidebarHost.style.visibility = this._sidebarPrevState.visibility || "";
350
+ if (this._sidebarPrevState.opacity != null) {
351
+ this._sidebarHost.style.opacity = this._sidebarPrevState.opacity;
352
+ }
353
+ } catch { }
354
+ this._sidebarPrevState = null;
355
+ }
356
+ this._sidebarHost = null;
357
+ if (this._ctxBar && v?.container) {
358
+ try {
359
+ v.container.removeChild(this._ctxBar);
360
+ } catch { }
361
+ this._ctxBar = null;
362
+ }
363
+ if (this._sketchGroup && v?.scene) {
364
+ try {
365
+ v.scene.remove(this._sketchGroup);
366
+ } catch { }
367
+ this._sketchGroup = null;
368
+ }
369
+ if (this._dim3D && v?.scene) {
370
+ try {
371
+ v.scene.remove(this._dim3D);
372
+ } catch { }
373
+ this._dim3D = null;
374
+ }
375
+ // No debug vectors to clean up
376
+ // Do not restore or alter camera/controls
377
+ // No clipping plane to restore
378
+ // remove listeners
379
+ const el = v?.renderer?.domElement;
380
+ if (el) {
381
+ try {
382
+ el.removeEventListener("pointermove", this._onMove);
383
+ } catch { }
384
+ try {
385
+ el.removeEventListener("pointerdown", this._onDown, { capture: true });
386
+ } catch { }
387
+ }
388
+ try {
389
+ window.removeEventListener("pointerup", this._onUp, true);
390
+ } catch { }
391
+ try { window.removeEventListener('keydown', this._onKeyDown); } catch { }
392
+ if (this._undoTimer) {
393
+ try { clearTimeout(this._undoTimer); } catch { }
394
+ this._undoTimer = null;
395
+ }
396
+ this._undoReady = false;
397
+ this._lock = null;
398
+ try {
399
+ cancelAnimationFrame(this._sizeRAF);
400
+ } catch { }
401
+ // Remove dimension overlay
402
+ try {
403
+ if (this._dimRoot && v?.container) v.container.removeChild(this._dimRoot);
404
+ } catch { }
405
+ this._dimRoot = null;
406
+ this._dimOffsets.clear();
407
+
408
+ // No camera layer changes to restore
409
+
410
+ // Restore visibility of any SKETCH groups we hid on open
411
+ try {
412
+ if (Array.isArray(this._hiddenSketches)) {
413
+ for (const obj of this._hiddenSketches) {
414
+ if (obj && obj.type === 'SKETCH') obj.visible = true;
415
+ }
416
+ }
417
+ } catch { }
418
+ this._hiddenSketches = [];
419
+
420
+ // Restore toolbar buttons
421
+ try {
422
+ if (Array.isArray(this._toolbarButtons)) {
423
+ for (const btn of this._toolbarButtons) {
424
+ try { btn.remove(); } catch { }
425
+ }
426
+ }
427
+ if (Array.isArray(this._toolbarPrevButtons)) {
428
+ for (const entry of this._toolbarPrevButtons) {
429
+ try {
430
+ if (entry?.el) entry.el.style.display = entry.display || "";
431
+ } catch { }
432
+ }
433
+ }
434
+ } catch { }
435
+ this._toolbarButtons = null;
436
+ this._toolbarPrevButtons = null;
437
+ this._toolButtons = null;
438
+ this._undoButtons = { undo: null, redo: null };
439
+ }
440
+
441
+ dispose() {
442
+ this.close();
443
+ }
444
+
445
+ undo() {
446
+ this.#undoSketch();
447
+ }
448
+
449
+ redo() {
450
+ this.#redoSketch();
451
+ }
452
+
453
+ finish() {
454
+ // Persist dimension offsets onto the feature before delegating to viewer
455
+ try {
456
+ const ph = this.viewer?.partHistory;
457
+ const f = Array.isArray(ph?.features)
458
+ ? ph.features.find((x) => x?.inputParams?.featureID === this.featureID)
459
+ : null;
460
+ if (f) {
461
+ f.persistentData = f.persistentData || {};
462
+ const obj = {};
463
+ for (const [cid, off] of this._dimOffsets.entries()) {
464
+ if (off && typeof off.d === "number") {
465
+ obj[String(cid)] = { d: Number(off.d) };
466
+ } else if (off && (off.dr !== undefined || off.dp !== undefined)) {
467
+ obj[String(cid)] = {
468
+ dr: Number(off.dr) || 0,
469
+ dp: Number(off.dp) || 0,
470
+ };
471
+ } else {
472
+ obj[String(cid)] = {
473
+ du: Number(off?.du) || 0,
474
+ dv: Number(off?.dv) || 0,
475
+ };
476
+ }
477
+ }
478
+ f.persistentData.sketchDimOffsets = obj;
479
+ }
480
+ } catch { }
481
+
482
+ const sketch = this._solver ? this._solver.sketchObject : null;
483
+ try {
484
+ if (typeof this.viewer?.onSketchFinished === "function")
485
+ this.viewer.onSketchFinished(this.featureID, sketch);
486
+ } catch { }
487
+ this.close();
488
+ }
489
+
490
+ cancel() {
491
+ try {
492
+ if (typeof this.viewer?.onSketchCancelled === "function")
493
+ this.viewer.onSketchCancelled(this.featureID);
494
+ } catch { }
495
+ this.close();
496
+ }
497
+
498
+ // -------------------------- internals --------------------------
499
+ #mountOverlayUI() {
500
+ const v = this.viewer;
501
+ const host = v?.container;
502
+ if (!host) return;
503
+ const ui = document.createElement("div");
504
+ ui.style.position = "absolute";
505
+ ui.style.top = "8px";
506
+ ui.style.right = "8px";
507
+ ui.style.display = "flex";
508
+ ui.style.gap = "8px";
509
+ ui.style.zIndex = "1000";
510
+
511
+ const mk = (label, primary, onClick) => {
512
+ const b = document.createElement("button");
513
+ b.textContent = label;
514
+ b.style.appearance = "none";
515
+ b.style.border = "1px solid #262b36";
516
+ b.style.borderRadius = "8px";
517
+ b.style.padding = "6px 10px";
518
+ b.style.cursor = "pointer";
519
+ b.style.background = primary
520
+ ? "linear-gradient(180deg, rgba(110,168,254,.25), rgba(110,168,254,.15))"
521
+ : "rgba(255,255,255,.05)";
522
+ b.style.color = "#e6e6e6";
523
+ b.addEventListener("click", (e) => {
524
+ e.preventDefault();
525
+ onClick();
526
+ });
527
+ return b;
528
+ };
529
+ ui.appendChild(mk("Finish", true, () => this.finish()));
530
+ ui.appendChild(mk("Cancel", false, () => this.cancel()));
531
+ host.style.position = host.style.position || "relative";
532
+ host.appendChild(ui);
533
+ this._ui = ui;
534
+ }
535
+
536
+ #onPointerDown(e) {
537
+ let consumed = false; // whether we handled the event and should block controls
538
+ // Tool-based behavior
539
+ if (this._tool !== "select" && e.button === 0) {
540
+ // Pick Edges tool: click scene edges to add external refs
541
+ if (this._tool === "pickEdges") {
542
+ const hit = this.#hitTestSceneEdge(e);
543
+ if (hit && hit.object?.type === 'EDGE') {
544
+ this.#ensureExternalRefForEdge(hit.object);
545
+ this.#persistExternalRefs();
546
+ try { this._solver.solveSketch("full"); } catch { }
547
+ this.#rebuildSketchGraphics();
548
+ this.#refreshContextBar();
549
+ this.#renderExternalRefsList();
550
+ }
551
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
552
+ consumed = true;
553
+ return;
554
+ }
555
+
556
+ if (this._tool === "trim") {
557
+ const ghit = this.#hitTestGeometry(e);
558
+ if (ghit) {
559
+ const trimmed = this.#trimGeometry(ghit, e);
560
+ if (trimmed) {
561
+ this._selection.clear();
562
+ }
563
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
564
+ consumed = true;
565
+ }
566
+ return;
567
+ }
568
+
569
+ // Point tool: drop a new point directly on the sketch plane
570
+ if (this._tool === "point") {
571
+ const pid = this.#createPointAtCursor(e);
572
+ if (pid != null) {
573
+ this._selection.clear();
574
+ this.#refreshLists();
575
+ this.#refreshContextBar();
576
+ }
577
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
578
+ consumed = true;
579
+ return;
580
+ }
581
+
582
+ const hit = this.#hitTestPoint(e);
583
+ if (this._tool === "bezier" && hit == null) {
584
+ const inserted = this.#tryInsertBezierPointAtCursor(e);
585
+ if (inserted) {
586
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
587
+ consumed = true;
588
+ return;
589
+ }
590
+ }
591
+ let pid = hit;
592
+ if (pid == null) {
593
+ pid = this.#createPointAtCursor(e);
594
+ }
595
+ if (pid != null) {
596
+ // Geometry creation flows
597
+ if (this._tool === "line") {
598
+ this.#toggleSelection({ type: "point", id: pid });
599
+ if (
600
+ Array.from(this._selection).filter((i) => i.type === "point")
601
+ .length === 2
602
+ ) {
603
+ this._solver.geometryCreateLine();
604
+ this._selection.clear();
605
+ this.#rebuildSketchGraphics();
606
+ this.#refreshLists();
607
+ this.#refreshContextBar();
608
+ }
609
+ } else if (this._tool === "circle") {
610
+ this.#toggleSelection({ type: "point", id: pid });
611
+ if (
612
+ Array.from(this._selection).filter((i) => i.type === "point")
613
+ .length === 2
614
+ ) {
615
+ this._solver.geometryCreateCircle();
616
+ this._selection.clear();
617
+ this.#rebuildSketchGraphics();
618
+ this.#refreshLists();
619
+ this.#refreshContextBar();
620
+ }
621
+ } else if (this._tool === "rect") {
622
+ this.#toggleSelection({ type: "point", id: pid });
623
+ if (
624
+ Array.from(this._selection).filter((i) => i.type === "point")
625
+ .length === 2
626
+ ) {
627
+ this._solver.geometryCreateRectangle();
628
+ this._selection.clear();
629
+ this.#rebuildSketchGraphics();
630
+ this.#refreshLists();
631
+ this.#refreshContextBar();
632
+ }
633
+ } else if (this._tool === "arc") {
634
+ // Center -> start -> end ordering
635
+ this._arcSel = this._arcSel || { c: null, a: null };
636
+ if (!this._arcSel.c) {
637
+ this._arcSel.c = pid;
638
+ this.#toggleSelection({ type: "point", id: pid });
639
+ } else if (!this._arcSel.a) {
640
+ this._arcSel.a = pid;
641
+ this.#toggleSelection({ type: "point", id: pid });
642
+ } else {
643
+ const c = this._arcSel.c,
644
+ a = this._arcSel.a,
645
+ b = pid;
646
+ this._solver.createGeometry("arc", [c, a, b]);
647
+ this._solver.solveSketch("full");
648
+ this._arcSel = null;
649
+ this._selection.clear();
650
+ this.#rebuildSketchGraphics();
651
+ this.#refreshLists();
652
+ this.#refreshContextBar();
653
+ }
654
+ } else if (this._tool === "bezier") {
655
+ // Cubic Bezier: end0, ctrl0, ctrl1, end1 (4 points)
656
+ this._bezierSel = this._bezierSel || [];
657
+ this._bezierSel.push(pid);
658
+ this.#toggleSelection({ type: "point", id: pid });
659
+ if (this._bezierSel.length === 4) {
660
+ const [p0, p1, p2, p3] = this._bezierSel;
661
+ // Create the curve
662
+ this._solver.createGeometry("bezier", [p0, p1, p2, p3]);
663
+ // Also create construction guide lines so they can be constrained
664
+ try {
665
+ const sObj = this._solver.sketchObject;
666
+ // end0 -> ctrl0
667
+ this._solver.createGeometry("line", [p0, p1]);
668
+ const gid1 = Math.max(0, ...sObj.geometries.map(g => +g.id || 0));
669
+ const g1 = sObj.geometries.find(g => g.id === gid1);
670
+ if (g1) g1.construction = true;
671
+ // end1 -> ctrl1
672
+ this._solver.createGeometry("line", [p3, p2]);
673
+ const gid2 = Math.max(0, ...sObj.geometries.map(g => +g.id || 0));
674
+ const g2 = sObj.geometries.find(g => g.id === gid2);
675
+ if (g2) g2.construction = true;
676
+ } catch { }
677
+ this._bezierSel = null;
678
+ this._selection.clear();
679
+ this.#rebuildSketchGraphics();
680
+ this.#refreshLists();
681
+ this.#refreshContextBar();
682
+ }
683
+ }
684
+ }
685
+ if (e.button === 0) {
686
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
687
+ consumed = true;
688
+ }
689
+ return;
690
+ }
691
+
692
+ // Select tool: if clicking a point, arm a pending drag; else try dim/geometry; else pan
693
+ const hit = this.#hitTestPoint(e);
694
+ if (hit != null) {
695
+ // Disable camera controls immediately when pressing on a sketch point
696
+ if (e.button === 0) {
697
+ try { if (this.viewer?.controls) this.viewer.controls.enabled = false; } catch { }
698
+ }
699
+ // Prevent dragging of external reference points; allow selection only
700
+ try {
701
+ const f = this.#getSketchFeature();
702
+ const isExternal = (f?.persistentData?.externalRefs || []).some((r) => r.p0 === hit || r.p1 === hit);
703
+ if (isExternal) {
704
+ if (e.button === 0) {
705
+ this.#toggleSelection({ type: "point", id: hit });
706
+ this.#refreshContextBar();
707
+ this.#rebuildSketchGraphics();
708
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
709
+ }
710
+ consumed = true;
711
+ return;
712
+ }
713
+ } catch { }
714
+ // Prevent dragging of fixed sketch points
715
+ try {
716
+ const p = this._solver?.getPointById?.(hit);
717
+ if (p && p.fixed) {
718
+ if (e.button === 0) {
719
+ this.#toggleSelection({ type: "point", id: hit });
720
+ this.#refreshContextBar();
721
+ this.#rebuildSketchGraphics();
722
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
723
+ }
724
+ consumed = true;
725
+ return;
726
+ }
727
+ } catch { }
728
+ this._pendingDrag.pointId = hit;
729
+ this._pendingDrag.x = e.clientX;
730
+ this._pendingDrag.y = e.clientY;
731
+ this._pendingDrag.started = false;
732
+ consumed = true; // we are arming a drag → suppress controls
733
+ } else {
734
+ // Prefer selecting sketch geometry over constraints when clicking the canvas
735
+ const ghit = this.#hitTestGeometry(e);
736
+ if (ghit && e.button === 0) {
737
+ // Arm a pending geometry drag (translate its points together)
738
+ try {
739
+ const s = this._solver?.sketchObject;
740
+ const geo = (s?.geometries || []).find(g => g.id === parseInt(ghit.id));
741
+ const idsRaw = Array.isArray(geo?.points) ? geo.points.slice() : [];
742
+ const ids = Array.from(new Set(idsRaw.map(x => parseInt(x))));
743
+ // Filter out external reference or fixed points (not draggable)
744
+ const f = this.#getSketchFeature();
745
+ const ext = (f?.persistentData?.externalRefs || []);
746
+ const isExternal = (pid) => ext.some(r => r.p0 === pid || r.p1 === pid);
747
+ const movable = ids.filter(pid => {
748
+ const p = this._solver?.getPointById?.(pid);
749
+ return p && !p.fixed && !isExternal(pid);
750
+ });
751
+ const uv = this.#pointerToPlaneUV(e);
752
+ this._pendingGeo = { ids: movable, x: e.clientX, y: e.clientY, startUV: uv, started: false, geometryId: ghit.id };
753
+ } catch { this._pendingGeo = { ids: null, x: 0, y: 0, startUV: null, started: false, geometryId: null }; }
754
+ consumed = true;
755
+ return;
756
+ }
757
+ // Then try dimension leaders/graphics selection in canvas
758
+ const dhit = this.#hitTestDim(e);
759
+ if (dhit && e.button === 0) {
760
+ try { this.toggleSelectConstraint?.(dhit.cid); } catch { }
761
+ // Re-render dimension styling to reflect selection state
762
+ try { this.#renderDimensions(); } catch { }
763
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
764
+ consumed = true;
765
+ return;
766
+ }
767
+ // Finally, constraint glyph selection (non-dimension symbols)
768
+ const ghit2 = this.#hitTestGlyph(e);
769
+ if (ghit2 && e.button === 0) {
770
+ try { this.toggleSelectConstraint?.(ghit2.cid); } catch { }
771
+ try { this.#renderDimensions(); } catch { }
772
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
773
+ consumed = true;
774
+ return;
775
+ } else {
776
+ // Clicked empty space: do not consume so ArcballControls can spin the model.
777
+ // Arm a blank click so on pointerup we can clear selection if it wasn't a drag.
778
+ if (e.button === 0) {
779
+ this._blankDown.active = true;
780
+ this._blankDown.x = e.clientX;
781
+ this._blankDown.y = e.clientY;
782
+ }
783
+ }
784
+ }
785
+ if (consumed && e.button === 0) {
786
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
787
+ }
788
+ }
789
+
790
+ #onPointerMove(e) {
791
+ // Promote pending to active when moved sufficiently
792
+ const threshold = 4;
793
+ if (!this._drag.active && this._pendingDrag.pointId != null) {
794
+ const d = Math.hypot(
795
+ e.clientX - this._pendingDrag.x,
796
+ e.clientY - this._pendingDrag.y,
797
+ );
798
+ if (d >= threshold) {
799
+ this._drag.active = true;
800
+ this._drag.pointId = this._pendingDrag.pointId;
801
+ this._pendingDrag.started = true;
802
+ // Disable camera controls while dragging sketch points
803
+ try { if (this.viewer?.controls) this.viewer.controls.enabled = false; } catch { }
804
+ try { e.target.setPointerCapture?.(e.pointerId); } catch { }
805
+ }
806
+ }
807
+
808
+ // Promote pending geometry drag
809
+ if (!this._dragGeo.active && this._pendingGeo?.ids && Array.isArray(this._pendingGeo.ids)) {
810
+ const d = Math.hypot((e.clientX - (this._pendingGeo.x || 0)), (e.clientY - (this._pendingGeo.y || 0)));
811
+ if (d >= threshold && this._pendingGeo.ids.length > 0) {
812
+ this._dragGeo.active = true;
813
+ this._dragGeo.ids = this._pendingGeo.ids.slice();
814
+ this._dragGeo.startUV = this._pendingGeo.startUV || this.#pointerToPlaneUV(e) || { u: 0, v: 0 };
815
+ // Capture starting positions of all points
816
+ this._dragGeo.pointsStart = new Map();
817
+ try {
818
+ for (const pid of this._dragGeo.ids) {
819
+ const p = this._solver?.getPointById?.(pid);
820
+ if (p) this._dragGeo.pointsStart.set(pid, { x: p.x, y: p.y });
821
+ }
822
+ } catch { }
823
+ this._pendingGeo.started = true;
824
+ try { if (this.viewer?.controls) this.viewer.controls.enabled = false; } catch { }
825
+ try { e.target.setPointerCapture?.(e.pointerId); } catch { }
826
+ }
827
+ }
828
+
829
+ if (this._drag.active) {
830
+ const uv = this.#pointerToPlaneUV(e);
831
+ if (!uv) return;
832
+ const p = this._solver?.getPointById(this._drag.pointId);
833
+ if (p) {
834
+ if (p.fixed) {
835
+ // Do not move fixed points
836
+ try { e.preventDefault(); e.stopPropagation(); } catch { }
837
+ this._drag.active = false;
838
+ this._drag.pointId = null;
839
+ return;
840
+ }
841
+ p.x = uv.u;
842
+ p.y = uv.v;
843
+ this._solver.solveSketch("full");
844
+ this.#rebuildSketchGraphics();
845
+ }
846
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
847
+ return;
848
+ }
849
+ if (this._dragGeo.active) {
850
+ const uv = this.#pointerToPlaneUV(e);
851
+ if (uv) {
852
+ const du = uv.u - (this._dragGeo.startUV?.u || 0);
853
+ const dv = uv.v - (this._dragGeo.startUV?.v || 0);
854
+ try {
855
+ for (const pid of this._dragGeo.ids || []) {
856
+ const p = this._solver?.getPointById?.(pid);
857
+ const st = this._dragGeo.pointsStart?.get?.(pid);
858
+ if (p && st) { p.x = st.x + du; p.y = st.y + dv; }
859
+ }
860
+ } catch { }
861
+ try { this._solver.solveSketch("full"); } catch { }
862
+ this.#rebuildSketchGraphics();
863
+ }
864
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
865
+ return;
866
+ }
867
+ if (this._dragDim?.active) {
868
+ this.#moveDimDrag(e);
869
+ try { e.preventDefault(); e.stopImmediatePropagation?.(); e.stopPropagation(); } catch { }
870
+ return;
871
+ }
872
+ // Passive hover highlighting
873
+ {
874
+ // Edge/trim cursor hint
875
+ if (this._tool === 'pickEdges') {
876
+ const h = this.#hitTestSceneEdge(e);
877
+ try { this.viewer.renderer.domElement.style.cursor = h ? 'crosshair' : ''; } catch { }
878
+ } else if (this._tool === 'trim') {
879
+ const h = this.#hitTestGeometry(e);
880
+ try { this.viewer.renderer.domElement.style.cursor = h ? 'crosshair' : ''; } catch { }
881
+ }
882
+ const pid = this.#hitTestPoint(e);
883
+ if (pid != null) this.#setHover({ type: "point", id: pid });
884
+ else {
885
+ const gh = this.#hitTestGeometry(e);
886
+ if (gh) this.#setHover({ type: "geometry", id: gh.id });
887
+ else {
888
+ const dh = this.#hitTestDim(e) || this.#hitTestGlyph(e);
889
+ if (dh && dh.cid != null) this.#setHover({ type: 'constraint', id: dh.cid });
890
+ else this.#setHover(null);
891
+ }
892
+ }
893
+ }
894
+
895
+ // No manual camera panning or position changes
896
+ }
897
+
898
+ #onPointerUp(e) {
899
+ const draggedPointId = this._drag.active ? this._drag.pointId : null;
900
+ // If no drag happened, treat as selection toggle
901
+ if (
902
+ !this._drag.active &&
903
+ this._pendingDrag.pointId != null &&
904
+ !this._pendingDrag.started
905
+ ) {
906
+ // Toggle the clicked point without requiring a modifier key
907
+ this.#toggleSelection({ type: "point", id: this._pendingDrag.pointId });
908
+ this.#refreshContextBar();
909
+ this.#rebuildSketchGraphics();
910
+ }
911
+ // If geometry pending but not dragged, toggle its selection
912
+ if (!this._dragGeo.active && this._pendingGeo?.ids && this._pendingGeo.started === false) {
913
+ const gid = this._pendingGeo.geometryId != null ? parseInt(this._pendingGeo.geometryId) : null;
914
+ if (gid != null) {
915
+ this.#toggleSelection({ type: "geometry", id: gid });
916
+ this.#refreshContextBar();
917
+ this.#rebuildSketchGraphics();
918
+ }
919
+ }
920
+ // If pressed on blank space and didn't drag, clear selection
921
+ if (this._blankDown?.active) {
922
+ const threshold = (this.viewer && typeof this.viewer._dragThreshold === 'number') ? this.viewer._dragThreshold : 5;
923
+ const dx = (e.clientX || 0) - (this._blankDown.x || 0);
924
+ const dy = (e.clientY || 0) - (this._blankDown.y || 0);
925
+ const moved = Math.abs(dx) + Math.abs(dy) > threshold;
926
+ if (!this._drag.active && !this._pendingDrag.started && !this._dragDim?.active && !moved) {
927
+ if (this._selection.size) {
928
+ this._selection.clear();
929
+ this.#refreshContextBar();
930
+ this.#rebuildSketchGraphics();
931
+ }
932
+ }
933
+ this._blankDown.active = false;
934
+ }
935
+ // End any dimension drag
936
+ try {
937
+ if (this._dragDim?.active) this.#endDimDrag(e);
938
+ } catch { }
939
+ // End any geometry drag
940
+ if (this._dragGeo.active) {
941
+ this._dragGeo.active = false;
942
+ this._dragGeo.ids = [];
943
+ this._dragGeo.pointsStart = null;
944
+ try { if (this.viewer?.controls) this.viewer.controls.enabled = true; } catch { }
945
+ }
946
+ // If a point drag ended atop another point, add coincident constraint
947
+ if (draggedPointId != null) {
948
+ try { this.#maybeAddCoincidentOnDrop(draggedPointId); } catch { }
949
+ }
950
+ // Re-enable camera controls after any sketch drag
951
+ try { if (this.viewer?.controls) this.viewer.controls.enabled = true; } catch { }
952
+ try { this.#notifyControlsEnd(e); } catch { }
953
+ this._drag.active = false;
954
+ this._drag.pointId = null;
955
+ this._pendingDrag.pointId = null;
956
+ this._pendingDrag.started = false;
957
+ this._pendingGeo = { ids: null, x: 0, y: 0, startUV: null, started: false, geometryId: null };
958
+ }
959
+
960
+ #canvasClientSize(canvas) {
961
+ return {
962
+ width: canvas.clientWidth || canvas.width || 1,
963
+ height: canvas.clientHeight || canvas.height || 1,
964
+ };
965
+ }
966
+
967
+ #worldPerPixel(camera, width, height) {
968
+ if (camera && camera.isOrthographicCamera) {
969
+ const zoom =
970
+ typeof camera.zoom === "number" && camera.zoom > 0 ? camera.zoom : 1;
971
+ const wppX = (camera.right - camera.left) / (width * zoom);
972
+ const wppY = (camera.top - camera.bottom) / (height * zoom);
973
+ return Math.max(wppX, wppY);
974
+ }
975
+ // Perspective fallback
976
+ const dist = camera.position.length();
977
+ const fovRad = (camera.fov * Math.PI) / 180;
978
+ return (2 * Math.tan(fovRad / 2) * dist) / height;
979
+ }
980
+
981
+ #plane() {
982
+ const n = this._lock?.basis?.z?.clone();
983
+ const o = this._lock?.basis?.origin?.clone();
984
+ if (!n || !o) return null;
985
+ return new THREE.Plane().setFromNormalAndCoplanarPoint(n, o);
986
+ }
987
+
988
+ #pointerToPlaneUV(e) {
989
+ const v = this.viewer;
990
+ if (!v || !this._lock) return null;
991
+ const rect = v.renderer.domElement.getBoundingClientRect();
992
+ const ndc = new THREE.Vector2(
993
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
994
+ -(((e.clientY - rect.top) / rect.height) * 2 - 1),
995
+ );
996
+ this.#setRayFromCamera(ndc);
997
+ const pl = this.#plane();
998
+ if (!pl) return null;
999
+ const hit = new THREE.Vector3();
1000
+ const ok = this.#_intersectPlaneBothSides(this._raycaster.ray, pl, hit);
1001
+ if (!ok) return null;
1002
+ const o = this._lock.basis.origin;
1003
+ const bx = this._lock.basis.x;
1004
+ const by = this._lock.basis.y;
1005
+ const d = hit.clone().sub(o);
1006
+ return { u: d.dot(bx), v: d.dot(by) };
1007
+ }
1008
+
1009
+ #createPointAtUV(u, v, fixed = false) {
1010
+ if (!this._solver) return null;
1011
+ const s = this._solver.sketchObject;
1012
+ if (!s) return null;
1013
+ const pts = Array.isArray(s.points) ? s.points : (s.points = []);
1014
+ const nextId = Math.max(0, ...pts.map((p) => +p.id || 0)) + 1;
1015
+ pts.push({ id: nextId, x: u, y: v, fixed: !!fixed });
1016
+ return nextId;
1017
+ }
1018
+
1019
+ #createPointAtCursor(e) {
1020
+ if (!this._solver) return null;
1021
+ const uv = this.#pointerToPlaneUV(e);
1022
+ if (!uv) return null;
1023
+ const nextId = this.#createPointAtUV(uv.u, uv.v, false);
1024
+ if (nextId == null) return null;
1025
+ try { this._solver.solveSketch("full"); } catch { }
1026
+ this.#rebuildSketchGraphics();
1027
+ return nextId;
1028
+ }
1029
+
1030
+ #createConstructionLine(aId, bId) {
1031
+ if (!this._solver) return;
1032
+ try {
1033
+ const sObj = this._solver.sketchObject;
1034
+ this._solver.createGeometry("line", [aId, bId]);
1035
+ const gid = Math.max(0, ...sObj.geometries.map((g) => +g.id || 0));
1036
+ const g = sObj.geometries.find((geo) => geo.id === gid);
1037
+ if (g) g.construction = true;
1038
+ } catch { }
1039
+ }
1040
+
1041
+ #closestBezierParam(p0, p1, p2, p3, uv, segs = 64) {
1042
+ const distToSeg = (ax, ay, bx, by, px, py) => {
1043
+ const vx = bx - ax, vy = by - ay;
1044
+ const wx = px - ax, wy = py - ay;
1045
+ const L2 = vx * vx + vy * vy || 1e-12;
1046
+ let t = (wx * vx + wy * vy) / L2;
1047
+ if (t < 0) t = 0; else if (t > 1) t = 1;
1048
+ const nx = ax + vx * t, ny = ay + vy * t;
1049
+ return { d: Math.hypot(px - nx, py - ny), t };
1050
+ };
1051
+
1052
+ let bestT = null;
1053
+ let bestD = Infinity;
1054
+ let prevx = p0.x, prevy = p0.y, prevt = 0;
1055
+ for (let i = 1; i <= segs; i++) {
1056
+ const t = i / segs;
1057
+ const mt = 1 - t;
1058
+ const bx = mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x;
1059
+ const by = mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y;
1060
+ const hit = distToSeg(prevx, prevy, bx, by, uv.u, uv.v);
1061
+ if (hit.d < bestD) {
1062
+ bestD = hit.d;
1063
+ bestT = prevt + hit.t * (t - prevt);
1064
+ }
1065
+ prevx = bx; prevy = by; prevt = t;
1066
+ }
1067
+ return bestT == null ? null : { t: bestT, dist: bestD };
1068
+ }
1069
+
1070
+ #tryInsertBezierPointAtCursor(e) {
1071
+ if (!this._solver || !this._lock) return false;
1072
+ const s = this._solver.sketchObject;
1073
+ if (!s) return false;
1074
+ const uv = this.#pointerToPlaneUV(e);
1075
+ if (!uv) return false;
1076
+ const v = this.viewer;
1077
+ const { width, height } = this.#canvasClientSize(v.renderer.domElement);
1078
+ const wpp = this.#worldPerPixel(v.camera, width, height);
1079
+ const tol = Math.max(0.05, wpp * 6);
1080
+
1081
+ let best = null;
1082
+ for (const geo of s.geometries || []) {
1083
+ if (geo.type !== "bezier" || !Array.isArray(geo.points) || geo.points.length < 4) continue;
1084
+ const ids = geo.points;
1085
+ const segCount = Math.floor((ids.length - 1) / 3);
1086
+ if (segCount < 1) continue;
1087
+ for (let seg = 0; seg < segCount; seg++) {
1088
+ const i0 = seg * 3;
1089
+ const p0 = s.points.find((p) => p.id === ids[i0]);
1090
+ const p1 = s.points.find((p) => p.id === ids[i0 + 1]);
1091
+ const p2 = s.points.find((p) => p.id === ids[i0 + 2]);
1092
+ const p3 = s.points.find((p) => p.id === ids[i0 + 3]);
1093
+ if (!p0 || !p1 || !p2 || !p3) continue;
1094
+ const closest = this.#closestBezierParam(p0, p1, p2, p3, uv);
1095
+ if (!closest) continue;
1096
+ if (!best || closest.dist < best.dist) {
1097
+ best = { geo, segmentIndex: seg, t: closest.t, dist: closest.dist };
1098
+ }
1099
+ }
1100
+ }
1101
+ if (!best || !Number.isFinite(best.t) || best.dist > tol) return false;
1102
+
1103
+ const geo = best.geo;
1104
+ const ids = geo.points;
1105
+ const segCount = Math.floor((ids.length - 1) / 3);
1106
+ if (segCount < 1) return false;
1107
+ const segmentIndex = best.segmentIndex;
1108
+ const t = best.t;
1109
+
1110
+ if (segmentIndex < 0 || segmentIndex >= segCount) return false;
1111
+ const base = segmentIndex * 3;
1112
+ const p0 = s.points.find((p) => p.id === ids[base]);
1113
+ const p1 = s.points.find((p) => p.id === ids[base + 1]);
1114
+ const p2 = s.points.find((p) => p.id === ids[base + 2]);
1115
+ const p3 = s.points.find((p) => p.id === ids[base + 3]);
1116
+ if (!p0 || !p1 || !p2 || !p3) return false;
1117
+
1118
+ const tt = Math.min(0.9999, Math.max(0.0001, t));
1119
+ if (!Number.isFinite(tt)) return false;
1120
+ const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
1121
+ const P0 = { x: p0.x, y: p0.y };
1122
+ const P1 = { x: p1.x, y: p1.y };
1123
+ const P2 = { x: p2.x, y: p2.y };
1124
+ const P3 = { x: p3.x, y: p3.y };
1125
+ // Split cubic bezier via de Casteljau to preserve shape.
1126
+ const q0 = lerp(P0, P1, tt);
1127
+ const q1 = lerp(P1, P2, tt);
1128
+ const q2 = lerp(P2, P3, tt);
1129
+ const r0 = lerp(q0, q1, tt);
1130
+ const r1 = lerp(q1, q2, tt);
1131
+ const sPt = lerp(r0, r1, tt);
1132
+
1133
+ // Update existing handles to preserve curve shape
1134
+ p1.x = q0.x; p1.y = q0.y;
1135
+ p2.x = q2.x; p2.y = q2.y;
1136
+
1137
+ const r0Id = this.#createPointAtUV(r0.x, r0.y, false);
1138
+ const sId = this.#createPointAtUV(sPt.x, sPt.y, false);
1139
+ const r1Id = this.#createPointAtUV(r1.x, r1.y, false);
1140
+ if (r0Id == null || sId == null || r1Id == null) return false;
1141
+
1142
+ // Insert new handle + anchor points between existing handles
1143
+ geo.points.splice(base + 2, 0, r0Id, sId, r1Id);
1144
+
1145
+ this.#createConstructionLine(sId, r0Id);
1146
+ this.#createConstructionLine(sId, r1Id);
1147
+
1148
+ try { this._solver.solveSketch("full"); } catch { }
1149
+ this._bezierSel = null;
1150
+ this._selection.clear();
1151
+ this._selection.add({ type: "point", id: sId });
1152
+ this.#rebuildSketchGraphics();
1153
+ this.#refreshContextBar();
1154
+ return true;
1155
+ }
1156
+
1157
+ // Helper: set ray from camera and shift origin far behind camera along ray direction
1158
+ #setRayFromCamera(ndc) {
1159
+ const v = this.viewer;
1160
+ this._raycaster.setFromCamera(ndc, v.camera);
1161
+ try {
1162
+ const ray = this._raycaster.ray;
1163
+ // Use a large offset relative to camera frustum, fallback to fixed large number
1164
+ const span = Math.abs((v.camera?.far ?? 0) - (v.camera?.near ?? 0)) || 1;
1165
+ const back = Math.max(1e6, span * 10);
1166
+ ray.origin.addScaledVector(ray.direction, -back);
1167
+ } catch { /* noop */ }
1168
+ }
1169
+
1170
+ // Allow ray-plane intersection even if the plane is behind the ray origin
1171
+ #_intersectPlaneBothSides(ray, plane, out = new THREE.Vector3()) {
1172
+ try {
1173
+ if (!ray || !plane) return null;
1174
+ if (ray.intersectPlane(plane, out)) return out;
1175
+ const flipped = new THREE.Ray(ray.origin.clone(), ray.direction.clone().negate());
1176
+ return flipped.intersectPlane(plane, out);
1177
+ } catch { return null; }
1178
+ }
1179
+
1180
+ #basisFromReference(obj) {
1181
+ const x = new THREE.Vector3(1, 0, 0);
1182
+ const y = new THREE.Vector3(0, 1, 0);
1183
+ const z = new THREE.Vector3(0, 0, 1);
1184
+ const origin = new THREE.Vector3(0, 0, 0);
1185
+ if (!obj) return { x, y, z, origin };
1186
+
1187
+ // Compute origin: object world position or centroid of geometry
1188
+ obj.updateWorldMatrix(true, true);
1189
+ origin.copy(obj.getWorldPosition(new THREE.Vector3()));
1190
+
1191
+ // If FACE, attempt to use its average normal and a stable X axis
1192
+ if (obj.type === "FACE" && typeof obj.getAverageNormal === "function") {
1193
+ // Raw normal from face triangles (may be inward)
1194
+ let n = obj.getAverageNormal();
1195
+ const rawN = n.clone();
1196
+ // origin ~ face centroid if available (used for outward test)
1197
+ try {
1198
+ const g = obj.geometry;
1199
+ const bs = g.boundingSphere || (g.computeBoundingSphere(), g.boundingSphere);
1200
+ if (bs) origin.copy(obj.localToWorld(bs.center.clone()));
1201
+ else origin.copy(obj.getWorldPosition(new THREE.Vector3()));
1202
+ } catch { origin.copy(obj.getWorldPosition(new THREE.Vector3())); }
1203
+
1204
+ // Determine solid center if possible
1205
+ let solidCenter = null;
1206
+ try {
1207
+ let solid = obj.parent;
1208
+ while (solid && solid.type !== 'SOLID') solid = solid.parent;
1209
+ if (solid) {
1210
+ const box = new THREE.Box3().setFromObject(solid);
1211
+ if (!box.isEmpty()) solidCenter = box.getCenter(new THREE.Vector3());
1212
+ }
1213
+ } catch { }
1214
+
1215
+ // If we know a center, align normal to point from center -> face (outward)
1216
+ let flipped = false;
1217
+ if (solidCenter) {
1218
+ const toFace = origin.clone().sub(solidCenter).normalize();
1219
+ if (toFace.lengthSq() > 0 && n.dot(toFace) < 0) { n.multiplyScalar(-1); flipped = true; }
1220
+ }
1221
+
1222
+ const worldUp = new THREE.Vector3(0, 1, 0);
1223
+ const tmp = new THREE.Vector3();
1224
+ const zx = Math.abs(n.dot(worldUp)) > 0.9 ? new THREE.Vector3(1, 0, 0) : worldUp; // pick a non-parallel ref
1225
+ x.copy(tmp.crossVectors(zx, n).normalize());
1226
+ y.copy(tmp.crossVectors(n, x).normalize());
1227
+ z.copy(n.clone().normalize());
1228
+ return { x, y, z, origin, rawNormal: rawN, flippedByCenter: flipped, solidCenter };
1229
+ }
1230
+
1231
+ // For generic Mesh (plane), derive z from its world normal
1232
+ const n = new THREE.Vector3(0, 0, 1)
1233
+ .applyQuaternion(obj.getWorldQuaternion(new THREE.Quaternion()))
1234
+ .normalize();
1235
+ const worldUp = new THREE.Vector3(0, 1, 0);
1236
+ const tmp = new THREE.Vector3();
1237
+ const zx =
1238
+ Math.abs(n.dot(worldUp)) > 0.9 ? new THREE.Vector3(1, 0, 0) : worldUp; // non-parallel ref
1239
+ x.copy(tmp.crossVectors(zx, n).normalize());
1240
+ y.copy(tmp.crossVectors(n, x).normalize());
1241
+ z.copy(n);
1242
+ return { x, y, z, origin, rawNormal: n.clone() };
1243
+ }
1244
+
1245
+
1246
+
1247
+ // ---------- UI + Drawing ----------
1248
+ #mountSketchSidebar() {
1249
+ const v = this.viewer;
1250
+ const host = v?.sidebar;
1251
+ if (!host) return;
1252
+ const acc = new AccordionWidget();
1253
+ this._sidebarHost = host;
1254
+ try {
1255
+ this._sidebarPrevState = {
1256
+ hidden: host.hidden,
1257
+ display: host.style.display,
1258
+ visibility: host.style.visibility,
1259
+ opacity: host.style.opacity,
1260
+ };
1261
+ host.hidden = false;
1262
+ if (host.style.display === "none") host.style.display = "";
1263
+ if (host.style.visibility === "hidden") host.style.visibility = "visible";
1264
+ } catch { }
1265
+ this._sidebarPrevChildren = Array.from(host.children || []).map((el) => ({
1266
+ el,
1267
+ display: el.style.display,
1268
+ }));
1269
+ for (const entry of this._sidebarPrevChildren) {
1270
+ try { if (entry?.el) entry.el.style.display = "none"; } catch { }
1271
+ }
1272
+ host.appendChild(acc.uiElement);
1273
+ this._left = acc.uiElement;
1274
+ this._acc = acc;
1275
+ (async () => {
1276
+ this._secConstraints = await acc.addSection("Constraints");
1277
+ this._secCurves = await acc.addSection("Curves");
1278
+ this._secPoints = await acc.addSection("Points");
1279
+ this._secSettings = await acc.addSection("Solver Settings");
1280
+ this._secExternal = await acc.addSection("External References");
1281
+ this.#mountExternalRefsUI();
1282
+ this.#mountSolverSettingsUI();
1283
+ this.#refreshLists();
1284
+ })();
1285
+ }
1286
+
1287
+ // Build UI for External References section
1288
+ #mountExternalRefsUI() {
1289
+ const sec = this._secExternal;
1290
+ if (!sec) return;
1291
+ const wrap = sec.uiElement;
1292
+ wrap.innerHTML = "";
1293
+ const row = document.createElement("div");
1294
+ row.style.display = "flex";
1295
+ row.style.gap = "6px";
1296
+ row.style.margin = "4px 0";
1297
+
1298
+ const addBtn = document.createElement("button");
1299
+ addBtn.textContent = "Add Selected Edges";
1300
+ addBtn.style.flex = "1";
1301
+ addBtn.style.background = "transparent";
1302
+ addBtn.style.color = "#ddd";
1303
+ addBtn.style.border = "1px solid #364053";
1304
+ addBtn.style.borderRadius = "6px";
1305
+ addBtn.style.padding = "4px 8px";
1306
+ addBtn.onclick = () => this.#addExternalReferencesFromSelection();
1307
+ row.appendChild(addBtn);
1308
+
1309
+ const refreshBtn = document.createElement("button");
1310
+ refreshBtn.textContent = "Refresh";
1311
+ refreshBtn.style.background = "transparent";
1312
+ refreshBtn.style.color = "#ddd";
1313
+ refreshBtn.style.border = "1px solid #364053";
1314
+ refreshBtn.style.borderRadius = "6px";
1315
+ refreshBtn.style.padding = "4px 8px";
1316
+ refreshBtn.onclick = () => this.#refreshExternalPointsPositions(true);
1317
+ row.appendChild(refreshBtn);
1318
+
1319
+ wrap.appendChild(row);
1320
+
1321
+ const list = document.createElement("div");
1322
+ list.className = "ext-ref-list";
1323
+ wrap.appendChild(list);
1324
+ this._extRefListEl = list;
1325
+
1326
+ this.#renderExternalRefsList();
1327
+ }
1328
+
1329
+ // Build UI for Solver Settings section
1330
+ #mountSolverSettingsUI() {
1331
+ const sec = this._secSettings;
1332
+ if (!sec) return;
1333
+ const wrap = sec.uiElement;
1334
+ wrap.innerHTML = "";
1335
+
1336
+ // Initialize default solver settings if not already set
1337
+ if (!this._solverSettings) {
1338
+ this._solverSettings = {
1339
+ maxIterations: 500,
1340
+ tolerance: 0.00001,
1341
+ decimalPlaces: 6,
1342
+ autoCleanupOrphans: true
1343
+ };
1344
+ }
1345
+
1346
+ // Create input fields for solver settings
1347
+ const createSettingRow = (label, key, type = "number", step = null, min = null, max = null) => {
1348
+ const row = document.createElement("div");
1349
+ row.style.display = "flex";
1350
+ row.style.alignItems = "center";
1351
+ row.style.gap = "6px";
1352
+ row.style.margin = "4px 0";
1353
+ row.style.fontSize = "12px";
1354
+
1355
+ const labelEl = document.createElement("label");
1356
+ labelEl.textContent = label;
1357
+ labelEl.style.color = "#ddd";
1358
+ labelEl.style.flex = "1";
1359
+ labelEl.style.minWidth = "80px";
1360
+ row.appendChild(labelEl);
1361
+
1362
+ const input = document.createElement("input");
1363
+ input.type = type;
1364
+ if (step !== null) input.step = step;
1365
+ if (min !== null) input.min = min;
1366
+ if (max !== null) input.max = max;
1367
+ input.value = this._solverSettings[key];
1368
+ input.style.background = "#2a3441";
1369
+ input.style.border = "1px solid #364053";
1370
+ input.style.borderRadius = "4px";
1371
+ input.style.color = "#ddd";
1372
+ input.style.padding = "4px 8px";
1373
+ input.style.width = "80px";
1374
+
1375
+ input.onchange = () => {
1376
+ const value = type === "number" ? parseFloat(input.value) || 0 : input.value;
1377
+ this._solverSettings[key] = value;
1378
+ this.#applySolverSettings();
1379
+ };
1380
+
1381
+ row.appendChild(input);
1382
+ return row;
1383
+ };
1384
+ const createCheckboxRow = (label, key) => {
1385
+ const row = document.createElement("div");
1386
+ row.style.display = "flex";
1387
+ row.style.alignItems = "center";
1388
+ row.style.gap = "6px";
1389
+ row.style.margin = "6px 0";
1390
+ row.style.fontSize = "12px";
1391
+
1392
+ const input = document.createElement("input");
1393
+ input.type = "checkbox";
1394
+ input.checked = !!this._solverSettings[key];
1395
+ input.onchange = () => {
1396
+ this._solverSettings[key] = !!input.checked;
1397
+ };
1398
+
1399
+ const labelEl = document.createElement("label");
1400
+ labelEl.textContent = label;
1401
+ labelEl.style.color = "#ddd";
1402
+ labelEl.style.flex = "1";
1403
+
1404
+ row.appendChild(input);
1405
+ row.appendChild(labelEl);
1406
+ return row;
1407
+ };
1408
+
1409
+ wrap.appendChild(createSettingRow("Max Iterations:", "maxIterations", "number", "1", "1", "10000"));
1410
+ wrap.appendChild(createSettingRow("Tolerance:", "tolerance", "number", "0.000001", "0.000001", "0.1"));
1411
+ wrap.appendChild(createSettingRow("Decimal Places:", "decimalPlaces", "number", "1", "1", "10"));
1412
+ wrap.appendChild(createCheckboxRow("Auto-remove orphan points", "autoCleanupOrphans"));
1413
+
1414
+ // Add a reset button
1415
+ const resetRow = document.createElement("div");
1416
+ resetRow.style.margin = "8px 0 4px 0";
1417
+
1418
+ const resetBtn = document.createElement("button");
1419
+ resetBtn.textContent = "Reset to Defaults";
1420
+ resetBtn.style.background = "transparent";
1421
+ resetBtn.style.color = "#ddd";
1422
+ resetBtn.style.border = "1px solid #364053";
1423
+ resetBtn.style.borderRadius = "6px";
1424
+ resetBtn.style.padding = "4px 8px";
1425
+ resetBtn.style.width = "100%";
1426
+ resetBtn.onclick = () => {
1427
+ this._solverSettings = {
1428
+ maxIterations: 500,
1429
+ tolerance: 0.00001,
1430
+ decimalPlaces: 6,
1431
+ autoCleanupOrphans: true
1432
+ };
1433
+ this.#mountSolverSettingsUI(); // Refresh the UI
1434
+ this.#applySolverSettings();
1435
+ };
1436
+ resetRow.appendChild(resetBtn);
1437
+ wrap.appendChild(resetRow);
1438
+
1439
+ // Add continuous solve button
1440
+ const continuousRow = document.createElement("div");
1441
+ continuousRow.style.margin = "8px 0 4px 0";
1442
+
1443
+ const continuousBtn = document.createElement("button");
1444
+ continuousBtn.textContent = "Hold to Solve Continuously";
1445
+ continuousBtn.style.background = "linear-gradient(135deg, #2c5f41, #3d7a56)";
1446
+ continuousBtn.style.color = "#fff";
1447
+ continuousBtn.style.border = "1px solid #4a8b65";
1448
+ continuousBtn.style.borderRadius = "6px";
1449
+ continuousBtn.style.padding = "6px 12px";
1450
+ continuousBtn.style.width = "100%";
1451
+ continuousBtn.style.cursor = "pointer";
1452
+ continuousBtn.style.transition = "all 0.2s ease";
1453
+
1454
+ // Variables to track continuous solving
1455
+ let isContinuousSolving = false;
1456
+
1457
+ continuousBtn.onmousedown = (e) => {
1458
+ e.preventDefault();
1459
+ if (isContinuousSolving) return;
1460
+
1461
+ isContinuousSolving = true;
1462
+ continuousBtn.textContent = "Solving... (release to stop)";
1463
+ continuousBtn.style.background = "linear-gradient(135deg, #5f2c2c, #7a3d3d)";
1464
+ continuousBtn.style.borderColor = "#8b4a4a";
1465
+
1466
+ // Start continuous solving
1467
+ const startContinuousSolve = () => {
1468
+ if (!isContinuousSolving) return;
1469
+
1470
+ try {
1471
+ if (this._solver) {
1472
+ this._solver.solveSketch("full");
1473
+ }
1474
+ } catch (error) {
1475
+ console.warn("Solver error during continuous solve:", error);
1476
+ }
1477
+
1478
+ if (isContinuousSolving) {
1479
+ requestAnimationFrame(startContinuousSolve);
1480
+ }
1481
+ };
1482
+
1483
+ startContinuousSolve();
1484
+ };
1485
+
1486
+ const stopContinuousSolve = () => {
1487
+ if (!isContinuousSolving) return;
1488
+
1489
+ isContinuousSolving = false;
1490
+ continuousBtn.textContent = "Hold to Solve Continuously";
1491
+ continuousBtn.style.background = "linear-gradient(135deg, #2c5f41, #3d7a56)";
1492
+ continuousBtn.style.borderColor = "#4a8b65";
1493
+ };
1494
+
1495
+ continuousBtn.onmouseup = stopContinuousSolve;
1496
+ continuousBtn.onmouseleave = stopContinuousSolve;
1497
+
1498
+ // Also handle touch events for mobile devices
1499
+ continuousBtn.ontouchstart = (e) => {
1500
+ e.preventDefault();
1501
+ continuousBtn.onmousedown(e);
1502
+ };
1503
+ continuousBtn.ontouchend = stopContinuousSolve;
1504
+ continuousBtn.ontouchcancel = stopContinuousSolve;
1505
+
1506
+ continuousRow.appendChild(continuousBtn);
1507
+ wrap.appendChild(continuousRow);
1508
+
1509
+ // Apply the current settings
1510
+ this.#applySolverSettings();
1511
+ }
1512
+
1513
+ // Apply solver settings to the actual solver
1514
+ #applySolverSettings() {
1515
+ if (!this._solver || !this._solverSettings) return;
1516
+
1517
+ // Update the solver's default methods
1518
+ this._solver.defaultLoops = () => this._solverSettings.maxIterations;
1519
+ this._solver.fullSolve = () => this._solverSettings.maxIterations;
1520
+
1521
+ // Update tolerance in constraint definitions (using dynamic import)
1522
+ import('../../features/sketch/sketchSolver2D/constraintDefinitions.js')
1523
+ .then(({ constraints }) => {
1524
+ if (constraints && typeof constraints.tolerance !== 'undefined') {
1525
+ constraints.tolerance = this._solverSettings.tolerance;
1526
+ }
1527
+ })
1528
+ .catch(error => {
1529
+ console.warn('Could not update solver tolerance:', error);
1530
+ });
1531
+ }
1532
+
1533
+ // Helper: get current Sketch feature object
1534
+ #getSketchFeature() {
1535
+ try {
1536
+ const ph = this.viewer?.partHistory;
1537
+ const f = Array.isArray(ph?.features)
1538
+ ? ph.features.find((x) => x?.inputParams?.featureID === this.featureID)
1539
+ : null;
1540
+ return f || null;
1541
+ } catch {
1542
+ return null;
1543
+ }
1544
+ }
1545
+
1546
+ #initSketchUndo() {
1547
+ this._undoStack = [];
1548
+ this._redoStack = [];
1549
+ this._undoSignature = null;
1550
+ this._undoApplying = false;
1551
+ this._undoReady = true;
1552
+ this.#pushSketchSnapshot({ force: true });
1553
+ this.#updateSketchUndoButtons();
1554
+ }
1555
+
1556
+ #computeSketchSignature(snapshot = null) {
1557
+ try {
1558
+ const sketch = snapshot?.sketch || this._solver?.sketchObject || null;
1559
+ const dimOffsets = snapshot?.dimOffsets || this._dimOffsets || null;
1560
+ const feature = this.#getSketchFeature();
1561
+ const externalRefs = snapshot?.externalRefs
1562
+ || feature?.persistentData?.externalRefs
1563
+ || [];
1564
+ const dimEntries = dimOffsets instanceof Map ? Array.from(dimOffsets.entries()) : dimOffsets;
1565
+ return JSON.stringify({ sketch, dimEntries, externalRefs });
1566
+ } catch {
1567
+ return null;
1568
+ }
1569
+ }
1570
+
1571
+ #captureSketchSnapshot() {
1572
+ if (!this._solver?.sketchObject) return null;
1573
+ const feature = this.#getSketchFeature();
1574
+ const dimOffsets = this._dimOffsets instanceof Map ? deepClone(this._dimOffsets) : new Map(this._dimOffsets || []);
1575
+ return {
1576
+ sketch: deepClone(this._solver.sketchObject),
1577
+ dimOffsets,
1578
+ externalRefs: deepClone(feature?.persistentData?.externalRefs || []),
1579
+ };
1580
+ }
1581
+
1582
+ #pushSketchSnapshot({ force = false } = {}) {
1583
+ if (!this._undoReady || this._undoApplying) return;
1584
+ const snap = this.#captureSketchSnapshot();
1585
+ if (!snap) return;
1586
+ const signature = this.#computeSketchSignature(snap);
1587
+ if (!force && signature && signature === this._undoSignature) return;
1588
+ this._undoStack.push(snap);
1589
+ if (this._undoStack.length > this._undoMax) this._undoStack.shift();
1590
+ this._redoStack.length = 0;
1591
+ this._undoSignature = signature || this._undoSignature;
1592
+ this.#updateSketchUndoButtons();
1593
+ }
1594
+
1595
+ #scheduleSketchSnapshot() {
1596
+ if (!this._undoReady || this._undoApplying) return;
1597
+ if (this._undoTimer) {
1598
+ try { clearTimeout(this._undoTimer); } catch { }
1599
+ }
1600
+ this._undoTimer = setTimeout(() => {
1601
+ this._undoTimer = null;
1602
+ this.#pushSketchSnapshot();
1603
+ }, 300);
1604
+ }
1605
+
1606
+ #applySketchSnapshot(snapshot) {
1607
+ if (!snapshot || !this._solver) return;
1608
+ this._undoApplying = true;
1609
+ try {
1610
+ this._solver.sketchObject = deepClone(snapshot.sketch || {});
1611
+ this._dimOffsets = snapshot.dimOffsets instanceof Map
1612
+ ? deepClone(snapshot.dimOffsets)
1613
+ : new Map(snapshot.dimOffsets || []);
1614
+ const feature = this.#getSketchFeature();
1615
+ if (feature) {
1616
+ feature.persistentData = feature.persistentData || {};
1617
+ feature.persistentData.externalRefs = deepClone(snapshot.externalRefs || []);
1618
+ }
1619
+ this._selection.clear();
1620
+ this.#rebuildSketchGraphics();
1621
+ this.#renderDimensions();
1622
+ try { this.#renderExternalRefsList(); } catch { }
1623
+ try { this.#refreshExternalPointsPositions(true); } catch { }
1624
+ this._undoSignature = this.#computeSketchSignature(snapshot);
1625
+ } catch { }
1626
+ this._undoApplying = false;
1627
+ this.#updateSketchUndoButtons();
1628
+ }
1629
+
1630
+ #undoSketch() {
1631
+ if (this._undoStack.length <= 1) return;
1632
+ const current = this._undoStack.pop();
1633
+ if (current) this._redoStack.push(current);
1634
+ const prev = this._undoStack[this._undoStack.length - 1];
1635
+ if (prev) this.#applySketchSnapshot(prev);
1636
+ }
1637
+
1638
+ #redoSketch() {
1639
+ if (!this._redoStack.length) return;
1640
+ const next = this._redoStack.pop();
1641
+ if (next) {
1642
+ this._undoStack.push(next);
1643
+ this.#applySketchSnapshot(next);
1644
+ }
1645
+ }
1646
+
1647
+ #updateSketchUndoButtons() {
1648
+ const undoBtn = this._undoButtons?.undo || null;
1649
+ const redoBtn = this._undoButtons?.redo || null;
1650
+ if (undoBtn) undoBtn.disabled = this._undoStack.length <= 1;
1651
+ if (redoBtn) redoBtn.disabled = this._redoStack.length === 0;
1652
+ }
1653
+
1654
+ // Helper: compute world endpoints for a BREP Edge object
1655
+ #edgeEndpointsWorld(edge) {
1656
+ if (!edge) return null;
1657
+ const toWorld = (v) => v.applyMatrix4(edge.matrixWorld);
1658
+ const a = new THREE.Vector3();
1659
+ const b = new THREE.Vector3();
1660
+ const pts = Array.isArray(edge?.userData?.polylineLocal)
1661
+ ? edge.userData.polylineLocal
1662
+ : null;
1663
+ if (pts && pts.length >= 2) {
1664
+ a.set(pts[0][0], pts[0][1], pts[0][2]);
1665
+ b.set(pts[pts.length - 1][0], pts[pts.length - 1][1], pts[pts.length - 1][2]);
1666
+ return { a: toWorld(a), b: toWorld(b) };
1667
+ }
1668
+ // Try fat-line geometry (Line2/LineSegments2) endpoints
1669
+ const aStart = edge?.geometry?.attributes?.instanceStart;
1670
+ const aEnd = edge?.geometry?.attributes?.instanceEnd;
1671
+ if (aStart && aEnd && aStart.count >= 1) {
1672
+ a.set(aStart.getX(0), aStart.getY(0), aStart.getZ(0));
1673
+ b.set(aEnd.getX(0), aEnd.getY(0), aEnd.getZ(0));
1674
+ return { a: toWorld(a), b: toWorld(b) };
1675
+ }
1676
+ const pos = edge?.geometry?.getAttribute?.("position");
1677
+ if (pos && pos.itemSize === 3 && pos.count >= 2) {
1678
+ a.set(pos.getX(0), pos.getY(0), pos.getZ(0));
1679
+ b.set(pos.getX(pos.count - 1), pos.getY(pos.count - 1), pos.getZ(pos.count - 1));
1680
+ return { a: toWorld(a), b: toWorld(b) };
1681
+ }
1682
+ return null;
1683
+ }
1684
+
1685
+ // Helper: project world point to current sketch UV
1686
+ #projectWorldToUV(world) {
1687
+ if (!this._lock?.basis) return { u: 0, v: 0 };
1688
+ const o = this._lock.basis.origin;
1689
+ const bx = this._lock.basis.x;
1690
+ const by = this._lock.basis.y;
1691
+ const d = world.clone().sub(o);
1692
+ return { u: d.dot(bx), v: d.dot(by) };
1693
+ }
1694
+
1695
+ // Ensure external refs exist for currently selected edges
1696
+ #addExternalReferencesFromSelection() {
1697
+ try {
1698
+ const scene = this.viewer?.partHistory?.scene;
1699
+ if (!scene || !this._solver) return;
1700
+ const edges = [];
1701
+ scene.traverse((obj) => { if (obj?.type === 'EDGE' && obj.selected) edges.push(obj); });
1702
+ if (!edges.length) return;
1703
+ for (const e of edges) this.#ensureExternalRefForEdge(e);
1704
+ this.#persistExternalRefs();
1705
+ this._solver.solveSketch("full");
1706
+ this.#rebuildSketchGraphics();
1707
+ this.#refreshContextBar();
1708
+ this.#renderExternalRefsList();
1709
+ } catch { }
1710
+ }
1711
+
1712
+ // Create mapping + points for edge if not present; else update positions
1713
+ #ensureExternalRefForEdge(edge) {
1714
+ const f = this.#getSketchFeature();
1715
+ if (!f || !this._solver || !edge) return;
1716
+ f.persistentData = f.persistentData || {};
1717
+ f.persistentData.externalRefs = Array.isArray(f.persistentData.externalRefs)
1718
+ ? f.persistentData.externalRefs
1719
+ : [];
1720
+ const refs = f.persistentData.externalRefs;
1721
+ let ref = refs.find((r) => r && (r.edgeId === edge.id || (r.edgeName && r.edgeName === edge.name)));
1722
+ const s = this._solver.sketchObject;
1723
+ const ends = this.#edgeEndpointsWorld(edge);
1724
+ if (!ends) return;
1725
+ const uvA = this.#projectWorldToUV(ends.a);
1726
+ const uvB = this.#projectWorldToUV(ends.b);
1727
+
1728
+ const nextPointId = () => Math.max(0, ...s.points.map((p) => +p.id || 0)) + 1;
1729
+
1730
+ if (!ref) {
1731
+ // Generate two unique point IDs for the edge endpoints.
1732
+ // Note: calling nextPointId() twice without pushing in between would return the same value.
1733
+ const id0 = nextPointId();
1734
+ const id1 = id0 + 1;
1735
+ const p0 = { id: id0, x: uvA.u, y: uvA.v, fixed: true };
1736
+ const p1 = { id: id1, x: uvB.u, y: uvB.v, fixed: true };
1737
+ s.points.push(p0, p1);
1738
+ const pushGround = (pid) => {
1739
+ const exists = s.constraints.some((c) => c.type === '⏚' && Array.isArray(c.points) && c.points[0] === pid);
1740
+ if (!exists) {
1741
+ const cid = Math.max(0, ...s.constraints.map((c) => +c.id || 0)) + 1;
1742
+ s.constraints.push({ id: cid, type: '⏚', points: [pid] });
1743
+ }
1744
+ };
1745
+ pushGround(p0.id);
1746
+ pushGround(p1.id);
1747
+ ref = { edgeId: edge.id, edgeName: edge.name || null, solidName: edge.parent?.name || null, p0: p0.id, p1: p1.id };
1748
+ refs.push(ref);
1749
+ } else {
1750
+ // Ensure referenced points exist and are distinct; repair legacy refs if needed
1751
+ let pt0 = s.points.find((p) => p.id === ref.p0);
1752
+ let pt1 = s.points.find((p) => p.id === ref.p1);
1753
+ if (!pt0) {
1754
+ const nid = nextPointId();
1755
+ pt0 = { id: nid, x: uvA.u, y: uvA.v, fixed: true };
1756
+ s.points.push(pt0);
1757
+ ref.p0 = nid;
1758
+ }
1759
+ if (!pt1 || ref.p1 === ref.p0) {
1760
+ const nid = Math.max(nextPointId(), pt0.id + 1);
1761
+ pt1 = { id: nid, x: uvB.u, y: uvB.v, fixed: true };
1762
+ s.points.push(pt1);
1763
+ ref.p1 = nid;
1764
+ }
1765
+ // Ensure stored name metadata stays fresh
1766
+ try { ref.edgeName = edge.name || ref.edgeName || null; } catch { }
1767
+ try { ref.solidName = edge.parent?.name || ref.solidName || null; } catch { }
1768
+ if (pt0) { pt0.x = uvA.u; pt0.y = uvA.v; pt0.fixed = true; }
1769
+ if (pt1) { pt1.x = uvB.u; pt1.y = uvB.v; pt1.fixed = true; }
1770
+ const ensureGround = (pid) => {
1771
+ const exists = s.constraints.some((c) => c.type === '⏚' && Array.isArray(c.points) && c.points[0] === pid);
1772
+ if (!exists) {
1773
+ const cid = Math.max(0, ...s.constraints.map((c) => +c.id || 0)) + 1;
1774
+ s.constraints.push({ id: cid, type: '⏚', points: [pid] });
1775
+ }
1776
+ };
1777
+ if (pt0) ensureGround(pt0.id);
1778
+ if (pt1) ensureGround(pt1.id);
1779
+ }
1780
+ }
1781
+
1782
+ // Refresh positions for all existing external refs; optionally solve
1783
+ #refreshExternalPointsPositions(runSolve) {
1784
+ const f = this.#getSketchFeature();
1785
+ if (!f || !Array.isArray(f?.persistentData?.externalRefs) || !this._solver) return;
1786
+ const scene = this.viewer?.partHistory?.scene;
1787
+ const s = this._solver.sketchObject;
1788
+ let changed = false;
1789
+ for (const ref of f.persistentData.externalRefs) {
1790
+ try {
1791
+ let edge = scene.getObjectById(ref.edgeId);
1792
+ if (!edge || edge.type !== 'EDGE') {
1793
+ // Fallback by name within solid, then global
1794
+ if (ref.solidName) {
1795
+ const solid = this.viewer?.partHistory?.scene?.getObjectByName(ref.solidName);
1796
+ if (solid) {
1797
+ let found = null;
1798
+ solid.traverse((obj) => { if (!found && obj.type === 'EDGE' && obj.name === ref.edgeName) found = obj; });
1799
+ if (found) edge = found;
1800
+ }
1801
+ }
1802
+ if ((!edge || edge.type !== 'EDGE') && ref.edgeName) {
1803
+ let found = null;
1804
+ this.viewer?.partHistory?.scene?.traverse((obj) => { if (!found && obj.type === 'EDGE' && obj.name === ref.edgeName) found = obj; });
1805
+ if (found) edge = found;
1806
+ }
1807
+ if (edge && edge.type === 'EDGE') {
1808
+ // refresh stored id/name metadata
1809
+ ref.edgeId = edge.id;
1810
+ ref.edgeName = edge.name || ref.edgeName || null;
1811
+ ref.solidName = edge.parent?.name || ref.solidName || null;
1812
+ }
1813
+ }
1814
+ if (!edge || edge.type !== 'EDGE') continue;
1815
+ const ends = this.#edgeEndpointsWorld(edge);
1816
+ if (!ends) continue;
1817
+ const uvA = this.#projectWorldToUV(ends.a);
1818
+ const uvB = this.#projectWorldToUV(ends.b);
1819
+ let pt0 = s.points.find((p) => p.id === ref.p0);
1820
+ let pt1 = s.points.find((p) => p.id === ref.p1);
1821
+ // Repair legacy refs with missing/duplicate endpoint IDs
1822
+ if (!pt0) {
1823
+ const nid = Math.max(0, ...s.points.map((p) => +p.id || 0)) + 1;
1824
+ pt0 = { id: nid, x: uvA.u, y: uvA.v, fixed: true };
1825
+ s.points.push(pt0);
1826
+ ref.p0 = nid;
1827
+ changed = true;
1828
+ }
1829
+ if (!pt1 || ref.p1 === ref.p0) {
1830
+ const nid = Math.max(0, ...s.points.map((p) => +p.id || 0)) + 1;
1831
+ // Ensure pt1 ID is distinct from pt0
1832
+ const id1 = (nid === pt0.id) ? nid + 1 : nid;
1833
+ pt1 = { id: id1, x: uvB.u, y: uvB.v, fixed: true };
1834
+ s.points.push(pt1);
1835
+ ref.p1 = id1;
1836
+ changed = true;
1837
+ }
1838
+ if (pt0 && (pt0.x !== uvA.u || pt0.y !== uvA.v)) { pt0.x = uvA.u; pt0.y = uvA.v; pt0.fixed = true; changed = true; }
1839
+ if (pt1 && (pt1.x !== uvB.u || pt1.y !== uvB.v)) { pt1.x = uvB.u; pt1.y = uvB.v; pt1.fixed = true; changed = true; }
1840
+ const ensureGround = (pid) => {
1841
+ const exists = s.constraints.some((c) => c.type === '⏚' && Array.isArray(c.points) && c.points[0] === pid);
1842
+ if (!exists) {
1843
+ const cid = Math.max(0, ...s.constraints.map((c) => +c.id || 0)) + 1;
1844
+ s.constraints.push({ id: cid, type: '⏚', points: [pid] });
1845
+ changed = true;
1846
+ }
1847
+ };
1848
+ if (pt0) ensureGround(pt0.id);
1849
+ if (pt1) ensureGround(pt1.id);
1850
+ } catch { }
1851
+ }
1852
+ if (changed || runSolve) {
1853
+ try { this._solver.solveSketch("full"); } catch { }
1854
+ this.#rebuildSketchGraphics();
1855
+ this.#refreshContextBar();
1856
+ this.#renderExternalRefsList();
1857
+ this.#persistExternalRefs();
1858
+ }
1859
+ }
1860
+
1861
+ // Persist refs (already on feature object)
1862
+ #persistExternalRefs() {
1863
+ const f = this.#getSketchFeature();
1864
+ if (!f) return;
1865
+ try { f.persistentData = f.persistentData || {}; } catch { }
1866
+ }
1867
+
1868
+ // Render the list of external references
1869
+ #renderExternalRefsList() {
1870
+ const list = this._extRefListEl;
1871
+ if (!list) return;
1872
+ const f = this.#getSketchFeature();
1873
+ const s = this._solver?.sketchObject;
1874
+ const refs = (f?.persistentData?.externalRefs) || [];
1875
+ const row = (label, act, del) => `
1876
+ <div class="sk-row" style="display:flex;align-items:center;gap:6px;margin:2px 0">
1877
+ <button data-ext-act="${act}" style="flex:1;text-align:left;background:transparent;color:#ddd;border:1px solid #364053;border-radius:4px;padding:3px 6px">${label}</button>
1878
+ <button data-ext-del="${del}" title="Unlink" style="color:#ffcf8b;background:transparent;border:1px solid #5b4a2b;border-radius:4px;padding:3px 6px">Unlink</button>
1879
+ </div>`;
1880
+ list.innerHTML = refs
1881
+ .map((r) => {
1882
+ const p0 = s?.points?.find((p) => p.id === r.p0);
1883
+ const p1 = s?.points?.find((p) => p.id === r.p1);
1884
+ const p0s = p0 ? `P${p0.id} (${p0.x.toFixed(2)}, ${p0.y.toFixed(2)})` : "?";
1885
+ const p1s = p1 ? `P${p1.id} (${p1.x.toFixed(2)}, ${p1.y.toFixed(2)})` : "?";
1886
+ return row(`Edge #${r.edgeId} → ${p0s}, ${p1s}`, `e:${r.edgeId}`, `e:${r.edgeId}`);
1887
+ })
1888
+ .join("");
1889
+
1890
+ list.onclick = (ev) => {
1891
+ const t = ev.target;
1892
+ if (!(t instanceof HTMLElement)) return;
1893
+ const del = t.getAttribute("data-ext-del");
1894
+ if (del) {
1895
+ const [_k, idStr] = del.split(":");
1896
+ const edgeId = parseInt(idStr);
1897
+ const f2 = this.#getSketchFeature();
1898
+ if (!f2) return;
1899
+ const arr = Array.isArray(f2?.persistentData?.externalRefs)
1900
+ ? f2.persistentData.externalRefs
1901
+ : [];
1902
+ const idx = arr.findIndex((r) => r.edgeId === edgeId);
1903
+ if (idx >= 0) {
1904
+ const r = arr[idx];
1905
+ arr.splice(idx, 1);
1906
+ try {
1907
+ const sObj = this._solver?.sketchObject;
1908
+ if (sObj) {
1909
+ sObj.constraints = sObj.constraints.filter((c) => !(c.type === '⏚' && Array.isArray(c.points) && (c.points[0] === r.p0 || c.points[0] === r.p1)));
1910
+ }
1911
+ } catch { }
1912
+ this.#persistExternalRefs();
1913
+ this._solver?.solveSketch("full");
1914
+ this.#rebuildSketchGraphics();
1915
+ this.#refreshContextBar();
1916
+ this.#renderExternalRefsList();
1917
+ }
1918
+ return;
1919
+ }
1920
+ const act = t.getAttribute("data-ext-act");
1921
+ if (act) {
1922
+ const [_k, idStr] = act.split(":");
1923
+ const edgeId = parseInt(idStr);
1924
+ const f2 = this.#getSketchFeature();
1925
+ const r = (f2?.persistentData?.externalRefs || []).find((x) => x.edgeId === edgeId);
1926
+ if (r) {
1927
+ this._selection.clear();
1928
+ if (this._solver?.getPointById(r.p0)) this._selection.add({ type: 'point', id: r.p0 });
1929
+ if (this._solver?.getPointById(r.p1)) this._selection.add({ type: 'point', id: r.p1 });
1930
+ this.#refreshContextBar();
1931
+ this.#rebuildSketchGraphics();
1932
+ }
1933
+ }
1934
+ };
1935
+ }
1936
+
1937
+ #mountTopToolbar() {
1938
+ const v = this.viewer;
1939
+ const toolbar = v?.mainToolbar;
1940
+ const container = toolbar?._left;
1941
+ if (!toolbar || !container) return;
1942
+ // Track buttons to reflect active tool
1943
+ this._toolButtons = this._toolButtons || new Map();
1944
+ this._toolbarButtons = [];
1945
+ this._toolbarPrevButtons = [];
1946
+ for (const child of Array.from(container.children)) {
1947
+ this._toolbarPrevButtons.push({ el: child, display: child.style.display });
1948
+ try { child.style.display = "none"; } catch { }
1949
+ }
1950
+
1951
+ const mk = ({ label, tool, tooltip }) => {
1952
+ const btn = toolbar.addCustomButton({
1953
+ label,
1954
+ title: tooltip,
1955
+ onClick: () => { this.#setTool(tool); },
1956
+ });
1957
+ if (!btn) return null;
1958
+ btn.setAttribute("data-tool", tool);
1959
+ if (tooltip) btn.setAttribute("aria-label", tooltip);
1960
+ btn.setAttribute("aria-pressed", "false");
1961
+ if (label && label.length <= 2) btn.classList.add("mtb-icon");
1962
+ this._toolButtons.set(tool, btn);
1963
+ this._toolbarButtons.push(btn);
1964
+ return btn;
1965
+ };
1966
+ const buttons = [
1967
+ { label: "👆", tool: "select", tooltip: "Select and edit sketch items" },
1968
+ { label: "✂", tool: "trim", tooltip: "Trim curve" },
1969
+ { label: "⌖", tool: "point", tooltip: "Create point" },
1970
+ { label: "/", tool: "line", tooltip: "Create line" },
1971
+ { label: "☐", tool: "rect", tooltip: "Create rectangle" },
1972
+ { label: "◯", tool: "circle", tooltip: "Create circle" },
1973
+ { label: "◠", tool: "arc", tooltip: "Create arc" },
1974
+ { label: "∿", tool: "bezier", tooltip: "Create Bezier curve" },
1975
+ { label: "🔗", tool: "pickEdges", tooltip: "Link external edge" },
1976
+ ];
1977
+ buttons.forEach((btn) => mk(btn));
1978
+ const mkAction = ({ label, tooltip, onClick }) => {
1979
+ const btn = toolbar.addCustomButton({
1980
+ label,
1981
+ title: tooltip,
1982
+ onClick,
1983
+ });
1984
+ if (!btn) return null;
1985
+ if (tooltip) btn.setAttribute("aria-label", tooltip);
1986
+ if (label && label.length <= 2) btn.classList.add("mtb-icon");
1987
+ this._toolbarButtons.push(btn);
1988
+ return btn;
1989
+ };
1990
+ this._undoButtons.undo = mkAction({
1991
+ label: "↶",
1992
+ tooltip: "Undo (Ctrl+Z)",
1993
+ onClick: () => this.undo(),
1994
+ });
1995
+ this._undoButtons.redo = mkAction({
1996
+ label: "↷",
1997
+ tooltip: "Redo (Ctrl+Y)",
1998
+ onClick: () => this.redo(),
1999
+ });
2000
+ this.#refreshTopToolbarActive();
2001
+ this.#updateSketchUndoButtons();
2002
+ }
2003
+
2004
+ #setTool(tool) {
2005
+ this._tool = tool;
2006
+ // Clear any pending creation state when switching tools
2007
+ try { this._arcSel = null; } catch { }
2008
+ try { this._bezierSel = null; } catch { }
2009
+ this.#refreshTopToolbarActive();
2010
+ }
2011
+
2012
+ #refreshTopToolbarActive() {
2013
+ if (!this._toolButtons) return;
2014
+ for (const [tool, btn] of this._toolButtons.entries()) {
2015
+ const active = (tool === this._tool);
2016
+ try {
2017
+ btn.classList.toggle("is-active", active);
2018
+ btn.setAttribute("aria-pressed", active ? "true" : "false");
2019
+ } catch { }
2020
+ }
2021
+ }
2022
+
2023
+ #mountContextBar() {
2024
+ const v = this.viewer;
2025
+ const host = v?.container;
2026
+ if (!host) return;
2027
+ const ctx = document.createElement("div");
2028
+ ctx.style.position = "absolute";
2029
+ ctx.style.top = "100px";
2030
+ ctx.style.right = "8px";
2031
+ ctx.style.display = "flex";
2032
+ ctx.style.gap = "6px";
2033
+ ctx.style.flexDirection = "column";
2034
+ ctx.style.alignItems = "stretch";
2035
+ ctx.style.background = "rgba(20,24,30,.85)";
2036
+ ctx.style.border = "1px solid #262b36";
2037
+ ctx.style.borderRadius = "8px";
2038
+ ctx.style.padding = "6px";
2039
+ ctx.style.color = "#ddd";
2040
+ ctx.style.minWidth = "40px";
2041
+ ctx.style.maxWidth = "150px";
2042
+ host.appendChild(ctx);
2043
+ this._ctxBar = ctx;
2044
+ this.#refreshContextBar();
2045
+ }
2046
+
2047
+ #refreshLists() {
2048
+ if (!this._acc || !this._solver) return;
2049
+ const s = this._solver.sketchObject;
2050
+ const row = (label, act, delAct) => `
2051
+ <div class=\"sk-row\" style=\"display:flex;align-items:center;gap:6px;margin:2px 0\">
2052
+ <button data-act=\"${act}\" style=\"flex:1;text-align:left;background:transparent;color:#ddd;border:1px solid #364053;border-radius:4px;padding:3px 6px\">${label}</button>
2053
+ <button data-del=\"${delAct}\" title=\"Delete\" style=\"color:#ff8b8b;background:transparent;border:1px solid #5b2b2b;border-radius:4px;padding:3px 6px\">✕</button>
2054
+ </div>`;
2055
+ if (this._secConstraints)
2056
+ this._secConstraints.uiElement.innerHTML = (s.constraints || [])
2057
+ .map((c) =>
2058
+ row(
2059
+ `${c.id} ${c.type} ${c.value ?? ""} [${c.points?.join(",")}]`,
2060
+ `c:${c.id}`,
2061
+ `c:${c.id}`,
2062
+ ),
2063
+ )
2064
+ .join("");
2065
+ if (this._secCurves)
2066
+ this._secCurves.uiElement.innerHTML = (s.geometries || [])
2067
+ .map((g) =>
2068
+ row(
2069
+ `${g.type}:${g.id} [${g.points?.join(",")}]`,
2070
+ `g:${g.id}`,
2071
+ `g:${g.id}`,
2072
+ ),
2073
+ )
2074
+ .join("");
2075
+ if (this._secPoints)
2076
+ this._secPoints.uiElement.innerHTML = (s.points || [])
2077
+ .map((p) =>
2078
+ row(
2079
+ `P${p.id} (${p.x.toFixed(2)}, ${p.y.toFixed(2)})${p.fixed ? " ⏚" : ""}`,
2080
+ `p:${p.id}`,
2081
+ `p:${p.id}`,
2082
+ ),
2083
+ )
2084
+ .join("");
2085
+ // Delegate clicks for selection
2086
+ this._acc.uiElement.onclick = (ev) => {
2087
+ const t = ev.target;
2088
+ if (!(t instanceof HTMLElement)) return;
2089
+ const del = t.getAttribute("data-del");
2090
+ if (del) {
2091
+ const [k, id] = del.split(":");
2092
+ if (k === "p") {
2093
+ try {
2094
+ this._solver.removePointById?.(parseInt(id));
2095
+ } catch { }
2096
+ }
2097
+ if (k === "g") {
2098
+ try {
2099
+ this._solver.removeGeometryById?.(parseInt(id));
2100
+ } catch { }
2101
+ }
2102
+ if (k === "c") {
2103
+ try {
2104
+ this._solver.removeConstraintById?.(parseInt(id));
2105
+ } catch { }
2106
+ }
2107
+ const cleaned = this.#maybeAutoCleanupPoints();
2108
+ if (!cleaned) {
2109
+ try { this._solver.solveSketch("full"); } catch { }
2110
+ this.#rebuildSketchGraphics();
2111
+ this.#refreshContextBar();
2112
+ }
2113
+ try { updateListHighlights(this); } catch { }
2114
+ return;
2115
+ }
2116
+ const act = t.getAttribute("data-act");
2117
+ if (!act) return;
2118
+ const [k, id] = act.split(":");
2119
+ if (k === "p") this.#toggleSelection({ type: "point", id: parseInt(id) });
2120
+ if (k === "g")
2121
+ this.#toggleSelection({ type: "geometry", id: parseInt(id) });
2122
+ if (k === "c") {
2123
+ this.#toggleSelection({ type: "constraint", id: parseInt(id) });
2124
+ }
2125
+ this.#refreshContextBar();
2126
+ };
2127
+
2128
+ // Hover sync from list to 3D
2129
+ this._acc.uiElement.onmousemove = (ev) => {
2130
+ const t = ev.target;
2131
+ if (!(t instanceof HTMLElement)) return;
2132
+ const act = t.getAttribute("data-act");
2133
+ if (!act) return this.#setHover(null);
2134
+ const [k, id] = act.split(":");
2135
+ if (k === "p") this.#setHover({ type: "point", id: parseInt(id) });
2136
+ else if (k === "g") this.#setHover({ type: "geometry", id: parseInt(id) });
2137
+ else if (k === "c") this.#setHover({ type: "constraint", id: parseInt(id) });
2138
+ };
2139
+ this._acc.uiElement.onmouseleave = () => this.#setHover(null);
2140
+
2141
+ // Immediately style with selection/hover states
2142
+ try { updateListHighlights(this); } catch { }
2143
+ }
2144
+
2145
+ #updateListHighlights() { try { updateListHighlights(this); } catch { } }
2146
+ #applyHoverAndSelectionColors() { try { applyHoverAndSelectionColors(this); } catch { } }
2147
+
2148
+ #refreshContextBar() {
2149
+ if (!this._ctxBar || !this._solver) return;
2150
+ const items = Array.from(this._selection);
2151
+ const s = this._solver.sketchObject;
2152
+ // Gather point coverage from selection
2153
+ const points = new Set(
2154
+ items.filter((i) => i.type === "point").map((i) => i.id),
2155
+ );
2156
+ const selectedPointIds = items
2157
+ .filter((i) => i.type === "point")
2158
+ .map((i) => parseInt(i.id))
2159
+ .filter((id) => Number.isFinite(id));
2160
+ const geos = items
2161
+ .filter((i) => i.type === "geometry")
2162
+ .map((i) => s.geometries.find((g) => g.id === parseInt(i.id)))
2163
+ .filter(Boolean);
2164
+ for (const g of geos) {
2165
+ const gp = g.type === "arc" ? g.points.slice(0, 2) : g.points;
2166
+ gp.forEach((pid) => points.add(pid));
2167
+ }
2168
+ const pointCount = points.size;
2169
+
2170
+ this._ctxBar.innerHTML = "";
2171
+ const appendButton = ({ label, tooltip, variant = "default", onClick }) => {
2172
+ const btn = document.createElement("button");
2173
+ btn.textContent = label;
2174
+ if (tooltip) {
2175
+ btn.title = tooltip;
2176
+ btn.setAttribute("aria-label", tooltip);
2177
+ }
2178
+ btn.style.background = "transparent";
2179
+ btn.style.borderRadius = "6px";
2180
+ btn.style.padding = "4px 8px";
2181
+ btn.style.width = "100%";
2182
+ btn.style.minHeight = "34px";
2183
+ btn.style.boxSizing = "border-box";
2184
+ if (variant === "danger") {
2185
+ btn.style.color = "#ff8b8b";
2186
+ btn.style.border = "1px solid #5b2b2b";
2187
+ } else {
2188
+ btn.style.color = "#ddd";
2189
+ btn.style.border = "1px solid #364053";
2190
+ }
2191
+ btn.onclick = onClick;
2192
+ this._ctxBar.appendChild(btn);
2193
+ return btn;
2194
+ };
2195
+ const addConstraintButton = ({ label, type, tooltip }) =>
2196
+ appendButton({
2197
+ label,
2198
+ tooltip,
2199
+ onClick: () => {
2200
+ this._solver.createConstraint(type, items);
2201
+ this.#refreshLists();
2202
+ this.#refreshContextBar();
2203
+ },
2204
+ });
2205
+ const addCleanupButton = () =>
2206
+ appendButton({
2207
+ label: "🧹",
2208
+ tooltip: "Remove unused points",
2209
+ onClick: () => this.#cleanupUnusedPoints(),
2210
+ });
2211
+ const addDeleteButton = () =>
2212
+ appendButton({
2213
+ label: "🗑",
2214
+ tooltip: "Delete selection",
2215
+ variant: "danger",
2216
+ onClick: () => this.#deleteSelection(),
2217
+ });
2218
+
2219
+ // Always offer cleanup
2220
+ addCleanupButton();
2221
+
2222
+ // Fix/Unfix selected points
2223
+ if (selectedPointIds.length) {
2224
+ const allFixed = selectedPointIds.every((pid) => this.#pointHasGround(pid));
2225
+ appendButton({
2226
+ label: allFixed ? "Unfix" : "Fix",
2227
+ tooltip: allFixed ? "Remove ground constraint" : "Add ground constraint",
2228
+ onClick: () => this.#toggleGroundConstraints(selectedPointIds, allFixed),
2229
+ });
2230
+ }
2231
+
2232
+ // Constraint-specific actions
2233
+ const constraintItems = items.filter((i) => i.type === "constraint");
2234
+ let selectedAngleConstraint = null;
2235
+ if (
2236
+ constraintItems.length === 1 &&
2237
+ Array.isArray(s?.constraints)
2238
+ ) {
2239
+ const cid = Number(constraintItems[0].id);
2240
+ selectedAngleConstraint = s.constraints.find((c) => Number(c.id) === cid) || null;
2241
+ }
2242
+ if (selectedAngleConstraint && selectedAngleConstraint.type === "∠") {
2243
+ appendButton({
2244
+ label: "Reverse Angle",
2245
+ tooltip: "Swap the angle measurement to the opposite side",
2246
+ onClick: () => {
2247
+ this.#reverseAngleConstraint(Number(selectedAngleConstraint.id));
2248
+ },
2249
+ });
2250
+
2251
+ appendButton({
2252
+ label: "Alternative Angle",
2253
+ tooltip: "Flip the first line direction and measure the other arc",
2254
+ onClick: () => {
2255
+ this.#alternativeAngleConstraint(Number(selectedAngleConstraint.id));
2256
+ },
2257
+ });
2258
+ }
2259
+
2260
+ // Construction toggle for selected geometry
2261
+ if (geos.length > 0) {
2262
+ const allCons = geos.every((g) => !!g.construction);
2263
+ appendButton({
2264
+ label: "◐",
2265
+ tooltip: allCons ? "Convert to regular geometry" : "Convert to construction geometry",
2266
+ onClick: () => {
2267
+ try { this._solver.toggleConstruction(); } catch { }
2268
+ try { this._solver.solveSketch("full"); } catch { }
2269
+ this.#rebuildSketchGraphics();
2270
+ this.#refreshLists();
2271
+ this.#refreshContextBar();
2272
+ },
2273
+ });
2274
+ }
2275
+
2276
+ // Arc/Circle → Radius / Diameter
2277
+ const oneArc =
2278
+ geos.length === 1 &&
2279
+ (geos[0]?.type === "arc" || geos[0]?.type === "circle");
2280
+ if (oneArc) {
2281
+ const mkAct = (label, mode, tooltip) =>
2282
+ appendButton({
2283
+ label,
2284
+ tooltip,
2285
+ onClick: () => {
2286
+ this.#addRadialDimension(mode, items);
2287
+ },
2288
+ });
2289
+ mkAct("R", "radius", "Create radius dimension");
2290
+ mkAct("⌀", "diameter", "Create diameter dimension");
2291
+ // Also allow delete
2292
+ addDeleteButton();
2293
+ return;
2294
+ }
2295
+
2296
+ // Geometry x Geometry (2 lines) → Parallel / Perp / Angle / Equal Length
2297
+ const twoLines = geos.length === 2 && geos.every((g) => g?.type === "line");
2298
+ if (twoLines) {
2299
+ addConstraintButton({ label: "∥", type: "∥", tooltip: "Parallel" });
2300
+ addConstraintButton({ label: "⟂", type: "⟂", tooltip: "Perpendicular" });
2301
+ addConstraintButton({ label: "∠", type: "∠", tooltip: "Angle" });
2302
+ addConstraintButton({ label: "⇌", type: "⇌", tooltip: "Equal distance" });
2303
+ addConstraintButton({ label: "⏛", type: "⏛", tooltip: "Point on line" });
2304
+ // Also allow delete when any selection exists
2305
+ if (items.length) addDeleteButton();
2306
+ return;
2307
+ }
2308
+
2309
+ // Geometry x Geometry (2 arcs/circles) → Equal Radius
2310
+ const twoRadial = geos.length === 2 && geos.every((g) => g && (g.type === "arc" || g.type === "circle"));
2311
+ if (twoRadial) {
2312
+ // Equal distance between center→rim pairs implies equal radii
2313
+ addConstraintButton({ label: "⇌", type: "⇌", tooltip: "Equal radius" });
2314
+ // Also allow delete when any selection exists
2315
+ if (items.length) addDeleteButton();
2316
+ return;
2317
+ }
2318
+
2319
+ // Geometry x Geometry (line + arc/circle) → Tangent (creates perpendicular constraint)
2320
+ const lineAndRadial = geos.length === 2 &&
2321
+ ((geos[0]?.type === "line" && (geos[1]?.type === "arc" || geos[1]?.type === "circle")) ||
2322
+ (geos[1]?.type === "line" && (geos[0]?.type === "arc" || geos[0]?.type === "circle")));
2323
+ if (lineAndRadial) {
2324
+ addConstraintButton({ label: "⟠", type: "⟂", tooltip: "Tangent" });
2325
+ // Also allow delete when any selection exists
2326
+ if (items.length) addDeleteButton();
2327
+ return;
2328
+ }
2329
+
2330
+ if (pointCount === 1) addConstraintButton({ label: "⏚", type: "⏚", tooltip: "Ground (fix point)" });
2331
+ if (pointCount === 2) {
2332
+ addConstraintButton({ label: "━", type: "━", tooltip: "Horizontal" });
2333
+ addConstraintButton({ label: "│", type: "│", tooltip: "Vertical" });
2334
+ addConstraintButton({ label: "≡", type: "≡", tooltip: "Coincident" });
2335
+ addConstraintButton({ label: "⟺", type: "⟺", tooltip: "Distance" });
2336
+ }
2337
+ if (pointCount === 3) {
2338
+ addConstraintButton({ label: "⋯", type: "⋯", tooltip: "Midpoint" });
2339
+ addConstraintButton({ label: "⏛", type: "⏛", tooltip: "Point on line" });
2340
+ addConstraintButton({ label: "∠", type: "∠", tooltip: "Angle" });
2341
+ }
2342
+
2343
+ // Generic Delete: show if any selection (points, curves, constraints)
2344
+ if (items.length) addDeleteButton();
2345
+ }
2346
+
2347
+ // Remove selected items (geometries first, then points) and refresh
2348
+ #deleteSelection() {
2349
+ try {
2350
+ const s = this._solver;
2351
+ if (!s) return;
2352
+ const items = Array.from(this._selection || []);
2353
+ // Delete constraints first
2354
+ for (const it of items)
2355
+ if (it?.type === "constraint") {
2356
+ try { s.removeConstraintById?.(parseInt(it.id)); } catch { }
2357
+ }
2358
+ // Delete geometries next to avoid dangling refs
2359
+ for (const it of items)
2360
+ if (it?.type === "geometry") {
2361
+ try {
2362
+ s.removeGeometryById?.(parseInt(it.id));
2363
+ } catch { }
2364
+ }
2365
+ for (const it of items)
2366
+ if (it?.type === "point") {
2367
+ try {
2368
+ s.removePointById?.(parseInt(it.id));
2369
+ } catch { }
2370
+ }
2371
+ this._selection.clear();
2372
+ const cleaned = this.#maybeAutoCleanupPoints();
2373
+ if (!cleaned) {
2374
+ try { s.solveSketch("full"); } catch { }
2375
+ this.#rebuildSketchGraphics();
2376
+ this.#refreshContextBar();
2377
+ }
2378
+ } catch { }
2379
+ }
2380
+
2381
+ // Remove points not used by any geometry and with no constraints,
2382
+ // or with only a single coincident/point-on-line constraint.
2383
+ #cleanupUnusedPoints() {
2384
+ const solver = this._solver;
2385
+ const sketch = solver?.sketchObject;
2386
+ if (!solver || !sketch) return 0;
2387
+
2388
+ const usedByGeo = new Set();
2389
+ for (const g of sketch.geometries || []) {
2390
+ if (!g || !Array.isArray(g.points)) continue;
2391
+ for (const pid of g.points) usedByGeo.add(parseInt(pid));
2392
+ }
2393
+
2394
+ const constraintsByPoint = new Map();
2395
+ for (const c of sketch.constraints || []) {
2396
+ if (!c || c.temporary || !Array.isArray(c.points)) continue;
2397
+ for (const pid of c.points) {
2398
+ const id = parseInt(pid);
2399
+ if (!Number.isFinite(id)) continue;
2400
+ const arr = constraintsByPoint.get(id);
2401
+ if (arr) arr.push(c);
2402
+ else constraintsByPoint.set(id, [c]);
2403
+ }
2404
+ }
2405
+
2406
+ const toRemove = [];
2407
+ for (const p of sketch.points || []) {
2408
+ const pid = parseInt(p.id);
2409
+ if (!Number.isFinite(pid) || pid === 0) continue; // never remove origin
2410
+ if (usedByGeo.has(pid)) continue;
2411
+ const cons = constraintsByPoint.get(pid) || [];
2412
+ if (cons.length === 0) {
2413
+ toRemove.push(pid);
2414
+ continue;
2415
+ }
2416
+ if (cons.length === 1) {
2417
+ const t = cons[0]?.type;
2418
+ if (t === "≡" || t === "⏛") toRemove.push(pid);
2419
+ }
2420
+ }
2421
+
2422
+ if (!toRemove.length) return 0;
2423
+ for (const pid of toRemove) {
2424
+ try { solver.removePointById?.(pid); } catch { }
2425
+ }
2426
+ try { solver.solveSketch("full"); } catch { }
2427
+ this._selection.clear();
2428
+ this.#rebuildSketchGraphics();
2429
+ this.#refreshLists();
2430
+ this.#refreshContextBar();
2431
+ return toRemove.length;
2432
+ }
2433
+
2434
+ #maybeAutoCleanupPoints() {
2435
+ if (!this._solverSettings?.autoCleanupOrphans) return 0;
2436
+ return this.#cleanupUnusedPoints();
2437
+ }
2438
+
2439
+ #pointHasGround(pid) {
2440
+ const s = this._solver?.sketchObject;
2441
+ if (!s || !Array.isArray(s.constraints)) return false;
2442
+ return s.constraints.some((c) => c?.type === "⏚" && Array.isArray(c.points) && parseInt(c.points[0]) === pid);
2443
+ }
2444
+
2445
+ #toggleGroundConstraints(pointIds, remove) {
2446
+ const solver = this._solver;
2447
+ const sketch = solver?.sketchObject;
2448
+ if (!solver || !sketch || !Array.isArray(pointIds) || !pointIds.length) return;
2449
+
2450
+ const ids = pointIds
2451
+ .map((id) => parseInt(id))
2452
+ .filter((id) => Number.isFinite(id) && id !== 0);
2453
+ if (!ids.length) return;
2454
+
2455
+ if (remove) {
2456
+ sketch.constraints = (sketch.constraints || []).filter((c) => {
2457
+ if (c?.type !== "⏚" || !Array.isArray(c.points)) return true;
2458
+ return !ids.includes(parseInt(c.points[0]));
2459
+ });
2460
+ for (const pid of ids) {
2461
+ const p = sketch.points?.find((pt) => parseInt(pt.id) === pid);
2462
+ if (p) p.fixed = false;
2463
+ }
2464
+ } else {
2465
+ const nextId = () => Math.max(0, ...(sketch.constraints || []).map((c) => +c.id || 0)) + 1;
2466
+ for (const pid of ids) {
2467
+ if (this.#pointHasGround(pid)) continue;
2468
+ const cid = nextId();
2469
+ sketch.constraints = sketch.constraints || [];
2470
+ sketch.constraints.push({ id: cid, type: "⏚", points: [pid] });
2471
+ const p = sketch.points?.find((pt) => parseInt(pt.id) === pid);
2472
+ if (p) p.fixed = true;
2473
+ }
2474
+ }
2475
+
2476
+ try { solver.solveSketch("full"); } catch { }
2477
+ this.#rebuildSketchGraphics();
2478
+ this.#refreshContextBar();
2479
+ }
2480
+
2481
+ #reverseAngleConstraint(cid) {
2482
+ const solver = this._solver;
2483
+ const sketch = solver?.sketchObject;
2484
+ if (!solver || !sketch || !Array.isArray(sketch.constraints)) return;
2485
+ const targetId = Number(cid);
2486
+ const constraint = sketch.constraints.find((c) => Number(c.id) === targetId);
2487
+ if (!constraint || constraint.type !== "∠") return;
2488
+ if (!Array.isArray(constraint.points) || constraint.points.length < 4) return;
2489
+
2490
+ const pts = constraint.points.slice();
2491
+ const swapped = [pts[3], pts[2], pts[0], pts[1], ...pts.slice(4)];
2492
+ constraint.points = swapped;
2493
+
2494
+ // Mirror any stored angle label offset so the annotation follows the flip
2495
+ const off = this._dimOffsets.get(constraint.id);
2496
+ if (off && (typeof off.du === "number" || typeof off.dv === "number")) {
2497
+ this._dimOffsets.set(constraint.id, {
2498
+ ...off,
2499
+ du: typeof off.du === "number" ? -off.du : off.du,
2500
+ dv: typeof off.dv === "number" ? -off.dv : off.dv,
2501
+ });
2502
+ }
2503
+
2504
+ constraint.value = null;
2505
+ if ("valueExpr" in constraint) delete constraint.valueExpr;
2506
+
2507
+ try { solver.solveSketch("full"); } catch { }
2508
+ this.#rebuildSketchGraphics();
2509
+ this.#refreshContextBar();
2510
+ }
2511
+
2512
+ #alternativeAngleConstraint(cid) {
2513
+ const solver = this._solver;
2514
+ const sketch = solver?.sketchObject;
2515
+ if (!solver || !sketch || !Array.isArray(sketch.constraints)) return;
2516
+ const targetId = Number(cid);
2517
+ const constraint = sketch.constraints.find((c) => Number(c.id) === targetId);
2518
+ if (!constraint || constraint.type !== "∠") return;
2519
+ if (!Array.isArray(constraint.points) || constraint.points.length < 2) return;
2520
+
2521
+ const pts = constraint.points.slice();
2522
+ const swapped = [pts[0], pts[1], pts[3], pts[2],];
2523
+ constraint.points = swapped;
2524
+ constraint.value = null;
2525
+ if ("valueExpr" in constraint) delete constraint.valueExpr;
2526
+
2527
+ try { solver.solveSketch("full"); } catch { }
2528
+ this.#rebuildSketchGraphics();
2529
+ this.#refreshContextBar();
2530
+ }
2531
+
2532
+ // Create a radial dimension visualization as a solver constraint
2533
+ #addRadialDimension(mode, items) {
2534
+ try {
2535
+ // Create a radius constraint via solver
2536
+ this._solver.createConstraint("⟺", items);
2537
+ // Find newest constraint
2538
+ const s = this._solver.sketchObject;
2539
+ const newest = (s.constraints || []).reduce(
2540
+ (a, b) => (+(a?.id || 0) > +b.id ? a : b),
2541
+ null,
2542
+ );
2543
+ if (!newest) return;
2544
+ // Set display style for visualization only
2545
+ newest.displayStyle = mode === "diameter" ? "diameter" : "radius";
2546
+ // Seed a default offset so text/leaders are visible outside the rim
2547
+ const rect = this.viewer.renderer.domElement.getBoundingClientRect();
2548
+ const base = Math.max(
2549
+ 0.1,
2550
+ this.#worldPerPixel(this.viewer.camera, rect.width, rect.height) * 10,
2551
+ );
2552
+ this._dimOffsets.set(newest.id, { dr: base * 0.5, dp: base * 0.5 });
2553
+ // Re-solve and redraw
2554
+ this._solver.solveSketch("full");
2555
+ this.#rebuildSketchGraphics();
2556
+ this.#refreshContextBar();
2557
+ } catch { }
2558
+ }
2559
+
2560
+ #toggleSelection(item) {
2561
+ const key = item.type + ":" + item.id;
2562
+ const existing = Array.from(this._selection).find(
2563
+ (s) => s.type + ":" + s.id === key,
2564
+ );
2565
+ if (existing) this._selection.delete(existing);
2566
+ else this._selection.add(item);
2567
+ try { updateListHighlights(this); } catch { }
2568
+ try { applyHoverAndSelectionColors(this); } catch { }
2569
+ // Keep dimension visuals in sync with constraint selection state
2570
+ try { this.#renderDimensions(); } catch { }
2571
+ // Ensure the corresponding list section is visible and the row is in view
2572
+ try { this.revealListForItem?.(item.type, item.id); } catch { }
2573
+ }
2574
+
2575
+ #setHover(item) {
2576
+ const prev = this._hover ? this._hover.type + ":" + this._hover.id : null;
2577
+ const next = item ? item.type + ":" + item.id : null;
2578
+ if (prev === next) return;
2579
+ this._hover = item;
2580
+ try { updateListHighlights(this); } catch { }
2581
+ try { applyHoverAndSelectionColors(this); } catch { }
2582
+ // Auto-expand and reveal hovered item in the list
2583
+ if (item && item.type && (item.id != null)) {
2584
+ try { this.revealListForItem?.(item.type, item.id); } catch { }
2585
+ }
2586
+ }
2587
+
2588
+ // Public: allow external UI (e.g., dim labels) to set hover on constraints
2589
+ hoverConstraintFromLabel(cid) {
2590
+ this.#setHover({ type: 'constraint', id: cid });
2591
+ try { this.revealListForItem?.('constraint', cid); } catch { }
2592
+ }
2593
+ clearHoverFromLabel(_cid) {
2594
+ // Only clear if we're not dragging a dimension
2595
+ if (this._dragDim?.active) return;
2596
+ this.#setHover(null);
2597
+ }
2598
+
2599
+ // Public: toggle select a constraint from label click
2600
+ toggleSelectConstraint(cid) {
2601
+ this.#toggleSelection({ type: 'constraint', id: cid });
2602
+ this.#refreshContextBar();
2603
+ this.#rebuildSketchGraphics();
2604
+ }
2605
+
2606
+ // Ensure the relevant accordion section is expanded and the row scrolled into view
2607
+ async revealListForItem(kind, id) {
2608
+ try {
2609
+ const acc = this._acc; if (!acc) return;
2610
+ const title = kind === 'point' ? 'Points' : (kind === 'geometry' ? 'Curves' : (kind === 'constraint' ? 'Constraints' : null));
2611
+ if (!title) return;
2612
+ // Expand the section
2613
+ try { await acc.expandSection(title); } catch { }
2614
+ // Find and scroll the row into view
2615
+ const root = acc.uiElement; if (!root) return;
2616
+ const key = (kind === 'point') ? `p:${id}` : (kind === 'geometry') ? `g:${id}` : `c:${id}`;
2617
+ const btn = root.querySelector(`[data-act="${key}"]`);
2618
+ const row = btn && btn.closest ? btn.closest('.sk-row') : null;
2619
+ if (row && typeof row.scrollIntoView === 'function') {
2620
+ try { row.scrollIntoView({ block: 'nearest' }); } catch { row.scrollIntoView(); }
2621
+ }
2622
+ } catch { /* noop */ }
2623
+ }
2624
+
2625
+ #hitTestPoint(e) {
2626
+ if (!this._sketchGroup || !this._solver) return null;
2627
+ const v = this.viewer;
2628
+ const uv = this.#pointerToPlaneUV(e);
2629
+ if (!uv) return null;
2630
+ const s = this._solver.sketchObject;
2631
+ const { width, height } = this.#canvasClientSize(v.renderer.domElement);
2632
+ const wpp = this.#worldPerPixel(v.camera, width, height);
2633
+ // Match handle radius used for point spheres
2634
+ const handleR = Math.max(0.02, wpp * 8 * 0.5);
2635
+ const tol = handleR * 1.2;
2636
+ let bestId = null, bestD = Infinity;
2637
+ for (const p of s.points || []) {
2638
+ const d = Math.hypot(uv.u - p.x, uv.v - p.y);
2639
+ if (d < bestD) { bestD = d; bestId = p.id; }
2640
+ }
2641
+ return (bestId != null && bestD <= tol) ? bestId : null;
2642
+ }
2643
+
2644
+ #maybeAddCoincidentOnDrop(pointId) {
2645
+ if (!this._solver || pointId == null) return;
2646
+ const s = this._solver.sketchObject;
2647
+ if (!s || !Array.isArray(s.points)) return;
2648
+ const p = this._solver.getPointById?.(pointId) || s.points.find((pp) => pp.id === pointId);
2649
+ if (!p) return;
2650
+ const v = this.viewer;
2651
+ if (!v?.renderer || !v?.camera) return;
2652
+ const { width, height } = this.#canvasClientSize(v.renderer.domElement);
2653
+ const wpp = this.#worldPerPixel(v.camera, width, height);
2654
+ const handleR = Math.max(0.02, wpp * 8 * 0.5);
2655
+ const tol = handleR * 1.2;
2656
+ let bestId = null;
2657
+ let bestD = Infinity;
2658
+ for (const q of s.points) {
2659
+ if (q.id === pointId) continue;
2660
+ const d = Math.hypot(p.x - q.x, p.y - q.y);
2661
+ if (d < bestD) { bestD = d; bestId = q.id; }
2662
+ }
2663
+ if (bestId == null || bestD > tol) return;
2664
+ const existing = (s.constraints || []).some((c) => {
2665
+ if (c?.type !== "≡" || !Array.isArray(c.points) || c.points.length < 2) return false;
2666
+ const a = c.points[0];
2667
+ const b = c.points[1];
2668
+ return (a === pointId && b === bestId) || (a === bestId && b === pointId);
2669
+ });
2670
+ if (existing) return;
2671
+ this._solver.createConstraint("≡", [
2672
+ { type: "point", id: pointId },
2673
+ { type: "point", id: bestId },
2674
+ ]);
2675
+ this.#refreshLists();
2676
+ this.#refreshContextBar();
2677
+ }
2678
+
2679
+ #hitTestGeometry(e) {
2680
+ // Prefer true closest distance in sketch plane (u,v) over ray hit order
2681
+ const v = this.viewer;
2682
+ if (!v || !this._solver || !this._lock) return null;
2683
+ const uv = this.#pointerToPlaneUV(e);
2684
+ if (!uv) return null;
2685
+ const s = this._solver.sketchObject;
2686
+ if (!s) return null;
2687
+
2688
+ // Tolerance based on screen scale (world units per pixel)
2689
+ const { width, height } = this.#canvasClientSize(v.renderer.domElement);
2690
+ const wpp = this.#worldPerPixel(v.camera, width, height);
2691
+ const tol = Math.max(0.05, wpp * 6);
2692
+
2693
+ let best = null;
2694
+ let bestDist = Infinity;
2695
+
2696
+ const distToSeg = (ax, ay, bx, by, px, py) => {
2697
+ const vx = bx - ax, vy = by - ay;
2698
+ const wx = px - ax, wy = py - ay;
2699
+ const L2 = vx * vx + vy * vy || 1e-12;
2700
+ let t = (wx * vx + wy * vy) / L2;
2701
+ if (t < 0) t = 0; else if (t > 1) t = 1;
2702
+ const nx = ax + vx * t, ny = ay + vy * t;
2703
+ const dx = px - nx, dy = py - ny;
2704
+ return Math.hypot(dx, dy);
2705
+ };
2706
+
2707
+ const normAng = (a) => {
2708
+ const twoPi = Math.PI * 2;
2709
+ a = a % twoPi; if (a < 0) a += twoPi; return a;
2710
+ };
2711
+
2712
+ for (const geo of s.geometries || []) {
2713
+ if (geo.type === 'line' && Array.isArray(geo.points) && geo.points.length >= 2) {
2714
+ const p0 = s.points.find(p => p.id === geo.points[0]);
2715
+ const p1 = s.points.find(p => p.id === geo.points[1]);
2716
+ if (!p0 || !p1) continue;
2717
+ const d = distToSeg(p0.x, p0.y, p1.x, p1.y, uv.u, uv.v);
2718
+ if (d < bestDist) { bestDist = d; best = { id: geo.id, type: 'line' }; }
2719
+ } else if (geo.type === 'circle' && Array.isArray(geo.points) && geo.points.length >= 2) {
2720
+ const pc = s.points.find(p => p.id === geo.points[0]);
2721
+ const pr = s.points.find(p => p.id === geo.points[1]);
2722
+ if (!pc || !pr) continue;
2723
+ const rr = Math.hypot(pr.x - pc.x, pr.y - pc.y);
2724
+ const d = Math.abs(Math.hypot(uv.u - pc.x, uv.v - pc.y) - rr);
2725
+ if (d < bestDist) { bestDist = d; best = { id: geo.id, type: 'circle' }; }
2726
+ } else if (geo.type === 'arc' && Array.isArray(geo.points) && geo.points.length >= 3) {
2727
+ const pc = s.points.find(p => p.id === geo.points[0]);
2728
+ const pa = s.points.find(p => p.id === geo.points[1]);
2729
+ const pb = s.points.find(p => p.id === geo.points[2]);
2730
+ if (!pc || !pa || !pb) continue;
2731
+ const cx = pc.x, cy = pc.y;
2732
+ const rr = Math.hypot(pa.x - cx, pa.y - cy);
2733
+ let a0 = Math.atan2(pa.y - cy, pa.x - cx);
2734
+ let a1 = Math.atan2(pb.y - cy, pb.x - cx);
2735
+ a0 = normAng(a0); a1 = normAng(a1);
2736
+ let dAng = a1 - a0; if (dAng < 0) dAng += Math.PI * 2; // CCW sweep [0,2π)
2737
+ // If start≈end, treat as full circle fallback
2738
+ const fullCircle = (Math.abs(dAng) < 1e-6);
2739
+ if (fullCircle) {
2740
+ const d = Math.abs(Math.hypot(uv.u - cx, uv.v - cy) - rr);
2741
+ if (d < bestDist) { bestDist = d; best = { id: geo.id, type: 'arc' }; }
2742
+ } else {
2743
+ // Project point angle to arc range
2744
+ let av = normAng(Math.atan2(uv.v - cy, uv.u - cx));
2745
+ let t = (av - a0); if (t < 0) t += Math.PI * 2; t = t / dAng;
2746
+ if (t < 0) t = 0; else if (t > 1) t = 1;
2747
+ const px = cx + rr * Math.cos(a0 + t * dAng);
2748
+ const py = cy + rr * Math.sin(a0 + t * dAng);
2749
+ const d = Math.hypot(uv.u - px, uv.v - py);
2750
+ if (d < bestDist) { bestDist = d; best = { id: geo.id, type: 'arc' }; }
2751
+ }
2752
+ } else if (geo.type === 'bezier' && Array.isArray(geo.points) && geo.points.length >= 4) {
2753
+ const ids = geo.points || [];
2754
+ const segCount = Math.floor((ids.length - 1) / 3);
2755
+ for (let seg = 0; seg < segCount; seg++) {
2756
+ const i0 = seg * 3;
2757
+ const p0 = s.points.find(p => p.id === ids[i0]);
2758
+ const p1 = s.points.find(p => p.id === ids[i0 + 1]);
2759
+ const p2 = s.points.find(p => p.id === ids[i0 + 2]);
2760
+ const p3 = s.points.find(p => p.id === ids[i0 + 3]);
2761
+ if (!p0 || !p1 || !p2 || !p3) continue;
2762
+ const closest = this.#closestBezierParam(p0, p1, p2, p3, uv, 64);
2763
+ if (!closest) continue;
2764
+ if (closest.dist < bestDist) {
2765
+ bestDist = closest.dist;
2766
+ best = { id: geo.id, type: 'bezier', segmentIndex: seg, t: closest.t };
2767
+ }
2768
+ }
2769
+ }
2770
+ }
2771
+
2772
+ if (best && bestDist <= tol) return best;
2773
+ return null;
2774
+ }
2775
+
2776
+ #trimGeometry(hit, e) {
2777
+ if (!this._solver) return false;
2778
+ const s = this._solver.sketchObject;
2779
+ if (!s) return false;
2780
+ const geo = (s.geometries || []).find(g => g && g.id === parseInt(hit.id));
2781
+ if (!geo) return false;
2782
+ const uv = this.#pointerToPlaneUV(e);
2783
+ if (!uv) return false;
2784
+
2785
+ const pointById = new Map((s.points || []).map((p) => [p.id, p]));
2786
+ const target = this.#sampleGeometry(geo, pointById);
2787
+ if (!target || !Array.isArray(target.samples) || target.samples.length < 2) return false;
2788
+
2789
+ const click = this.#closestParamOnSamples(uv, target.samples);
2790
+ if (!click || !Number.isFinite(click.param)) return false;
2791
+
2792
+ const targetEndpoints = this.#getGeometryEndpoints(geo, pointById);
2793
+ const intersections = [];
2794
+ const cache = new Map();
2795
+ let overlap = false;
2796
+ let endpointTouch = false;
2797
+ for (const other of (s.geometries || [])) {
2798
+ if (!other || other.id === geo.id) continue;
2799
+ let sample = cache.get(other.id);
2800
+ if (!sample) {
2801
+ sample = this.#sampleGeometry(other, pointById);
2802
+ cache.set(other.id, sample);
2803
+ }
2804
+ if (!sample) continue;
2805
+ this.#collectIntersections(target, sample, intersections, other);
2806
+ if (!overlap && this.#isTrimOverlap(geo, other, pointById)) overlap = true;
2807
+ if (!endpointTouch && targetEndpoints.length) {
2808
+ if (this.#endpointsTouchSample(targetEndpoints, sample)) endpointTouch = true;
2809
+ }
2810
+ }
2811
+
2812
+ const bounds = this.#selectTrimBounds(intersections, click.param, target);
2813
+ if (!bounds) {
2814
+ if (overlap || endpointTouch) {
2815
+ this._solver.removeGeometryById?.(geo.id);
2816
+ this._selection.clear();
2817
+ const cleaned = this.#maybeAutoCleanupPoints();
2818
+ if (!cleaned) {
2819
+ try { this._solver.solveSketch("full"); } catch { }
2820
+ this.#rebuildSketchGraphics();
2821
+ this.#refreshContextBar();
2822
+ }
2823
+ return true;
2824
+ }
2825
+ return false;
2826
+ }
2827
+
2828
+ let trimmed = false;
2829
+ if (geo.type === "line") trimmed = this.#trimLineGeometry(geo, bounds);
2830
+ else if (geo.type === "circle") trimmed = this.#trimCircleGeometry(geo, bounds);
2831
+ else if (geo.type === "arc") {
2832
+ if (target.closed) trimmed = this.#trimCircleGeometry(geo, bounds);
2833
+ else trimmed = this.#trimArcGeometry(geo, bounds);
2834
+ }
2835
+ else if (geo.type === "bezier") trimmed = this.#trimBezierGeometry(geo, bounds, target);
2836
+
2837
+ if (trimmed) {
2838
+ this._selection.clear();
2839
+ const cleaned = this.#maybeAutoCleanupPoints();
2840
+ if (!cleaned) {
2841
+ try { this._solver.solveSketch("full"); } catch { }
2842
+ this.#rebuildSketchGraphics();
2843
+ this.#refreshContextBar();
2844
+ }
2845
+ }
2846
+ return trimmed;
2847
+ }
2848
+
2849
+ #trimLineGeometry(geo, bounds) {
2850
+ const s = this._solver?.sketchObject;
2851
+ if (!s) return false;
2852
+ const ids = Array.isArray(geo?.points) ? geo.points : [];
2853
+ if (ids.length < 2) return false;
2854
+ const p0 = s.points.find(p => p.id === ids[0]);
2855
+ const p1 = s.points.find(p => p.id === ids[1]);
2856
+ if (!p0 || !p1) return false;
2857
+
2858
+ const epsParam = 1e-5;
2859
+ const usePrev = bounds.prev && bounds.prev.param > epsParam;
2860
+ const useNext = bounds.next && bounds.next.param < 1 - epsParam;
2861
+ if (!usePrev && !useNext) return false;
2862
+ if (usePrev && useNext && (bounds.next.param - bounds.prev.param) <= epsParam) return false;
2863
+
2864
+ const pointAt = (t) => {
2865
+ const x = p0.x + (p1.x - p0.x) * t;
2866
+ const y = p0.y + (p1.y - p0.y) * t;
2867
+ return { x, y };
2868
+ };
2869
+
2870
+ const prevId = usePrev
2871
+ ? this.#getOrCreatePointId(pointAt(bounds.prev.param))
2872
+ : ids[0];
2873
+ const nextId = useNext
2874
+ ? this.#getOrCreatePointId(pointAt(bounds.next.param))
2875
+ : ids[1];
2876
+
2877
+ const addLine = (aId, bId) => {
2878
+ if (aId == null || bId == null || aId === bId) return false;
2879
+ const a = s.points.find(p => p.id === aId);
2880
+ const b = s.points.find(p => p.id === bId);
2881
+ if (!a || !b) return false;
2882
+ if (Math.hypot(a.x - b.x, a.y - b.y) < 1e-7) return false;
2883
+ this.#addGeometry("line", [aId, bId], geo);
2884
+ return true;
2885
+ };
2886
+
2887
+ let added = false;
2888
+ if (usePrev) added = addLine(ids[0], prevId) || added;
2889
+ if (useNext) added = addLine(nextId, ids[1]) || added;
2890
+
2891
+ if (usePrev) this.#applyTrimIntersectionConstraint(prevId, bounds.prev);
2892
+ if (useNext) this.#applyTrimIntersectionConstraint(nextId, bounds.next);
2893
+
2894
+ if (added) this._solver.removeGeometryById?.(geo.id);
2895
+ return added;
2896
+ }
2897
+
2898
+ #trimArcGeometry(geo, bounds) {
2899
+ const s = this._solver?.sketchObject;
2900
+ if (!s) return false;
2901
+ const ids = Array.isArray(geo?.points) ? geo.points : [];
2902
+ if (ids.length < 3) return false;
2903
+ const pc = s.points.find(p => p.id === ids[0]);
2904
+ const pa = s.points.find(p => p.id === ids[1]);
2905
+ const pb = s.points.find(p => p.id === ids[2]);
2906
+ if (!pc || !pa || !pb) return false;
2907
+
2908
+ const r = Math.hypot(pa.x - pc.x, pa.y - pc.y);
2909
+ if (!Number.isFinite(r) || r < 1e-9) return false;
2910
+
2911
+ const epsParam = 1e-5;
2912
+ const usePrev = bounds.prev && bounds.prev.param > epsParam;
2913
+ const useNext = bounds.next && bounds.next.param < 1 - epsParam;
2914
+ if (!usePrev && !useNext) return false;
2915
+ if (usePrev && useNext && (bounds.next.param - bounds.prev.param) <= epsParam) return false;
2916
+
2917
+ const pointOnCircle = (inter) => {
2918
+ const ang = Math.atan2(inter.y - pc.y, inter.x - pc.x);
2919
+ return {
2920
+ x: pc.x + r * Math.cos(ang),
2921
+ y: pc.y + r * Math.sin(ang),
2922
+ };
2923
+ };
2924
+
2925
+ const prevId = usePrev ? this.#getOrCreatePointId(pointOnCircle(bounds.prev)) : ids[1];
2926
+ const nextId = useNext ? this.#getOrCreatePointId(pointOnCircle(bounds.next)) : ids[2];
2927
+
2928
+ const addArc = (startId, endId) => {
2929
+ if (startId == null || endId == null || startId === endId) return false;
2930
+ const a = s.points.find(p => p.id === startId);
2931
+ const b = s.points.find(p => p.id === endId);
2932
+ if (!a || !b) return false;
2933
+ if (Math.hypot(a.x - b.x, a.y - b.y) < 1e-7) return false;
2934
+ this.#addGeometry("arc", [ids[0], startId, endId], geo);
2935
+ return true;
2936
+ };
2937
+
2938
+ let added = false;
2939
+ if (usePrev) added = addArc(ids[1], prevId) || added;
2940
+ if (useNext) added = addArc(nextId, ids[2]) || added;
2941
+
2942
+ if (usePrev) {
2943
+ this.#applyTrimIntersectionConstraint(prevId, bounds.prev);
2944
+ this.#ensurePointOnArcConstraint(geo, prevId);
2945
+ }
2946
+ if (useNext) {
2947
+ this.#applyTrimIntersectionConstraint(nextId, bounds.next);
2948
+ this.#ensurePointOnArcConstraint(geo, nextId);
2949
+ }
2950
+
2951
+ if (added) this._solver.removeGeometryById?.(geo.id);
2952
+ return added;
2953
+ }
2954
+
2955
+ #trimCircleGeometry(geo, bounds) {
2956
+ const s = this._solver?.sketchObject;
2957
+ if (!s) return false;
2958
+ const ids = Array.isArray(geo?.points) ? geo.points : [];
2959
+ if (ids.length < 2) return false;
2960
+ const pc = s.points.find(p => p.id === ids[0]);
2961
+ const pr = s.points.find(p => p.id === ids[1]);
2962
+ if (!pc || !pr) return false;
2963
+ const r = Math.hypot(pr.x - pc.x, pr.y - pc.y);
2964
+ if (!Number.isFinite(r) || r < 1e-9) return false;
2965
+
2966
+ if (!bounds.prev || !bounds.next) return false;
2967
+ const maxParam = bounds.maxParam || 1;
2968
+ const delta = (bounds.next.param - bounds.prev.param + maxParam) % maxParam;
2969
+ if (delta < 1e-5 || delta > maxParam - 1e-5) return false;
2970
+
2971
+ const pointOnCircle = (inter) => {
2972
+ const ang = Math.atan2(inter.y - pc.y, inter.x - pc.x);
2973
+ return {
2974
+ x: pc.x + r * Math.cos(ang),
2975
+ y: pc.y + r * Math.sin(ang),
2976
+ };
2977
+ };
2978
+
2979
+ const prevId = this.#getOrCreatePointId(pointOnCircle(bounds.prev));
2980
+ const nextId = this.#getOrCreatePointId(pointOnCircle(bounds.next));
2981
+ if (prevId == null || nextId == null || prevId === nextId) return false;
2982
+
2983
+ const newId = this.#addGeometry("arc", [ids[0], nextId, prevId], geo);
2984
+ if (!newId) return false;
2985
+ this.#applyTrimIntersectionConstraint(prevId, bounds.prev);
2986
+ this.#applyTrimIntersectionConstraint(nextId, bounds.next);
2987
+ this.#ensurePointOnArcConstraint(geo, prevId);
2988
+ this.#ensurePointOnArcConstraint(geo, nextId);
2989
+ this._solver.removeGeometryById?.(geo.id);
2990
+ return true;
2991
+ }
2992
+
2993
+ #trimBezierGeometry(geo, bounds, target) {
2994
+ const s = this._solver?.sketchObject;
2995
+ if (!s || !geo || !Array.isArray(geo.points)) return false;
2996
+ const segCount = target?.segCount || Math.floor((geo.points.length - 1) / 3);
2997
+ if (segCount < 1) return false;
2998
+
2999
+ const prevInt = bounds.prev || null;
3000
+ const nextInt = bounds.next || null;
3001
+ if (!prevInt && !nextInt) return false;
3002
+ if (prevInt && nextInt && (nextInt.param - prevInt.param) <= 1e-5) return false;
3003
+
3004
+ const boundaries = [];
3005
+ if (prevInt) {
3006
+ const segIndex = Math.min(segCount - 1, Math.max(0, Math.floor(prevInt.param)));
3007
+ const t = prevInt.param - segIndex;
3008
+ boundaries.push({ kind: "prev", segIndex, t, pos: prevInt.param });
3009
+ }
3010
+ if (nextInt) {
3011
+ const segIndex = Math.min(segCount - 1, Math.max(0, Math.floor(nextInt.param)));
3012
+ const t = nextInt.param - segIndex;
3013
+ boundaries.push({ kind: "next", segIndex, t, pos: nextInt.param });
3014
+ }
3015
+ boundaries.sort((a, b) => a.pos - b.pos);
3016
+
3017
+ let splitsBefore = 0;
3018
+ if (boundaries.length === 2 && boundaries[0].segIndex === boundaries[1].segIndex) {
3019
+ const first = boundaries[0];
3020
+ const second = boundaries[1];
3021
+ if (second.t - first.t < 1e-5) return false;
3022
+ const res1 = this.#splitBezierAt(geo, first.segIndex + splitsBefore, first.t);
3023
+ if (!res1) return false;
3024
+ first.anchorIndex = res1.anchorIndex;
3025
+ splitsBefore += 1;
3026
+ const t2 = (second.t - first.t) / (1 - first.t);
3027
+ const res2 = this.#splitBezierAt(geo, first.segIndex + splitsBefore, t2);
3028
+ if (!res2) return false;
3029
+ second.anchorIndex = res2.anchorIndex;
3030
+ } else {
3031
+ for (const b of boundaries) {
3032
+ const res = this.#splitBezierAt(geo, b.segIndex + splitsBefore, b.t);
3033
+ if (!res) return false;
3034
+ b.anchorIndex = res.anchorIndex;
3035
+ splitsBefore += 1;
3036
+ }
3037
+ }
3038
+
3039
+ const totalSegs = Math.floor((geo.points.length - 1) / 3);
3040
+ const prevBoundary = boundaries.find(b => b.kind === "prev");
3041
+ const nextBoundary = boundaries.find(b => b.kind === "next");
3042
+ const prevSeg = prevBoundary ? Math.floor(prevBoundary.anchorIndex / 3) : 0;
3043
+ const nextSeg = nextBoundary ? Math.floor(nextBoundary.anchorIndex / 3) : totalSegs;
3044
+ if (nextSeg <= prevSeg) return false;
3045
+
3046
+ const keepRanges = [];
3047
+ if (prevSeg > 0) keepRanges.push([0, prevSeg]);
3048
+ if (nextSeg < totalSegs) keepRanges.push([nextSeg, totalSegs]);
3049
+
3050
+ let added = false;
3051
+ for (const [a, b] of keepRanges) {
3052
+ if (b - a < 1) continue;
3053
+ const startIdx = a * 3;
3054
+ const endIdx = b * 3;
3055
+ const pts = geo.points.slice(startIdx, endIdx + 1);
3056
+ if (pts.length >= 4) {
3057
+ this.#addGeometry("bezier", pts, geo);
3058
+ added = true;
3059
+ }
3060
+ }
3061
+
3062
+ if (added) this._solver.removeGeometryById?.(geo.id);
3063
+ return added;
3064
+ }
3065
+
3066
+ #getGeometryEndpoints(geo, pointById) {
3067
+ if (!geo || !pointById || !Array.isArray(geo.points)) return [];
3068
+ if (geo.type === "line" && geo.points.length >= 2) {
3069
+ const a = pointById.get(geo.points[0]);
3070
+ const b = pointById.get(geo.points[1]);
3071
+ return [a, b].filter(Boolean);
3072
+ }
3073
+ if (geo.type === "arc" && geo.points.length >= 3) {
3074
+ const a = pointById.get(geo.points[1]);
3075
+ const b = pointById.get(geo.points[2]);
3076
+ return [a, b].filter(Boolean);
3077
+ }
3078
+ return [];
3079
+ }
3080
+
3081
+ #endpointsTouchSample(endpoints, sample) {
3082
+ if (!Array.isArray(endpoints) || endpoints.length === 0) return false;
3083
+ const samples = sample?.samples;
3084
+ if (!Array.isArray(samples) || samples.length < 2) return false;
3085
+ const tol = this.#sampleTol(samples);
3086
+ for (const pt of endpoints) {
3087
+ if (pt && this.#pointNearSamples(pt, samples, tol)) return true;
3088
+ }
3089
+ return false;
3090
+ }
3091
+
3092
+ #sampleTol(samples) {
3093
+ if (!Array.isArray(samples) || samples.length === 0) return 1e-3;
3094
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
3095
+ for (const s of samples) {
3096
+ if (!s) continue;
3097
+ if (s.x < minX) minX = s.x;
3098
+ if (s.y < minY) minY = s.y;
3099
+ if (s.x > maxX) maxX = s.x;
3100
+ if (s.y > maxY) maxY = s.y;
3101
+ }
3102
+ const dx = maxX - minX;
3103
+ const dy = maxY - minY;
3104
+ const diag = Math.hypot(dx, dy);
3105
+ if (!Number.isFinite(diag) || diag < 1e-9) return 1e-3;
3106
+ return Math.max(1e-5, Math.min(1e-2, diag * 1e-3));
3107
+ }
3108
+
3109
+ #pointNearSamples(pt, samples, tol) {
3110
+ if (!pt || !Array.isArray(samples) || samples.length < 2) return false;
3111
+ const px = pt.x, py = pt.y;
3112
+ for (let i = 0; i < samples.length - 1; i++) {
3113
+ const a = samples[i];
3114
+ const b = samples[i + 1];
3115
+ if (!a || !b) continue;
3116
+ const d = this.#distancePointToSeg(a.x, a.y, b.x, b.y, px, py);
3117
+ if (d <= tol) return true;
3118
+ }
3119
+ return false;
3120
+ }
3121
+
3122
+ #distancePointToSeg(ax, ay, bx, by, px, py) {
3123
+ const vx = bx - ax;
3124
+ const vy = by - ay;
3125
+ const wx = px - ax;
3126
+ const wy = py - ay;
3127
+ const L2 = vx * vx + vy * vy || 1e-12;
3128
+ let t = (wx * vx + wy * vy) / L2;
3129
+ if (t < 0) t = 0; else if (t > 1) t = 1;
3130
+ const nx = ax + vx * t;
3131
+ const ny = ay + vy * t;
3132
+ return Math.hypot(px - nx, py - ny);
3133
+ }
3134
+
3135
+ #isTrimOverlap(targetGeo, otherGeo, pointById) {
3136
+ if (!targetGeo || !otherGeo || !pointById) return false;
3137
+ if (targetGeo.type === "line" && otherGeo.type === "line") {
3138
+ return this.#lineLiesOnLine(targetGeo, otherGeo, pointById);
3139
+ }
3140
+ if ((targetGeo.type === "arc" || targetGeo.type === "circle") &&
3141
+ (otherGeo.type === "arc" || otherGeo.type === "circle")) {
3142
+ return this.#arcLiesOnArc(targetGeo, otherGeo, pointById);
3143
+ }
3144
+ return false;
3145
+ }
3146
+
3147
+ #lineLiesOnLine(targetGeo, otherGeo, pointById) {
3148
+ const tIds = Array.isArray(targetGeo.points) ? targetGeo.points : [];
3149
+ const oIds = Array.isArray(otherGeo.points) ? otherGeo.points : [];
3150
+ if (tIds.length < 2 || oIds.length < 2) return false;
3151
+ const t0 = pointById.get(tIds[0]);
3152
+ const t1 = pointById.get(tIds[1]);
3153
+ const o0 = pointById.get(oIds[0]);
3154
+ const o1 = pointById.get(oIds[1]);
3155
+ if (!t0 || !t1 || !o0 || !o1) return false;
3156
+
3157
+ const dx = o1.x - o0.x;
3158
+ const dy = o1.y - o0.y;
3159
+ const len2 = dx * dx + dy * dy;
3160
+ if (len2 < 1e-12) return false;
3161
+ const len = Math.sqrt(len2);
3162
+ const tol = Math.max(1e-5, Math.min(1e-2, len * 1e-3));
3163
+ const eps = 1e-4;
3164
+
3165
+ const onOther = (p) => {
3166
+ const t = ((p.x - o0.x) * dx + (p.y - o0.y) * dy) / len2;
3167
+ if (t < -eps || t > 1 + eps) return false;
3168
+ const cx = o0.x + t * dx;
3169
+ const cy = o0.y + t * dy;
3170
+ return Math.hypot(p.x - cx, p.y - cy) <= tol;
3171
+ };
3172
+
3173
+ return onOther(t0) && onOther(t1);
3174
+ }
3175
+
3176
+ #arcLiesOnArc(targetGeo, otherGeo, pointById) {
3177
+ const tInfo = this.#arcInfo(targetGeo, pointById);
3178
+ const oInfo = this.#arcInfo(otherGeo, pointById);
3179
+ if (!tInfo || !oInfo) return false;
3180
+ const r = Math.max(tInfo.r, oInfo.r);
3181
+ const tol = Math.max(1e-5, Math.min(1e-2, r * 1e-3));
3182
+ if (Math.hypot(tInfo.cx - oInfo.cx, tInfo.cy - oInfo.cy) > tol) return false;
3183
+ if (Math.abs(tInfo.r - oInfo.r) > tol) return false;
3184
+ if (oInfo.full) return true;
3185
+ if (tInfo.full) return false;
3186
+
3187
+ const endAng = this.#normAngle(tInfo.a0 + tInfo.d);
3188
+ return this.#angleOnArc(oInfo, tInfo.a0) && this.#angleOnArc(oInfo, endAng);
3189
+ }
3190
+
3191
+ #arcInfo(geo, pointById) {
3192
+ if (!geo || !pointById || !Array.isArray(geo.points)) return null;
3193
+ if (geo.type === "circle" && geo.points.length >= 2) {
3194
+ const pc = pointById.get(geo.points[0]);
3195
+ const pr = pointById.get(geo.points[1]);
3196
+ if (!pc || !pr) return null;
3197
+ const r = Math.hypot(pr.x - pc.x, pr.y - pc.y);
3198
+ if (!Number.isFinite(r) || r < 1e-9) return null;
3199
+ return { cx: pc.x, cy: pc.y, r, a0: 0, d: Math.PI * 2, full: true };
3200
+ }
3201
+ if (geo.type === "arc" && geo.points.length >= 3) {
3202
+ const pc = pointById.get(geo.points[0]);
3203
+ const pa = pointById.get(geo.points[1]);
3204
+ const pb = pointById.get(geo.points[2]);
3205
+ if (!pc || !pa || !pb) return null;
3206
+ const r = Math.hypot(pa.x - pc.x, pa.y - pc.y);
3207
+ if (!Number.isFinite(r) || r < 1e-9) return null;
3208
+ let a0 = this.#normAngle(Math.atan2(pa.y - pc.y, pa.x - pc.x));
3209
+ let a1 = this.#normAngle(Math.atan2(pb.y - pc.y, pb.x - pc.x));
3210
+ let d = a1 - a0;
3211
+ if (d < 0) d += Math.PI * 2;
3212
+ const full = d < 1e-6;
3213
+ if (full) d = Math.PI * 2;
3214
+ return { cx: pc.x, cy: pc.y, r, a0, d, full };
3215
+ }
3216
+ return null;
3217
+ }
3218
+
3219
+ #normAngle(a) {
3220
+ const twoPi = Math.PI * 2;
3221
+ a = a % twoPi;
3222
+ if (a < 0) a += twoPi;
3223
+ return a;
3224
+ }
3225
+
3226
+ #angleOnArc(arcInfo, ang) {
3227
+ if (!arcInfo) return false;
3228
+ if (arcInfo.full) return true;
3229
+ const twoPi = Math.PI * 2;
3230
+ const delta = this.#normAngle(ang - arcInfo.a0);
3231
+ return delta <= arcInfo.d + Math.max(1e-6, twoPi * 1e-6);
3232
+ }
3233
+
3234
+ #splitBezierAt(geo, segIndex, t) {
3235
+ const s = this._solver?.sketchObject;
3236
+ if (!s || !geo || !Array.isArray(geo.points)) return null;
3237
+ const ids = geo.points;
3238
+ const segCount = Math.floor((ids.length - 1) / 3);
3239
+ if (segIndex < 0 || segIndex >= segCount) return null;
3240
+ const base = segIndex * 3;
3241
+ const p0 = s.points.find((p) => p.id === ids[base]);
3242
+ const p1 = s.points.find((p) => p.id === ids[base + 1]);
3243
+ const p2 = s.points.find((p) => p.id === ids[base + 2]);
3244
+ const p3 = s.points.find((p) => p.id === ids[base + 3]);
3245
+ if (!p0 || !p1 || !p2 || !p3) return null;
3246
+
3247
+ const tt = Math.min(0.9999, Math.max(0.0001, t));
3248
+ const lerp = (a, b, tVal) => ({ x: a.x + (b.x - a.x) * tVal, y: a.y + (b.y - a.y) * tVal });
3249
+ const P0 = { x: p0.x, y: p0.y };
3250
+ const P1 = { x: p1.x, y: p1.y };
3251
+ const P2 = { x: p2.x, y: p2.y };
3252
+ const P3 = { x: p3.x, y: p3.y };
3253
+
3254
+ const q0 = lerp(P0, P1, tt);
3255
+ const q1 = lerp(P1, P2, tt);
3256
+ const q2 = lerp(P2, P3, tt);
3257
+ const r0 = lerp(q0, q1, tt);
3258
+ const r1 = lerp(q1, q2, tt);
3259
+ const sPt = lerp(r0, r1, tt);
3260
+
3261
+ p1.x = q0.x; p1.y = q0.y;
3262
+ p2.x = q2.x; p2.y = q2.y;
3263
+
3264
+ const r0Id = this.#createPointAtUV(r0.x, r0.y, false);
3265
+ const sId = this.#createPointAtUV(sPt.x, sPt.y, false);
3266
+ const r1Id = this.#createPointAtUV(r1.x, r1.y, false);
3267
+ if (r0Id == null || sId == null || r1Id == null) return null;
3268
+
3269
+ geo.points.splice(base + 2, 0, r0Id, sId, r1Id);
3270
+ this.#addGeometry("line", [sId, r0Id], null, { construction: true });
3271
+ this.#addGeometry("line", [sId, r1Id], null, { construction: true });
3272
+ return { anchorIndex: base + 3, anchorId: sId };
3273
+ }
3274
+
3275
+ #sampleGeometry(geo, pointById) {
3276
+ if (!geo || !pointById) return null;
3277
+ const get = (id) => pointById.get(id);
3278
+
3279
+ if (geo.type === "line" && Array.isArray(geo.points) && geo.points.length >= 2) {
3280
+ const p0 = get(geo.points[0]);
3281
+ const p1 = get(geo.points[1]);
3282
+ if (!p0 || !p1) return null;
3283
+ return {
3284
+ type: "line",
3285
+ closed: false,
3286
+ maxParam: 1,
3287
+ segCount: 1,
3288
+ samples: [
3289
+ { x: p0.x, y: p0.y, param: 0 },
3290
+ { x: p1.x, y: p1.y, param: 1 },
3291
+ ],
3292
+ };
3293
+ }
3294
+
3295
+ if (geo.type === "circle" && Array.isArray(geo.points) && geo.points.length >= 2) {
3296
+ const pc = get(geo.points[0]);
3297
+ const pr = get(geo.points[1]);
3298
+ if (!pc || !pr) return null;
3299
+ const r = Math.hypot(pr.x - pc.x, pr.y - pc.y);
3300
+ if (!Number.isFinite(r) || r < 1e-9) return null;
3301
+ const segs = 96;
3302
+ const samples = [];
3303
+ for (let i = 0; i <= segs; i++) {
3304
+ const t = i / segs;
3305
+ const a = t * Math.PI * 2;
3306
+ samples.push({ x: pc.x + r * Math.cos(a), y: pc.y + r * Math.sin(a), param: t });
3307
+ }
3308
+ return { type: "circle", closed: true, maxParam: 1, segCount: 1, samples };
3309
+ }
3310
+
3311
+ if (geo.type === "arc" && Array.isArray(geo.points) && geo.points.length >= 3) {
3312
+ const pc = get(geo.points[0]);
3313
+ const pa = get(geo.points[1]);
3314
+ const pb = get(geo.points[2]);
3315
+ if (!pc || !pa || !pb) return null;
3316
+ const r = Math.hypot(pa.x - pc.x, pa.y - pc.y);
3317
+ if (!Number.isFinite(r) || r < 1e-9) return null;
3318
+ let a0 = Math.atan2(pa.y - pc.y, pa.x - pc.x);
3319
+ let a1 = Math.atan2(pb.y - pc.y, pb.x - pc.x);
3320
+ let d = a1 - a0;
3321
+ d = ((d % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
3322
+ const full = d < 1e-6;
3323
+ if (full) d = 2 * Math.PI;
3324
+ const segs = Math.max(8, Math.ceil(96 * (d / (2 * Math.PI))));
3325
+ const samples = [];
3326
+ for (let i = 0; i <= segs; i++) {
3327
+ const t = i / segs;
3328
+ const a = a0 + d * t;
3329
+ samples.push({ x: pc.x + r * Math.cos(a), y: pc.y + r * Math.sin(a), param: t });
3330
+ }
3331
+ return { type: "arc", closed: full, maxParam: 1, segCount: 1, samples };
3332
+ }
3333
+
3334
+ if (geo.type === "bezier" && Array.isArray(geo.points) && geo.points.length >= 4) {
3335
+ const ids = geo.points;
3336
+ const segCount = Math.floor((ids.length - 1) / 3);
3337
+ if (segCount < 1) return null;
3338
+ const segSamples = 24;
3339
+ const samples = [];
3340
+ for (let seg = 0; seg < segCount; seg++) {
3341
+ const i0 = seg * 3;
3342
+ const p0 = get(ids[i0]);
3343
+ const p1 = get(ids[i0 + 1]);
3344
+ const p2 = get(ids[i0 + 2]);
3345
+ const p3 = get(ids[i0 + 3]);
3346
+ if (!p0 || !p1 || !p2 || !p3) continue;
3347
+ for (let i = 0; i <= segSamples; i++) {
3348
+ if (seg > 0 && i === 0) continue;
3349
+ const t = i / segSamples;
3350
+ const mt = 1 - t;
3351
+ const bx = mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x;
3352
+ const by = mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y;
3353
+ samples.push({ x: bx, y: by, param: seg + t });
3354
+ }
3355
+ }
3356
+ return { type: "bezier", closed: false, maxParam: segCount, segCount, samples };
3357
+ }
3358
+
3359
+ return null;
3360
+ }
3361
+
3362
+ #closestParamOnSamples(uv, samples) {
3363
+ if (!uv || !Array.isArray(samples) || samples.length < 2) return null;
3364
+ let best = { param: samples[0].param, dist: Infinity };
3365
+ for (let i = 0; i < samples.length - 1; i++) {
3366
+ const a = samples[i];
3367
+ const b = samples[i + 1];
3368
+ const vx = b.x - a.x, vy = b.y - a.y;
3369
+ const wx = uv.u - a.x, wy = uv.v - a.y;
3370
+ const L2 = vx * vx + vy * vy || 1e-12;
3371
+ let t = (wx * vx + wy * vy) / L2;
3372
+ if (t < 0) t = 0; else if (t > 1) t = 1;
3373
+ const nx = a.x + vx * t, ny = a.y + vy * t;
3374
+ const d = Math.hypot(uv.u - nx, uv.v - ny);
3375
+ const param = a.param + (b.param - a.param) * t;
3376
+ if (d < best.dist) best = { param, dist: d };
3377
+ }
3378
+ return best;
3379
+ }
3380
+
3381
+ #collectIntersections(target, other, out, otherGeo) {
3382
+ const A = target?.samples;
3383
+ const B = other?.samples;
3384
+ if (!Array.isArray(A) || !Array.isArray(B) || A.length < 2 || B.length < 2) return;
3385
+ for (let i = 0; i < A.length - 1; i++) {
3386
+ const a0 = A[i];
3387
+ const a1 = A[i + 1];
3388
+ for (let j = 0; j < B.length - 1; j++) {
3389
+ const b0 = B[j];
3390
+ const b1 = B[j + 1];
3391
+ const hit = this.#segmentIntersection(a0, a1, b0, b1);
3392
+ if (!hit) continue;
3393
+ const param = a0.param + (a1.param - a0.param) * hit.ta;
3394
+ const otherParam = b0.param + (b1.param - b0.param) * hit.tb;
3395
+ out.push({
3396
+ param,
3397
+ x: hit.x,
3398
+ y: hit.y,
3399
+ otherParam,
3400
+ otherGeoId: otherGeo?.id ?? null,
3401
+ otherType: otherGeo?.type ?? null,
3402
+ });
3403
+ }
3404
+ }
3405
+ }
3406
+
3407
+ #segmentIntersection(a, b, c, d, eps = 1e-9) {
3408
+ const rdx = b.x - a.x;
3409
+ const rdy = b.y - a.y;
3410
+ const sdx = d.x - c.x;
3411
+ const sdy = d.y - c.y;
3412
+ const denom = rdx * sdy - rdy * sdx;
3413
+ if (Math.abs(denom) < eps) return null;
3414
+ const t = ((c.x - a.x) * sdy - (c.y - a.y) * sdx) / denom;
3415
+ const u = ((c.x - a.x) * rdy - (c.y - a.y) * rdx) / denom;
3416
+ if (t < -eps || t > 1 + eps || u < -eps || u > 1 + eps) return null;
3417
+ const tt = Math.min(1, Math.max(0, t));
3418
+ return { x: a.x + rdx * tt, y: a.y + rdy * tt, ta: tt, tb: Math.min(1, Math.max(0, u)) };
3419
+ }
3420
+
3421
+ #selectTrimBounds(intersections, clickParam, target) {
3422
+ const maxParam = target?.maxParam || 1;
3423
+ const paramEps = Math.max(1e-5, maxParam * 1e-4);
3424
+ const cleaned = [];
3425
+ for (const inter of intersections || []) {
3426
+ if (!inter || !Number.isFinite(inter.param)) continue;
3427
+ let p = inter.param;
3428
+ if (target?.closed) {
3429
+ p = ((p % maxParam) + maxParam) % maxParam;
3430
+ if (p < paramEps || p > maxParam - paramEps) p = 0;
3431
+ } else {
3432
+ if (p <= paramEps || p >= maxParam - paramEps) continue;
3433
+ }
3434
+ cleaned.push({
3435
+ ...inter,
3436
+ param: p,
3437
+ x: inter.x,
3438
+ y: inter.y,
3439
+ });
3440
+ }
3441
+ cleaned.sort((a, b) => a.param - b.param);
3442
+ const uniq = [];
3443
+ for (const inter of cleaned) {
3444
+ if (!uniq.length || Math.abs(inter.param - uniq[uniq.length - 1].param) > paramEps) {
3445
+ uniq.push(inter);
3446
+ }
3447
+ }
3448
+ if (target?.closed) {
3449
+ if (uniq.length < 2) return null;
3450
+ let prev = null;
3451
+ let next = null;
3452
+ let bestNext = Infinity;
3453
+ let bestPrev = -Infinity;
3454
+ for (const inter of uniq) {
3455
+ let delta = inter.param - clickParam;
3456
+ delta = ((delta % maxParam) + maxParam) % maxParam;
3457
+ if (delta < paramEps) continue;
3458
+ if (delta < bestNext) { bestNext = delta; next = inter; }
3459
+ if (delta > bestPrev) { bestPrev = delta; prev = inter; }
3460
+ }
3461
+ if (!prev || !next) return null;
3462
+ return { prev, next, closed: true, maxParam };
3463
+ }
3464
+ if (!uniq.length) return null;
3465
+ let prev = null;
3466
+ let next = null;
3467
+ for (const inter of uniq) {
3468
+ if (inter.param < clickParam - paramEps) prev = inter;
3469
+ else if (inter.param > clickParam + paramEps) { next = inter; break; }
3470
+ }
3471
+ if (!prev && !next) return null;
3472
+ return { prev, next, closed: false, maxParam };
3473
+ }
3474
+
3475
+ #findExistingPointId(x, y, eps = 1e-6) {
3476
+ const s = this._solver?.sketchObject;
3477
+ if (!s || !Array.isArray(s.points)) return null;
3478
+ for (const p of s.points) {
3479
+ if (Math.hypot(p.x - x, p.y - y) <= eps) return p.id;
3480
+ }
3481
+ return null;
3482
+ }
3483
+
3484
+ #getOrCreatePointId(pt) {
3485
+ const existing = this.#findExistingPointId(pt.x, pt.y);
3486
+ if (existing != null) return existing;
3487
+ return this.#createPointAtUV(pt.x, pt.y, false);
3488
+ }
3489
+
3490
+ #applyTrimIntersectionConstraint(pointId, inter) {
3491
+ if (!inter || pointId == null) return false;
3492
+ const s = this._solver?.sketchObject;
3493
+ if (!s) return false;
3494
+ const otherId = inter.otherGeoId;
3495
+ if (otherId == null) return false;
3496
+ const other = (s.geometries || []).find((g) => g && g.id === parseInt(otherId));
3497
+ if (!other || !other.type) return false;
3498
+ if (other.type === "line") {
3499
+ return this.#ensurePointOnLineConstraint(other, pointId, inter);
3500
+ }
3501
+ if (other.type === "arc" || other.type === "circle") {
3502
+ return this.#ensurePointOnArcConstraint(other, pointId);
3503
+ }
3504
+ return false;
3505
+ }
3506
+
3507
+ #ensurePointOnLineConstraint(lineGeo, pointId, inter) {
3508
+ const s = this._solver?.sketchObject;
3509
+ if (!s || !lineGeo) return false;
3510
+ const ids = Array.isArray(lineGeo.points) ? lineGeo.points : [];
3511
+ if (ids.length < 2) return false;
3512
+ const aId = ids[0];
3513
+ const bId = ids[1];
3514
+ const a = s.points.find(p => p.id === aId);
3515
+ const b = s.points.find(p => p.id === bId);
3516
+ const p = s.points.find(p => p.id === pointId);
3517
+ if (!a || !b || !p) return false;
3518
+
3519
+ const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
3520
+ const epsParam = 1e-3;
3521
+ const epsDist = Math.max(1e-5, Math.min(1e-2, len * 1e-3));
3522
+ const otherParam = Number.isFinite(inter?.otherParam) ? inter.otherParam : null;
3523
+ const nearA = (otherParam != null && otherParam <= epsParam) || (Math.hypot(p.x - a.x, p.y - a.y) <= epsDist);
3524
+ const nearB = (otherParam != null && otherParam >= 1 - epsParam) || (Math.hypot(p.x - b.x, p.y - b.y) <= epsDist);
3525
+
3526
+ if (nearA && pointId !== aId) return this.#addConstraintIfMissing("≡", [aId, pointId]);
3527
+ if (nearB && pointId !== bId) return this.#addConstraintIfMissing("≡", [bId, pointId]);
3528
+ if (pointId === aId || pointId === bId) return false;
3529
+ return this.#addConstraintIfMissing("⏛", [aId, bId, pointId]);
3530
+ }
3531
+
3532
+ #ensurePointOnArcConstraint(arcGeo, pointId) {
3533
+ const s = this._solver?.sketchObject;
3534
+ if (!s || !arcGeo) return false;
3535
+ const ids = Array.isArray(arcGeo.points) ? arcGeo.points : [];
3536
+ if (ids.length < 2) return false;
3537
+ const centerId = ids[0];
3538
+ const radiusId = ids[1];
3539
+ if (centerId == null || radiusId == null) return false;
3540
+ if (pointId === centerId || pointId === radiusId) return false;
3541
+ return this.#addConstraintIfMissing("⇌", [centerId, radiusId, centerId, pointId]);
3542
+ }
3543
+
3544
+ #addConstraintIfMissing(type, points) {
3545
+ const s = this._solver?.sketchObject;
3546
+ if (!s || !Array.isArray(points)) return false;
3547
+ if (!Array.isArray(s.constraints)) s.constraints = [];
3548
+ const exists = s.constraints.some((c) => this.#constraintMatches(c, type, points));
3549
+ if (exists) return false;
3550
+ const cid = Math.max(0, ...s.constraints.map((c) => +c.id || 0)) + 1;
3551
+ s.constraints.push({
3552
+ id: cid,
3553
+ type,
3554
+ points: points.slice(),
3555
+ labelX: 0,
3556
+ labelY: 0,
3557
+ displayStyle: "",
3558
+ value: null,
3559
+ valueNeedsSetup: true,
3560
+ });
3561
+ return true;
3562
+ }
3563
+
3564
+ #constraintMatches(c, type, points) {
3565
+ if (!c || c.type !== type || !Array.isArray(c.points)) return false;
3566
+ if (type === "≡") {
3567
+ if (points.length < 2 || c.points.length < 2) return false;
3568
+ const [a, b] = points;
3569
+ const [p0, p1] = c.points;
3570
+ return (p0 === a && p1 === b) || (p0 === b && p1 === a);
3571
+ }
3572
+ if (type === "⏛") {
3573
+ if (points.length < 3 || c.points.length < 3) return false;
3574
+ const [a, b, p] = points;
3575
+ const [p0, p1, p2] = c.points;
3576
+ return ((p0 === a && p1 === b) || (p0 === b && p1 === a)) && p2 === p;
3577
+ }
3578
+ if (type === "⇌") {
3579
+ if (points.length < 4 || c.points.length < 4) return false;
3580
+ const [a, b, c0, d] = points;
3581
+ const [p0, p1, p2, p3] = c.points;
3582
+ const samePair = (x0, x1, y0, y1) => (x0 === y0 && x1 === y1) || (x0 === y1 && x1 === y0);
3583
+ const first = samePair(p0, p1, a, b) && samePair(p2, p3, c0, d);
3584
+ const second = samePair(p0, p1, c0, d) && samePair(p2, p3, a, b);
3585
+ return first || second;
3586
+ }
3587
+ return false;
3588
+ }
3589
+
3590
+ #addGeometry(type, pointIds, templateGeo = null, opts = {}) {
3591
+ const s = this._solver?.sketchObject;
3592
+ if (!s) return null;
3593
+ const gid = Math.max(0, ...s.geometries.map((g) => +g.id || 0)) + 1;
3594
+ const construction = (typeof opts.construction === "boolean")
3595
+ ? opts.construction
3596
+ : !!templateGeo?.construction;
3597
+ s.geometries.push({
3598
+ id: gid,
3599
+ type,
3600
+ points: pointIds,
3601
+ construction,
3602
+ });
3603
+ return gid;
3604
+ }
3605
+
3606
+ // Hit-test any EDGE in the whole scene (for external ref picking)
3607
+ #hitTestSceneEdge(e) {
3608
+ const v = this.viewer;
3609
+ if (!v) return null;
3610
+ const rect = v.renderer.domElement.getBoundingClientRect();
3611
+ const ndc = new THREE.Vector2(
3612
+ ((e.clientX - rect.left) / rect.width) * 2 - 1,
3613
+ -(((e.clientY - rect.top) / rect.height) * 2 - 1),
3614
+ );
3615
+ this.#setRayFromCamera(ndc);
3616
+ try {
3617
+ const { width, height } = this.#canvasClientSize(v.renderer.domElement);
3618
+ const wpp = this.#worldPerPixel(v.camera, width, height);
3619
+ this._raycaster.params.Line = this._raycaster.params.Line || {};
3620
+ this._raycaster.params.Line.threshold = Math.max(0.05, wpp * 6);
3621
+ // Ensure fat-line intersections are generous enough in pixels
3622
+ const dpr = (window.devicePixelRatio || 1);
3623
+ this._raycaster.params.Line2 = this._raycaster.params.Line2 || {};
3624
+ this._raycaster.params.Line2.threshold = Math.max(1, 2 * dpr);
3625
+ } catch { }
3626
+ // Intersect only EDGE objects (ignore faces and everything else)
3627
+ const edgeObjects = [];
3628
+ try {
3629
+ v.scene.traverse((obj) => { if (obj && obj.type === 'EDGE' && obj.visible !== false) edgeObjects.push(obj); });
3630
+ } catch { }
3631
+ const hits = edgeObjects.length ? this._raycaster.intersectObjects(edgeObjects, true) : [];
3632
+ if (hits && hits.length) return hits[0];
3633
+ return null;
3634
+ }
3635
+ #hitTestDim(e) {
3636
+ // Choose the closest dimension (constraint) in plane-space to the cursor
3637
+ const v = this.viewer;
3638
+ if (!v || !this._solver || !this._lock) return null;
3639
+ const uv = this.#pointerToPlaneUV(e);
3640
+ if (!uv) return null;
3641
+ const s = this._solver.sketchObject;
3642
+ if (!s) return null;
3643
+ const P = (id) => s.points.find((p) => p.id === id);
3644
+ const { width, height } = this.#canvasClientSize(v.renderer.domElement);
3645
+ const wpp = this.#worldPerPixel(v.camera, width, height);
3646
+ const tol = Math.max(0.05, wpp * 10);
3647
+
3648
+ const distToSeg = (ax, ay, bx, by, px, py) => {
3649
+ const vx = bx - ax, vy = by - ay;
3650
+ const wx = px - ax, wy = py - ay;
3651
+ const L2 = vx * vx + vy * vy || 1e-12;
3652
+ let t = (wx * vx + wy * vy) / L2; if (t < 0) t = 0; else if (t > 1) t = 1;
3653
+ const nx = ax + vx * t, ny = ay + vy * t;
3654
+ return Math.hypot(px - nx, py - ny);
3655
+ };
3656
+ const intersect = (A, B, C, D) => {
3657
+ const den = (A.x - B.x) * (C.y - D.y) - (A.y - B.y) * (C.x - D.x);
3658
+ if (Math.abs(den) < 1e-9) return { x: B.x, y: B.y };
3659
+ const x = ((A.x * A.y - B.x * B.y) * (C.x - D.x) - (A.x - B.x) * (C.x * C.y - D.x * D.y)) / den;
3660
+ const y = ((A.x * A.y - B.x * B.y) * (C.y - D.y) - (A.y - B.y) * (C.x * C.y - D.x * D.y)) / den;
3661
+ return { x, y };
3662
+ };
3663
+ const normAng = (a) => { const t = Math.PI * 2; a = a % t; return a < 0 ? a + t : a; };
3664
+
3665
+ let bestCid = null;
3666
+ let bestDist = Infinity;
3667
+
3668
+ for (const c of (s.constraints || [])) {
3669
+ if (c.type === '⟺' && Array.isArray(c.points) && c.points.length >= 2) {
3670
+ if (c.displayStyle === 'radius') {
3671
+ const pc = P(c.points[0]); const pr = P(c.points[1]); if (!pc || !pr) continue;
3672
+ const rr = Math.hypot(pr.x - pc.x, pr.y - pc.y);
3673
+ const d = Math.abs(Math.hypot(uv.u - pc.x, uv.v - pc.y) - rr);
3674
+ if (d < bestDist) { bestDist = d; bestCid = c.id; }
3675
+ } else {
3676
+ const p0 = P(c.points[0]); const p1 = P(c.points[1]); if (!p0 || !p1) continue;
3677
+ const d = distToSeg(p0.x, p0.y, p1.x, p1.y, uv.u, uv.v);
3678
+ if (d < bestDist) { bestDist = d; bestCid = c.id; }
3679
+ }
3680
+ } else if (c.type === '∠' && Array.isArray(c.points) && c.points.length >= 4) {
3681
+ const p0 = P(c.points[0]), p1 = P(c.points[1]), p2 = P(c.points[2]), p3 = P(c.points[3]);
3682
+ if (!p0 || !p1 || !p2 || !p3) continue;
3683
+ const I = intersect(p0, p1, p2, p3);
3684
+ // Approximate: distance to circular arc at nominal radius around I
3685
+ const rSel = Math.max(0.2, wpp * 12);
3686
+ const d = Math.abs(Math.hypot(uv.u - I.x, uv.v - I.y) - rSel);
3687
+ if (d < bestDist) { bestDist = d; bestCid = c.id; }
3688
+ }
3689
+ }
3690
+
3691
+ if (bestCid != null && bestDist <= tol) return { cid: bestCid };
3692
+ return null;
3693
+ }
3694
+
3695
+ #hitTestGlyph(e) {
3696
+ // Hit test constraint glyph centers placed by glyph renderer
3697
+ const v = this.viewer;
3698
+ if (!v || !this._lock || !this._glyphCenters) return null;
3699
+ const uv = this.#pointerToPlaneUV(e);
3700
+ if (!uv) return null;
3701
+ const { width, height } = this.#canvasClientSize(v.renderer.domElement);
3702
+ const wpp = this.#worldPerPixel(v.camera, width, height);
3703
+ const tol = Math.max(0.05, wpp * 8);
3704
+ let best = null, bestD = Infinity;
3705
+ try {
3706
+ for (const [cid, pt] of this._glyphCenters.entries()) {
3707
+ const d = Math.hypot((uv.u - pt.u), (uv.v - pt.v));
3708
+ if (d < bestD) { bestD = d; best = cid; }
3709
+ }
3710
+ } catch { }
3711
+ return (best != null && bestD <= tol) ? { cid: best } : null;
3712
+ }
3713
+
3714
+ #rebuildSketchGraphics() {
3715
+ const grp = this._sketchGroup;
3716
+ if (!grp || !this._solver) return;
3717
+ for (let i = grp.children.length - 1; i >= 0; i--) {
3718
+ const ch = grp.children[i];
3719
+ grp.remove(ch);
3720
+ try {
3721
+ ch.geometry?.dispose();
3722
+ ch.material?.dispose?.();
3723
+ } catch { }
3724
+ }
3725
+ const s = this._solver.sketchObject;
3726
+ const b = this._lock?.basis;
3727
+ if (!b) return;
3728
+ const constrainedPoints = new Set();
3729
+ try {
3730
+ for (const c of s.constraints || []) {
3731
+ if (!c || c.temporary || !Array.isArray(c.points)) continue;
3732
+ for (const pid of c.points) constrainedPoints.add(parseInt(pid));
3733
+ }
3734
+ } catch { }
3735
+ const O = b.origin,
3736
+ X = b.x,
3737
+ Y = b.y;
3738
+ const to3 = (u, v) =>
3739
+ new THREE.Vector3().copy(O).addScaledVector(X, u).addScaledVector(Y, v);
3740
+ // Sketch curves should always render on top of scene geometry
3741
+ const lineMat = new THREE.LineBasicMaterial({
3742
+ color: 0xffff88,
3743
+ depthTest: false, // <- renders on top regardless of depth
3744
+ depthWrite: false, // <- doesn't modify the depth buffer
3745
+ transparent: true,
3746
+ });
3747
+ const dashedMatBase = new THREE.LineDashedMaterial({
3748
+ color: 0xffff88,
3749
+ depthTest: false, // <- renders on top regardless of depth
3750
+ depthWrite: false, // <- doesn't modify the depth buffer
3751
+ transparent: true,
3752
+ dashSize: 0.1, // placeholder; scaled per viewport below
3753
+ gapSize: 0.08,
3754
+ });
3755
+ // Determine world-per-pixel to scale dash size for consistent screen appearance
3756
+ let wpp = 0.05;
3757
+ try {
3758
+ const { width, height } = this.#canvasClientSize(this.viewer.renderer.domElement);
3759
+ wpp = this.#worldPerPixel(this.viewer.camera, width, height);
3760
+ } catch { }
3761
+ for (const geo of s.geometries || []) {
3762
+ if (geo.type === "line" && geo.points?.length === 2) {
3763
+ const p0 = s.points.find((p) => p.id === geo.points[0]);
3764
+ const p1 = s.points.find((p) => p.id === geo.points[1]);
3765
+ if (!p0 || !p1) continue;
3766
+ const a = to3(p0.x, p0.y),
3767
+ b3 = to3(p1.x, p1.y);
3768
+ const bg = new THREE.BufferGeometry().setFromPoints([a, b3]);
3769
+ const sel = Array.from(this._selection).some(
3770
+ (it) => it.type === "geometry" && it.id === geo.id,
3771
+ );
3772
+ const mat = (geo.construction ? dashedMatBase.clone() : lineMat.clone());
3773
+ if (geo.construction) {
3774
+ try { mat.dashSize = Math.max(0.02, 8 * wpp); mat.gapSize = Math.max(0.01, 6 * wpp); } catch { }
3775
+ }
3776
+ try {
3777
+ mat.color.set(sel ? 0x6fe26f : 0xffff88);
3778
+ } catch { }
3779
+ const ln = new THREE.Line(bg, mat);
3780
+ if (geo.construction) { try { ln.computeLineDistances(); } catch { } }
3781
+ ln.renderOrder = 10000;
3782
+
3783
+ ln.userData = { kind: "geometry", id: geo.id, type: "line" };
3784
+ grp.add(ln);
3785
+ } else if (geo.type === "circle") {
3786
+ const ids = geo.points || [];
3787
+ const pC = s.points.find((p) => p.id === ids[0]);
3788
+ const pR = s.points.find((p) => p.id === ids[1]);
3789
+ if (!pC || !pR) continue;
3790
+ const rr = Math.hypot(pR.x - pC.x, pR.y - pC.y);
3791
+ const segs = 64;
3792
+ const pts = [];
3793
+ for (let i = 0; i <= segs; i++) {
3794
+ const t = (i / segs) * Math.PI * 2;
3795
+ pts.push(to3(pC.x + rr * Math.cos(t), pC.y + rr * Math.sin(t)));
3796
+ }
3797
+ const bg = new THREE.BufferGeometry().setFromPoints(pts);
3798
+ const sel = Array.from(this._selection).some(
3799
+ (it) => it.type === "geometry" && it.id === geo.id,
3800
+ );
3801
+ const mat = (geo.construction ? dashedMatBase.clone() : lineMat.clone());
3802
+ if (geo.construction) {
3803
+ try { mat.dashSize = Math.max(0.02, 8 * wpp); mat.gapSize = Math.max(0.01, 6 * wpp); } catch { }
3804
+ }
3805
+ try {
3806
+ mat.color.set(sel ? 0x6fe26f : 0xffff88);
3807
+ } catch { }
3808
+ const ln = new THREE.Line(bg, mat);
3809
+ if (geo.construction) { try { ln.computeLineDistances(); } catch { } }
3810
+ ln.renderOrder = 10000;
3811
+
3812
+ ln.userData = { kind: "geometry", id: geo.id, type: geo.type };
3813
+ grp.add(ln);
3814
+ } else if (geo.type === "arc") {
3815
+ const ids = geo.points || [];
3816
+ const pC = s.points.find((p) => p.id === ids[0]);
3817
+ const pA = s.points.find((p) => p.id === ids[1]);
3818
+ const pB = s.points.find((p) => p.id === ids[2]);
3819
+ if (!pC || !pA || !pB) continue;
3820
+ const cx = pC.x,
3821
+ cy = pC.y;
3822
+ const rr = Math.hypot(pA.x - cx, pA.y - cy);
3823
+ let a0 = Math.atan2(pA.y - cy, pA.x - cx);
3824
+ let a1 = Math.atan2(pB.y - cy, pB.x - cx);
3825
+ // Use CCW sweep in [0, 2π). If start≈end, draw full circle (2π).
3826
+ let d = a1 - a0;
3827
+ d = ((d % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
3828
+ if (Math.abs(d) < 1e-6) d = 2 * Math.PI;
3829
+ const segs = Math.max(8, Math.ceil((64 * d) / (2 * Math.PI)));
3830
+ const pts = [];
3831
+ for (let i = 0; i <= segs; i++) {
3832
+ const t = a0 + d * (i / segs);
3833
+ pts.push(to3(cx + rr * Math.cos(t), cy + rr * Math.sin(t)));
3834
+ }
3835
+ const bg = new THREE.BufferGeometry().setFromPoints(pts);
3836
+ const sel = Array.from(this._selection).some(
3837
+ (it) => it.type === "geometry" && it.id === geo.id,
3838
+ );
3839
+ const mat = (geo.construction ? dashedMatBase.clone() : lineMat.clone());
3840
+ if (geo.construction) {
3841
+ try { mat.dashSize = Math.max(0.02, 8 * wpp); mat.gapSize = Math.max(0.01, 6 * wpp); } catch { }
3842
+ }
3843
+ try {
3844
+ mat.color.set(sel ? 0x6fe26f : 0xffff88);
3845
+ } catch { }
3846
+ const ln = new THREE.Line(bg, mat);
3847
+ if (geo.construction) { try { ln.computeLineDistances(); } catch { } }
3848
+ ln.renderOrder = 10000;
3849
+
3850
+ ln.userData = { kind: "geometry", id: geo.id, type: geo.type };
3851
+ grp.add(ln);
3852
+ } else if (geo.type === "bezier") {
3853
+ const ids = geo.points || [];
3854
+ const segCount = Math.floor((ids.length - 1) / 3);
3855
+ if (segCount < 1) continue;
3856
+ const segs = 64;
3857
+ const pts = [];
3858
+ for (let seg = 0; seg < segCount; seg++) {
3859
+ const i0 = seg * 3;
3860
+ const p0 = s.points.find((p) => p.id === ids[i0]);
3861
+ const p1 = s.points.find((p) => p.id === ids[i0 + 1]);
3862
+ const p2 = s.points.find((p) => p.id === ids[i0 + 2]);
3863
+ const p3 = s.points.find((p) => p.id === ids[i0 + 3]);
3864
+ if (!p0 || !p1 || !p2 || !p3) continue;
3865
+ for (let i = 0; i <= segs; i++) {
3866
+ if (seg > 0 && i === 0) continue;
3867
+ const t = i / segs;
3868
+ const mt = 1 - t;
3869
+ const bx = mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x;
3870
+ const by = mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y;
3871
+ pts.push(to3(bx, by));
3872
+ }
3873
+ }
3874
+ const bg = new THREE.BufferGeometry().setFromPoints(pts);
3875
+ const sel = Array.from(this._selection).some(
3876
+ (it) => it.type === "geometry" && it.id === geo.id,
3877
+ );
3878
+ const mat = (geo.construction ? dashedMatBase.clone() : lineMat.clone());
3879
+ if (geo.construction) {
3880
+ try { mat.dashSize = Math.max(0.02, 8 * wpp); mat.gapSize = Math.max(0.01, 6 * wpp); } catch { }
3881
+ }
3882
+ try { mat.color.set(sel ? 0x6fe26f : 0xffff88); } catch { }
3883
+ const ln = new THREE.Line(bg, mat);
3884
+ if (geo.construction) { try { ln.computeLineDistances(); } catch { } }
3885
+ ln.renderOrder = 10000;
3886
+
3887
+ ln.userData = { kind: "geometry", id: geo.id, type: geo.type };
3888
+ grp.add(ln);
3889
+
3890
+ // No explicit guide rendering here: actual construction lines are created on curve creation
3891
+ }
3892
+ }
3893
+ const { width, height } = this.#canvasClientSize(
3894
+ this.viewer.renderer.domElement,
3895
+ );
3896
+ wpp = this.#worldPerPixel(this.viewer.camera, width, height);
3897
+ const r = Math.max(0.02, wpp * 8 * 0.5);
3898
+ for (const p of s.points || []) {
3899
+ const selected = Array.from(this._selection).some(
3900
+ (it) => it.type === "point" && it.id === p.id,
3901
+ );
3902
+ const underConstrained = !selected && !p.fixed && !constrainedPoints.has(p.id);
3903
+ const baseColor = underConstrained ? 0xffb347 : 0x9ec9ff;
3904
+ const mat = new THREE.MeshBasicMaterial({
3905
+ color: selected ? 0x6fe26f : baseColor,
3906
+ depthTest: false,
3907
+ depthWrite: false,
3908
+ transparent: true,
3909
+ });
3910
+ const m = new THREE.Mesh(this._handleGeom, mat);
3911
+ m.renderOrder = 10001;
3912
+
3913
+ m.position.copy(to3(p.x, p.y));
3914
+ m.userData = { kind: "point", id: p.id, underConstrained };
3915
+ // Enlarge selected points 2x for better visibility
3916
+ m.scale.setScalar(selected ? r * 2 : r);
3917
+ grp.add(m);
3918
+ }
3919
+ this.#refreshLists();
3920
+ this.#renderDimensions();
3921
+ this.#applyHoverAndSelectionColors();
3922
+ this.#scheduleSketchSnapshot();
3923
+ }
3924
+
3925
+ #updateHandleSizes() {
3926
+ if (!this._sketchGroup) return;
3927
+ const { width, height } = this.#canvasClientSize(
3928
+ this.viewer.renderer.domElement,
3929
+ );
3930
+ const r = Math.max(
3931
+ 0.02,
3932
+ this.#worldPerPixel(this.viewer.camera, width, height) * 8 * 0.5,
3933
+ );
3934
+ if (Math.abs(r - this._lastHandleScale) < 1e-4) return;
3935
+ this._lastHandleScale = r;
3936
+ for (const ch of this._sketchGroup.children) {
3937
+ if (ch?.userData?.kind === "point") {
3938
+ const isSelected = Array.from(this._selection).some(
3939
+ (it) => it.type === 'point' && it.id === ch.userData.id,
3940
+ );
3941
+ ch.scale.setScalar(isSelected ? r * 2 : r);
3942
+ }
3943
+ }
3944
+ }
3945
+
3946
+ // Camera locking/remapping removed: no camera adjustments during sketch mode
3947
+
3948
+ // ============================= Dimension overlays =============================
3949
+ #mountDimRoot() {
3950
+ const host = this.viewer?.container;
3951
+ if (!host) return;
3952
+ const el = document.createElement("div");
3953
+ el.className = "sketch-dims";
3954
+ el.style.position = "absolute";
3955
+ el.style.left = "0";
3956
+ el.style.top = "0";
3957
+ el.style.right = "0";
3958
+ el.style.bottom = "0";
3959
+ el.style.pointerEvents = "none";
3960
+ // SVG for lines/leaders under labels
3961
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
3962
+ svg.setAttribute("width", "100%");
3963
+ svg.setAttribute("height", "100%");
3964
+ svg.style.position = "absolute";
3965
+ svg.style.left = "0";
3966
+ svg.style.top = "0";
3967
+ svg.style.pointerEvents = "none";
3968
+ el.appendChild(svg);
3969
+ this._dimSVG = svg;
3970
+
3971
+ host.appendChild(el);
3972
+ this._dimRoot = el;
3973
+ }
3974
+
3975
+
3976
+
3977
+ #renderDimensions() { try { dimsRender(this); } catch { } }
3978
+
3979
+ // Public: called by Viewer when camera or viewport changes
3980
+ onCameraChanged() {
3981
+ try { this.#renderDimensions(); } catch { }
3982
+ }
3983
+
3984
+
3985
+
3986
+
3987
+
3988
+
3989
+
3990
+
3991
+ // Lookup a constraint by id from the current sketch
3992
+ #getConstraintById(id) {
3993
+ const s = this._solver?.sketchObject;
3994
+ if (!s) return null;
3995
+ const cid = parseInt(id);
3996
+ return (s.constraints || []).find((c) => parseInt(c.id) === cid) || null;
3997
+ }
3998
+
3999
+
4000
+
4001
+
4002
+
4003
+ #startDimDrag(cid, e) {
4004
+ this._dragDim.active = true;
4005
+ this._dragDim.cid = cid;
4006
+ const uv = this.#pointerToPlaneUV(e) || { u: 0, v: 0 };
4007
+ this._dragDim.sx = uv.u;
4008
+ this._dragDim.sy = uv.v;
4009
+ const off = this._dimOffsets.get(cid) || {};
4010
+ const c = this.#getConstraintById(cid);
4011
+ if (c && c.type === "⟺" && c.displayStyle === "radius") {
4012
+ this._dragDim.mode = "radius";
4013
+ this._dragDim.start = {
4014
+ dr: Number(off.dr) || 0,
4015
+ dp: Number(off.dp) || 0,
4016
+ };
4017
+ } else {
4018
+ this._dragDim.mode = "distance";
4019
+ this._dragDim.start = { d: typeof off.d === "number" ? off.d : 0 };
4020
+ }
4021
+ try {
4022
+ e.target.setPointerCapture?.(e.pointerId);
4023
+ } catch { }
4024
+ // Disable camera controls during dimension drag
4025
+ try { if (this.viewer?.controls) this.viewer.controls.enabled = false; } catch { }
4026
+ e.preventDefault();
4027
+ try { e.stopImmediatePropagation(); } catch { }
4028
+ e.stopPropagation();
4029
+ }
4030
+ #moveDimDrag(e) {
4031
+ if (!this._dragDim.active) return;
4032
+ const uv = this.#pointerToPlaneUV(e);
4033
+ if (!uv) return;
4034
+ const c = this.#getConstraintById(this._dragDim.cid);
4035
+ if (!c) return;
4036
+ const s = this._solver.sketchObject;
4037
+ if (
4038
+ c.type === "⟺" &&
4039
+ c.displayStyle === "radius" &&
4040
+ (c.points || []).length >= 2
4041
+ ) {
4042
+ const pc = s.points.find((p) => p.id === c.points[0]);
4043
+ const pr = s.points.find((p) => p.id === c.points[1]);
4044
+ if (!pc || !pr) return;
4045
+ const rx = pr.x - pc.x,
4046
+ ry = pr.y - pc.y;
4047
+ const L = Math.hypot(rx, ry) || 1;
4048
+ const ux = rx / L,
4049
+ uy = ry / L;
4050
+ const nx = -uy,
4051
+ ny = ux;
4052
+ const du = uv.u - pr.x,
4053
+ dv = uv.v - pr.y;
4054
+ const dr = this._dragDim.start.dr + (du * ux + dv * uy);
4055
+ const dp = this._dragDim.start.dp + (du * nx + dv * ny);
4056
+ this._dimOffsets.set(this._dragDim.cid, { dr, dp });
4057
+ } else if (c.type === "⟺" && (c.points || []).length >= 2) {
4058
+ const p0 = s.points.find((p) => p.id === c.points[0]);
4059
+ const p1 = s.points.find((p) => p.id === c.points[1]);
4060
+ if (!p0 || !p1) return;
4061
+ const dx = p1.x - p0.x,
4062
+ dy = p1.y - p0.y;
4063
+ const L = Math.hypot(dx, dy) || 1;
4064
+ const nx = -(dy / L),
4065
+ ny = dx / L;
4066
+ const deltaN =
4067
+ (uv.u - this._dragDim.sx) * nx + (uv.v - this._dragDim.sy) * ny;
4068
+ const d = this._dragDim.start.d + deltaN;
4069
+ this._dimOffsets.set(this._dragDim.cid, { d });
4070
+ }
4071
+ this.#renderDimensions();
4072
+ e.preventDefault();
4073
+ e.stopPropagation();
4074
+ }
4075
+ #endDimDrag(e) {
4076
+ this._dragDim.active = false;
4077
+ this._dragDim.last = null;
4078
+ try {
4079
+ e.target.releasePointerCapture?.(e.pointerId);
4080
+ } catch { }
4081
+ e.preventDefault();
4082
+ e.stopPropagation();
4083
+ // Notify controls that interaction ended (no lock/unlock)
4084
+ try { if (this.viewer?.controls) this.viewer.controls.enabled = true; } catch { }
4085
+ this.#scheduleSketchSnapshot();
4086
+ setTimeout(() => { this.#notifyControlsEnd(e); }, 30);
4087
+ }
4088
+
4089
+ #notifyControlsEnd(e) {
4090
+ // Notify controls the interaction ended without synthesizing DOM events,
4091
+ // to avoid re-entering our own pointerup handler.
4092
+ try { this.viewer?.controls?.dispatchEvent?.({ type: "end" }); } catch { }
4093
+ }
4094
+ // Controls locking removed
4095
+ }