brep-io-kernel 1.0.0-ci.10

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 (271) hide show
  1. package/LICENSE.md +32 -0
  2. package/README.md +157 -0
  3. package/dist-kernel/brep-kernel.js +74699 -0
  4. package/package.json +58 -0
  5. package/src/BREP/AssemblyComponent.js +42 -0
  6. package/src/BREP/BREP.js +43 -0
  7. package/src/BREP/BetterSolid.js +805 -0
  8. package/src/BREP/Edge.js +103 -0
  9. package/src/BREP/Extrude.js +403 -0
  10. package/src/BREP/Face.js +187 -0
  11. package/src/BREP/MeshRepairer.js +634 -0
  12. package/src/BREP/OffsetShellSolid.js +614 -0
  13. package/src/BREP/PointCloudWrap.js +302 -0
  14. package/src/BREP/Revolve.js +345 -0
  15. package/src/BREP/SolidMethods/authoring.js +112 -0
  16. package/src/BREP/SolidMethods/booleanOps.js +230 -0
  17. package/src/BREP/SolidMethods/chamfer.js +122 -0
  18. package/src/BREP/SolidMethods/edgeResolution.js +25 -0
  19. package/src/BREP/SolidMethods/fillet.js +792 -0
  20. package/src/BREP/SolidMethods/index.js +72 -0
  21. package/src/BREP/SolidMethods/io.js +105 -0
  22. package/src/BREP/SolidMethods/lifecycle.js +103 -0
  23. package/src/BREP/SolidMethods/manifoldOps.js +375 -0
  24. package/src/BREP/SolidMethods/meshCleanup.js +2512 -0
  25. package/src/BREP/SolidMethods/meshQueries.js +264 -0
  26. package/src/BREP/SolidMethods/metadata.js +106 -0
  27. package/src/BREP/SolidMethods/metrics.js +51 -0
  28. package/src/BREP/SolidMethods/transforms.js +361 -0
  29. package/src/BREP/SolidMethods/visualize.js +508 -0
  30. package/src/BREP/SolidShared.js +26 -0
  31. package/src/BREP/Sweep.js +1596 -0
  32. package/src/BREP/Tube.js +857 -0
  33. package/src/BREP/Vertex.js +43 -0
  34. package/src/BREP/applyBooleanOperation.js +704 -0
  35. package/src/BREP/boundsUtils.js +48 -0
  36. package/src/BREP/chamfer.js +551 -0
  37. package/src/BREP/edgePolylineUtils.js +85 -0
  38. package/src/BREP/fillets/common.js +388 -0
  39. package/src/BREP/fillets/fillet.js +1422 -0
  40. package/src/BREP/fillets/filletGeometry.js +15 -0
  41. package/src/BREP/fillets/inset.js +389 -0
  42. package/src/BREP/fillets/offsetHelper.js +143 -0
  43. package/src/BREP/fillets/outset.js +88 -0
  44. package/src/BREP/helix.js +193 -0
  45. package/src/BREP/meshToBrep.js +234 -0
  46. package/src/BREP/primitives.js +279 -0
  47. package/src/BREP/setupManifold.js +71 -0
  48. package/src/BREP/threadGeometry.js +1120 -0
  49. package/src/BREP/triangleUtils.js +8 -0
  50. package/src/BREP/triangulate.js +608 -0
  51. package/src/FeatureRegistry.js +183 -0
  52. package/src/PartHistory.js +1132 -0
  53. package/src/UI/AccordionWidget.js +292 -0
  54. package/src/UI/CADmaterials.js +850 -0
  55. package/src/UI/EnvMonacoEditor.js +522 -0
  56. package/src/UI/FloatingWindow.js +396 -0
  57. package/src/UI/HistoryWidget.js +457 -0
  58. package/src/UI/MainToolbar.js +131 -0
  59. package/src/UI/ModelLibraryView.js +194 -0
  60. package/src/UI/OrthoCameraIdle.js +206 -0
  61. package/src/UI/PluginsWidget.js +280 -0
  62. package/src/UI/SceneListing.js +606 -0
  63. package/src/UI/SelectionFilter.js +629 -0
  64. package/src/UI/ViewCube.js +389 -0
  65. package/src/UI/assembly/AssemblyConstraintCollectionWidget.js +329 -0
  66. package/src/UI/assembly/AssemblyConstraintControlsWidget.js +282 -0
  67. package/src/UI/assembly/AssemblyConstraintsWidget.css +292 -0
  68. package/src/UI/assembly/AssemblyConstraintsWidget.js +1373 -0
  69. package/src/UI/assembly/constraintFaceUtils.js +115 -0
  70. package/src/UI/assembly/constraintHighlightUtils.js +70 -0
  71. package/src/UI/assembly/constraintLabelUtils.js +31 -0
  72. package/src/UI/assembly/constraintPointUtils.js +64 -0
  73. package/src/UI/assembly/constraintSelectionUtils.js +185 -0
  74. package/src/UI/assembly/constraintStatusUtils.js +142 -0
  75. package/src/UI/componentSelectorModal.js +240 -0
  76. package/src/UI/controls/CombinedTransformControls.js +386 -0
  77. package/src/UI/dialogs.js +351 -0
  78. package/src/UI/expressionsManager.js +100 -0
  79. package/src/UI/featureDialogWidgets/booleanField.js +25 -0
  80. package/src/UI/featureDialogWidgets/booleanOperationField.js +97 -0
  81. package/src/UI/featureDialogWidgets/buttonField.js +45 -0
  82. package/src/UI/featureDialogWidgets/componentSelectorField.js +102 -0
  83. package/src/UI/featureDialogWidgets/defaultField.js +23 -0
  84. package/src/UI/featureDialogWidgets/fileField.js +66 -0
  85. package/src/UI/featureDialogWidgets/index.js +34 -0
  86. package/src/UI/featureDialogWidgets/numberField.js +165 -0
  87. package/src/UI/featureDialogWidgets/optionsField.js +33 -0
  88. package/src/UI/featureDialogWidgets/referenceSelectionField.js +208 -0
  89. package/src/UI/featureDialogWidgets/stringField.js +24 -0
  90. package/src/UI/featureDialogWidgets/textareaField.js +28 -0
  91. package/src/UI/featureDialogWidgets/threadDesignationField.js +160 -0
  92. package/src/UI/featureDialogWidgets/transformField.js +252 -0
  93. package/src/UI/featureDialogWidgets/utils.js +43 -0
  94. package/src/UI/featureDialogWidgets/vec3Field.js +133 -0
  95. package/src/UI/featureDialogs.js +1414 -0
  96. package/src/UI/fileManagerWidget.js +615 -0
  97. package/src/UI/history/HistoryCollectionWidget.js +1294 -0
  98. package/src/UI/history/historyCollectionWidget.css.js +257 -0
  99. package/src/UI/history/historyDisplayInfo.js +133 -0
  100. package/src/UI/mobile.js +28 -0
  101. package/src/UI/objectDump.js +442 -0
  102. package/src/UI/pmi/AnnotationCollectionWidget.js +120 -0
  103. package/src/UI/pmi/AnnotationHistory.js +353 -0
  104. package/src/UI/pmi/AnnotationRegistry.js +90 -0
  105. package/src/UI/pmi/BaseAnnotation.js +269 -0
  106. package/src/UI/pmi/LabelOverlay.css +102 -0
  107. package/src/UI/pmi/LabelOverlay.js +191 -0
  108. package/src/UI/pmi/PMIMode.js +1550 -0
  109. package/src/UI/pmi/PMIViewsWidget.js +1098 -0
  110. package/src/UI/pmi/annUtils.js +729 -0
  111. package/src/UI/pmi/dimensions/AngleDimensionAnnotation.js +647 -0
  112. package/src/UI/pmi/dimensions/ExplodeBodyAnnotation.js +507 -0
  113. package/src/UI/pmi/dimensions/HoleCalloutAnnotation.js +462 -0
  114. package/src/UI/pmi/dimensions/LeaderAnnotation.js +403 -0
  115. package/src/UI/pmi/dimensions/LinearDimensionAnnotation.js +532 -0
  116. package/src/UI/pmi/dimensions/NoteAnnotation.js +110 -0
  117. package/src/UI/pmi/dimensions/RadialDimensionAnnotation.js +659 -0
  118. package/src/UI/pmi/pmiStyle.js +44 -0
  119. package/src/UI/sketcher/SketchMode3D.js +4095 -0
  120. package/src/UI/sketcher/dimensions.js +674 -0
  121. package/src/UI/sketcher/glyphs.js +236 -0
  122. package/src/UI/sketcher/highlights.js +60 -0
  123. package/src/UI/toolbarButtons/aboutButton.js +5 -0
  124. package/src/UI/toolbarButtons/exportButton.js +609 -0
  125. package/src/UI/toolbarButtons/flatPatternButton.js +307 -0
  126. package/src/UI/toolbarButtons/importButton.js +160 -0
  127. package/src/UI/toolbarButtons/inspectorToggleButton.js +12 -0
  128. package/src/UI/toolbarButtons/metadataButton.js +1063 -0
  129. package/src/UI/toolbarButtons/orientToFaceButton.js +114 -0
  130. package/src/UI/toolbarButtons/registerDefaultButtons.js +46 -0
  131. package/src/UI/toolbarButtons/saveButton.js +99 -0
  132. package/src/UI/toolbarButtons/scriptRunnerButton.js +302 -0
  133. package/src/UI/toolbarButtons/testsButton.js +26 -0
  134. package/src/UI/toolbarButtons/undoRedoButtons.js +25 -0
  135. package/src/UI/toolbarButtons/wireframeToggleButton.js +5 -0
  136. package/src/UI/toolbarButtons/zoomToFitButton.js +5 -0
  137. package/src/UI/triangleDebuggerWindow.js +945 -0
  138. package/src/UI/viewer.js +4228 -0
  139. package/src/assemblyConstraints/AssemblyConstraintHistory.js +1576 -0
  140. package/src/assemblyConstraints/AssemblyConstraintRegistry.js +120 -0
  141. package/src/assemblyConstraints/BaseAssemblyConstraint.js +66 -0
  142. package/src/assemblyConstraints/constraintExpressionUtils.js +35 -0
  143. package/src/assemblyConstraints/constraintUtils/parallelAlignment.js +676 -0
  144. package/src/assemblyConstraints/constraints/AngleConstraint.js +485 -0
  145. package/src/assemblyConstraints/constraints/CoincidentConstraint.js +194 -0
  146. package/src/assemblyConstraints/constraints/DistanceConstraint.js +616 -0
  147. package/src/assemblyConstraints/constraints/FixedConstraint.js +78 -0
  148. package/src/assemblyConstraints/constraints/ParallelConstraint.js +252 -0
  149. package/src/assemblyConstraints/constraints/TouchAlignConstraint.js +961 -0
  150. package/src/core/entities/HistoryCollectionBase.js +72 -0
  151. package/src/core/entities/ListEntityBase.js +109 -0
  152. package/src/core/entities/schemaProcesser.js +121 -0
  153. package/src/exporters/sheetMetalFlatPattern.js +659 -0
  154. package/src/exporters/sheetMetalUnfold.js +862 -0
  155. package/src/exporters/step.js +1135 -0
  156. package/src/exporters/threeMF.js +575 -0
  157. package/src/features/assemblyComponent/AssemblyComponentFeature.js +780 -0
  158. package/src/features/boolean/BooleanFeature.js +94 -0
  159. package/src/features/chamfer/ChamferFeature.js +116 -0
  160. package/src/features/datium/DatiumFeature.js +80 -0
  161. package/src/features/edgeFeatureUtils.js +41 -0
  162. package/src/features/extrude/ExtrudeFeature.js +143 -0
  163. package/src/features/fillet/FilletFeature.js +197 -0
  164. package/src/features/helix/HelixFeature.js +405 -0
  165. package/src/features/hole/HoleFeature.js +1050 -0
  166. package/src/features/hole/screwClearance.js +86 -0
  167. package/src/features/hole/threadDesignationCatalog.js +149 -0
  168. package/src/features/imageHeightSolid/ImageHeightmapSolidFeature.js +463 -0
  169. package/src/features/imageToFace/ImageToFaceFeature.js +727 -0
  170. package/src/features/imageToFace/imageEditor.js +1270 -0
  171. package/src/features/imageToFace/traceUtils.js +971 -0
  172. package/src/features/import3dModel/Import3dModelFeature.js +151 -0
  173. package/src/features/loft/LoftFeature.js +605 -0
  174. package/src/features/mirror/MirrorFeature.js +151 -0
  175. package/src/features/offsetFace/OffsetFaceFeature.js +370 -0
  176. package/src/features/offsetShell/OffsetShellFeature.js +89 -0
  177. package/src/features/overlapCleanup/OverlapCleanupFeature.js +85 -0
  178. package/src/features/pattern/PatternFeature.js +275 -0
  179. package/src/features/patternLinear/PatternLinearFeature.js +120 -0
  180. package/src/features/patternRadial/PatternRadialFeature.js +186 -0
  181. package/src/features/plane/PlaneFeature.js +154 -0
  182. package/src/features/primitiveCone/primitiveConeFeature.js +99 -0
  183. package/src/features/primitiveCube/primitiveCubeFeature.js +70 -0
  184. package/src/features/primitiveCylinder/primitiveCylinderFeature.js +91 -0
  185. package/src/features/primitivePyramid/primitivePyramidFeature.js +72 -0
  186. package/src/features/primitiveSphere/primitiveSphereFeature.js +62 -0
  187. package/src/features/primitiveTorus/primitiveTorusFeature.js +109 -0
  188. package/src/features/remesh/RemeshFeature.js +97 -0
  189. package/src/features/revolve/RevolveFeature.js +111 -0
  190. package/src/features/selectionUtils.js +118 -0
  191. package/src/features/sheetMetal/SheetMetalContourFlangeFeature.js +1656 -0
  192. package/src/features/sheetMetal/SheetMetalCutoutFeature.js +1056 -0
  193. package/src/features/sheetMetal/SheetMetalFlangeFeature.js +1568 -0
  194. package/src/features/sheetMetal/SheetMetalHemFeature.js +43 -0
  195. package/src/features/sheetMetal/SheetMetalObject.js +141 -0
  196. package/src/features/sheetMetal/SheetMetalTabFeature.js +176 -0
  197. package/src/features/sheetMetal/UNFOLD_NEUTRAL_REQUIREMENTS.md +153 -0
  198. package/src/features/sheetMetal/contour-flange-rebuild-spec.md +261 -0
  199. package/src/features/sheetMetal/profileUtils.js +25 -0
  200. package/src/features/sheetMetal/sheetMetalCleanup.js +9 -0
  201. package/src/features/sheetMetal/sheetMetalFaceTypes.js +146 -0
  202. package/src/features/sheetMetal/sheetMetalMetadata.js +165 -0
  203. package/src/features/sheetMetal/sheetMetalPipeline.js +169 -0
  204. package/src/features/sheetMetal/sheetMetalProfileUtils.js +216 -0
  205. package/src/features/sheetMetal/sheetMetalTabUtils.js +29 -0
  206. package/src/features/sheetMetal/sheetMetalTree.js +210 -0
  207. package/src/features/sketch/SketchFeature.js +955 -0
  208. package/src/features/sketch/sketchSolver2D/ConstraintEngine.js +800 -0
  209. package/src/features/sketch/sketchSolver2D/constraintDefinitions.js +704 -0
  210. package/src/features/sketch/sketchSolver2D/mathHelpersMod.js +307 -0
  211. package/src/features/spline/SplineEditorSession.js +988 -0
  212. package/src/features/spline/SplineFeature.js +1388 -0
  213. package/src/features/spline/splineUtils.js +218 -0
  214. package/src/features/sweep/SweepFeature.js +110 -0
  215. package/src/features/transform/TransformFeature.js +152 -0
  216. package/src/features/tube/TubeFeature.js +635 -0
  217. package/src/fs.proxy.js +625 -0
  218. package/src/idbStorage.js +254 -0
  219. package/src/index.js +12 -0
  220. package/src/main.js +15 -0
  221. package/src/metadataManager.js +64 -0
  222. package/src/path.proxy.js +277 -0
  223. package/src/plugins/ghLoader.worker.js +151 -0
  224. package/src/plugins/pluginManager.js +286 -0
  225. package/src/pmi/PMIViewsManager.js +134 -0
  226. package/src/services/componentLibrary.js +198 -0
  227. package/src/tests/ConsoleCapture.js +189 -0
  228. package/src/tests/S7-diagnostics-2025-12-23T18-37-23-570Z.json +630 -0
  229. package/src/tests/browserTests.js +597 -0
  230. package/src/tests/debugBoolean.js +225 -0
  231. package/src/tests/partFiles/badBoolean.json +957 -0
  232. package/src/tests/partFiles/extrudeTest.json +88 -0
  233. package/src/tests/partFiles/filletFail.json +58 -0
  234. package/src/tests/partFiles/import_TEst.part.part.json +646 -0
  235. package/src/tests/partFiles/sheetMetalHem.BREP.json +734 -0
  236. package/src/tests/test_boolean_subtract.js +27 -0
  237. package/src/tests/test_chamfer.js +17 -0
  238. package/src/tests/test_extrudeFace.js +24 -0
  239. package/src/tests/test_fillet.js +17 -0
  240. package/src/tests/test_fillet_nonClosed.js +45 -0
  241. package/src/tests/test_filletsMoreDifficult.js +46 -0
  242. package/src/tests/test_history_features_basic.js +149 -0
  243. package/src/tests/test_hole.js +282 -0
  244. package/src/tests/test_mirror.js +16 -0
  245. package/src/tests/test_offsetShellGrouping.js +85 -0
  246. package/src/tests/test_plane.js +4 -0
  247. package/src/tests/test_primitiveCone.js +11 -0
  248. package/src/tests/test_primitiveCube.js +7 -0
  249. package/src/tests/test_primitiveCylinder.js +8 -0
  250. package/src/tests/test_primitivePyramid.js +9 -0
  251. package/src/tests/test_primitiveSphere.js +17 -0
  252. package/src/tests/test_primitiveTorus.js +21 -0
  253. package/src/tests/test_pushFace.js +126 -0
  254. package/src/tests/test_sheetMetalContourFlange.js +125 -0
  255. package/src/tests/test_sheetMetal_features.js +80 -0
  256. package/src/tests/test_sketch_openLoop.js +45 -0
  257. package/src/tests/test_solidMetrics.js +58 -0
  258. package/src/tests/test_stlLoader.js +1889 -0
  259. package/src/tests/test_sweepFace.js +55 -0
  260. package/src/tests/test_tube.js +45 -0
  261. package/src/tests/test_tube_closedLoop.js +67 -0
  262. package/src/tests/tests.js +493 -0
  263. package/src/tools/assemblyConstraintDialogCapturePage.js +56 -0
  264. package/src/tools/dialogCapturePageFactory.js +227 -0
  265. package/src/tools/featureDialogCapturePage.js +47 -0
  266. package/src/tools/pmiAnnotationDialogCapturePage.js +60 -0
  267. package/src/utils/axisHelpers.js +99 -0
  268. package/src/utils/deepClone.js +69 -0
  269. package/src/utils/geometryTolerance.js +37 -0
  270. package/src/utils/normalizeTypeString.js +8 -0
  271. package/src/utils/xformMath.js +51 -0
