room settings button: the VISUAL centre for L-shaped rooms

The owner's kitchen-living room is L-shaped, and interiorPoint() only
promises 'somewhere inside' — the button sat near the seam, visibly
off-centre. poleOfInaccessibility() (largest inscribed circle, grid search
plus one refinement pass) puts it in the middle of the widest open space:
the slab of an L, the exact centre of a rectangle or square. Cached per
poly array in a WeakMap — the memoized model keeps the arrays stable, so
the search runs once per geometry, not per render.

Unit: square -> centre; thick-slab L -> mid-slab, always inside.
This commit is contained in:
Matysh
2026-07-29 13:55:03 +03:00
parent 0bb9282edd
commit 9eec856d50
5 changed files with 84 additions and 14 deletions
File diff suppressed because one or more lines are too long
+4 -4
View File
File diff suppressed because one or more lines are too long
+13 -4
View File
@@ -15,7 +15,7 @@ import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf, sharedBoundary, openZoneOf, distToSegment, outlineWithout, cutSegments, alignGuides, segmentAngle, is45, type AlignGuide, swipeTarget, clampScale, migratePdfUrls, roomFillModeOf, contentUrl,
snapToWall, openingAmount, interiorPoint,
snapToWall, openingAmount, interiorPoint, poleOfInaccessibility,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
@@ -4505,13 +4505,22 @@ class HouseplanCard extends LitElement {
/** The room-settings button: detached from the (movable) label, always at
* the geometric CENTRE of the room, sized at 70% of a device icon and
* therefore zooming with the plan (owner's spec, 2026-07-29). */
private _gearPtCache = new WeakMap<number[][], number[]>();
private _renderRoomGear(
r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number },
): TemplateResult | typeof nothing {
if (!r.id) return nothing;
const c = r.poly
? (interiorPoint(r.poly) || [r.poly[0][0], r.poly[0][1]])
: r.x != null && r.y != null ? [r.x + (r.w || 0) / 2, r.y + (r.h || 0) / 2] : null;
let c: number[] | null = null;
if (r.poly) {
// the VISUAL centre (largest inscribed circle) — interiorPoint only
// promises "inside", which sat visibly off-centre on an L-shaped room.
// The model is memoized, so the poly array is a stable cache key.
c = this._gearPtCache.get(r.poly) || null;
if (!c) { c = poleOfInaccessibility(r.poly); this._gearPtCache.set(r.poly, c); }
} else if (r.x != null && r.y != null) {
c = [r.x + (r.w || 0) / 2, r.y + (r.h || 0) / 2];
}
if (!c) return nothing;
const left = ((c[0] - view.x) / view.w) * 100;
const top = ((c[1] - view.y) / view.h) * 100;
+45
View File
@@ -238,6 +238,51 @@ export function segmentsProperlyCross(
* triangle centroid of consecutive vertices — the first point that passes
* pointStrictlyInside wins.
*/
/**
* The visual centre of a polygon: the centre of the largest inscribed circle
* (pole of inaccessibility). `interiorPoint` only promises "inside", and for
* an L-shaped room it lands near the seam — the owner's kitchen-living room
* got its settings button visibly off-centre (2026-07-29). Grid search with
* one refinement pass; exact enough for a button, cheap enough to cache.
*/
export function poleOfInaccessibility(poly: number[][], steps = 24): number[] {
const xs = poly.map((p) => p[0]);
const ys = poly.map((p) => p[1]);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const clearance = (x: number, y: number): number => {
if (!pointInPolygon([x, y], poly)) return -Infinity;
let d = Infinity;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
d = Math.min(d, distToSegment([x, y], [a[0], a[1], b[0], b[1]]));
}
return d;
};
let best: number[] | null = null;
let bestD = -Infinity;
for (let i = 1; i < steps; i++) {
for (let j = 1; j < steps; j++) {
const x = minX + ((maxX - minX) * i) / steps;
const y = minY + ((maxY - minY) * j) / steps;
const d = clearance(x, y);
if (d > bestD) { bestD = d; best = [x, y]; }
}
}
if (best) {
const [bx, by] = best;
const cw = (maxX - minX) / steps, ch = (maxY - minY) / steps;
for (let i = -4; i <= 4; i++) {
for (let j = -4; j <= 4; j++) {
const x = bx + (cw * i) / 4, y = by + (ch * j) / 4;
const d = clearance(x, y);
if (d > bestD) { bestD = d; best = [x, y]; }
}
}
}
return best || interiorPoint(poly) || poly[0];
}
export function interiorPoint(poly: number[][], eps = 1e-6): number[] | null {
if (!poly || poly.length < 3) return null;
const n = poly.length;
+18 -2
View File
@@ -15,8 +15,7 @@ import {
contentUrl, chunk, referencedContentUrls, MAX_SIGN_PATHS,
interiorPoint,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js';
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, poleOfInaccessibility } from '../test-build/logic.js';
import {
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
} from '../test-build/rules.js';
@@ -910,3 +909,20 @@ test('chunk / referencedContentUrls: signing batches and cache pruning (review R
assert.equal(referencedContentUrls(null).size, 0);
assert.equal(referencedContentUrls({}).size, 0);
});
test('poleOfInaccessibility: the VISUAL centre, not just any interior point', () => {
// a square: the pole is the centre
const sq = [[0, 0], [10, 0], [10, 10], [0, 10]];
const p = poleOfInaccessibility(sq);
assert.ok(Math.abs(p[0] - 5) < 0.6 && Math.abs(p[1] - 5) < 0.6);
// 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
// the slab — the widest open space — not near the seam or down the column.
const L = [[0, 0], [20, 0], [20, 16], [16, 16], [16, 8], [0, 8]];
const q = poleOfInaccessibility(L);
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[0] > 2 && q[0] < 16, 'horizontally inside the slab body: ' + q);
});