room settings button: centroid pull breaks the plateau tie

The inscribed-circle criterion is FLAT along the long axis of any elongated
room — every midline point fits the same circle — and a plain argmax took
the first plateau sample: left of centre on the owner's kitchen-living
room, above centre in the sauna. The score now subtracts a soft pull
toward the area centroid (shoelace-weighted): on the plateau the nearest-
to-centroid point wins, while real clearance differences still dominate,
so the point never wanders into a thinner limb of an L.

Verified on a replica of the owner's floor: kitchen slab centre within a
few units, sauna dead-centre both axes. Units: wide and tall rectangles
centre on both axes; the L keeps to its slab near the centroid x.
This commit is contained in:
Matysh
2026-07-29 14:00:52 +03:00
parent 9eec856d50
commit fa15598e67
4 changed files with 111 additions and 78 deletions
File diff suppressed because one or more lines are too long
+35 -35
View File
File diff suppressed because one or more lines are too long
+28 -5
View File
@@ -250,6 +250,24 @@ export function poleOfInaccessibility(poly: number[][], steps = 24): number[] {
const ys = poly.map((p) => p[1]); const ys = poly.map((p) => p[1]);
const minX = Math.min(...xs), maxX = Math.max(...xs); const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys); const minY = Math.min(...ys), maxY = Math.max(...ys);
const span = Math.max(maxX - minX, maxY - minY) || 1;
// Area centroid (shoelace-weighted). Clearance alone has a PLATEAU on any
// elongated room — every point of the long midline fits the same circle —
// and a plain argmax took the first plateau point: left of centre on the
// owner's kitchen, above centre in the sauna. A soft pull toward the
// centroid breaks the tie along the plateau without ever dragging the
// point into a thinner limb (clearance differences dominate the score).
let a2 = 0, cx = 0, cy = 0;
for (let i = 0; i < poly.length; i++) {
const p = poly[i], q = poly[(i + 1) % poly.length];
const cross = p[0] * q[1] - q[0] * p[1];
a2 += cross;
cx += (p[0] + q[0]) * cross;
cy += (p[1] + q[1]) * cross;
}
const centroid = Math.abs(a2) > 1e-9
? [cx / (3 * a2), cy / (3 * a2)]
: [(minX + maxX) / 2, (minY + maxY) / 2];
const clearance = (x: number, y: number): number => { const clearance = (x: number, y: number): number => {
if (!pointInPolygon([x, y], poly)) return -Infinity; if (!pointInPolygon([x, y], poly)) return -Infinity;
let d = Infinity; let d = Infinity;
@@ -259,14 +277,19 @@ export function poleOfInaccessibility(poly: number[][], steps = 24): number[] {
} }
return d; return d;
}; };
const score = (x: number, y: number): number => {
const d = clearance(x, y);
if (d === -Infinity) return d;
return d - 0.08 * Math.hypot(x - centroid[0], y - centroid[1]) - 0.0001 * span;
};
let best: number[] | null = null; let best: number[] | null = null;
let bestD = -Infinity; let bestS = -Infinity;
for (let i = 1; i < steps; i++) { for (let i = 1; i < steps; i++) {
for (let j = 1; j < steps; j++) { for (let j = 1; j < steps; j++) {
const x = minX + ((maxX - minX) * i) / steps; const x = minX + ((maxX - minX) * i) / steps;
const y = minY + ((maxY - minY) * j) / steps; const y = minY + ((maxY - minY) * j) / steps;
const d = clearance(x, y); const sc = score(x, y);
if (d > bestD) { bestD = d; best = [x, y]; } if (sc > bestS) { bestS = sc; best = [x, y]; }
} }
} }
if (best) { if (best) {
@@ -275,8 +298,8 @@ export function poleOfInaccessibility(poly: number[][], steps = 24): number[] {
for (let i = -4; i <= 4; i++) { for (let i = -4; i <= 4; i++) {
for (let j = -4; j <= 4; j++) { for (let j = -4; j <= 4; j++) {
const x = bx + (cw * i) / 4, y = by + (ch * j) / 4; const x = bx + (cw * i) / 4, y = by + (ch * j) / 4;
const d = clearance(x, y); const sc = score(x, y);
if (d > bestD) { bestD = d; best = [x, y]; } if (sc > bestS) { bestS = sc; best = [x, y]; }
} }
} }
} }
+13 -3
View File
@@ -917,12 +917,22 @@ test('poleOfInaccessibility: the VISUAL centre, not just any interior point', ()
const p = poleOfInaccessibility(sq); const p = poleOfInaccessibility(sq);
assert.ok(Math.abs(p[0] - 5) < 0.6 && Math.abs(p[1] - 5) < 0.6); assert.ok(Math.abs(p[0] - 5) < 0.6 && Math.abs(p[1] - 5) < 0.6);
// an ELONGATED rectangle: clearance is a plateau along the midline, and a
// plain argmax used to take its first point — left of centre on the
// owner's kitchen, above centre in the sauna. The centroid pull centres it.
const wide = [[0, 0], [30, 0], [30, 10], [0, 10]];
const w = poleOfInaccessibility(wide);
assert.ok(Math.abs(w[0] - 15) < 1.2 && Math.abs(w[1] - 5) < 1.2, 'centred both axes: ' + w);
const tall = [[0, 0], [8, 0], [8, 28], [0, 28]];
const tl = poleOfInaccessibility(tall);
assert.ok(Math.abs(tl[0] - 4) < 1 && Math.abs(tl[1] - 14) < 1.2, 'sauna case, centred vertically: ' + tl);
// the owner's kitchen-living room shape: a THICK top slab with a narrower // the owner's kitchen-living room shape: a THICK top slab with a narrower
// column hanging off the right side. The button belongs in the middle of // column hanging off the right side. The button belongs near the middle of
// the slab — the widest open space — not near the seam or down the column. // the slab — pulled toward the area centroid, never down the thin column.
const L = [[0, 0], [20, 0], [20, 16], [16, 16], [16, 8], [0, 8]]; const L = [[0, 0], [20, 0], [20, 16], [16, 16], [16, 8], [0, 8]];
const q = poleOfInaccessibility(L); const q = poleOfInaccessibility(L);
assert.ok(pointInPolygon(q, L), 'inside, always'); assert.ok(pointInPolygon(q, L), 'inside, always');
assert.ok(q[1] < 8, 'in the thick slab (y < 8), not down the column: ' + q); assert.ok(q[1] < 8, 'in the thick slab (y < 8), not down the column: ' + q);
assert.ok(q[0] > 2 && q[0] < 16, 'horizontally inside the slab body: ' + q); assert.ok(Math.abs(q[0] - 11.3) < 2.5, 'near the centroid x within the slab: ' + q);
}); });