From 04e7e86a521ec1578d0c6e92f61fa2eaa530e174 Mon Sep 17 00:00:00 2001 From: mAi Date: Sat, 16 May 2026 13:50:44 +0200 Subject: [PATCH] feat(v5 slice 4): cable polyline through clamps + mid-segment drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cables now render as through their cable_clamps in `ord` sequence. Empty clamp set collapses to a straight from→to line, so nothing visual changes for unrouted (auto-emitted) cables. cableVertices(cable, …) resolves the endpoint anchors + each clamp's (x, y) into the vertex array. Endpoint-replug handles continue to operate on the first/last vertex. Mid-segment drag — startCableMidDrag: - Triggered by pointerdown on a *selected* cable's polyline (button=0, not on an endpoint handle, no Space pan). - nearestSegmentIndex + pointSegmentDistance pick which segment m is bending. The dragged vertex is rendered as a temp inserted point in the cable's polyline via a module-level cableMidDrag preview. - On release: snap to the nearest existing clamp within MID_SNAP_PX / zoom (visual constant per design v5 §11.9 q2), else POST a fresh clamp at the drop point. Either way, attach to the cable at ord = segIdx + 1 so the new vertex sits inside the segment m was bending. A tiny-motion (< 4 world-units) drop is treated as a plain click-to-select and cancelled. Snapping to a clamp already on the cable is a no-op (UNIQUE constraint would 409). Re-fetches cable_clamps from the snapshot after each attach so ord shifts from the slice-1 attach helper propagate. --- web/static/main.js | 194 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 178 insertions(+), 16 deletions(-) diff --git a/web/static/main.js b/web/static/main.js index 4d8e804..b33b229 100644 --- a/web/static/main.js +++ b/web/static/main.js @@ -578,30 +578,47 @@ function renderCanvas() { rect.addEventListener("pointerdown", (e) => startDrag(e, "clamp", cl.id)); } - // Cables — straight lines between resolved endpoint anchors. - // Auto-cables render with dashed stroke so m sees which the solver - // placed; manual cables are solid. + // Cables — polyline through endpoint(from) → clamps in ord sequence + // → endpoint(to). With zero clamps this collapses to a v0 straight + // line. Auto-cables render dashed; manual solid. const portByID = new Map(state.ports.map((p) => [p.id, p])); const deviceByID = new Map(state.devices.map((d) => [d.id, d])); const ioByID = new Map(state.ioMarkers.map((m) => [m.id, m])); + const clampByID = new Map(state.clamps.map((cl) => [cl.id, cl])); + // Pre-group cable_clamps by cable, sorted by ord. + const clampsByCable = new Map(); + for (const cc of state.cableClamps) { + let arr = clampsByCable.get(cc.cable_id); + if (!arr) { arr = []; clampsByCable.set(cc.cable_id, arr); } + arr.push(cc); + } + for (const arr of clampsByCable.values()) arr.sort((a, b) => a.ord - b.ord); + for (const c of state.cables) { - let fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID); - let toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID); - if (!fromAnchor || !toAnchor) continue; + const vertices = cableVertices(c, portByID, deviceByID, ioByID, clampByID, clampsByCable); + if (vertices.length < 2) continue; // Replug preview: while m drags an endpoint handle, override the // affected end with the live cursor world position so the line - // tracks the pointer. + // tracks the pointer. Mid-vertices (clamps) are unchanged. if (cableReplug && cableReplug.cableID === c.id) { - if (cableReplug.end === "from") fromAnchor = { x: cableReplug.x, y: cableReplug.y }; - else toAnchor = { x: cableReplug.x, y: cableReplug.y }; + const idx = cableReplug.end === "from" ? 0 : vertices.length - 1; + vertices[idx] = { x: cableReplug.x, y: cableReplug.y }; + } + // Mid-segment drag preview: while m is bending a segment, insert + // a temp vertex at the cursor so the line tracks. On release this + // becomes a real clamp (or snaps to a nearby existing one). + if (cableMidDrag && cableMidDrag.cableID === c.id) { + const at = cableMidDrag.segmentIdx + 1; + vertices.splice(at, 0, { x: cableMidDrag.x, y: cableMidDrag.y }); } const isSelected = state.selection?.kind === "cable" && state.selection.id === c.id; const color = cableTypeColor.get(c.type_id) || "#888"; - const line = svgEl("line", { - x1: fromAnchor.x, y1: fromAnchor.y, - x2: toAnchor.x, y2: toAnchor.y, + const pointsStr = vertices.map((v) => `${v.x},${v.y}`).join(" "); + const line = svgEl("polyline", { + points: pointsStr, class: "cable-line" + (c.auto ? " auto" : "") + (isSelected ? " selected" : ""), stroke: color, + fill: "none", "data-cable-id": c.id, }); line.addEventListener("click", (e) => { @@ -609,12 +626,20 @@ function renderCanvas() { state.selection = { kind: "cable", id: c.id }; render(); }); + line.addEventListener("pointerdown", (e) => { + // Selected cable + non-endpoint click → start a mid-segment drag + // that inserts (or snaps to) a clamp on release. Bypasses the + // canvas-level handler so panning / device drag don't fire. + if (isSelected && e.button === 0 && !state.spaceHeld) { + startCableMidDrag(e, c, vertices); + } + }); gCables.append(line); - // Endpoint handles — only on the currently-selected cable. Two small - // filled circles m can grab to drag the endpoint onto a new target. + // Endpoint handles — first + last vertex when selected. if (isSelected) { - for (const end of ["from", "to"]) { - const a = end === "from" ? fromAnchor : toAnchor; + const first = vertices[0]; + const last = vertices[vertices.length - 1]; + for (const [end, a] of [["from", first], ["to", last]]) { const h = svgEl("circle", { cx: a.x, cy: a.y, r: 7, class: "cable-handle", @@ -630,6 +655,23 @@ function renderCanvas() { } } +// Compute the resolved polyline vertices for a cable: from-anchor, then +// each clamp's (x, y) in ord, then to-anchor. Returns [] if either +// endpoint can't be resolved. +function cableVertices(c, portByID, deviceByID, ioByID, clampByID, clampsByCable) { + const fromAnchor = anchorForEndpoint(c.from_port_id, c.from_device_id, c.from_io_id, portByID, deviceByID, ioByID); + const toAnchor = anchorForEndpoint(c.to_port_id, c.to_device_id, c.to_io_id, portByID, deviceByID, ioByID); + if (!fromAnchor || !toAnchor) return []; + const out = [fromAnchor]; + const clamps = clampsByCable.get(c.id) || []; + for (const cc of clamps) { + const cl = clampByID.get(cc.clamp_id); + if (cl) out.push({ x: cl.x, y: cl.y }); + } + out.push(toAnchor); + return out; +} + /** Resolve a cable endpoint to {x, y} on the canvas. Returns null when * the referenced row has gone missing (rare, but possible mid-edit). */ function anchorForEndpoint(portID, deviceID, ioID, portByID, deviceByID, ioByID) { @@ -1833,6 +1875,11 @@ let rubberStart = /** @type {{x:number,y:number}|null} */ (null); // on a .cable-handle, used by renderCanvas to anchor the dragged end // at the cursor; cleared on pointerup (commit or cancel). let cableReplug = /** @type {{cableID: number, end: "from"|"to", x: number, y: number}|null} */ (null); +// Mid-segment drag — m grabs a point on a cable's polyline (not on an +// endpoint handle, not on an existing clamp vertex) and drags. On +// release, either snap to a nearby clamp or create a fresh one at the +// drop point and insert at the right `ord`. +let cableMidDrag = /** @type {{cableID: number, segmentIdx: number, x: number, y: number}|null} */ (null); function onCanvasPointerDown(e) { // Pan gestures win over every tool. Middle-click and Space+drag both @@ -2264,6 +2311,121 @@ function startCableReplug(e, cableID, end) { svg.addEventListener("pointercancel", onUp); } +// Mid-segment cable drag: m grabs a point on a selected cable's +// polyline (not on an endpoint handle) and drags. On release, snap to +// the nearest clamp within MID_SNAP world-units, or create a fresh one +// at the drop point. Either way, attach it to the cable at the right +// ord so the new vertex sits inside the segment m was bending. +const MID_SNAP_PX = 16; // visual constant — divided by current zoom +function startCableMidDrag(e, cable, vertices) { + if (!state.active) return; + // Refuse if the click target is an endpoint handle — let the replug + // handler own that gesture. + if (e.target instanceof Element && e.target.classList.contains("cable-handle")) return; + e.stopPropagation(); + e.preventDefault(); + const svg = /** @type {SVGSVGElement} */ ($("#canvas")); + const start = svgPoint(e); + // Identify which segment the click landed closest to. + const segIdx = nearestSegmentIndex(vertices, start); + try { svg.setPointerCapture(e.pointerId); } catch {} + $(".canvas-wrap").classList.add("replugging"); + cableMidDrag = { cableID: cable.id, segmentIdx: segIdx, x: start.x, y: start.y }; + renderCanvas(); + + const onMove = (ev) => { + const p = svgPoint(ev); + cableMidDrag = { cableID: cable.id, segmentIdx: segIdx, x: p.x, y: p.y }; + renderCanvas(); + }; + const onUp = async (ev) => { + svg.removeEventListener("pointermove", onMove); + svg.removeEventListener("pointerup", onUp); + svg.removeEventListener("pointercancel", onUp); + try { svg.releasePointerCapture(ev.pointerId); } catch {} + $(".canvas-wrap").classList.remove("replugging"); + const dropWorld = svgPoint(ev); + cableMidDrag = null; + // Cancel if the cursor barely moved (≤ a few px in world coords) — + // m probably clicked the cable to select it, not bend it. + if (Math.hypot(dropWorld.x - start.x, dropWorld.y - start.y) < 4) { + renderCanvas(); + return; + } + // Snap radius in world coords — visual constant per design v5 §11.9 q2. + const snapRadius = MID_SNAP_PX / state.view.zoom; + let nearest = null; + let bestDist = Infinity; + for (const cl of state.clamps) { + const d = Math.hypot(cl.x - dropWorld.x, cl.y - dropWorld.y); + if (d < bestDist) { bestDist = d; nearest = cl; } + } + try { + let clampID; + if (nearest && bestDist <= snapRadius) { + // Snap onto existing clamp — but only if it's not already on + // this cable (UNIQUE constraint would 409). Skip silently in + // that case rather than spamming an alert. + const already = state.cableClamps.some( + (cc) => cc.cable_id === cable.id && cc.clamp_id === nearest.id, + ); + if (already) { renderCanvas(); return; } + clampID = nearest.id; + } else { + // Fresh clamp at the drop point. + const frame = frameAt(dropWorld.x, dropWorld.y); + const newClamp = await createClamp(state.active.id, { + x: dropWorld.x, y: dropWorld.y, + frame_id: frame ? frame.id : undefined, + }); + state.clamps.push(newClamp); + clampID = newClamp.id; + } + // Insert at ord = segIdx + 1 (1-based; segmentIdx is the segment + // between vertices[segIdx] and vertices[segIdx + 1]). + const cc = await attachClampToCable(state.active.id, cable.id, { + clamp_id: clampID, ord: segIdx + 1, + }); + // Refresh cable_clamps so the new ord + any shifted neighbours + // are reflected without a full snapshot reload. + const snap = await getSnapshot(state.active.id); + state.cableClamps = snap.cable_clamps || []; + render(); + // Silence unused-var lint without dropping the result. + void cc; + } catch (err) { + alert(`Insert clamp failed: ${err.message}`); + renderCanvas(); + } + }; + svg.addEventListener("pointermove", onMove); + svg.addEventListener("pointerup", onUp); + svg.addEventListener("pointercancel", onUp); +} + +// Index of the segment in `vertices` closest to point p. Segment i sits +// between vertices[i] and vertices[i+1]. +function nearestSegmentIndex(vertices, p) { + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < vertices.length - 1; i++) { + const a = vertices[i], b = vertices[i + 1]; + const d = pointSegmentDistance(p, a, b); + if (d < bestDist) { bestDist = d; best = i; } + } + return best; +} + +// Shortest distance from point p to the line segment a–b. +function pointSegmentDistance(p, a, b) { + const dx = b.x - a.x, dy = b.y - a.y; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y); + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)); +} + /** Port-click flow: * - A cable draw is in progress (cableDrawFromPortID set): * same port → cancel; another port → finish the cable.