Commit 440b67836dd5
tarball1 file changed+49-8
@@ -48,6 +48,8 @@
4848
<li>Phases are synchronised. Everything in a phase starts and stops together.</li>4949
<li>In orthogonal style, the default, a window moves or resizes along one axis per phase.</li>5050
<li>An interrupt throws the plan away and replans from what is on screen. Plans never stack.</li>51
+ <li>Neighbours whose edges touch at both ends of a transition keep touching throughout. No holes open mid-plan.</li>52
+ <li>Planning B to A is the exact reverse of planning A to B, so undoing a change looks like rewinding it.</li>5153
</ul>5254
<p>New requirements wry never met.</p>5355
<ul>@@ -89,6 +91,8 @@
8991
<h2>5. Two phases, axis order</h2>9092
<p>When a pair turns a corner (left of becomes above), the middle keyframe is built from a <em>mixed</em> rectangle. Each window takes its horizontal extent from one end and its vertical extent from the other. "X first" means the window takes its target horizontal extent in phase 1 and its target vertical extent in phase 2. "Y first" is the reverse. Every window then moves along exactly one axis per phase.</p>9193
<p>Each window's choice is one boolean. Every pair rules out some of the four combinations, and each forbidden combination is a clause over two variables. That makes it a <strong>2-SAT problem, solvable in linear time</strong> with no search. Windows that only change one axis use the same boolean to choose whether they move in phase 1 or phase 2, and the planner prefers phase 1 so nothing waits needlessly.</p>94
+<p><strong>Coupled edges.</strong> Avoiding overlap is not enough. A window can vanish in phase 1 while the neighbour that absorbs its space only moves in phase 2, leaving a hole in between. Any two neighbours whose edges touch at both ends must also touch in the middle keyframe. Among valid orderings the solver picks one that keeps every such edge together, and only accepts a hole if none exists. In the 6,000-case stress test no two-phase plan needed one.</p>95
+<p><strong>Rewind symmetry.</strong> Every pair of layouts is planned in one canonical direction, and the other direction plays that plan backwards. Insert D and split B with E together, and D arrives first, then E. Undo it, and E leaves first, then D.</p>9296
<div class="demo" id="d4a"></div>9397
<p>Growing a window in both directions inside a grid. Orthogonal makes space along one axis and then grows along the other. Direct does it in one diagonal phase.</p>9498
<div class="demo" id="d4b"></div>@@ -145,12 +149,13 @@
145149
146150
<h2>14. Algorithm</h2>147151
<pre>plan(from, to):152
+ if from > to in a canonical order: return reverse(plan(to, from))148153
groups = motion groups (windows whose swept areas can touch, transitively)149154
for each group:150155
if every pair shares a relation between from and to151156
and (style is direct or every window changes only one axis):152157
return [from, to] # 1 phase153
- if 2-SAT over axis order finds K1: # mixed rects158
+ if 2-SAT over axis order finds K1, preferring no holes: # mixed rects154159
return [from, K1, to] # 2 phases155160
for lane_axis in [y, x]: # strips, then columns156161
for set in [windows in reversals, all movers, all]:@@ -271,10 +276,18 @@ function pureOk(keys) {
271276
return true;272277
}273278
279
+function signature(L) { return Object.keys(L).sort().map(id => { const r = L[id]; return [r.x, r.y, r.w, r.h].map(v => v.toFixed(1)).join(','); }).join(';'); }280
+274281
function plan(from, to, opts = {}) {282
+ if (opts.naive) return { kind: 'naive lerp', keys: [from, to], durs: [PHASE_MS] };283
+ if (signature(from) <= signature(to)) return planOneWay(from, to);284
+ const p = planOneWay(to, from);285
+ return { kind: p.kind, keys: [...p.keys].reverse(), durs: [...p.durs].reverse() };286
+}287
+288
+function planOneWay(from, to) {275289
const list = Object.keys(to);276290
const T = PHASE_MS;277
- if (opts.naive) return { kind: 'naive lerp', keys: [from, to], durs: [T] };278291
const movers = list.filter(id => { const c = chg(from[id], to[id]); return c.x || c.y; });279292
if (!movers.length) return { kind: 'no-op', keys: [from, to], durs: [0] };280293
const pure = movers.every(id => { const c = chg(from[id], to[id]); return !(c.x && c.y); });@@ -333,24 +346,52 @@ function grid(from, to, list, movers) {
333346
334347
function mixed(f, t, xFirst) { return xFirst ? R(t.x, f.y, t.w, f.h) : R(f.x, t.y, f.w, t.h); }335348
349
+function adjBits(a, b) {350
+ const ovX = a.x < b.x + b.w + EPS && b.x < a.x + a.w + EPS;351
+ const ovY = a.y < b.y + b.h + EPS && b.y < a.y + a.h + EPS;352
+ let s = 0;353
+ if (ovY && Math.abs(b.x - (a.x + a.w)) <= G + EPS) s |= 1;354
+ if (ovY && Math.abs(a.x - (b.x + b.w)) <= G + EPS) s |= 2;355
+ if (ovX && Math.abs(b.y - (a.y + a.h)) <= G + EPS) s |= 4;356
+ if (ovX && Math.abs(a.y - (b.y + b.h)) <= G + EPS) s |= 8;357
+ return s;358
+}359
+function coupledEdges(from, to, list) {360
+ const out = [];361
+ for (let i = 0; i < list.length; i++)362
+ for (let j = i + 1; j < list.length; j++) {363
+ const a = list[i], b = list[j], s = adjBits(from[a], from[b]) & adjBits(to[a], to[b]);364
+ if (s) out.push([a, b, s]);365
+ }366
+ return out;367
+}368
+function holes(K, coupled) {369
+ let n = 0;370
+ for (const [a, b, s] of coupled) if ((adjBits(K[a], K[b]) & s) !== s) n++;371
+ return n;372
+}373
+336374
function axisOrder(from, to, list, movers) {337375
const n = movers.length;338376
if (n > 14) return null;377
+ const coupled = coupledEdges(from, to, list);339378
let best = null, bestScore = Infinity;340379
for (let mask = 0; mask < (1 << n); mask++) {341380
const K1 = { ...from };342
- let score = 0;381
+ let late = 0;343382
movers.forEach((id, i) => {344383
const xf = !!(mask & (1 << i));345384
const c = chg(from[id], to[id]);346385
K1[id] = mixed(from[id], to[id], xf);347
- if (c.x && !c.y && !xf) score++;348
- if (c.y && !c.x && xf) score++;386
+ if (c.x && !c.y && !xf) late++;387
+ if (c.y && !c.x && xf) late++;349388
});350
- if (score >= bestScore) continue;351
- if (phaseOk(from, K1, list) && phaseOk(K1, to, list)) { best = K1; bestScore = score; if (score === 0) break; }389
+ if (late >= bestScore) continue;390
+ if (!phaseOk(from, K1, list) || !phaseOk(K1, to, list)) continue;391
+ const score = holes(K1, coupled) * 1000 + late;392
+ if (score < bestScore) { best = K1; bestScore = score; if (score === 0) break; }352393
}353
- return best ? { kind: '2 phases (axis order)', keys: [from, best, to], durs: [PHASE_MS, PHASE_MS] } : null;394
+ return best ? { kind: '2 phases (axis order)' + (bestScore >= 1000 ? ', opens a hole' : ''), keys: [from, best, to], durs: [PHASE_MS, PHASE_MS] } : null;354395
}355396
356397
function lanes(from, to, list, movers, ax) {