mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
feat: measure resize labels between wall faces
Issue: #233 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- При изменении размера комнаты показываются внутренние размеры — расстояние от
|
||||
стены до стены, то самое, которое даёт рулетка: пролёт 300 см между стенами
|
||||
15 см читается как 285 см. Площадь и раньше считалась по полу, поэтому в одном
|
||||
облачке подписей жили две разные конвенции и ни одно число нельзя было ни с чем
|
||||
сверить. Проём и сторона, открытая в соседнюю комнату, сохраняют полную длину:
|
||||
измерять там не от чего. Площадь по-прежнему вычитает колонны и перегородки,
|
||||
поэтому произведение длин может с ней не совпасть — это мебель в комнате, а не
|
||||
смена линейки ([#233](https://github.com/Matysh/houseplan-card/issues/233)).
|
||||
|
||||
- Цепочка стен сохраняет ту толщину, которой её нарисовали. Отрезок, толщина
|
||||
которого не была записана — это случалось, если поле толщины правили между
|
||||
отрезками, — показывался с нарисованной толщиной, а сохранялся с 15 см, и
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@ T-соединение входит в проходящую стену без в
|
||||
|---|---|
|
||||
| Объединить | Склеивает соседние комнаты; в диалоге выбирается сохраняемая идентичность, имя и HA-зона |
|
||||
| Split | Делит комнату путём от одной стены до другой; большая часть сохраняет исходную комнату |
|
||||
| Resize | Двигает стену с общей геометрией либо масштабирует выбранную комнату угловой рамкой |
|
||||
| Resize | Двигает стену с общей геометрией либо масштабирует выбранную комнату угловой рамкой. Подписи во время перетаскивания показывают **внутренние** размеры — расстояние от стены до стены, то самое, которое даёт рулетка, — и площадь комнаты |
|
||||
| Толщина | Задаёт толщину выбранному участку или всем стенам комнаты |
|
||||
| Удалить комнату | После подтверждения удаляет только выбранную комнату |
|
||||
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user