|
1176 | 1176 | if (nearElem) { |
1177 | 1177 | const ni = modelStore.getNode(nearElem.nodeI); |
1178 | 1178 | const nj = modelStore.getNode(nearElem.nodeJ); |
1179 | | - if (ni && nj) { |
| 1179 | + if (ni && nj && ((nj.x - ni.x) ** 2 + (nj.y - ni.y) ** 2) > 1e-10) { |
| 1180 | + // (zero-length guard: a degenerate element would make t NaN) |
1180 | 1181 | const edx = nj.x - ni.x; |
1181 | 1182 | const edy = nj.y - ni.y; |
1182 | 1183 | const lenSq = edx * edx + edy * edy; |
|
1194 | 1195 | } |
1195 | 1196 | } |
1196 | 1197 | } else { |
1197 | | - // Create node mode (default) |
| 1198 | + // Create node mode (default). |
| 1199 | + // |
| 1200 | + // Auto-split-on-node-place (opt-in via uiStore.autoSplitOnNodePlace): |
| 1201 | + // when ON and the click lands on the interior of an existing element |
| 1202 | + // (and not on/near an existing node), subdivide that element instead |
| 1203 | + // of creating a free-floating node. We delegate to the existing |
| 1204 | + // splitElementAtPoint so distributed/point/thermal loads are |
| 1205 | + // redistributed by the same logic the hinge-mode subdivide already |
| 1206 | + // uses in production. |
1198 | 1207 | const ms = snapWithMidpoint(world.x, world.y); |
1199 | | - const p3d = to3D(uiStore.drawPlane2D, ms.x, ms.y, { x: 0, y: 0, z: 0 }); |
1200 | | - modelStore.addNode(p3d.x, p3d.y, p3d.z || undefined); |
| 1208 | + let didSplit = false; |
| 1209 | + // Attempt to subdivide the element nearest to (searchX, searchY), |
| 1210 | + // splitting at the projection of (projX, projY) onto it. Interior |
| 1211 | + // guard: endpoints + thin slivers would either duplicate an existing |
| 1212 | + // node or produce near-zero-length sub-elements. |
| 1213 | + const attemptSplit = (searchX: number, searchY: number, maxDist: number, projX: number, projY: number): boolean => { |
| 1214 | + const nearElem = findNearestElement(searchX, searchY, maxDist); |
| 1215 | + if (!nearElem) return false; |
| 1216 | + const ni = modelStore.getNode(nearElem.nodeI); |
| 1217 | + const nj = modelStore.getNode(nearElem.nodeJ); |
| 1218 | + if (!ni || !nj) return false; |
| 1219 | + const edx = nj.x - ni.x; |
| 1220 | + const edy = nj.y - ni.y; |
| 1221 | + const lenSq = edx * edx + edy * edy; |
| 1222 | + if (lenSq <= 1e-10) return false; |
| 1223 | + const tParam = ((projX - ni.x) * edx + (projY - ni.y) * edy) / lenSq; |
| 1224 | + if (tParam < 0.05 || tParam > 0.95) return false; |
| 1225 | + const result = modelStore.splitElementAtPoint(nearElem.id, tParam); |
| 1226 | + if (!result) return false; |
| 1227 | + uiStore.selectNode(result.nodeId); |
| 1228 | + uiStore.toast(t('viewport.barSubdivided'), 'info'); |
| 1229 | + resultsStore.clear(); |
| 1230 | + return true; |
| 1231 | + }; |
| 1232 | + // One shared scan: both the auto-split guard and the |
| 1233 | + // duplicate-coincident-node guard below answer the same question |
| 1234 | + // ('is the cursor on an existing node?') with the same threshold — |
| 1235 | + // two separate calls would silently diverge if one threshold is tuned. |
| 1236 | + const nodeAtCursor = findNearestNode(world.x, world.y, 0.5); |
| 1237 | + if (uiStore.autoSplitOnNodePlace) { |
| 1238 | + // Only auto-split when the cursor isn't already targeting an |
| 1239 | + // existing node. snapWithMidpoint returns node coords if a node is |
| 1240 | + // within nodeThreshold of the raw cursor; we re-check explicitly |
| 1241 | + // to keep the guard tight (also handles the same threshold). |
| 1242 | + if (!nodeAtCursor) { |
| 1243 | + // 1st attempt — cursor-based: use the RAW cursor to find which |
| 1244 | + // element the user is pointing at (grid-snap can warp the cursor |
| 1245 | + // off the element line), but project the GRID-SNAPPED cursor so |
| 1246 | + // the new node lands at a grid-aligned position on the bar when |
| 1247 | + // snap-to-grid is on. When it's off, `snapped` equals `world`. |
| 1248 | + const projInputX = uiStore.snapToGrid ? snapped.x : world.x; |
| 1249 | + const projInputY = uiStore.snapToGrid ? snapped.y : world.y; |
| 1250 | + didSplit = attemptSplit(world.x, world.y, 0.3, projInputX, projInputY); |
| 1251 | + // 2nd attempt — placement-point-based: snapWithMidpoint can |
| 1252 | + // resolve `ms` ONTO a bar even when the cursor attempt missed |
| 1253 | + // (its midpoint snap reaches 0.4 > the 0.3 search above, and a |
| 1254 | + // grid intersection can lie on the bar). Without this, the node |
| 1255 | + // would sit exactly on the element without subdividing it — the |
| 1256 | + // coincident-unconnected trap auto-split exists to prevent. |
| 1257 | + // Tight tolerance: only when ms is effectively ON the element. |
| 1258 | + if (!didSplit) { |
| 1259 | + didSplit = attemptSplit(ms.x, ms.y, 0.01, ms.x, ms.y); |
| 1260 | + } |
| 1261 | + } |
| 1262 | + } |
| 1263 | + if (!didSplit) { |
| 1264 | + // Duplicate-coincident-node guard: if the click effectively lands |
| 1265 | + // on an existing node (within nodeThreshold of the raw cursor), |
| 1266 | + // treat it as "select that node" + start a drag so the user can |
| 1267 | + // reposition. Node-tool create-mode is the only place where |
| 1268 | + // node repositioning lives — the select tool no longer drags |
| 1269 | + // (would silently move nodes whenever the user just wanted to |
| 1270 | + // click around). Checked at the raw cursor AND at the resolved |
| 1271 | + // placement point `ms`: with grid snap on, `ms` can land exactly |
| 1272 | + // on an existing grid-aligned node that is >0.5m from the cursor — |
| 1273 | + // creating an exact coincident duplicate. |
| 1274 | + const onExisting = nodeAtCursor |
| 1275 | + ?? findNearestNode(ms.x, ms.y, 0.01); |
| 1276 | + if (onExisting) { |
| 1277 | + if (!uiStore.selectedNodes.has(onExisting.id)) { |
| 1278 | + uiStore.selectNode(onExisting.id, e.shiftKey); |
| 1279 | + } |
| 1280 | + historyStore.pushState(); |
| 1281 | + draggedNodeId = onExisting.id; |
| 1282 | + dragMoved = false; |
| 1283 | + dragStartWorld = { x: snapped.x, y: snapped.y }; |
| 1284 | + } else { |
| 1285 | + const p3d = to3D(uiStore.drawPlane2D, ms.x, ms.y, { x: 0, y: 0, z: 0 }); |
| 1286 | + modelStore.addNode(p3d.x, p3d.y, p3d.z || undefined); |
| 1287 | + } |
| 1288 | + } |
1201 | 1289 | } |
1202 | 1290 | } else if (uiStore.currentTool === 'element') { |
1203 | | - // For element tool: snap to existing node, or midpoint (create node there), or grid |
1204 | | - const nearNode = findNearestNode(snapped.x, snapped.y, 0.5); |
| 1291 | + // For element tool: snap to existing node, or midpoint (create node there), or grid. |
| 1292 | + // Node search uses RAW world coords so off-grid nodes are reachable when |
| 1293 | + // grid snap is on — searching from `snapped` would warp the search center |
| 1294 | + // to the nearest grid intersection and miss any node further than 0.5m |
| 1295 | + // from that intersection (matches snapWithMidpoint's precedence rule). |
| 1296 | + const nearNode = findNearestNode(world.x, world.y, 0.5); |
1205 | 1297 | const targetNode = nearNode ?? (() => { |
1206 | 1298 | const mid = findNearestMidpoint(world.x, world.y, 0.4); |
1207 | 1299 | if (mid) { |
|
1461 | 1553 | } |
1462 | 1554 | } |
1463 | 1555 |
|
1464 | | - // Try to select/drag a node |
| 1556 | + // Select a node. Drag-to-reposition has been moved out of the |
| 1557 | + // select tool because users were accidentally moving nodes while |
| 1558 | + // just trying to inspect / click around the model. Node |
| 1559 | + // repositioning now lives in the node tool only — this branch is |
| 1560 | + // strictly for selection. |
1465 | 1561 | const nearNode = findNearestNode(snapped.x, snapped.y, 0.3); |
1466 | 1562 | if (nearNode) { |
1467 | | - if (!uiStore.selectedNodes.has(nearNode.id)) { |
1468 | | - uiStore.selectNode(nearNode.id, e.shiftKey); |
1469 | | - } |
1470 | | - historyStore.pushState(); |
1471 | | - draggedNodeId = nearNode.id; |
1472 | | - dragMoved = false; |
1473 | | - dragStartWorld = { x: snapped.x, y: snapped.y }; |
| 1563 | + uiStore.selectNode(nearNode.id, e.shiftKey); |
1474 | 1564 | } else { |
1475 | 1565 | const nearElem = findNearestElement(world.x, world.y, 0.3); |
1476 | 1566 | if (nearElem) { |
|
0 commit comments