feat(v5 slice 4): cable polyline through clamps + mid-segment drag
Cables now render as <polyline> 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.
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user