diff --git a/library/packet/circularLayout.js b/library/packet/circularLayout.js index 6740824eb..81ecbdfb6 100644 --- a/library/packet/circularLayout.js +++ b/library/packet/circularLayout.js @@ -345,33 +345,78 @@ function pairedRows({ pins, box, opts, fmt, measure, placePinsInState, forkRoute } else { var spread = { outsideState: 0, overlaps: 0, moved: 0, maxDisp: 0, infeasible: 0 }; } - // ring order: median x-split into L/R, then y within a side (monotone, non-crossing) - const byX = [...P].sort((a, b) => a.px - b.px || cmpId(a.id, b.id)); - const half = Math.ceil(byX.length / 2); - byX.forEach((p, i) => { p.side = i < half ? 'L' : 'R'; }); const rowsOut = []; const leaderPolys = []; const allDots = P.map((p) => ({ id: p.id, x: p.px, y: p.py })); - const bandTop = box.LABEL_TOP ?? MAP_Y, bandBot = box.LABEL_BOT ?? (MAP_Y + MAP_H); - for (const side of ['L', 'R']) { - const dirOut = side === 'L' ? -1 : 1; - const laneX = side === 'L' ? MAP_X - 6 : MAP_X + MAP_W + 6; - const col = P.filter((p) => p.side === side).sort((a, b) => a.py - b.py || cmpId(a.id, b.id)); - // two groups per row, in ring (y) order. A row's PORT sits at the mean y of its - // own two pins (not an evenly-pitched slot): because the pairs are consecutive - // in y, these port ys are themselves monotone, so each fork stays local to its - // pins and neighbouring forks cannot sweep across one another. (An evenly-pitched - // port put a row's port far from its pins and produced same-side crossings.) + // FOUR-SIDED is OPT-IN via opts.rowsPerSide. Absent → the original L/R-columns-only + // layout, byte/behaviour-identical (every existing caller unchanged). + const rps = opts.rowsPerSide || null; + const bandNameMaxPt = opts.bandNameMaxPt != null ? opts.bandNameMaxPt : (fmt.nameMaxPt || 11) * 1.5; + + // Route ONE fork per row for a side. axis 'y' (L/R columns) or 'x' (T/B bands): + // the port sits at the mean of the pair along the PRIMARY axis so, with the pairs + // consecutive along it, the ports stay monotone and neighbouring forks never sweep + // across one another. For a band the lane runs along x and the stem goes vertically + // into the map; for a column the lane runs along y and the stem goes horizontally. + const routeSide = (side, members, { axis, dirOut, lanePos }) => { + const col = axis === 'y' + ? members.sort((a, b) => a.py - b.py || cmpId(a.id, b.id)) + : members.sort((a, b) => a.px - b.px || cmpId(a.id, b.id)); for (let i = 0; i < col.length; i += 2) { const a = col[i], b = col[i + 1] || col[i]; - const rowY = (a.py + (b.py)) / 2; + const lane = axis === 'y' + ? { x: lanePos, y: (a.py + b.py) / 2 } // L/R column: lane at the map edge, port at mean y + : { x: (a.px + b.px) / 2, y: lanePos }; // T/B band: lane on the band, port at mean x const fork = forkRoute({ - lane: { x: laneX, y: rowY }, + lane, a: { id: a.id, x: a.px, y: a.py }, b: { id: b.id, x: b.px, y: b.py }, dirOut, mode: 'row', obstacles: allDots.filter((d) => d.id !== a.id && d.id !== b.id), r: rPin, }); - rowsOut.push({ side, rowY, aId: a.id, bId: b.id, octilinear: fork.octilinear, throughPins: fork.throughPins }); + rowsOut.push({ side, port: lane, aId: a.id, bId: b.id, octilinear: fork.octilinear, throughPins: fork.throughPins }); leaderPolys.push({ id: `${side}${i}`, polys: fork.polys, segs: fork.segs }); } + }; + + if (!rps) { + // ── L/R columns only (unchanged default) ──────────────────────────────── + const byX = [...P].sort((a, b) => a.px - b.px || cmpId(a.id, b.id)); + const half = Math.ceil(byX.length / 2); + byX.forEach((p, i) => { p.side = i < half ? 'L' : 'R'; }); + for (const side of ['L', 'R']) { + const col = P.filter((p) => p.side === side).sort((a, b) => a.py - b.py || cmpId(a.id, b.id)); + for (let i = 0; i < col.length; i += 2) { + const a = col[i], b = col[i + 1] || col[i]; + const rowY = (a.py + (b.py)) / 2; + const fork = forkRoute({ + lane: { x: side === 'L' ? MAP_X - 6 : MAP_X + MAP_W + 6, y: rowY }, + a: { id: a.id, x: a.px, y: a.py }, b: { id: b.id, x: b.px, y: b.py }, + dirOut: side === 'L' ? -1 : 1, mode: 'row', + obstacles: allDots.filter((d) => d.id !== a.id && d.id !== b.id), r: rPin, + }); + rowsOut.push({ side, rowY, aId: a.id, bId: b.id, octilinear: fork.octilinear, throughPins: fork.throughPins }); + leaderPolys.push({ id: `${side}${i}`, polys: fork.polys, segs: fork.segs }); + } + } + } else { + // ── FOUR SIDES ─────────────────────────────────────────────────────────── + // T/B take the vertically-extreme pins (capped at rowsPerSide rows = cap*2 pins), + // then the remaining middle pins split L/R by median px (uncapped). Overflow from + // a capped band falls into the L/R middle automatically (it is never assigned to T/B). + const capT = (rps.T != null ? rps.T : Infinity) * 2; // pins = rows * 2 + const capB = (rps.B != null ? rps.B : Infinity) * 2; + const byCy = [...P].sort((a, b) => a.py - b.py || cmpId(a.id, b.id)); + const T = byCy.slice(0, Math.min(capT, byCy.length)); + const B = byCy.slice(Math.max(0, byCy.length - capB)).filter((p) => !T.includes(p)); + const taken = new Set([...T, ...B]); + const mid = P.filter((p) => !taken.has(p)).sort((a, b) => a.px - b.px || cmpId(a.id, b.id)); + const halfM = Math.ceil(mid.length / 2); + const L = mid.slice(0, halfM), R = mid.slice(halfM); + for (const p of T) p.side = 'T'; for (const p of B) p.side = 'B'; + for (const p of L) p.side = 'L'; for (const p of R) p.side = 'R'; + // bands: lane on the top/bottom edge, stem vertically into the map (dirOut: T down +, B up -). + routeSide('T', T, { axis: 'x', dirOut: 1, lanePos: MAP_Y - 6 }); + routeSide('B', B, { axis: 'x', dirOut: -1, lanePos: MAP_Y + MAP_H + 6 }); + routeSide('L', L, { axis: 'y', dirOut: -1, lanePos: MAP_X - 6 }); + routeSide('R', R, { axis: 'y', dirOut: 1, lanePos: MAP_X + MAP_W + 6 }); } // crossings: one polyline id per fork (its deduped segs), skip same-id // build as [ [segpts...] per fork ] so countPolyCrossings skips intra-fork @@ -393,6 +438,8 @@ function pairedRows({ pins, box, opts, fmt, measure, placePinsInState, forkRoute crossings, touches, overflow: offFrame, octilinear, pinsOutsideState: spread.outsideState, pinOverlaps: spread.overlaps, pinsMoved: spread.moved, maxDisp: +(+spread.maxDisp).toFixed(1), infeasible: spread.infeasible, + fourSided: !!rps, bandNameMaxPt: rps ? +bandNameMaxPt.toFixed(2) : null, + sides: rowsOut.reduce((m, r) => { m[r.side] = (m[r.side] || 0) + 1; return m; }, {}), }; return { numbers: P.map((p, i) => ({ id: p.id, num: i + 1 })), pins: P.map((p) => ({ id: p.id, x: p.px, y: p.py, state: p._state })), rows: rowsOut, leaders: leaderPolys.map((lp) => ({ id: lp.id, polys: lp.polys || null, segs: lp.segs })), badges: [], labels: [], dots: allDots, stats }; } diff --git a/library/packet/scripts/check-circular-layout.mjs b/library/packet/scripts/check-circular-layout.mjs index b63ff7513..64871c277 100644 --- a/library/packet/scripts/check-circular-layout.mjs +++ b/library/packet/scripts/check-circular-layout.mjs @@ -181,6 +181,36 @@ if (paired.stats.placed < 800) fails.push(`pairedRows: only ${paired.stats.place note.push(`pairedRows check: crossings(recount) ${xr}, outside(recount) ${outPr}, leaders-with-2-polys ${paired.leaders.length - badPolys}/${paired.leaders.length}`); } +// ── 2b. pairedRows FOUR-SIDED (opt-in via opts.rowsPerSide) ───────────────────── +{ + // Byte-safety anchor: the default run above (NO opts.rowsPerSide) must stay L/R-only. + const defSides = paired.rows.reduce((m, r) => { m[r.side] = 1; return m; }, {}); + if (defSides.T || defSides.B) fails.push('pairedRows default (no rowsPerSide) placed on T/B — must stay L/R-only'); + if (paired.stats.fourSided) fails.push('pairedRows default reports fourSided:true — opt-in leaked'); + + // 4-sided run: bands capped at 3 rows each, overflow spills to L/R. + const four = await layoutCircular({ pins, states: null, box, mode: 'pairedRows', opts: { ...optsBase, rowsPerSide: { T: 3, B: 3 } } }); + const sc = four.rows.reduce((m, r) => { m[r.side] = (m[r.side] || 0) + 1; return m; }, {}); + note.push(`pairedRows 4-sided: sides ${JSON.stringify(sc)}, crossings ${four.stats.crossings}, octilinear ${four.stats.octilinear}, outside ${four.stats.pinsOutsideState}, fourSided ${four.stats.fourSided}`); + if (!(sc.T > 0 && sc.B > 0 && sc.L > 0 && sc.R > 0)) fails.push(`pairedRows 4-sided: not all four sides populated (${JSON.stringify(sc)})`); + if ((sc.T || 0) > 3) fails.push(`pairedRows 4-sided: top band ${sc.T} rows over cap 3`); + if ((sc.B || 0) > 3) fails.push(`pairedRows 4-sided: bottom band ${sc.B} rows over cap 3`); + if (!four.stats.fourSided) fails.push('pairedRows 4-sided: stats.fourSided not set'); + // recount crossings from the OUTPUT (one id per fork), must be 0 + const fseg = []; + four.leaders.forEach((lf, li) => { for (const s of (lf.segs || [])) fseg.push({ s, li }); }); + let fx = 0; + for (let i = 0; i < fseg.length; i++) for (let j = i + 1; j < fseg.length; j++) { + if (fseg[i].li === fseg[j].li) continue; + if (segCrossGate(fseg[i].s, fseg[j].s)) fx++; + } + if (fx !== 0) fails.push(`pairedRows 4-sided: recount found ${fx} crossings (expected 0)`); + if (!four.stats.octilinear) fails.push('pairedRows 4-sided: a leader is not octilinear'); + if (four.stats.pinsOutsideState !== 0) fails.push(`pairedRows 4-sided: ${four.stats.pinsOutsideState} pins outside their state`); + const fBad = four.leaders.filter((l) => !Array.isArray(l.polys) || l.polys.length !== 2).length; + if (fBad !== 0) fails.push(`pairedRows 4-sided: ${fBad} leaders missing polys.length===2`); +} + // ── 3. sideLanes ────────────────────────────────────────────────────────────── const sideFmt = { nameMaxPt: 14, sideLanes: true, photos: false, pageWidthIn: 60, w: PW, h: PH }; const side = await layoutCircular({ pins, states: null, box: { ...box, LABEL_TOP: HEADER - 40, LABEL_BOT: PH - FOOTER + 40 }, mode: 'sideLanes', opts: { fmt: sideFmt, feats } });