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.
- package/LICENSE.md +32 -0
- package/README.md +157 -0
- package/dist-kernel/brep-kernel.js +74699 -0
- package/package.json +58 -0
- package/src/BREP/AssemblyComponent.js +42 -0
- package/src/BREP/BREP.js +43 -0
- package/src/BREP/BetterSolid.js +805 -0
- package/src/BREP/Edge.js +103 -0
- package/src/BREP/Extrude.js +403 -0
- package/src/BREP/Face.js +187 -0
- package/src/BREP/MeshRepairer.js +634 -0
- package/src/BREP/OffsetShellSolid.js +614 -0
- package/src/BREP/PointCloudWrap.js +302 -0
- package/src/BREP/Revolve.js +345 -0
- package/src/BREP/SolidMethods/authoring.js +112 -0
- package/src/BREP/SolidMethods/booleanOps.js +230 -0
- package/src/BREP/SolidMethods/chamfer.js +122 -0
- package/src/BREP/SolidMethods/edgeResolution.js +25 -0
- package/src/BREP/SolidMethods/fillet.js +792 -0
- package/src/BREP/SolidMethods/index.js +72 -0
- package/src/BREP/SolidMethods/io.js +105 -0
- package/src/BREP/SolidMethods/lifecycle.js +103 -0
- package/src/BREP/SolidMethods/manifoldOps.js +375 -0
- package/src/BREP/SolidMethods/meshCleanup.js +2512 -0
- package/src/BREP/SolidMethods/meshQueries.js +264 -0
- package/src/BREP/SolidMethods/metadata.js +106 -0
- package/src/BREP/SolidMethods/metrics.js +51 -0
- package/src/BREP/SolidMethods/transforms.js +361 -0
- package/src/BREP/SolidMethods/visualize.js +508 -0
- package/src/BREP/SolidShared.js +26 -0
- package/src/BREP/Sweep.js +1596 -0
- package/src/BREP/Tube.js +857 -0
- package/src/BREP/Vertex.js +43 -0
- package/src/BREP/applyBooleanOperation.js +704 -0
- package/src/BREP/boundsUtils.js +48 -0
- package/src/BREP/chamfer.js +551 -0
- package/src/BREP/edgePolylineUtils.js +85 -0
- package/src/BREP/fillets/common.js +388 -0
- package/src/BREP/fillets/fillet.js +1422 -0
- package/src/BREP/fillets/filletGeometry.js +15 -0
- package/src/BREP/fillets/inset.js +389 -0
- package/src/BREP/fillets/offsetHelper.js +143 -0
- package/src/BREP/fillets/outset.js +88 -0
- package/src/BREP/helix.js +193 -0
- package/src/BREP/meshToBrep.js +234 -0
- package/src/BREP/primitives.js +279 -0
- package/src/BREP/setupManifold.js +71 -0
- package/src/BREP/threadGeometry.js +1120 -0
- package/src/BREP/triangleUtils.js +8 -0
- package/src/BREP/triangulate.js +608 -0
- package/src/FeatureRegistry.js +183 -0
- package/src/PartHistory.js +1132 -0
- package/src/UI/AccordionWidget.js +292 -0
- package/src/UI/CADmaterials.js +850 -0
- package/src/UI/EnvMonacoEditor.js +522 -0
- package/src/UI/FloatingWindow.js +396 -0
- package/src/UI/HistoryWidget.js +457 -0
- package/src/UI/MainToolbar.js +131 -0
- package/src/UI/ModelLibraryView.js +194 -0
- package/src/UI/OrthoCameraIdle.js +206 -0
- package/src/UI/PluginsWidget.js +280 -0
- package/src/UI/SceneListing.js +606 -0
- package/src/UI/SelectionFilter.js +629 -0
- package/src/UI/ViewCube.js +389 -0
- package/src/UI/assembly/AssemblyConstraintCollectionWidget.js +329 -0
- package/src/UI/assembly/AssemblyConstraintControlsWidget.js +282 -0
- package/src/UI/assembly/AssemblyConstraintsWidget.css +292 -0
- package/src/UI/assembly/AssemblyConstraintsWidget.js +1373 -0
- package/src/UI/assembly/constraintFaceUtils.js +115 -0
- package/src/UI/assembly/constraintHighlightUtils.js +70 -0
- package/src/UI/assembly/constraintLabelUtils.js +31 -0
- package/src/UI/assembly/constraintPointUtils.js +64 -0
- package/src/UI/assembly/constraintSelectionUtils.js +185 -0
- package/src/UI/assembly/constraintStatusUtils.js +142 -0
- package/src/UI/componentSelectorModal.js +240 -0
- package/src/UI/controls/CombinedTransformControls.js +386 -0
- package/src/UI/dialogs.js +351 -0
- package/src/UI/expressionsManager.js +100 -0
- package/src/UI/featureDialogWidgets/booleanField.js +25 -0
- package/src/UI/featureDialogWidgets/booleanOperationField.js +97 -0
- package/src/UI/featureDialogWidgets/buttonField.js +45 -0
- package/src/UI/featureDialogWidgets/componentSelectorField.js +102 -0
- package/src/UI/featureDialogWidgets/defaultField.js +23 -0
- package/src/UI/featureDialogWidgets/fileField.js +66 -0
- package/src/UI/featureDialogWidgets/index.js +34 -0
- package/src/UI/featureDialogWidgets/numberField.js +165 -0
- package/src/UI/featureDialogWidgets/optionsField.js +33 -0
- package/src/UI/featureDialogWidgets/referenceSelectionField.js +208 -0
- package/src/UI/featureDialogWidgets/stringField.js +24 -0
- package/src/UI/featureDialogWidgets/textareaField.js +28 -0
- package/src/UI/featureDialogWidgets/threadDesignationField.js +160 -0
- package/src/UI/featureDialogWidgets/transformField.js +252 -0
- package/src/UI/featureDialogWidgets/utils.js +43 -0
- package/src/UI/featureDialogWidgets/vec3Field.js +133 -0
- package/src/UI/featureDialogs.js +1414 -0
- package/src/UI/fileManagerWidget.js +615 -0
- package/src/UI/history/HistoryCollectionWidget.js +1294 -0
- package/src/UI/history/historyCollectionWidget.css.js +257 -0
- package/src/UI/history/historyDisplayInfo.js +133 -0
- package/src/UI/mobile.js +28 -0
- package/src/UI/objectDump.js +442 -0
- package/src/UI/pmi/AnnotationCollectionWidget.js +120 -0
- package/src/UI/pmi/AnnotationHistory.js +353 -0
- package/src/UI/pmi/AnnotationRegistry.js +90 -0
- package/src/UI/pmi/BaseAnnotation.js +269 -0
- package/src/UI/pmi/LabelOverlay.css +102 -0
- package/src/UI/pmi/LabelOverlay.js +191 -0
- package/src/UI/pmi/PMIMode.js +1550 -0
- package/src/UI/pmi/PMIViewsWidget.js +1098 -0
- package/src/UI/pmi/annUtils.js +729 -0
- package/src/UI/pmi/dimensions/AngleDimensionAnnotation.js +647 -0
- package/src/UI/pmi/dimensions/ExplodeBodyAnnotation.js +507 -0
- package/src/UI/pmi/dimensions/HoleCalloutAnnotation.js +462 -0
- package/src/UI/pmi/dimensions/LeaderAnnotation.js +403 -0
- package/src/UI/pmi/dimensions/LinearDimensionAnnotation.js +532 -0
- package/src/UI/pmi/dimensions/NoteAnnotation.js +110 -0
- package/src/UI/pmi/dimensions/RadialDimensionAnnotation.js +659 -0
- package/src/UI/pmi/pmiStyle.js +44 -0
- package/src/UI/sketcher/SketchMode3D.js +4095 -0
- package/src/UI/sketcher/dimensions.js +674 -0
- package/src/UI/sketcher/glyphs.js +236 -0
- package/src/UI/sketcher/highlights.js +60 -0
- package/src/UI/toolbarButtons/aboutButton.js +5 -0
- package/src/UI/toolbarButtons/exportButton.js +609 -0
- package/src/UI/toolbarButtons/flatPatternButton.js +307 -0
- package/src/UI/toolbarButtons/importButton.js +160 -0
- package/src/UI/toolbarButtons/inspectorToggleButton.js +12 -0
- package/src/UI/toolbarButtons/metadataButton.js +1063 -0
- package/src/UI/toolbarButtons/orientToFaceButton.js +114 -0
- package/src/UI/toolbarButtons/registerDefaultButtons.js +46 -0
- package/src/UI/toolbarButtons/saveButton.js +99 -0
- package/src/UI/toolbarButtons/scriptRunnerButton.js +302 -0
- package/src/UI/toolbarButtons/testsButton.js +26 -0
- package/src/UI/toolbarButtons/undoRedoButtons.js +25 -0
- package/src/UI/toolbarButtons/wireframeToggleButton.js +5 -0
- package/src/UI/toolbarButtons/zoomToFitButton.js +5 -0
- package/src/UI/triangleDebuggerWindow.js +945 -0
- package/src/UI/viewer.js +4228 -0
- package/src/assemblyConstraints/AssemblyConstraintHistory.js +1576 -0
- package/src/assemblyConstraints/AssemblyConstraintRegistry.js +120 -0
- package/src/assemblyConstraints/BaseAssemblyConstraint.js +66 -0
- package/src/assemblyConstraints/constraintExpressionUtils.js +35 -0
- package/src/assemblyConstraints/constraintUtils/parallelAlignment.js +676 -0
- package/src/assemblyConstraints/constraints/AngleConstraint.js +485 -0
- package/src/assemblyConstraints/constraints/CoincidentConstraint.js +194 -0
- package/src/assemblyConstraints/constraints/DistanceConstraint.js +616 -0
- package/src/assemblyConstraints/constraints/FixedConstraint.js +78 -0
- package/src/assemblyConstraints/constraints/ParallelConstraint.js +252 -0
- package/src/assemblyConstraints/constraints/TouchAlignConstraint.js +961 -0
- package/src/core/entities/HistoryCollectionBase.js +72 -0
- package/src/core/entities/ListEntityBase.js +109 -0
- package/src/core/entities/schemaProcesser.js +121 -0
- package/src/exporters/sheetMetalFlatPattern.js +659 -0
- package/src/exporters/sheetMetalUnfold.js +862 -0
- package/src/exporters/step.js +1135 -0
- package/src/exporters/threeMF.js +575 -0
- package/src/features/assemblyComponent/AssemblyComponentFeature.js +780 -0
- package/src/features/boolean/BooleanFeature.js +94 -0
- package/src/features/chamfer/ChamferFeature.js +116 -0
- package/src/features/datium/DatiumFeature.js +80 -0
- package/src/features/edgeFeatureUtils.js +41 -0
- package/src/features/extrude/ExtrudeFeature.js +143 -0
- package/src/features/fillet/FilletFeature.js +197 -0
- package/src/features/helix/HelixFeature.js +405 -0
- package/src/features/hole/HoleFeature.js +1050 -0
- package/src/features/hole/screwClearance.js +86 -0
- package/src/features/hole/threadDesignationCatalog.js +149 -0
- package/src/features/imageHeightSolid/ImageHeightmapSolidFeature.js +463 -0
- package/src/features/imageToFace/ImageToFaceFeature.js +727 -0
- package/src/features/imageToFace/imageEditor.js +1270 -0
- package/src/features/imageToFace/traceUtils.js +971 -0
- package/src/features/import3dModel/Import3dModelFeature.js +151 -0
- package/src/features/loft/LoftFeature.js +605 -0
- package/src/features/mirror/MirrorFeature.js +151 -0
- package/src/features/offsetFace/OffsetFaceFeature.js +370 -0
- package/src/features/offsetShell/OffsetShellFeature.js +89 -0
- package/src/features/overlapCleanup/OverlapCleanupFeature.js +85 -0
- package/src/features/pattern/PatternFeature.js +275 -0
- package/src/features/patternLinear/PatternLinearFeature.js +120 -0
- package/src/features/patternRadial/PatternRadialFeature.js +186 -0
- package/src/features/plane/PlaneFeature.js +154 -0
- package/src/features/primitiveCone/primitiveConeFeature.js +99 -0
- package/src/features/primitiveCube/primitiveCubeFeature.js +70 -0
- package/src/features/primitiveCylinder/primitiveCylinderFeature.js +91 -0
- package/src/features/primitivePyramid/primitivePyramidFeature.js +72 -0
- package/src/features/primitiveSphere/primitiveSphereFeature.js +62 -0
- package/src/features/primitiveTorus/primitiveTorusFeature.js +109 -0
- package/src/features/remesh/RemeshFeature.js +97 -0
- package/src/features/revolve/RevolveFeature.js +111 -0
- package/src/features/selectionUtils.js +118 -0
- package/src/features/sheetMetal/SheetMetalContourFlangeFeature.js +1656 -0
- package/src/features/sheetMetal/SheetMetalCutoutFeature.js +1056 -0
- package/src/features/sheetMetal/SheetMetalFlangeFeature.js +1568 -0
- package/src/features/sheetMetal/SheetMetalHemFeature.js +43 -0
- package/src/features/sheetMetal/SheetMetalObject.js +141 -0
- package/src/features/sheetMetal/SheetMetalTabFeature.js +176 -0
- package/src/features/sheetMetal/UNFOLD_NEUTRAL_REQUIREMENTS.md +153 -0
- package/src/features/sheetMetal/contour-flange-rebuild-spec.md +261 -0
- package/src/features/sheetMetal/profileUtils.js +25 -0
- package/src/features/sheetMetal/sheetMetalCleanup.js +9 -0
- package/src/features/sheetMetal/sheetMetalFaceTypes.js +146 -0
- package/src/features/sheetMetal/sheetMetalMetadata.js +165 -0
- package/src/features/sheetMetal/sheetMetalPipeline.js +169 -0
- package/src/features/sheetMetal/sheetMetalProfileUtils.js +216 -0
- package/src/features/sheetMetal/sheetMetalTabUtils.js +29 -0
- package/src/features/sheetMetal/sheetMetalTree.js +210 -0
- package/src/features/sketch/SketchFeature.js +955 -0
- package/src/features/sketch/sketchSolver2D/ConstraintEngine.js +800 -0
- package/src/features/sketch/sketchSolver2D/constraintDefinitions.js +704 -0
- package/src/features/sketch/sketchSolver2D/mathHelpersMod.js +307 -0
- package/src/features/spline/SplineEditorSession.js +988 -0
- package/src/features/spline/SplineFeature.js +1388 -0
- package/src/features/spline/splineUtils.js +218 -0
- package/src/features/sweep/SweepFeature.js +110 -0
- package/src/features/transform/TransformFeature.js +152 -0
- package/src/features/tube/TubeFeature.js +635 -0
- package/src/fs.proxy.js +625 -0
- package/src/idbStorage.js +254 -0
- package/src/index.js +12 -0
- package/src/main.js +15 -0
- package/src/metadataManager.js +64 -0
- package/src/path.proxy.js +277 -0
- package/src/plugins/ghLoader.worker.js +151 -0
- package/src/plugins/pluginManager.js +286 -0
- package/src/pmi/PMIViewsManager.js +134 -0
- package/src/services/componentLibrary.js +198 -0
- package/src/tests/ConsoleCapture.js +189 -0
- package/src/tests/S7-diagnostics-2025-12-23T18-37-23-570Z.json +630 -0
- package/src/tests/browserTests.js +597 -0
- package/src/tests/debugBoolean.js +225 -0
- package/src/tests/partFiles/badBoolean.json +957 -0
- package/src/tests/partFiles/extrudeTest.json +88 -0
- package/src/tests/partFiles/filletFail.json +58 -0
- package/src/tests/partFiles/import_TEst.part.part.json +646 -0
- package/src/tests/partFiles/sheetMetalHem.BREP.json +734 -0
- package/src/tests/test_boolean_subtract.js +27 -0
- package/src/tests/test_chamfer.js +17 -0
- package/src/tests/test_extrudeFace.js +24 -0
- package/src/tests/test_fillet.js +17 -0
- package/src/tests/test_fillet_nonClosed.js +45 -0
- package/src/tests/test_filletsMoreDifficult.js +46 -0
- package/src/tests/test_history_features_basic.js +149 -0
- package/src/tests/test_hole.js +282 -0
- package/src/tests/test_mirror.js +16 -0
- package/src/tests/test_offsetShellGrouping.js +85 -0
- package/src/tests/test_plane.js +4 -0
- package/src/tests/test_primitiveCone.js +11 -0
- package/src/tests/test_primitiveCube.js +7 -0
- package/src/tests/test_primitiveCylinder.js +8 -0
- package/src/tests/test_primitivePyramid.js +9 -0
- package/src/tests/test_primitiveSphere.js +17 -0
- package/src/tests/test_primitiveTorus.js +21 -0
- package/src/tests/test_pushFace.js +126 -0
- package/src/tests/test_sheetMetalContourFlange.js +125 -0
- package/src/tests/test_sheetMetal_features.js +80 -0
- package/src/tests/test_sketch_openLoop.js +45 -0
- package/src/tests/test_solidMetrics.js +58 -0
- package/src/tests/test_stlLoader.js +1889 -0
- package/src/tests/test_sweepFace.js +55 -0
- package/src/tests/test_tube.js +45 -0
- package/src/tests/test_tube_closedLoop.js +67 -0
- package/src/tests/tests.js +493 -0
- package/src/tools/assemblyConstraintDialogCapturePage.js +56 -0
- package/src/tools/dialogCapturePageFactory.js +227 -0
- package/src/tools/featureDialogCapturePage.js +47 -0
- package/src/tools/pmiAnnotationDialogCapturePage.js +60 -0
- package/src/utils/axisHelpers.js +99 -0
- package/src/utils/deepClone.js +69 -0
- package/src/utils/geometryTolerance.js +37 -0
- package/src/utils/normalizeTypeString.js +8 -0
- 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
|
+
}
|