feat: measure resize labels between wall faces

Issue: #233
User-Visible: yes
This commit is contained in:
Matysh
2026-08-22 09:45:15 +03:00
parent 969847a4c4
commit abfaae3e38
12 changed files with 396 additions and 12 deletions
File diff suppressed because one or more lines are too long
+113
View File
@@ -0,0 +1,113 @@
/**
* Issue #233: resize labels measure between wall faces, not between centrelines.
*
* Before the fix one bubble carried two conventions at once — lengths along the
* centreline next to an area computed from the floor — so «3.00 × 4.00 · 11.0 m²»
* described a room that no tape measure would confirm.
*
* The room below is 300×400 cm centre-to-centre with 15 cm walls on all four
* sides, so every clear span is exactly 15 cm shorter than the axis it sits on:
* 285 and 385 cm. Thickness is set through the card's own dialog path
* (`_wallThickClick` → `_wallThickApply(true)`), and both label producers are
* then called as the drag handlers call them. What is deliberately NOT covered:
* the pointer sequence itself — `_rszEdgeDown`/`_rszMove` are unchanged by #233.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
// First number of a label: both formatters emit a dot, so this is locale-safe.
const num = (text) => Number((String(text).match(/-?\d+(?:\.\d+)?/) || [])[0]);
c._setMode('plan');
await upd();
const space = c._curSpaceCfg;
const saved = {
rooms: JSON.parse(JSON.stringify(space.rooms || [])),
walls: space.walls ? JSON.parse(JSON.stringify(space.walls)) : null,
partitions: JSON.parse(JSON.stringify(space.partitions || [])),
cell: space.cell_cm,
};
// 5 cm to a grid cell, so 300×400 cm is 60×80 cells of the plan.
space.cell_cm = 5;
space.partitions = [];
delete space.walls;
const g = c._gridPitch;
const x0 = 20 * g, y0 = 20 * g;
const render = [
[x0, y0], [x0 + 60 * g, y0], [x0 + 60 * g, y0 + 80 * g], [x0, y0 + 80 * g],
];
space.rooms = [{ id: 'r233', name: 'r233', poly: render.map((p) => [p[0] / 1000, p[1] / 1000]) }];
await upd();
out.roomBuilt = !!c._spaceModel()?.rooms?.some((r) => r.id === 'r233');
// 15 cm on every side, applied the way the Thickness tool applies it.
c._tool = 'wallthick';
await upd();
const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
c._wallThickClick(mid(render[0], render[1]));
await upd();
out.thicknessDialogOpened = !!c._wallDialog && c._wallDialog.roomId === 'r233';
if (c._wallDialog) c._wallDialog = { ...c._wallDialog, value: '15' };
c._wallThickApply(true);
await upd();
out.fourWallsRecorded = (c._curSpaceCfg.walls || []).length === 4;
out.everyWallFifteen = (c._curSpaceCfg.walls || []).every((w) => Math.abs(w.cm - 15) < 0.01);
// Wall labels: the clear span across is 285 cm, along it 385 cm.
const spans = c._rszInnerSpanCms('r233', render, {});
out.spansComputed = Array.isArray(spans) && spans.length === 4;
out.spanAcrossIsInner = !!spans && Math.abs(spans[0] - 285) < 1;
out.spanAlongIsInner = !!spans && Math.abs(spans[1] - 385) < 1;
out.spansSymmetric = !!spans
&& Math.abs(spans[0] - spans[2]) < 1 && Math.abs(spans[1] - spans[3]) < 1;
// The whole point of #233: no label may still read the centreline.
out.noCentrelineSpan = !!spans
&& !spans.some((s) => Math.abs(s - 300) < 0.5 || Math.abs(s - 400) < 0.5);
// The dragged-edge bubble, called the way `_rszLive` calls it.
c._rszSel = 'r233';
c._rszDrag = { rooms: c._spaceModel().rooms.map((r) => ({ id: r.id, poly: r.poly })) };
await upd();
const drag = c._rszEdgeLabels({ polys: { r233: render } }, { roomId: 'r233', edge: 0 });
const dragLens = drag.filter((l) => !l.area).map((l) => num(l.text));
out.dragLabelsThreeEdges = dragLens.length === 3;
out.dragLabelsInner = dragLens.every((v) => Math.abs(v - 2.85) < 0.02 || Math.abs(v - 3.85) < 0.02);
out.dragLabelsNotCentreline = !dragLens.some((v) => Math.abs(v - 3) < 0.005 || Math.abs(v - 4) < 0.005);
const dragArea = drag.find((l) => l.area);
out.dragAreaStillInner = !!dragArea && Math.abs(num(dragArea.text) - 11) < 0.06;
// The corner frame: same convention as the area beside it.
const labels = c._rszScaleLabels(render);
const size = labels.find((l) => !l.area);
const area = labels.find((l) => l.area);
out.frameLabelsPresent = !!size && !!area;
// Compare against the card's own formatter so the check survives a change of
// units: inner is 57×77 cells, the centreline it replaced was 60×80.
const fmt = (wCells, hCells) =>
`${c._fmtLen([0, 0], [wCells * g, 0])} × ${c._fmtLen([0, 0], [hCells * g, 0])}`;
out.frameIsInner = size?.text === fmt(57, 77);
out.frameIsNotCentreline = size?.text !== fmt(60, 80);
// 2.85 × 3.85 = 10.97 m² → «11.0»; the centreline rectangle would say «12.0».
out.frameAreaAgreesWithSize = Math.abs(num(area?.text) - 11) < 0.06;
c._rszDrag = null;
c._rszSel = null;
c._tool = null;
// Re-read: `_wallThickApply` saves, and a save may hand back a fresh config
// object, which would leave the reference captured above pointing at nothing.
const cur = c._curSpaceCfg;
cur.rooms = saved.rooms;
cur.partitions = saved.partitions;
cur.cell_cm = saved.cell;
if (saved.walls) cur.walls = saved.walls; else delete cur.walls;
await upd();
return out;
});
checkAll(res);
await finish(browser);
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -2,6 +2,15 @@
## Unreleased
- Resizing a room now reports the distance between wall faces, the number a tape
measure gives: a 300 cm span between 15 cm walls reads 285 cm. The area label
was already computed from the floor, so one bubble used to carry two
conventions and neither number could be checked against anything. A passage or
a side open to the next room keeps its full length, because there is no face to
measure from. Area still subtracts columns and partitions, so length times
length may differ from it — that gap is furniture in the room, not a change of
ruler ([#233](https://github.com/Matysh/houseplan-card/issues/233)).
- A chain of walls now stores the thickness it was drawn with. A segment whose
thickness was not recorded — which happened while the toolbar field was being
edited between segments — used to be previewed at the drawn value and saved at
+9
View File
@@ -8,6 +8,15 @@
## Не выпущено
- При изменении размера комнаты показываются внутренние размеры — расстояние от
стены до стены, то самое, которое даёт рулетка: пролёт 300 см между стенами
15 см читается как 285 см. Площадь и раньше считалась по полу, поэтому в одном
облачке подписей жили две разные конвенции и ни одно число нельзя было ни с чем
сверить. Проём и сторона, открытая в соседнюю комнату, сохраняют полную длину:
измерять там не от чего. Площадь по-прежнему вычитает колонны и перегородки,
поэтому произведение длин может с ней не совпасть — это мебель в комнате, а не
смена линейки ([#233](https://github.com/Matysh/houseplan-card/issues/233)).
- Цепочка стен сохраняет ту толщину, которой её нарисовали. Отрезок, толщина
которого не была записана — это случалось, если поле толщины правили между
отрезками, — показывался с нарисованной толщиной, а сохранялся с 15 см, и
+1 -1
View File
@@ -295,7 +295,7 @@ Other operations edit existing geometry:
|---|---|
| Merge | Joins adjacent rooms; a dialog chooses the surviving identity, name and area |
| Split | Cuts a room from one wall to another; the larger part keeps the original room |
| Resize | Moves a wall with shared geometry or scales a room by corner handles |
| Resize | Moves a wall with shared geometry or scales a room by corner handles. Live labels report **inner** dimensions — the clear distance between wall faces, the number a tape measure gives — and the room area |
| Thickness | Changes one physical span or every wall of a room |
| Delete room | Deletes only the selected room after confirmation |
+1 -1
View File
@@ -380,7 +380,7 @@ T-соединение входит в проходящую стену без в
|---|---|
| Объединить | Склеивает соседние комнаты; в диалоге выбирается сохраняемая идентичность, имя и HA-зона |
| Split | Делит комнату путём от одной стены до другой; большая часть сохраняет исходную комнату |
| Resize | Двигает стену с общей геометрией либо масштабирует выбранную комнату угловой рамкой |
| Resize | Двигает стену с общей геометрией либо масштабирует выбранную комнату угловой рамкой. Подписи во время перетаскивания показывают **внутренние** размеры — расстояние от стены до стены, то самое, которое даёт рулетка, — и площадь комнаты |
| Толщина | Задаёт толщину выбранному участку или всем стенам комнаты |
| Удалить комнату | После подтверждения удаляет только выбранную комнату |
+42
View File
@@ -94,6 +94,48 @@ export const MUTANTS = [
replace: ' // freshness intentionally skipped by the mutant',
}],
},
{
id: 'resize-labels-show-centreline',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="innerEdgeSpan measures between wall faces" '
+ 'test/wall-thickness.test.mjs',
because: 'resize labels must report the distance a person measures with a tape between wall '
+ 'faces; centreline numbers cannot be checked against anything and sat next to an area '
+ 'that was already computed from the floor (#233)',
patches: [{
file: 'src/wall-thickness.ts',
find: ' const span = end - start;',
replace: ' const span = centre;',
}],
},
{
id: 'inner-span-shortens-a-passage',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="keeps the centreline where there is no wall" '
+ 'test/wall-thickness.test.mjs',
because: 'a passage has no face to measure from, and insetContour leaves that joint as a flat '
+ 'cap, so shortening an open side would split length from area again at a new boundary '
+ '(#233, spec review r1/H1)',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (!(own > 0)) return centre;',
replace: ' if (false) return centre;',
}],
},
{
id: 'inner-span-reads-whole-edge-thickness',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="ownEdgeOffsets reads the atomic profile" '
+ 'test/wall-thickness.test.mjs',
because: 'thicknessCmAt returns 0 for a whole-edge query against a partially set thickness, so '
+ 'reading it instead of the atomic profile would silently stop shortening split-thickness '
+ 'edges (#233, spec review r1/H2)',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (distToSeg(mid[0], mid[1], p0[0], p0[1], p1[0], p1[1]) <= eps) {',
replace: ' if (false) {',
}],
},
{
id: 'snapn-returns-input-near-node',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+46 -3
View File
@@ -71,6 +71,7 @@ import {
intervalCmAt, wallBodyNeedsSolid, wallHatchNeedsSolid, wallHatchStepUnits,
HATCH_BASE_STEP_UNITS, type OpeningTunnelGeometry, type OpeningWallIndex,
type LinearWallSegment, type WallEntry, type WallInterval,
innerEdgeSpan, ownEdgeOffsets,
} from './wall-thickness';
import {
resolveOpenCuts, resolveBoundaryTarget, snapOpenPoint,
@@ -8248,9 +8249,20 @@ class HouseplanCard extends LitElement {
const own = res.polys[plan.roomId] || g.rooms.find((r) => r.id === plan.roomId)!.poly;
const n = own.length;
const i = plan.edge, j = (i + 1) % n;
// Длины — между внутренними гранями, как и площадь ниже (#233). Раньше
// здесь считалась осевая длина, и одно облачко подписей несло две разные
// конвенции: «3.00 × 4.00» по центрам стен рядом с площадью по полу.
const spanCms = this._rszInnerSpanCms(plan.roomId, own, res.polys);
// the dragged wall and its two adjacent walls
for (const [a, b] of [[own[(i - 1 + n) % n], own[i]], [own[i], own[j]], [own[j], own[(j + 1) % n]]]) {
labels.push({ x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, text: this._fmtLen(a, b) });
for (const edge of [(i - 1 + n) % n, i, j]) {
const a = own[edge], b = own[(edge + 1) % n];
const cm = spanCms?.[edge];
labels.push({
x: (a[0] + b[0]) / 2,
y: (a[1] + b[1]) / 2,
text: cm == null ? this._fmtLen(a, b)
: formatLength(cm, this.hass?.config?.unit_system?.length === 'mi'),
});
}
// live areas of EVERY room the drag reshapes (both sides of a shared wall)
const imperial = this.hass?.config?.unit_system?.length === 'mi';
@@ -8276,10 +8288,36 @@ class HouseplanCard extends LitElement {
return labels;
}
/**
* Внутренние длины рёбер комнаты в сантиметрах, по одному числу на ребро.
*
* Толщины берутся из атомарного профиля (#233): `thicknessCmAt` по целому
* ребру возвращает 0 на ребре со сплит-толщиной, и подписи молча перестали бы
* сокращаться. `null` означает «профиля нет» — тогда вызывающий показывает
* осевую длину, как до правки.
*/
private _rszInnerSpanCms(
roomId: string, own: number[][], polys: Record<string, number[][]>,
): number[] | null {
const rooms = Object.keys(polys).length
? Object.entries(polys).map(([id, poly]) => ({ id, poly }))
: this._spaceModel()?.rooms;
if (!rooms?.length) return null;
const walls = this._spaceWalls;
if (!walls.length) return null;
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
const offsets = ownEdgeOffsets(
rooms, roomId, walls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
if (!offsets || offsets.length !== own.length) return null;
const perUnitCm = this._cellCm / this._gridPitch;
return own.map((_, edge) => innerEdgeSpan(own, edge, offsets) * perUnitCm);
}
private _rszScaleLabels(poly: number[][]): { x: number; y: number; text: string; area?: boolean }[] {
const imperial = this.hass?.config?.unit_system?.length === 'mi';
const xs = poly.map((p) => p[0]), ys = poly.map((p) => p[1]);
const w = Math.max(...xs) - Math.min(...xs), h = Math.max(...ys) - Math.min(...ys);
const walls = this._spaceWalls;
const floor = walls.length && this._rszSel
? (innerContourForRoom(
@@ -8293,6 +8331,11 @@ class HouseplanCard extends LitElement {
? geometryArea(floorMinusBodies(floor, physical))
* Math.pow(this._cellCm / this._gridPitch, 2) / 1e4
: areaM2(floor, this._gridPitch, this._cellCm);
// Габарит — по внутреннему контуру, который уже посчитан выше для площади
// (#233): та же конвенция, что у площади, и никакой отдельной математики.
const fxs = floor.map((p) => p[0]), fys = floor.map((p) => p[1]);
const w = Math.max(...fxs) - Math.min(...fxs);
const h = Math.max(...fys) - Math.min(...fys);
return [
{ x: Math.min(...xs), y: Math.min(...ys), text: `${this._fmtLen([0, 0], [w, 0])} × ${this._fmtLen([0, 0], [h, 0])}` },
{ x: c[0], y: c[1], text: formatArea(area, imperial), area: true },
+104
View File
@@ -855,6 +855,110 @@ function lineIntersect(
return [p[0] + t * r[0], p[1] + t * r[1]];
}
/**
* Clear distance between the wall faces along edge `index` (#233).
*
* The resize labels used to measure centrelines while the area label already
* measured the floor, so one bubble carried two conventions: "3.00 x 4.00" from
* wall centres next to an area from inner faces, and neither number could be
* checked with a tape measure.
*
* Indices of `insetContour` are deliberately NOT used: that function emits one
* point per corner (mitre), two (bevel, collinear joint, zero-thickness joint)
* or the original vertex, so inner and centre polygons do not share an index
* space. The span is obtained by intersecting the inner offset LINES instead,
* which is exact for any angle, diagonals included.
*
* `poly` is the room's own polygon and `offsets` carries one half-depth per its
* edge — resolved from the atomic profile by the caller, because a whole-edge
* `thicknessCmAt` lookup returns 0 on a split-thickness edge.
*/
export function innerEdgeSpan(poly: number[][], index: number, offsets: number[]): number {
const n = poly?.length || 0;
if (n < 3 || !Array.isArray(offsets) || offsets.length !== n) return 0;
const i = ((Math.trunc(index) % n) + n) % n;
const a = poly[i], b = poly[(i + 1) % n];
const dx = b[0] - a[0], dy = b[1] - a[1];
const centre = Math.hypot(dx, dy);
if (!(centre > 0)) return 0;
const own = Math.max(0, Number(offsets[i]) || 0);
// The zero rule comes first (#233, spec review r1/H1): a passage or a side
// open to the neighbouring room has no face to measure from, so its label is
// the full centreline. `insetContour` treats the same joint as a flat cap
// (#172) and shortens nothing either — otherwise length and area would
// diverge again, merely at a different boundary.
if (!(own > 0)) return centre;
const u: [number, number] = [dx / centre, dy / centre];
const selfNormal = inwardNormal(poly, i);
const selfPoint = [a[0] + selfNormal[0] * own, a[1] + selfNormal[1] * own];
// Distance from `a` measured along the edge; the inner line of a neighbour
// that has no thickness does not cut this edge at all.
const cutAt = (edge: number): number | null => {
const o = Math.max(0, Number(offsets[edge]) || 0);
if (!(o > 0)) return null;
const p0 = poly[edge], p1 = poly[(edge + 1) % n];
const ex = p1[0] - p0[0], ey = p1[1] - p0[1];
const len = Math.hypot(ex, ey);
if (!(len > 0)) return null;
const nrm = inwardNormal(poly, edge);
const hit = lineIntersect(
selfPoint, u, [p0[0] + nrm[0] * o, p0[1] + nrm[1] * o], [ex / len, ey / len],
);
if (!hit) return null;
return (hit[0] - a[0]) * u[0] + (hit[1] - a[1]) * u[1];
};
const start = cutAt((i - 1 + n) % n) ?? 0;
const end = cutAt((i + 1) % n) ?? centre;
const span = end - start;
// Walls thicker than the room they enclose: report nothing left, never a
// negative length.
return span > 0 ? span : 0;
}
/**
* One half-depth per edge of the room's OWN polygon (#233).
*
* The profile is atomic — its polygon is cut at shared boundaries — so an own
* edge may cover several stretches. The stretch holding the edge midpoint is
* the representative: the clear distance between the walls at the two ENDS of
* the edge is what the label reports, and a differently thick stretch in the
* middle does not change that distance.
*/
export function ownEdgeOffsets(
rooms: any[],
roomId: string,
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale = 1,
): number[] | null {
const room = (rooms || []).find((r) => r?.id === roomId);
const own = roomPoly(room);
if (!own || own.length < 3) return null;
const profile = roomWallProfile(
rooms, roomId, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (!profile) return own.map(() => 0);
const eps = openEps(pitch, coordScale) * 4;
return own.map((a, i) => {
const b = own[(i + 1) % own.length];
const mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
for (let k = 0; k < profile.poly.length; k++) {
const p0 = profile.poly[k], p1 = profile.poly[(k + 1) % profile.poly.length];
// Уже существующая distToSeg принимает координаты, а не точки.
if (distToSeg(mid[0], mid[1], p0[0], p0[1], p1[0], p1[1]) <= eps) {
return Math.max(0, profile.offsets[k] || 0);
}
}
return 0;
});
}
/**
* Inset a polygon by a per-edge inward distance (same units as poly).
* Zero-offset edges stay on the original. Mitre joins; bevel when the mitre
+65 -1
View File
@@ -10,7 +10,7 @@ import {
wallCmToUnits, insetContour, outsetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
virtualJunctionPatches, stableJunctionPatch, unionJunctionPatches,
innerContourForRoom,
innerContourForRoom, innerEdgeSpan, ownEdgeOffsets,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
normalizeWallIntervals,
@@ -1486,3 +1486,67 @@ test('issue 230 a thin wall is not turned into a blot by the new rule', () => {
assert.equal(wallBodyNeedsSolid(thin, 1), true, 'body guard still owns the thin case');
assert.equal(wallBodyNeedsSolid(thin, 1.2), false, 'and lets it hatch once it is wide enough');
});
// --------------------- #233: внутренние размеры при ресайзе -----------------
const rect = (w, h) => [[0, 0], [w, 0], [w, h], [0, h]];
test('innerEdgeSpan measures between wall faces, not centrelines (#233)', () => {
// AC1: осевой пролёт 300 и стены 15 см дают 285 — то, что человек измерит
// рулеткой. В единицах: половинная глубина 7.5 при пролёте 300.
const poly = rect(300, 400);
const o = poly.map(() => 7.5);
assert.equal(innerEdgeSpan(poly, 0, o), 285);
assert.equal(innerEdgeSpan(poly, 1, o), 385);
// AC2: разные толщины на концах сокращают по-своему.
const mixed = [7.5, 15, 7.5, 15];
assert.equal(innerEdgeSpan(poly, 0, mixed), 300 - 15 - 15);
assert.equal(innerEdgeSpan(poly, 2, mixed), 300 - 15 - 15);
});
test('innerEdgeSpan keeps the centreline where there is no wall (#233)', () => {
const poly = rect(300, 400);
// AC3: нулевые толщины — внутренний размер равен осевому.
assert.equal(innerEdgeSpan(poly, 0, poly.map(() => 0)), 300);
// AC4: сосед без толщины не сокращает конец.
assert.equal(innerEdgeSpan(poly, 0, [7.5, 0, 7.5, 7.5]), 300 - 7.5);
// AC6a: сама сторона — проём, соседи-стены: полная осевая длина.
assert.equal(innerEdgeSpan(poly, 0, [0, 7.5, 7.5, 7.5]), 300);
});
test('innerEdgeSpan handles a diagonal edge and degenerate input (#233)', () => {
// AC5: на диагонали сокращение не равно простому o + o.
const tri = [[0, 0], [100, 0], [0, 100]];
const o = [5, 5, 5];
const diagonal = innerEdgeSpan(tri, 1, o);
const naive = Math.hypot(100, 100) - 10;
assert.ok(diagonal > 0 && Math.abs(diagonal - naive) > 1,
`диагональ ${diagonal} не должна совпадать с наивным ${naive}`);
// AC6: стены толще комнаты — ноль, а не отрицательное число.
assert.equal(innerEdgeSpan(rect(20, 20), 0, [30, 30, 30, 30]), 0);
// Мусор на входе не роняет функцию.
assert.equal(innerEdgeSpan([[0, 0], [1, 0]], 0, [1, 1]), 0);
assert.equal(innerEdgeSpan(rect(10, 10), 0, [1, 1, 1]), 0);
});
test('ownEdgeOffsets reads the atomic profile, not a whole-edge lookup (#233)', () => {
// AC6b: половина ребра задана толщиной, половина нет. thicknessCmAt по целому
// ребру вернул бы 0 (test выше «exact-parent fallback does not leak»), а
// атомарный профиль отдаёт толщину участка под серединой ребра.
const rooms = [{ id: 'r', poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }];
const walls = setWallThickness([], [0, 0], [1, 0], 20, pitch);
assert.equal(thicknessCmAt(walls, [0, 0], [1, 0], pitch), 20);
// Полигон здесь в нормализованных координатах, поэтому coordScale = 1:
// с NORM_W допуск поиска ключа стал бы больше самой комнаты и толщина
// «протекла» бы на противоположное ребро — проверено прогоном.
const offsets = ownEdgeOffsets(rooms, 'r', walls, [], pitch, 5, GRID_PITCH, 1);
assert.equal(offsets.length, 4);
assert.ok(offsets[0] > 0, 'ребро со стеной получает половинную глубину');
assert.deepEqual(offsets.slice(1), [0, 0, 0], 'рёбра без записи остаются нулевыми');
// Тот же ответ, что у канонического источника профиля.
assert.deepEqual(offsets, insetOffsetsForRoom(rooms, 'r', walls, [], pitch, 5, GRID_PITCH, 1));
// Комната без стен: все нули — вызывающий покажет осевую длину.
assert.deepEqual(ownEdgeOffsets(rooms, 'r', [], [], pitch, 5, GRID_PITCH, 1), [0, 0, 0, 0]);
assert.equal(ownEdgeOffsets(rooms, 'missing', walls, [], pitch, 5, GRID_PITCH, 1), null);
});