Skip to content

Commit 7c1e67f

Browse files
Merge pull request #51 from lambdaclass/pr/5-basic-mode-overhaul
[4] Basic-mode interaction overhaul + PRO connectors / eccentric connection (Phase B)
2 parents 4b104a8 + 010dc7d commit 7c1e67f

35 files changed

Lines changed: 2037 additions & 96 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ target/
2424
engine/target/
2525
engine/pkg/
2626
web/src/lib/wasm/
27+
# also match a symlinked wasm pkg (worktree setups)
28+
web/src/lib/wasm
2729

2830
# Nix
2931
result

web/node_modules

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/tmp/stabileo-review/web/node_modules

web/src/components/Viewport.svelte

Lines changed: 104 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,7 +1176,8 @@
11761176
if (nearElem) {
11771177
const ni = modelStore.getNode(nearElem.nodeI);
11781178
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)
11801181
const edx = nj.x - ni.x;
11811182
const edy = nj.y - ni.y;
11821183
const lenSq = edx * edx + edy * edy;
@@ -1194,14 +1195,105 @@
11941195
}
11951196
}
11961197
} 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.
11981207
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+
}
12011289
}
12021290
} 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);
12051297
const targetNode = nearNode ?? (() => {
12061298
const mid = findNearestMidpoint(world.x, world.y, 0.4);
12071299
if (mid) {
@@ -1461,16 +1553,14 @@
14611553
}
14621554
}
14631555
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.
14651561
const nearNode = findNearestNode(snapped.x, snapped.y, 0.3);
14661562
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);
14741564
} else {
14751565
const nearElem = findNearestElement(world.x, world.y, 0.3);
14761566
if (nearElem) {

web/src/components/Viewport3D.svelte

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -470,12 +470,15 @@
470470
// there is no need for a separate proxy. During orbit we force it visible
471471
// as the LOD stand-in; at idle its visibility follows `renderMode3D`.
472472
function setLowDetail(on: boolean): void {
473+
const dt = resultsStore.diagramType;
474+
const resultsColoringActive = !!resultsStore.results3D
475+
&& (dt === 'axialColor' || dt === 'colorMap' || dt === 'verification');
473476
applyLowDetail(on, {
474477
nodesParent, supportsParent, loadsParent, resultsParent, shellsParent,
475478
elementsParent,
476479
elementsBatchedMesh: elementsBatched.mesh,
477480
renderMode: uiStore.renderMode3D,
478-
});
481+
}, { resultsColoringActive });
479482
}
480483
controls.addEventListener('start', () => {
481484
isOrbiting = true;
@@ -562,6 +565,7 @@
562565
initialized: false,
563566
resultsParent, scene,
564567
elementGroups,
568+
elementsBatched,
565569
shellGroups: sceneCtx.shellGroups,
566570
deformedGroup: null, diagramGroup: null, overlayDiagramGroup: null,
567571
reactionGroup: null, constraintForcesGroup: null, nodeLabelsGroup: null, elementLabelsGroup: null, lengthLabelsGroup: null, verificationLabelsGroup: null,
@@ -1807,8 +1811,12 @@
18071811
if (group) {
18081812
const dt = resultsStore.diagramType;
18091813
if (resultsStore.results3D && (dt === 'axialColor' || dt === 'colorMap' || dt === 'verification')) {
1810-
// Re-apply color map instead of base color
1811-
syncColorMap3D();
1814+
// No-op: applyHoverColor skips painting while a color mode is
1815+
// active, so there is nothing to restore — and a full
1816+
// syncColorMap3D() here recolored EVERY element (plus a batched
1817+
// position+color re-upload) per hover-out, a multi-ms stall per
1818+
// element crossed on large models. Mode changes mid-hover are
1819+
// covered by the colorMap $effect, which repaints everything.
18121820
} else {
18131821
// In shells mode selectedElements holds plate/quad ids — a frame
18141822
// element with an overlapping id is not selected.

web/src/components/pro/ProAdvancedTab.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@
7474
const input = buildSolverInput3D(
7575
{ nodes: modelStore.nodes, elements: modelStore.elements, supports: modelStore.supports,
7676
loads: modelStore.loads, materials: modelStore.materials, sections: modelStore.sections,
77-
quads: modelStore.quads, plates: modelStore.plates, constraints: modelStore.constraints },
77+
quads: modelStore.quads, plates: modelStore.plates, constraints: modelStore.constraints,
78+
connectors: modelStore.connectors },
7879
uiStore.includeSelfWeight,
7980
);
8081
if (!input) throw new Error(t('advanced.emptyModel'));

0 commit comments

Comments
 (0)