@@ -0,0 +1,1388 @@
1
+ import { BREP } from "../../BREP/BREP.js";
2
+ const THREE = BREP.THREE;
3
+ import { LineGeometry } from "three/examples/jsm/Addons.js";
4
+ import {
5
+ DEFAULT_RESOLUTION,
6
+ normalizeSplineData,
7
+ buildHermitePolyline,
8
+ cloneSplineData,
9
+ } from "./splineUtils.js";
10
+ import { SplineEditorSession } from "./SplineEditorSession.js";
11
+
12
+ function renderSplinePointsWidget({ ui, key, controlWrap, row }) {
13
+ const normalizeNumber = (value) => {
14
+ const num = Number(value);
15
+ return Number.isFinite(num) ? num : 0;
16
+ };
17
+ const formatNumber = (value) => {
18
+ const num = Number(value);
19
+ if (!Number.isFinite(num)) return "0";
20
+ return num.toFixed(3).replace(/\.?0+$/, "") || "0";
21
+ };
22
+ const getFeatureID = () => ui?.params?.featureID != null ? String(ui.params.featureID) : null;
23
+ const getViewer = () => ui?.options?.viewer || null;
24
+ const getPartHistory = () =>
25
+ ui?.options?.partHistory || ui?.options?.viewer?.partHistory || null;
26
+ const getFeatureRef = () => {
27
+ const featureID = getFeatureID();
28
+ if (!featureID) return null;
29
+ const viaOption = ui?.options?.featureRef || null;
30
+ if (
31
+ viaOption &&
32
+ String(viaOption?.inputParams?.featureID ?? "") === featureID
33
+ ) {
34
+ return viaOption;
35
+ }
36
+ const ph = getPartHistory();
37
+ if (ph && Array.isArray(ph.features)) {
38
+ return (
39
+ ph.features.find(
40
+ (f) => String(f?.inputParams?.featureID ?? "") === featureID
41
+ ) || null
42
+ );
43
+ }
44
+ return null;
45
+ };
46
+ const markDirty = (feature, data) => {
47
+ if (!feature) return;
48
+ feature.lastRunInputParams = {};
49
+ feature.timestamp = 0;
50
+ feature.dirty = true;
51
+ feature.persistentData = feature.persistentData || {};
52
+ feature.persistentData.spline = cloneSplineData(data);
53
+ };
54
+ const computeSignature = (data) => {
55
+ let json;
56
+ try {
57
+ json = JSON.stringify(data);
58
+ } catch {
59
+ return String(Date.now());
60
+ }
61
+ let hash = 0;
62
+ for (let i = 0; i < json.length; i++) {
63
+ hash = (hash * 31 + json.charCodeAt(i)) | 0;
64
+ }
65
+ return `${json.length}:${hash >>> 0}`;
66
+ };
67
+
68
+ const host = document.createElement("div");
69
+ host.className = "spline-widget";
70
+ host.dataset.splineWidget = "true";
71
+ const style = document.createElement("style");
72
+ style.textContent = `
73
+ .spline-widget {
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 10px;
77
+ width: 100%;
78
+ box-sizing: border-box;
79
+ }
80
+ .spline-widget .spw-header {
81
+ display: flex;
82
+ justify-content: flex-start;
83
+ gap: 8px;
84
+ flex-wrap: wrap;
85
+ }
86
+ .spline-widget .spw-point-list {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: 8px;
90
+ width: 100%;
91
+ box-sizing: border-box;
92
+ }
93
+ .spline-widget .spw-point-row {
94
+ display: flex;
95
+ flex-direction: column;
96
+ gap: 8px;
97
+ align-items: stretch;
98
+ padding: 10px;
99
+ border-radius: 6px;
100
+ background: rgba(255, 255, 255, 0.04);
101
+ width: 100%;
102
+ box-sizing: border-box;
103
+ }
104
+ .spline-widget .spw-row-header {
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: space-between;
108
+ gap: 8px;
109
+ flex-wrap: wrap;
110
+ width: 100%;
111
+ box-sizing: border-box;
112
+ }
113
+ .spline-widget .spw-selected {
114
+ background: rgba(58, 74, 109, 0.35);
115
+ }
116
+ .spline-widget .spw-title {
117
+ font-weight: 600;
118
+ font-size: 12px;
119
+ color: rgba(255, 255, 255, 0.88);
120
+ text-decoration: underline;
121
+ text-underline-offset: 2px;
122
+ }
123
+ .spline-widget .spw-posline {
124
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
125
+ font-size: 12px;
126
+ color: rgba(255, 255, 255, 0.75);
127
+ }
128
+ .spline-widget .spw-coords {
129
+ display: grid;
130
+ grid-template-columns: 1fr;
131
+ gap: 6px;
132
+ width: 100%;
133
+ box-sizing: border-box;
134
+ }
135
+ .spline-widget .spw-axis {
136
+ display: grid;
137
+ grid-template-columns: auto minmax(0, 1fr);
138
+ align-items: center;
139
+ gap: 6px;
140
+ font-size: 12px;
141
+ color: rgba(255, 255, 255, 0.7);
142
+ width: 100%;
143
+ box-sizing: border-box;
144
+ }
145
+ .spline-widget .spw-axis input {
146
+ width: 100%;
147
+ max-width: 100%;
148
+ min-width: 0;
149
+ box-sizing: border-box;
150
+ padding: 4px 6px;
151
+ border-radius: 4px;
152
+ border: 1px solid rgba(255, 255, 255, 0.15);
153
+ background: rgba(0, 0, 0, 0.3);
154
+ color: inherit;
155
+ font-family: inherit;
156
+ font-size: 12px;
157
+ }
158
+ .spline-widget .spw-axis input:focus {
159
+ outline: none;
160
+ border-color: rgba(108, 195, 255, 0.9);
161
+ box-shadow: 0 0 0 1px rgba(108, 195, 255, 0.35);
162
+ }
163
+ .spline-widget .spw-actions {
164
+ display: flex;
165
+ gap: 6px;
166
+ justify-content: flex-end;
167
+ flex-wrap: wrap;
168
+ }
169
+ .spline-widget .spw-btn,
170
+ .spline-widget .spw-icon-btn,
171
+ .spline-widget .spw-link {
172
+ border: none;
173
+ border-radius: 4px;
174
+ font-size: 12px;
175
+ cursor: pointer;
176
+ padding: 6px 10px;
177
+ background: rgba(108, 195, 255, 0.12);
178
+ color: rgba(223, 239, 255, 0.95);
179
+ transition: background 0.15s ease, color 0.15s ease;
180
+ }
181
+ .spline-widget .spw-btn:hover,
182
+ .spline-widget .spw-icon-btn:hover,
183
+ .spline-widget .spw-link:hover {
184
+ background: rgba(108, 195, 255, 0.22);
185
+ }
186
+ .spline-widget .spw-icon-btn {
187
+ padding: 4px 6px;
188
+ min-width: 28px;
189
+ text-align: center;
190
+ }
191
+ .spline-widget .spw-icon-btn.danger {
192
+ background: rgba(255, 107, 107, 0.14);
193
+ color: rgba(255, 214, 214, 0.94);
194
+ }
195
+ .spline-widget .spw-icon-btn.danger:hover {
196
+ background: rgba(255, 107, 107, 0.24);
197
+ }
198
+ .spline-widget .spw-link {
199
+ background: none;
200
+ padding: 0;
201
+ color: rgba(108, 195, 255, 0.9);
202
+ }
203
+ .spline-widget .spw-link:hover {
204
+ color: rgba(154, 214, 255, 0.95);
205
+ }
206
+ .spline-widget .spw-empty {
207
+ opacity: 0.6;
208
+ font-size: 12px;
209
+ padding: 6px 0;
210
+ }
211
+ `;
212
+ host.appendChild(style);
213
+
214
+ if (row && typeof row.querySelector === "function") {
215
+ const labelEl = row.querySelector(".label");
216
+ if (labelEl) {
217
+ labelEl.style.alignSelf = "flex-start";
218
+ labelEl.style.paddingTop = "8px";
219
+ }
220
+ }
221
+
222
+ const header = document.createElement("div");
223
+ header.className = "spw-header";
224
+ const addBtn = document.createElement("button");
225
+ addBtn.type = "button";
226
+ addBtn.className = "spw-btn";
227
+ addBtn.textContent = "+";
228
+ // tooltip for addBtn
229
+ addBtn.title = "Add a new point to the spline";
230
+ header.appendChild(addBtn);
231
+ host.appendChild(header);
232
+
233
+ const pointList = document.createElement("div");
234
+ pointList.className = "spw-point-list";
235
+ host.appendChild(pointList);
236
+
237
+ controlWrap.appendChild(host);
238
+
239
+ const state = {
240
+ spline: null,
241
+ signature: null,
242
+ pendingFocusId: null,
243
+ pendingFocusNode: null,
244
+ session: null,
245
+ selection: null,
246
+ destroyed: false,
247
+ creatingSession: false,
248
+ refreshing: false,
249
+ inSelectionChange: false, // Guard against recursive selection changes
250
+ inSplineChange: false, // Guard against recursive spline changes
251
+ };
252
+
253
+ let pointRowMap = new Map();
254
+ let pointButtonMap = new Map();
255
+
256
+ const loadFromSource = () => {
257
+ const feature = getFeatureRef();
258
+ const raw = feature?.persistentData?.spline || null;
259
+ const normalized = normalizeSplineData(raw);
260
+ return cloneSplineData(normalized);
261
+ };
262
+
263
+ const ensureState = () => {
264
+ if (!state.spline) {
265
+ state.spline = loadFromSource();
266
+ state.signature = computeSignature(state.spline);
267
+ ui.params[key] = state.signature;
268
+ }
269
+ };
270
+
271
+ const shouldIgnorePointerEvent = (event) => {
272
+ const path =
273
+ typeof event.composedPath === "function" ? event.composedPath() : [];
274
+ for (const el of path) {
275
+ if (el === host) return true;
276
+ if (el && el.dataset && el.dataset.splineWidget === "true") return true;
277
+ }
278
+ return false;
279
+ };
280
+
281
+ const disposeSession = (force = false) => {
282
+ if (!state.session) return;
283
+
284
+ // Always dispose when explicitly requested or forced
285
+ try {
286
+ state.session.dispose();
287
+ } catch (error) {
288
+ /* ignore */
289
+ }
290
+ state.session = null;
291
+ };
292
+
293
+ const handleSessionSelectionChange = (id) => {
294
+ if (state.destroyed || state.inSelectionChange) {
295
+ return;
296
+ }
297
+
298
+ // Guard against recursive calls
299
+ state.inSelectionChange = true;
300
+
301
+ try {
302
+ // CRITICAL FIX: Don't call session.selectObject from within a session selection change event!
303
+ // This was causing infinite loops: session calls this handler -> we call selectObject -> triggers handler again
304
+ state.selection = id || null;
305
+
306
+ renderAll({ fromSession: true });
307
+ } finally {
308
+ state.inSelectionChange = false;
309
+ }
310
+ };
311
+
312
+ const handleSessionSplineChange = (nextData, reason = "transform") => {
313
+ if (state.destroyed || state.inSplineChange) {
314
+ return;
315
+ }
316
+
317
+ // Guard against recursive calls
318
+ state.inSplineChange = true;
319
+
320
+ try {
321
+ state.spline = cloneSplineData(normalizeSplineData(nextData));
322
+
323
+ // CRITICAL FIX: Always update persistent data when spline changes
324
+ // This ensures transform changes are preserved when parameters change
325
+ const feature = getFeatureRef();
326
+ if (feature) {
327
+ markDirty(feature, state.spline);
328
+ }
329
+
330
+ // CRITICAL CHANGE: Only update UI, don't trigger feature rebuild during editing
331
+ // The session preview handles the visual updates, feature rebuild happens on dialog close
332
+
333
+ renderAll({ fromSession: true });
334
+ } finally {
335
+ state.inSplineChange = false;
336
+ }
337
+ };
338
+
339
+ const ensureSession = () => {
340
+ const viewer = getViewer();
341
+ const featureID = getFeatureID();
342
+
343
+ // Prevent creating multiple sessions or infinite loops
344
+ if (state.session || state.creatingSession || state.destroyed) {
345
+ return state.session;
346
+ }
347
+ if (!viewer || !featureID) {
348
+ return null;
349
+ }
350
+
351
+ state.creatingSession = true;
352
+
353
+ try {
354
+
355
+ // Dispose any existing session first
356
+ disposeSession(true); // Force disposal when creating new session
357
+
358
+ const feature = getFeatureRef();
359
+ if (!feature) {
360
+
361
+ state.creatingSession = false;
362
+ return null;
363
+ }
364
+
365
+ const session = new SplineEditorSession(viewer, featureID, {
366
+ featureRef: feature,
367
+ onSplineChange: handleSessionSplineChange,
368
+ onSelectionChange: handleSessionSelectionChange,
369
+ shouldIgnorePointerEvent,
370
+ });
371
+
372
+ state.session = session;
373
+
374
+ const res = Number(feature?.inputParams?.curveResolution);
375
+ const preview = Number.isFinite(res) ? Math.max(4, Math.floor(res)) : undefined;
376
+ // Store desired selection before activation (since activation clears it)
377
+ const desiredSelection = state.selection || (state.spline?.points?.[0] ? `point:${state.spline.points[0].id}` : null);
378
+
379
+ session.activate(state.spline, {
380
+ featureRef: feature,
381
+ previewResolution: preview,
382
+ initialSelection: desiredSelection,
383
+ });
384
+
385
+
386
+ // After activation, restore or set the desired selection
387
+ let currentSelection = desiredSelection;
388
+
389
+ if (currentSelection) {
390
+
391
+ session.selectObject(currentSelection);
392
+ } else {
393
+
394
+ }
395
+
396
+ state.selection = currentSelection;
397
+
398
+ // Force transform controls to be visible by triggering a rebuild
399
+ // This ensures the controls appear immediately when the session is first created
400
+ if (currentSelection) {
401
+
402
+
403
+ session.setSplineData(state.spline, {
404
+ preserveSelection: true,
405
+ silent: true,
406
+ reason: "initial-selection"
407
+ });
408
+
409
+
410
+
411
+ // Double-check selection is active
412
+ if (session.getSelectedId() !== currentSelection) {
413
+
414
+ session.selectObject(currentSelection);
415
+ }
416
+
417
+ // Force transform visibility update as a final fallback
418
+ if (session._updateTransformVisibility) {
419
+
420
+ session._updateTransformVisibility();
421
+ }
422
+
423
+ // Comprehensive debugging of session state and force rebuild if needed
424
+ setTimeout(() => {
425
+
426
+
427
+ // Check if transform controls exist and are in scene
428
+ let hasVisibleControls = false;
429
+ if (session._transformsById) {
430
+ for (const [id, entry] of session._transformsById.entries()) {
431
+ const control = entry?.control;
432
+ const inScene = !!(control && session.viewer?.scene && session.viewer.scene.children.includes(control));
433
+
434
+ if (control?.enabled && control?.visible && inScene) {
435
+ hasVisibleControls = true;
436
+ }
437
+ }
438
+ }
439
+
440
+ // If no visible controls but we have a selection, force another rebuild
441
+ if (!hasVisibleControls && currentSelection && session.isActive()) {
442
+
443
+
444
+ // Force cleanup before rebuild
445
+ if (session.forceCleanup) {
446
+ session.forceCleanup();
447
+ }
448
+
449
+ session.setSplineData(state.spline, {
450
+ preserveSelection: true,
451
+ silent: true,
452
+ reason: "force-controls-visible"
453
+ });
454
+
455
+ // Force selection again
456
+ session.selectObject(currentSelection);
457
+
458
+ // Force visibility update
459
+ if (session._updateTransformVisibility) {
460
+ session._updateTransformVisibility();
461
+ }
462
+
463
+
464
+ }
465
+ }, 100);
466
+ }
467
+
468
+ } catch (error) {
469
+ /* ignore */
470
+ disposeSession(true); // Force disposal on error
471
+ } finally {
472
+ state.creatingSession = false;
473
+ }
474
+
475
+
476
+ return state.session;
477
+ };
478
+
479
+ const focusPendingPoint = () => {
480
+ if (!state.pendingFocusNode) return;
481
+ try {
482
+ state.pendingFocusNode.focus();
483
+ state.pendingFocusNode.select?.();
484
+ } catch {
485
+ /* ignore */
486
+ }
487
+ state.pendingFocusNode = null;
488
+ state.pendingFocusId = null;
489
+ };
490
+
491
+ // Centralized function to activate a point (same as clicking edit button)
492
+ const activatePoint = (pointId) => {
493
+ const keyId = `point:${pointId}`;
494
+
495
+ // Ensure session exists before selecting - this will create transform controls
496
+ let activeSession = state.session;
497
+ const viewer = getViewer();
498
+ const featureID = getFeatureID();
499
+ if (!activeSession && viewer && featureID && !state.creatingSession) {
500
+ activeSession = ensureSession();
501
+ }
502
+
503
+ if (activeSession) {
504
+ // Always force redraw to ensure preview and transform controls are rebuilt
505
+ activeSession.selectObject(keyId, { forceRedraw: true });
506
+ }
507
+
508
+ state.selection = keyId;
509
+ updateSelectionStyles();
510
+ };
511
+
512
+ const renderPointRows = () => {
513
+ pointList.textContent = "";
514
+ pointRowMap = new Map();
515
+ pointButtonMap = new Map();
516
+ state.pendingFocusNode = null;
517
+ const points = Array.isArray(state.spline?.points)
518
+ ? state.spline.points
519
+ : [];
520
+ if (!points.length) {
521
+ const empty = document.createElement("div");
522
+ empty.className = "spw-empty";
523
+ empty.textContent = "No points defined.";
524
+ pointList.appendChild(empty);
525
+ pointRowMap.clear();
526
+ pointButtonMap.clear();
527
+ updateSelectionStyles();
528
+ return;
529
+ }
530
+ points.forEach((pt, index) => {
531
+ const keyId = `point:${pt.id}`;
532
+ const rowEl = document.createElement("div");
533
+ rowEl.className = "spw-point-row";
534
+ rowEl.dataset.pointId = String(pt.id);
535
+ rowEl.addEventListener("click", (event) => {
536
+ if (event?.defaultPrevented) return;
537
+ const target = event?.target;
538
+ if (target && typeof target.closest === "function") {
539
+ if (target.closest("button")) return;
540
+ }
541
+ activatePoint(pt.id);
542
+ });
543
+
544
+ // Header: title + actions
545
+ const headerEl = document.createElement('div');
546
+ headerEl.className = 'spw-row-header';
547
+ const title = document.createElement("div");
548
+ title.className = "spw-title";
549
+ title.textContent = `Point ${index + 1}`;
550
+ headerEl.appendChild(title);
551
+
552
+ // Actions
553
+ const actions = document.createElement("div");
554
+ actions.className = "spw-actions";
555
+
556
+ const selectBtn = document.createElement("button");
557
+ selectBtn.type = "button";
558
+ selectBtn.className = "spw-btn";
559
+ selectBtn.textContent = "🖉";
560
+ selectBtn.addEventListener("click", () => {
561
+ activatePoint(pt.id);
562
+ });
563
+ actions.appendChild(selectBtn);
564
+ pointButtonMap.set(keyId, selectBtn);
565
+
566
+ const flipBtn = document.createElement("button");
567
+ flipBtn.type = "button";
568
+ flipBtn.className = "spw-btn";
569
+ flipBtn.textContent = ">|<";
570
+ flipBtn.title = "Toggle spline direction";
571
+ flipBtn.addEventListener("click", () => {
572
+ activatePoint(pt.id);
573
+ state.spline.points[index].flipDirection = !state.spline.points[index].flipDirection;
574
+ commit("flip-direction");
575
+ });
576
+ actions.appendChild(flipBtn);
577
+
578
+ const upBtn = document.createElement("button");
579
+ upBtn.type = "button";
580
+ upBtn.className = "spw-icon-btn";
581
+ upBtn.textContent = "△";
582
+ upBtn.title = "Move up";
583
+ if (index === 0) upBtn.disabled = true;
584
+ upBtn.addEventListener("click", () => {
585
+ activatePoint(pt.id);
586
+ movePoint(index, -1);
587
+ });
588
+ actions.appendChild(upBtn);
589
+
590
+ const downBtn = document.createElement("button");
591
+ downBtn.type = "button";
592
+ downBtn.className = "spw-icon-btn";
593
+ downBtn.textContent = "▽";
594
+ downBtn.title = "Move down";
595
+ if (index === points.length - 1) downBtn.disabled = true;
596
+ downBtn.addEventListener("click", () => {
597
+ activatePoint(pt.id);
598
+ movePoint(index, 1);
599
+ });
600
+ actions.appendChild(downBtn);
601
+
602
+ const removeBtn = document.createElement("button");
603
+ removeBtn.type = "button";
604
+ removeBtn.className = "spw-icon-btn danger";
605
+ removeBtn.textContent = "✕";
606
+ removeBtn.title = "Remove point";
607
+ if (points.length <= 2) removeBtn.disabled = true;
608
+ removeBtn.addEventListener("click", () => {
609
+ activatePoint(pt.id);
610
+ removePoint(index);
611
+ });
612
+ actions.appendChild(removeBtn);
613
+
614
+ headerEl.appendChild(actions);
615
+ rowEl.appendChild(headerEl);
616
+
617
+ // Extension Distances section
618
+ const extensionSection = document.createElement("div");
619
+ extensionSection.className = "spw-section";
620
+ const extensionTitle = document.createElement("div");
621
+ extensionTitle.className = "spw-section-title";
622
+ extensionTitle.textContent = "Extension Distances";
623
+ extensionSection.appendChild(extensionTitle);
624
+
625
+ const extensionCoords = document.createElement("div");
626
+ extensionCoords.className = "spw-coords";
627
+
628
+ // Forward distance
629
+ const forwardWrap = document.createElement("label");
630
+ forwardWrap.className = "spw-axis";
631
+ forwardWrap.textContent = "Forward:";
632
+ const forwardInput = document.createElement("input");
633
+ forwardInput.type = "number";
634
+ forwardInput.step = "0.1";
635
+ forwardInput.min = "0";
636
+ forwardInput.value = formatNumber(pt.forwardDistance ?? 1.0);
637
+ forwardInput.addEventListener("change", () => {
638
+ activatePoint(pt.id);
639
+ const next = Math.max(0, normalizeNumber(forwardInput.value));
640
+ if (pt.forwardDistance === next) return;
641
+ state.spline.points[index].forwardDistance = next;
642
+ commit("update-forward-distance");
643
+ });
644
+ forwardInput.addEventListener("focus", () => {
645
+ forwardInput.select?.();
646
+ });
647
+ forwardWrap.appendChild(forwardInput);
648
+ extensionCoords.appendChild(forwardWrap);
649
+
650
+ // Backward distance
651
+ const backwardWrap = document.createElement("label");
652
+ backwardWrap.className = "spw-axis";
653
+ backwardWrap.textContent = "Backward:";
654
+ const backwardInput = document.createElement("input");
655
+ backwardInput.type = "number";
656
+ backwardInput.step = "0.1";
657
+ backwardInput.min = "0";
658
+ backwardInput.value = formatNumber(pt.backwardDistance ?? 1.0);
659
+ backwardInput.addEventListener("change", () => {
660
+ activatePoint(pt.id);
661
+ const next = Math.max(0, normalizeNumber(backwardInput.value));
662
+ if (pt.backwardDistance === next) return;
663
+ state.spline.points[index].backwardDistance = next;
664
+ commit("update-backward-distance");
665
+ });
666
+ backwardInput.addEventListener("focus", () => {
667
+ backwardInput.select?.();
668
+ });
669
+ backwardWrap.appendChild(backwardInput);
670
+ extensionCoords.appendChild(backwardWrap);
671
+
672
+ extensionSection.appendChild(extensionCoords);
673
+ rowEl.appendChild(extensionSection);
674
+
675
+ pointList.appendChild(rowEl);
676
+ pointRowMap.set(keyId, rowEl);
677
+ });
678
+ updateSelectionStyles();
679
+ };
680
+
681
+ const updateSelectionStyles = () => {
682
+ const selected = state.selection || null;
683
+ for (const [key, rowEl] of pointRowMap.entries()) {
684
+ rowEl.classList.toggle('spw-selected', selected === key);
685
+ }
686
+ for (const [key, btn] of pointButtonMap.entries()) {
687
+ const isSelected = selected === key;
688
+ btn.style.background = isSelected ? 'rgba(58, 74, 109, 0.45)' : 'rgba(108, 195, 255, 0.12)';
689
+ btn.style.opacity = isSelected ? '1' : '0.95';
690
+ }
691
+ };
692
+
693
+ const renderAll = ({ fromSession = false } = {}) => {
694
+ const viewer = getViewer();
695
+ const featureID = getFeatureID();
696
+
697
+ if (state.destroyed || state.creatingSession) {
698
+ return;
699
+ }
700
+
701
+ ensureState();
702
+
703
+ // Always ensure session exists when we have viewer and featureID (but not during updates from session)
704
+ let activeSession = state.session;
705
+ if (!fromSession && viewer && featureID && !state.creatingSession) {
706
+ if (!activeSession) {
707
+ activeSession = ensureSession();
708
+ } else {
709
+ }
710
+ } else {
711
+
712
+ }
713
+
714
+ if (activeSession && !fromSession) {
715
+ state.selection = activeSession.getSelectedId?.() || state.selection;
716
+ }
717
+
718
+ renderPointRows();
719
+
720
+ addBtn.disabled = !getFeatureRef();
721
+ focusPendingPoint();
722
+ };
723
+
724
+ const movePoint = (index, delta) => {
725
+ const points = Array.isArray(state.spline?.points)
726
+ ? state.spline.points
727
+ : [];
728
+ const nextIndex = index + delta;
729
+ if (nextIndex < 0 || nextIndex >= points.length) return;
730
+ const [item] = points.splice(index, 1);
731
+ points.splice(nextIndex, 0, item);
732
+ state.pendingFocusId = item.id;
733
+ commit("reorder-point", { preserveSelection: true });
734
+ };
735
+
736
+ const removePoint = (index) => {
737
+ const points = Array.isArray(state.spline?.points)
738
+ ? state.spline.points
739
+ : [];
740
+ if (points.length <= 2) return;
741
+
742
+ const [removed] = points.splice(index, 1);
743
+
744
+ // Determine fallback selection after removal
745
+ let newSelection = null;
746
+ if (points.length > 0) {
747
+ const fallbackIdx = Math.min(index, points.length - 1);
748
+ const fallback = points[fallbackIdx];
749
+ if (fallback) {
750
+ newSelection = `point:${fallback.id}`;
751
+ state.pendingFocusId = fallback.id;
752
+ }
753
+ }
754
+
755
+ commit("remove-point", {
756
+ preserveSelection: false,
757
+ newSelection: newSelection
758
+ });
759
+ };
760
+
761
+ const commitChangesToFeature = () => {
762
+
763
+ const normalized = normalizeSplineData(state.spline);
764
+ state.spline = cloneSplineData(normalized);
765
+
766
+ const oldSignature = state.signature;
767
+ state.signature = computeSignature(state.spline);
768
+
769
+ ui.params[key] = state.signature;
770
+
771
+ const feature = getFeatureRef();
772
+ markDirty(feature, state.spline);
773
+
774
+ const ph = getPartHistory();
775
+ if (ph && Array.isArray(ph.features)) {
776
+ for (const item of ph.features) {
777
+ if (
778
+ String(item?.inputParams?.featureID ?? "") === featureID &&
779
+ item !== feature
780
+ ) {
781
+ markDirty(item, state.spline);
782
+ }
783
+ }
784
+ }
785
+
786
+ ui._emitParamsChange(key, {
787
+ signature: state.signature,
788
+ reason: "dialog-close",
789
+ timestamp: Date.now(),
790
+ });
791
+ };
792
+
793
+ const commit = (reason, options = {}) => {
794
+
795
+ const { skipSessionSync = false, preserveSelection = true, newSelection = null } = options;
796
+ const focusId = state.pendingFocusId || null;
797
+ const normalized = normalizeSplineData(state.spline);
798
+ state.spline = cloneSplineData(normalized);
799
+ state.pendingFocusId = focusId;
800
+
801
+ // For manual commits (add/remove/reorder points), we do need to update the feature
802
+ // But for transform operations, we rely on preview mode
803
+
804
+ const oldSignature = state.signature;
805
+ state.signature = computeSignature(state.spline);
806
+
807
+ ui.params[key] = state.signature;
808
+
809
+ const feature = getFeatureRef();
810
+ markDirty(feature, state.spline);
811
+
812
+ const ph = getPartHistory();
813
+ const featureID = getFeatureID();
814
+ if (ph && Array.isArray(ph.features)) {
815
+ for (const item of ph.features) {
816
+ if (
817
+ String(item?.inputParams?.featureID ?? "") === featureID &&
818
+ item !== feature
819
+ ) {
820
+ markDirty(item, state.spline);
821
+ }
822
+ }
823
+ }
824
+
825
+ if (!skipSessionSync && !state.creatingSession) {
826
+ // For structural changes (add/remove/reorder points), restart the session completely
827
+ const isStructuralChange = reason === "add-point" || reason === "remove-point" || reason === "reorder-point";
828
+
829
+ if (isStructuralChange) {
830
+ console.log(`SplineWidget: Restarting session due to structural change: ${reason}`);
831
+
832
+ // Completely dispose and recreate the session
833
+ disposeSession(true);
834
+ state.session = null;
835
+
836
+ // Create new session with updated spline data
837
+ const session = ensureSession();
838
+ if (session && newSelection) {
839
+ session.selectObject(newSelection);
840
+ }
841
+ } else {
842
+ // For non-structural changes, just update the existing session
843
+ const session = ensureSession();
844
+ if (session) {
845
+ session.setFeatureRef(feature);
846
+ session.setSplineData(state.spline, {
847
+ preserveSelection,
848
+ silent: true,
849
+ reason,
850
+ });
851
+ if (newSelection) session.selectObject(newSelection);
852
+ }
853
+ }
854
+ }
855
+ if (skipSessionSync && newSelection) {
856
+ state.selection = newSelection;
857
+ }
858
+ if (!state.session && newSelection) {
859
+ state.selection = newSelection;
860
+ }
861
+
862
+ // CRITICAL: Never trigger feature rebuild while session is active!
863
+ // The session handles all preview updates. Feature rebuild only happens when dialog closes.
864
+ // This prevents Spline.run() from being called while editing, which would interfere with the session.
865
+
866
+ // Only save the signature change, but don't emit params change to prevent feature rebuild
867
+ // Feature will be rebuilt when the dialog closes via commitChangesToFeature()
868
+ renderAll();
869
+ };
870
+
871
+ addBtn.addEventListener("click", () => {
872
+ ensureState();
873
+ const points = Array.isArray(state.spline?.points)
874
+ ? state.spline.points
875
+ : [];
876
+
877
+ if (points.length === 0) {
878
+ // If no points exist, add first point at origin
879
+ const newPoint = {
880
+ id: `p${Date.now().toString(36)}${Math.random()
881
+ .toString(36)
882
+ .slice(2, 6)}`,
883
+ position: [0, 0, 0],
884
+ forwardDistance: 1.0,
885
+ backwardDistance: 1.0,
886
+ };
887
+ points.push(newPoint);
888
+ state.pendingFocusId = newPoint.id;
889
+ commit("add-point", {
890
+ preserveSelection: false,
891
+ newSelection: `point:${newPoint.id}`
892
+ });
893
+ return;
894
+ }
895
+
896
+ // Helper function to find midpoint along polyline and get segment direction
897
+ const findPolylineMidpoint = (p0, p1, t0, t1) => {
898
+ // Generate the polyline between these two points using current spline settings
899
+ const tempSpline = {
900
+ points: [p0, p1]
901
+ };
902
+
903
+ const bendRadius = Number.isFinite(Number(state.spline?.bendRadius))
904
+ ? Math.max(0.1, Math.min(5.0, Number(state.spline.bendRadius)))
905
+ : 1.0;
906
+
907
+ // Use buildHermitePolyline to get the actual line segments
908
+ const { positions } = buildHermitePolyline(tempSpline, 20, bendRadius); // Use reasonable resolution
909
+
910
+ if (positions.length < 6) {
911
+ // Not enough points, fall back to simple midpoint
912
+ return {
913
+ position: [
914
+ (p0.position[0] + p1.position[0]) / 2,
915
+ (p0.position[1] + p1.position[1]) / 2,
916
+ (p0.position[2] + p1.position[2]) / 2
917
+ ],
918
+ direction: [1, 0, 0] // Default direction
919
+ };
920
+ }
921
+
922
+ // Calculate total polyline length
923
+ let totalLength = 0;
924
+ const segmentLengths = [];
925
+ for (let i = 0; i < positions.length - 3; i += 3) {
926
+ const dx = positions[i + 3] - positions[i];
927
+ const dy = positions[i + 4] - positions[i + 1];
928
+ const dz = positions[i + 5] - positions[i + 2];
929
+ const length = Math.sqrt(dx * dx + dy * dy + dz * dz);
930
+ segmentLengths.push(length);
931
+ totalLength += length;
932
+ }
933
+
934
+ // Find segment at 50% distance
935
+ const targetDistance = totalLength * 0.5;
936
+ let accumulatedDistance = 0;
937
+
938
+ for (let i = 0; i < segmentLengths.length; i++) {
939
+ if (accumulatedDistance + segmentLengths[i] >= targetDistance) {
940
+ // This segment contains the midpoint
941
+ const segmentStart = i * 3;
942
+ const segmentEnd = segmentStart + 3;
943
+
944
+ // Calculate position along this segment
945
+ const remainingDistance = targetDistance - accumulatedDistance;
946
+ const t = remainingDistance / segmentLengths[i];
947
+
948
+ const startPos = [positions[segmentStart], positions[segmentStart + 1], positions[segmentStart + 2]];
949
+ const endPos = [positions[segmentEnd], positions[segmentEnd + 1], positions[segmentEnd + 2]];
950
+
951
+ const position = [
952
+ startPos[0] + t * (endPos[0] - startPos[0]),
953
+ startPos[1] + t * (endPos[1] - startPos[1]),
954
+ startPos[2] + t * (endPos[2] - startPos[2])
955
+ ];
956
+
957
+ // Calculate segment direction
958
+ const direction = [
959
+ endPos[0] - startPos[0],
960
+ endPos[1] - startPos[1],
961
+ endPos[2] - startPos[2]
962
+ ];
963
+
964
+ // Normalize direction
965
+ const length = Math.sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]);
966
+ if (length > 0) {
967
+ direction[0] /= length;
968
+ direction[1] /= length;
969
+ direction[2] /= length;
970
+ } else {
971
+ direction[0] = 1; direction[1] = 0; direction[2] = 0;
972
+ }
973
+
974
+ return { position, direction };
975
+ }
976
+ accumulatedDistance += segmentLengths[i];
977
+ }
978
+
979
+ // Fallback to end of polyline
980
+ const lastIndex = positions.length - 3;
981
+ return {
982
+ position: [positions[lastIndex], positions[lastIndex + 1], positions[lastIndex + 2]],
983
+ direction: [1, 0, 0]
984
+ };
985
+ };
986
+
987
+ // Helper function to create rotation matrix from direction vector
988
+ const createRotationFromDirection = (direction) => {
989
+ // Use direction as X-axis (forward direction)
990
+ const xAxis = [...direction];
991
+
992
+ // Create Y-axis perpendicular to direction
993
+ // Try Y-up first, but use Z-up if direction is already along Y
994
+ let yAxis;
995
+ if (Math.abs(direction[1]) < 0.9) {
996
+ // Direction is not along Y, use Y-up
997
+ yAxis = [0, 1, 0];
998
+ } else {
999
+ // Direction is along Y, use Z-up
1000
+ yAxis = [0, 0, 1];
1001
+ }
1002
+
1003
+ // Cross product to get perpendicular axis
1004
+ const cross = (a, b) => [
1005
+ a[1] * b[2] - a[2] * b[1],
1006
+ a[2] * b[0] - a[0] * b[2],
1007
+ a[0] * b[1] - a[1] * b[0]
1008
+ ];
1009
+
1010
+ // Calculate Z-axis = X cross Y
1011
+ let zAxis = cross(xAxis, yAxis);
1012
+ let zLength = Math.sqrt(zAxis[0] * zAxis[0] + zAxis[1] * zAxis[1] + zAxis[2] * zAxis[2]);
1013
+ if (zLength > 0) {
1014
+ zAxis = [zAxis[0] / zLength, zAxis[1] / zLength, zAxis[2] / zLength];
1015
+ } else {
1016
+ zAxis = [0, 0, 1];
1017
+ }
1018
+
1019
+ // Recalculate Y-axis = Z cross X to ensure orthogonality
1020
+ yAxis = cross(zAxis, xAxis);
1021
+ let yLength = Math.sqrt(yAxis[0] * yAxis[0] + yAxis[1] * yAxis[1] + yAxis[2] * yAxis[2]);
1022
+ if (yLength > 0) {
1023
+ yAxis = [yAxis[0] / yLength, yAxis[1] / yLength, yAxis[2] / yLength];
1024
+ } else {
1025
+ yAxis = [0, 1, 0];
1026
+ }
1027
+
1028
+ // Return rotation matrix as flat array
1029
+ return [
1030
+ xAxis[0], xAxis[1], xAxis[2],
1031
+ yAxis[0], yAxis[1], yAxis[2],
1032
+ zAxis[0], zAxis[1], zAxis[2]
1033
+ ];
1034
+ }; // Helper function to calculate tangent vector from point data
1035
+ const calculateTangent = (pointData, isForward) => {
1036
+ const rotation = pointData.rotation || [1, 0, 0, 0, 1, 0, 0, 0, 1];
1037
+ let direction = [rotation[0], rotation[1], rotation[2]]; // X-axis from rotation matrix
1038
+
1039
+ if (pointData.flipDirection) {
1040
+ direction = [-direction[0], -direction[1], -direction[2]];
1041
+ }
1042
+
1043
+ const distance = isForward ? pointData.forwardDistance : pointData.backwardDistance;
1044
+ const tangentDir = isForward ? direction : [-direction[0], -direction[1], -direction[2]];
1045
+
1046
+ return [
1047
+ tangentDir[0] * distance,
1048
+ tangentDir[1] * distance,
1049
+ tangentDir[2] * distance
1050
+ ];
1051
+ };
1052
+
1053
+ // Find the currently selected point
1054
+ let selectedIndex = -1;
1055
+ const selectedId = state.selection?.replace("point:", "") || null;
1056
+ if (selectedId) {
1057
+ selectedIndex = points.findIndex(p => p.id === selectedId);
1058
+ }
1059
+
1060
+ // Determine insertion position and calculate new point position
1061
+ let insertIndex;
1062
+ let newPosition;
1063
+ let newRotation = [1, 0, 0, 0, 1, 0, 0, 0, 1]; // Default identity matrix
1064
+
1065
+ if (selectedIndex === -1 || selectedIndex === points.length - 1) {
1066
+ // No selection or last point selected - insert before last point
1067
+ if (points.length === 1) {
1068
+ // Only one point exists, add second point offset from first
1069
+ const base = points[0].position;
1070
+ newPosition = [base[0] + 2, base[1], base[2]];
1071
+ insertIndex = points.length; // Add at end
1072
+ // Keep default orientation for second point
1073
+ } else {
1074
+ insertIndex = points.length - 1; // Insert before last
1075
+ // Find point and direction along polyline between second-to-last and last point
1076
+ const p0 = points[points.length - 2];
1077
+ const p1 = points[points.length - 1];
1078
+ const t0 = calculateTangent(p0, true); // Forward tangent of first point
1079
+ const t1 = calculateTangent(p1, false); // Backward tangent of second point
1080
+ const result = findPolylineMidpoint(p0, p1, t0, t1);
1081
+ newPosition = result.position;
1082
+ newRotation = createRotationFromDirection(result.direction);
1083
+ }
1084
+ } else {
1085
+ // Insert after selected point
1086
+ insertIndex = selectedIndex + 1;
1087
+ if (insertIndex >= points.length) {
1088
+ // Selected point is last, add at end offset from selected
1089
+ const base = points[selectedIndex].position;
1090
+ newPosition = [base[0] + 2, base[1], base[2]];
1091
+ // Keep default orientation when adding at end
1092
+ } else {
1093
+ // Find point and direction along polyline between selected point and next point
1094
+ const p0 = points[selectedIndex];
1095
+ const p1 = points[selectedIndex + 1];
1096
+ const t0 = calculateTangent(p0, true); // Forward tangent of first point
1097
+ const t1 = calculateTangent(p1, false); // Backward tangent of second point
1098
+ const result = findPolylineMidpoint(p0, p1, t0, t1);
1099
+ newPosition = result.position;
1100
+ newRotation = createRotationFromDirection(result.direction);
1101
+ }
1102
+ }
1103
+
1104
+ const newPoint = {
1105
+ id: `p${Date.now().toString(36)}${Math.random()
1106
+ .toString(36)
1107
+ .slice(2, 6)}`,
1108
+ position: newPosition,
1109
+ rotation: newRotation,
1110
+ forwardDistance: 0.1,
1111
+ backwardDistance: 0.1,
1112
+ };
1113
+
1114
+ // Insert the point at the calculated position
1115
+ points.splice(insertIndex, 0, newPoint);
1116
+ state.pendingFocusId = newPoint.id;
1117
+ commit("add-point", {
1118
+ preserveSelection: false,
1119
+ newSelection: `point:${newPoint.id}`
1120
+ });
1121
+ });
1122
+
1123
+ ensureState();
1124
+
1125
+ // Set up initial selection to first point if no selection exists
1126
+ if (!state.selection && state.spline?.points?.length > 0) {
1127
+ const firstPoint = state.spline.points[0];
1128
+ state.selection = `point:${firstPoint.id}`;
1129
+ }
1130
+
1131
+ renderAll();
1132
+
1133
+ // If session creation failed initially due to missing viewer/featureID, retry multiple times
1134
+ if (!state.session) {
1135
+
1136
+ // Try multiple times with increasing delays
1137
+ const retryDelays = [50, 200, 500, 1000]; // Try at 50ms, 200ms, 500ms, and 1s
1138
+
1139
+ retryDelays.forEach((delay, index) => {
1140
+ setTimeout(() => {
1141
+ if (!state.session && !state.destroyed) {
1142
+ renderAll();
1143
+
1144
+ // If we successfully created a session and have a selection, make sure transform controls are visible
1145
+ if (state.session && state.selection) {
1146
+ state.session.selectObject(state.selection);
1147
+ }
1148
+ }
1149
+ }, delay);
1150
+ });
1151
+ }
1152
+
1153
+ return {
1154
+ inputEl: host,
1155
+ inputRegistered: false,
1156
+ skipDefaultRefresh: true,
1157
+ refreshFromParams() {
1158
+ if (state.destroyed || state.creatingSession || state.refreshing) return;
1159
+
1160
+ const viewer = getViewer();
1161
+ const featureID = getFeatureID();
1162
+
1163
+ const stack = new Error().stack;
1164
+ state.refreshing = true;
1165
+
1166
+ try {
1167
+ const next = loadFromSource();
1168
+ const nextSig = computeSignature(next);
1169
+ if (nextSig !== state.signature) {
1170
+ state.spline = next;
1171
+ state.signature = nextSig;
1172
+ ui.params[key] = state.signature;
1173
+
1174
+ // Only update existing session, don't create new one during refresh
1175
+ if (state.session) {
1176
+ state.session.setFeatureRef(getFeatureRef());
1177
+ state.session.setSplineData(state.spline, {
1178
+ preserveSelection: true,
1179
+ silent: true,
1180
+ });
1181
+ state.selection = state.session.getSelectedId?.() || state.selection;
1182
+ }
1183
+ renderAll({ fromSession: true });
1184
+ } else if (state.session) {
1185
+ // Only update existing session
1186
+ state.session.setFeatureRef(getFeatureRef());
1187
+ renderAll({ fromSession: true });
1188
+ }
1189
+
1190
+ // Try to create session if it doesn't exist and viewer/featureID are now available
1191
+ if (!state.session && viewer && featureID && !state.creatingSession) {
1192
+ renderAll(); // This will create the session
1193
+ }
1194
+ } catch (error) {
1195
+ /* ignore */
1196
+ } finally {
1197
+ // Use setTimeout to prevent rapid successive calls - increase delay to break loops
1198
+ setTimeout(() => {
1199
+ state.refreshing = false;
1200
+ }, 200); // Increased from 50ms to 200ms to break refresh loops
1201
+ }
1202
+ },
1203
+ destroy() {
1204
+ // CRITICAL: Commit all changes to the feature when dialog closes
1205
+ if (!state.destroyed && state.spline) {
1206
+ commitChangesToFeature();
1207
+ }
1208
+
1209
+ state.destroyed = true;
1210
+
1211
+
1212
+
1213
+ disposeSession(true); // Force disposal during destroy
1214
+
1215
+
1216
+
1217
+
1218
+ },
1219
+ };
1220
+ }
1221
+
1222
+ const inputParamsSchema = {
1223
+ id: {
1224
+ type: "string",
1225
+ default_value: null,
1226
+ hint: "unique identifier for the spline feature",
1227
+ },
1228
+ curveResolution: {
1229
+ type: "number",
1230
+ default_value: DEFAULT_RESOLUTION,
1231
+ hint: "Samples per segment used to visualize the spline",
1232
+ },
1233
+ bendRadius: {
1234
+ type: "number",
1235
+ default_value: 1.0,
1236
+ label: "Bend Radius",
1237
+ hint: "Controls the smoothness of curve transitions. Lower values create sharper bends, higher values create smoother curves.",
1238
+ min: 0.1,
1239
+ step: 0.5,
1240
+ },
1241
+ splinePoints: {
1242
+ type: "string",
1243
+ label: "Spline Points",
1244
+ hint: "Add, reorder, and position spline anchors",
1245
+ renderWidget: renderSplinePointsWidget,
1246
+ },
1247
+ };
1248
+
1249
+ export class SplineFeature {
1250
+ static shortName = "SP";
1251
+ static longName = "Spline";
1252
+ static inputParamsSchema = inputParamsSchema;
1253
+
1254
+ constructor() {
1255
+ this.inputParams = {};
1256
+ this.persistentData = this.persistentData || {};
1257
+ }
1258
+
1259
+ destroy() {
1260
+ // Dispose the spline session if it exists to remove preview and transform controls
1261
+ if (this._splineSession) {
1262
+ this._splineSession.dispose();
1263
+ this._splineSession = null;
1264
+ }
1265
+
1266
+ // Mark as destroyed to prevent further operations
1267
+ this._destroyed = true;
1268
+ }
1269
+
1270
+
1271
+
1272
+ _ensureSplineData() {
1273
+ const source = this.persistentData?.spline || null;
1274
+ const normalized = normalizeSplineData(source);
1275
+ this.persistentData = this.persistentData || {};
1276
+ this.persistentData.spline = normalized;
1277
+ return normalized;
1278
+ }
1279
+
1280
+ async run(partHistory) {
1281
+ const spline = this._ensureSplineData();
1282
+ const featureId = this.inputParams?.featureID
1283
+ ? String(this.inputParams.featureID)
1284
+ : "Spline";
1285
+
1286
+ const sceneGroup = new THREE.Group();
1287
+ sceneGroup.name = featureId;
1288
+ sceneGroup.type = "SKETCH";
1289
+ sceneGroup.onClick = () => { };
1290
+
1291
+ const resolution = Number.isFinite(Number(this.inputParams?.curveResolution))
1292
+ ? Math.max(4, Number(this.inputParams.curveResolution))
1293
+ : DEFAULT_RESOLUTION;
1294
+
1295
+ const bendRadius = Number.isFinite(Number(this.inputParams?.bendRadius))
1296
+ ? Math.max(0.1, Math.min(5.0, Number(this.inputParams.bendRadius)))
1297
+ : 1.0;
1298
+
1299
+ const { positions, polyline } = buildHermitePolyline(spline, resolution, bendRadius);
1300
+
1301
+ if (positions.length >= 6) {
1302
+ const geometry = new LineGeometry();
1303
+ geometry.setPositions(positions);
1304
+
1305
+ const edge = new BREP.Edge(geometry);
1306
+ edge.name = `${featureId}:SplineEdge`;
1307
+ edge.userData = {
1308
+ polylineLocal: polyline.map((p) => [p[0], p[1], p[2]]),
1309
+ polylineWorld: true,
1310
+ splineFeatureId: featureId,
1311
+ };
1312
+ sceneGroup.add(edge);
1313
+ }
1314
+
1315
+ try {
1316
+ const vertices = spline.points.map((pt, idx) => {
1317
+ const vertex = new BREP.Vertex(pt.position, {
1318
+ name: `${featureId}:P${idx}`,
1319
+ });
1320
+ vertex.userData = vertex.userData || {};
1321
+ vertex.userData.splineFeatureId = featureId;
1322
+ vertex.userData.splinePointId = pt.id;
1323
+ return vertex;
1324
+ });
1325
+ for (const v of vertices) {
1326
+ sceneGroup.add(v);
1327
+ }
1328
+ } catch {
1329
+ // optional vertices failed; ignore
1330
+ }
1331
+
1332
+ try {
1333
+ // Add extension handles as vertices for visualization
1334
+ spline.points.forEach((pt, idx) => {
1335
+ const forwardPos = [
1336
+ pt.position[0] + pt.forwardExtension[0],
1337
+ pt.position[1] + pt.forwardExtension[1],
1338
+ pt.position[2] + pt.forwardExtension[2]
1339
+ ];
1340
+ const backwardPos = [
1341
+ pt.position[0] + pt.backwardExtension[0],
1342
+ pt.position[1] + pt.backwardExtension[1],
1343
+ pt.position[2] + pt.backwardExtension[2]
1344
+ ];
1345
+
1346
+ const forwardVertex = new BREP.Vertex(forwardPos, {
1347
+ name: `${featureId}:F${idx}`,
1348
+ });
1349
+ forwardVertex.userData = {
1350
+ splineFeatureId: featureId,
1351
+ splinePointId: pt.id,
1352
+ extensionType: "forward",
1353
+ };
1354
+
1355
+ const backwardVertex = new BREP.Vertex(backwardPos, {
1356
+ name: `${featureId}:B${idx}`,
1357
+ });
1358
+ backwardVertex.userData = {
1359
+ splineFeatureId: featureId,
1360
+ splinePointId: pt.id,
1361
+ extensionType: "backward",
1362
+ };
1363
+
1364
+ sceneGroup.add(forwardVertex);
1365
+ sceneGroup.add(backwardVertex);
1366
+ });
1367
+ } catch {
1368
+ /* ignore extension vertex creation failure */
1369
+ }
1370
+
1371
+ this.persistentData = this.persistentData || {};
1372
+ this.persistentData.spline = cloneSplineData(spline);
1373
+
1374
+ // remove all children of the scene that have a name starting with "SplineEditorPreview"
1375
+ const existingPreviews = partHistory.scene.children.filter(child =>
1376
+
1377
+ child.name.startsWith("SplineEditor")
1378
+ );
1379
+ for (const preview of existingPreviews) {
1380
+ preview.userData.preventRemove = false;
1381
+ partHistory.scene.remove(preview);
1382
+ }
1383
+
1384
+
1385
+
1386
+ return { added: [sceneGroup], removed: [] };
1387
+ }
1388
+ }