circularLayout: 4-sided pairedRows (opt-in via opts.rowsPerSide)
pairedRows gains four-sided placement for Bless India: with opts.rowsPerSide set, rows go on all four sides — T/B take the vertically-extreme pins capped per band (overflow spills to L/R), L/R split the middle; a fork per row routes with the lane axis chosen per side (horizontal band lane + vertical stem on T/B). Absent rowsPerSide keeps the exact L/R-columns-only layout, byte/behaviour-identical. opts.bandNameMaxPt (default 1.5x the column pt) sizes band labels; stats gains fourSided/bandNameMaxPt/sides. Gate adds a 4-sided arm (all sides populated, bands capped, 0 crossings recount, polys==2, octilinear) and a byte-safety arm (default stays L/R); both mutation-tested.
This commit is contained in:
parent
789d90eb2f
commit
bb07391181
2 changed files with 94 additions and 17 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
Loading…
Reference in a new issue