mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
feat: a straight wall is one record, not a row of seams (#229)
Рисование прямой стены в несколько кликов оставляло по записи на каждый отрезок. Швы невидимы, пока их не тронешь: выделение хватает кусок, перетаскивание ломает стену пополам, толщина задаётся пофрагментно. У стен комнат этого давно нет — `normalizeWallIntervals` схлопывает каждый сплошной участок одной толщины. Независимые перегородки жили по другому правилу. Новый чистый модуль `src/wall-merge.ts` даёт им то же правило: - `mergeCollinearPartitions` сращивает соседей одинаковой толщины и направления до неподвижной точки, но только там, где узел никому не нужен. Узел остаётся, если в него приходит третья перегородка, стена комнаты (стороной, а не только вершиной), колонна или конец сохранённого черновика. - Направление выжившей записи канонизируется лексикографически: иначе одна и та же физическая стена выходила то a→b, то b→a в зависимости от порядка входа, и каждый host.t вдоль неё переворачивался вместе с ней. - `applyOpeningMoves` переносит проёмы на выжившую запись: и авторитетный `host`, и legacy-проекцию `x/y/angle`, которую рисует старый читатель конфига (docs/CONFIG-COMPATIBILITY.md, #132). Проекция здесь не кэш — канонизация направления разворачивает угол на 180°. Рисование сращивает только свою цепочку и то, чего она коснулась (§8.6 ТЗ): молча править чужие швы в стороне оно не вправе — для этого есть «Оптимизировать планы» с предпросмотром, отчётом и отменой. Оптимизация проходит по всему пространству без seed-ограничения и отдельной строкой сообщает, сколько записей исчезло. Issue: #229 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
|
||||
// Issue #229: a straight wall drawn in several clicks is stored as one record.
|
||||
//
|
||||
// The unit tests own the rules; this smoke owns the wiring — that finishing a
|
||||
// chain in the real editor actually calls the merge, that the seam disappears
|
||||
// from the saved config, and that a chain drawn round a corner keeps its node.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 3; i++) await new Promise((r) => requestAnimationFrame(r));
|
||||
await c.updateComplete;
|
||||
};
|
||||
const space = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
const partitionCount = () => (space().partitions || []).length;
|
||||
|
||||
c._mode = 'plan'; c.requestUpdate(); await settle();
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
c._tool = 'draw'; c._drawWallCm = 15; c.requestUpdate(); await settle();
|
||||
|
||||
const before = partitionCount();
|
||||
|
||||
// Four clicks along one straight line, then finish the chain.
|
||||
c._path = [[200, 500], [300, 500], [420, 500], [560, 500]];
|
||||
c._draftSegmentCms = [];
|
||||
out.chainHasThreeSegments = c._path.length - 1 === 3;
|
||||
c._finishWallChain();
|
||||
await settle();
|
||||
|
||||
const straight = (space().partitions || []).slice(before);
|
||||
out.straightRunIsOneRecord = straight.length === 1;
|
||||
out.straightRunSpansTheChain = !!straight[0]
|
||||
&& Math.abs(straight[0].a[0] * 1000 - 200) < 0.01
|
||||
&& Math.abs(straight[0].b[0] * 1000 - 560) < 0.01;
|
||||
out.straightRunKeepsThickness = !!straight[0] && straight[0].cm === 15;
|
||||
|
||||
// A corner is not a straight run: two records, one node between them.
|
||||
const beforeCorner = partitionCount();
|
||||
c._path = [[200, 700], [400, 700], [400, 820]];
|
||||
c._draftSegmentCms = [];
|
||||
c._finishWallChain();
|
||||
await settle();
|
||||
out.cornerKeepsBothRecords = partitionCount() - beforeCorner === 2;
|
||||
|
||||
// Drawing onto an existing wall merges with it — the seam belongs to the chain.
|
||||
const beforeTouch = partitionCount();
|
||||
c._path = [[560, 500], [700, 500]];
|
||||
c._draftSegmentCms = [];
|
||||
c._finishWallChain();
|
||||
await settle();
|
||||
out.chainMergesIntoTheWallItTouches = partitionCount() === beforeTouch;
|
||||
const extended = (space().partitions || []).find(
|
||||
(p) => Math.abs(p.a[1] * 1000 - 500) < 0.01 && Math.abs(p.b[1] * 1000 - 500) < 0.01,
|
||||
);
|
||||
out.touchedWallGrew = !!extended && Math.abs(extended.b[0] * 1000 - 700) < 0.01;
|
||||
|
||||
return out;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+61
-61
File diff suppressed because one or more lines are too long
@@ -2,6 +2,14 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- A straight wall drawn in several clicks is now stored as one partition:
|
||||
seams between segments of the same thickness and direction are merged as soon
|
||||
as the chain is finished. A node stays only where something holds it — a third
|
||||
wall, a room wall, a column or the end of a saved draft. Older plans lose
|
||||
their seams through “Optimize plans”, reported on its own preview line; doors
|
||||
and windows keep their place
|
||||
([#229](https://github.com/Matysh/houseplan-card/issues/229)).
|
||||
|
||||
- Space tabs can be reordered: grab a tab with the mouse in any editor mode and
|
||||
drop it where it belongs. The order is saved, and swipe and carousel
|
||||
navigation follow it. Markers stay exactly where they were
|
||||
|
||||
@@ -8,6 +8,14 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Прямая стена, нарисованная в несколько кликов, теперь хранится одной
|
||||
перегородкой: швы между отрезками одинаковой толщины и направления
|
||||
срастаются сразу при завершении цепочки. Узел остаётся там, где есть за что
|
||||
держаться — примыкание третьей стены, стена комнаты, колонна или конец
|
||||
сохранённого черновика. Старым планам швы убирает «Оптимизировать планы»
|
||||
отдельной строкой предпросмотра; двери и окна остаются на своих местах
|
||||
([#229](https://github.com/Matysh/houseplan-card/issues/229)).
|
||||
|
||||
- Порядок вкладок пространств теперь можно менять: в режиме редактора возьмите
|
||||
вкладку мышью и перетащите на нужное место. Порядок сохраняется, свайп и
|
||||
карусель следуют ему. Устройства при этом остаются на своих местах
|
||||
|
||||
@@ -416,6 +416,13 @@ T-соединение входит в проходящую стену без в
|
||||
|
||||
Resize показывает живые длины и чистую площадь по внутреннему контуру толстых стен. Завершённый drag становится одним именованным шагом общего 50-командного Undo/Redo.
|
||||
|
||||
Прямая стена, нарисованная в несколько кликов, сохраняется одной перегородкой:
|
||||
соседние отрезки одинаковой толщины и направления срастаются сразу после
|
||||
завершения цепочки. Узел остаётся там, где в него приходит третья стена, стена
|
||||
комнаты, колонна или конец сохранённого черновика — то есть везде, где он
|
||||
что-то значит. Планы, нарисованные раньше, приводит в порядок «Оптимизировать
|
||||
планы».
|
||||
|
||||
Перегородки и колонны можно перетаскивать по сетке и редактировать двойным
|
||||
кликом. У перегородки задаётся толщина 1–100 см. У колонны задаются квадратная
|
||||
или круглая форма и внешний размер 1–150 см; у квадрата доступен поворот, у
|
||||
@@ -1293,6 +1300,7 @@ show_signal: true
|
||||
| Устройства и подписи комнат | Позиции округляются к сетке |
|
||||
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
|
||||
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
|
||||
| Перегородки | Коллинеарные соседние отрезки одинаковой толщины сращиваются в одну запись; узел сохраняется, если в нём сходится третья стена, стена комнаты, колонна или конец черновика. Проёмы остаются на своих местах |
|
||||
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
|
||||
|
||||
Перед записью диалог показывает количество затрагиваемых элементов,
|
||||
|
||||
@@ -717,6 +717,85 @@ export const MUTANTS = [
|
||||
replace: ' .dev:not(.unavail):hover {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'partition-merge-disabled',
|
||||
guard: 'node demo/smoke_wall_chain_merge.mjs',
|
||||
because: 'без вызова слияния прямая, нарисованная в несколько кликов, снова хранится '
|
||||
+ 'набором отрезков со швами на каждом стыке (#229)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' this._mergeSpacePartitions(sp, drawnIds);',
|
||||
replace: ' void drawnIds;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'partition-merge-ignores-thickness',
|
||||
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
|
||||
because: 'сращивание отрезков разной толщины стирает намеренный переход толщины стены',
|
||||
patches: [{
|
||||
file: 'src/wall-merge.ts',
|
||||
find: ' if (p.cm !== q.cm) return null;',
|
||||
replace: ' if (false) return null;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'partition-merge-ignores-junction',
|
||||
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
|
||||
because: 'слияние сквозь примыкание убирает узел, который держит третья стена, '
|
||||
+ 'комната, колонна или черновик — §8.2 ТЗ',
|
||||
patches: [{
|
||||
file: 'src/wall-merge.ts',
|
||||
find: ' if (junctionAt(at, list, new Set([list[i].id, list[j].id]), options.geometry, join)) continue;',
|
||||
replace: ' if (false) continue;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-checks-room-vertices-only',
|
||||
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
|
||||
because: 'поиск примыкания комнаты только по вершинам пропускает T-стык к середине '
|
||||
+ 'комнатной стены — штатный случай продукта (docs/specs/141-wall-junctions.md)',
|
||||
patches: [{
|
||||
file: 'src/wall-merge.ts',
|
||||
find: ' if (finite(a) && finite(b) && distToSegment(point, a, b) <= join) return true;',
|
||||
replace: ' if (finite(a) && dist(point, a) <= join) return true; void b;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'partition-merge-keeps-relative-t',
|
||||
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
|
||||
because: 'сохранение прежней доли вместо пересчёта двигает дверь вдоль стены: '
|
||||
+ 'позиция проёма относительна длине хозяина, а она при слиянии меняется',
|
||||
patches: [{
|
||||
file: 'src/wall-merge.ts',
|
||||
find: ' const mapped = move.base + t * move.span;',
|
||||
replace: ' const mapped = t;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'partition-merge-skips-materialization',
|
||||
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
|
||||
because: 'устаревшие x/y/angle — это то, что рисует старый читатель конфига; '
|
||||
+ 'слияние канонизирует направление стены, поэтому проекция обязана '
|
||||
+ 'переписаться (docs/CONFIG-COMPATIBILITY.md, #132)',
|
||||
patches: [{
|
||||
file: 'src/wall-merge.ts',
|
||||
find: ` if (resolved) Object.assign(
|
||||
opening, materializePartitionOpening(opening, resolved, ctx.coordScale),
|
||||
);`,
|
||||
replace: ' void resolved;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'chain-merge-sweeps-whole-space',
|
||||
guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs',
|
||||
because: 'слияние всего пространства при рисовании молча правит старые швы в стороне, '
|
||||
+ 'минуя отчёт и отмену, которые обещаны им в «Оптимизировать планы» (§8.6 ТЗ)',
|
||||
patches: [{
|
||||
file: 'src/wall-merge.ts',
|
||||
find: ' if (seeds && !seeds.has(list[i].id) && !seeds.has(list[j].id)) continue;',
|
||||
replace: ' if (false) continue;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'tab-reorder-not-persisted',
|
||||
guard: 'node demo/smoke_space_tab_reorder.mjs',
|
||||
|
||||
+48
-3
@@ -265,6 +265,7 @@ import {
|
||||
applySpaceOrder, canStartTabDrag, markersNeedingPlacement, passedDragThreshold,
|
||||
reorderSpaceIds,
|
||||
} from './space-order';
|
||||
import { applyOpeningMoves, mergeCollinearPartitions } from './wall-merge';
|
||||
|
||||
const CARD_VERSION = '1.66.0';
|
||||
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
|
||||
@@ -6535,6 +6536,42 @@ class HouseplanCard extends LitElement {
|
||||
* A saved draft is crash safety only; once explicitly finished, its segments
|
||||
* become ordinary selectable partitions and can never auto-resume.
|
||||
*/
|
||||
/**
|
||||
* Collapse collinear partitions of one thickness (issue #229).
|
||||
*
|
||||
* `seedIds` confines the sweep to the connected component containing the
|
||||
* freshly drawn chain; the optimiser calls the pure module without seeds.
|
||||
* Openings ride along: their position is stored as a fraction of the host's
|
||||
* length, so every merge has to rewrite both the fraction and the legacy
|
||||
* `x/y/angle` projection that older readers still consume (#132).
|
||||
*/
|
||||
private _mergeSpacePartitions(sp: any, seedIds?: string[]): number {
|
||||
const partitions = (sp?.partitions || []) as PartitionCfg[];
|
||||
if (partitions.length < 2) return 0;
|
||||
const rooms = (sp.rooms || []) as any[];
|
||||
const result = mergeCollinearPartitions(partitions, {
|
||||
pitch: GRID_STEP_N,
|
||||
seedIds,
|
||||
geometry: {
|
||||
roomPolygons: rooms
|
||||
.map((room) => roomPoly(room))
|
||||
.filter((poly): poly is number[][] => !!poly)
|
||||
.map((poly) => poly.map((point) => [point[0] / NORM_W, point[1] / NORM_W])),
|
||||
columns: sp.wall_columns || [],
|
||||
draftEnds: (sp.room_drafts || []).flatMap((draft: any) => {
|
||||
const points = draft?.points || [];
|
||||
return points.length ? [points[0], points[points.length - 1]] : [];
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (!result.merged) return 0;
|
||||
sp.partitions = result.partitions;
|
||||
applyOpeningMoves(sp.openings, sp.partitions, result.openingMoves, {
|
||||
coordScale: NORM_W, cellCm: this._cellCm, gridPitch: this._gridPitch,
|
||||
});
|
||||
return result.merged;
|
||||
}
|
||||
|
||||
private _finishWallChain(): boolean {
|
||||
if (this._tool !== 'draw' || this._wallFaceBatch || this._roomDialog) return true;
|
||||
const sp = this._curSpaceCfg as any;
|
||||
@@ -6557,15 +6594,22 @@ class HouseplanCard extends LitElement {
|
||||
const before = this._geometrySnapshot();
|
||||
sp.partitions ||= [];
|
||||
const seed = Date.now().toString(36);
|
||||
const drawnIds: string[] = [];
|
||||
for (let i = 0; i < segmentCount; i++) {
|
||||
const segment = segments[i];
|
||||
const id = `partition-${seed}-${i}`;
|
||||
drawnIds.push(id);
|
||||
sp.partitions.push({
|
||||
id: `partition-${seed}-${i}`,
|
||||
id,
|
||||
a: [segment.a[0] / NORM_W, segment.a[1] / NORM_W],
|
||||
b: [segment.b[0] / NORM_W, segment.b[1] / NORM_W],
|
||||
cm: segment.cm,
|
||||
});
|
||||
}
|
||||
// A straight run drawn in several clicks is one wall, not five (#229).
|
||||
// Only what this chain touches is merged: what has piled up earlier waits
|
||||
// for «Optimise plans», where the sweep is explicit and undoable.
|
||||
this._mergeSpacePartitions(sp, drawnIds);
|
||||
if (this._activeDraftId && Array.isArray(sp.room_drafts)) {
|
||||
sp.room_drafts = sp.room_drafts.filter((draft: any) => draft.id !== this._activeDraftId);
|
||||
if (!sp.room_drafts.length) delete sp.room_drafts;
|
||||
@@ -14435,7 +14479,8 @@ class HouseplanCard extends LitElement {
|
||||
this._showToast(this._t('gs.align_done', {
|
||||
n: String(d.report.moved),
|
||||
m: String(d.report.migrated + d.report.canonicalized
|
||||
+ d.report.coordsCanonicalized + d.report.wallsMerged + d.report.spansMerged),
|
||||
+ d.report.coordsCanonicalized + d.report.wallsMerged + d.report.spansMerged
|
||||
+ d.report.partitionsMerged),
|
||||
}));
|
||||
} catch (e: any) {
|
||||
if (this._alignDialog) this._alignDialog = { ...this._alignDialog, busy: false };
|
||||
@@ -15439,7 +15484,7 @@ class HouseplanCard extends LitElement {
|
||||
<p class="alignmsg">${this._t('gs.optimize_changes', {
|
||||
m: String(r.migrated), c: String(r.canonicalized),
|
||||
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
|
||||
s: String(r.spansMerged),
|
||||
s: String(r.spansMerged), i: String(r.partitionsMerged),
|
||||
})}</p>
|
||||
${r.glowSpacesMigrated || r.glowRoomsMigrated
|
||||
? html`<p class="alignmsg">${this._t('gs.optimize_glow_migration', {
|
||||
|
||||
+1
-1
@@ -783,7 +783,7 @@
|
||||
"gs.align_where": "The largest shift is in “{s}”.",
|
||||
"gs.align_turned": "Openings whose angle is corrected: {n}.",
|
||||
"gs.align_removed_drafts": "Invalid outlines collapsed by the grid and removed: {n}.",
|
||||
"gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}.",
|
||||
"gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.",
|
||||
"gs.optimize_glow_migration": "Legacy Glow: {spaces} spaces → no data fill + independent Glow; {rooms} rooms → inherited data fill + independent Glow.",
|
||||
"gs.align_warn": "Elements deliberately placed between grid nodes will move. One undo is available after the operation, only until the next plan edit.",
|
||||
"gs.align_run": "Optimize",
|
||||
|
||||
+1
-1
@@ -783,7 +783,7 @@
|
||||
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
|
||||
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
|
||||
"gs.align_removed_drafts": "Схлопнувшиеся на сетке некорректные контуры удалены: {n}.",
|
||||
"gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}.",
|
||||
"gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.",
|
||||
"gs.optimize_glow_migration": "Старый Glow: пространств — {spaces} → без заливки данных + независимый Glow; комнат — {rooms} → наследуемая заливка + независимый Glow.",
|
||||
"gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
|
||||
"gs.align_run": "Оптимизировать",
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
degradeWalls, normalizeWallIntervals, rekeyWallsAfterMove, roomWallProfile,
|
||||
setWallThickness, type WallEntry,
|
||||
} from './wall-thickness';
|
||||
import { applyOpeningMoves, mergeCollinearPartitions } from './wall-merge';
|
||||
|
||||
/** Bump when a new lossless maintenance pass is added. */
|
||||
export const PLAN_MODEL_VERSION = 6;
|
||||
@@ -50,6 +51,8 @@ export interface OptimizeReport extends AlignReport {
|
||||
wallsMerged: number;
|
||||
/** Touching/overlapping virtual pieces merged on the same room pair. */
|
||||
spansMerged: number;
|
||||
/** Collinear independent-wall records collapsed into one (#229). */
|
||||
partitionsMerged: number;
|
||||
}
|
||||
|
||||
export interface OptimizeResult {
|
||||
@@ -401,6 +404,7 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
|
||||
|
||||
let wallsMerged = 0;
|
||||
let spansMerged = 0;
|
||||
let partitionsMerged = 0;
|
||||
let canonicalized = 0;
|
||||
for (let i = 0; i < config.spaces.length; i++) {
|
||||
const before = beforeSpaces[i];
|
||||
@@ -492,6 +496,33 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
|
||||
wallsMerged += Math.max(0, wallParts - walls.length);
|
||||
if (walls.length) space.walls = walls;
|
||||
else delete space.walls;
|
||||
|
||||
// Independent walls drawn in several clicks: collapse the seams that have
|
||||
// piled up. Drawing merges only its own chain, so this is where an older
|
||||
// plan finally loses them — explicitly, with a report and an undo (#229).
|
||||
const partitionMerge = mergeCollinearPartitions(space.partitions || [], {
|
||||
pitch: GRID_STEP_N,
|
||||
geometry: {
|
||||
roomPolygons: (space.rooms || [])
|
||||
.map((room: any) => roomPoly(room))
|
||||
.filter((poly: number[][] | null): poly is number[][] => !!poly)
|
||||
.map((poly: number[][]) => poly.map((p) => [p[0] / NORM_W, p[1] / NORM_W])),
|
||||
columns: space.wall_columns || [],
|
||||
draftEnds: (space.room_drafts || []).flatMap((draft: any) => {
|
||||
const points = draft?.points || [];
|
||||
return points.length ? [points[0], points[points.length - 1]] : [];
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (partitionMerge.merged) {
|
||||
partitionsMerged += partitionMerge.merged;
|
||||
space.partitions = partitionMerge.partitions;
|
||||
applyOpeningMoves(space.openings, space.partitions, partitionMerge.openingMoves, {
|
||||
coordScale: NORM_W,
|
||||
cellCm: Number(space.cell_cm) > 0 ? Number(space.cell_cm) : DEFAULT_CELL_CM,
|
||||
gridPitch: GRID_PITCH,
|
||||
});
|
||||
}
|
||||
const canonicalAfter = JSON.stringify({
|
||||
spans: space.open_spans || [],
|
||||
links: (space.rooms || []).map((r: any) => [r.id, r.open_to || []]),
|
||||
@@ -528,6 +559,7 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
|
||||
canonicalized,
|
||||
wallsMerged,
|
||||
spansMerged,
|
||||
partitionsMerged,
|
||||
},
|
||||
changed,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Merging collinear wall segments (issue #229).
|
||||
*
|
||||
* A straight wall drawn in five clicks is stored as five records with four
|
||||
* seams. The seams are invisible until the first interaction, and then every
|
||||
* one of them gets in the way: selection grabs a piece, dragging breaks the
|
||||
* wall in half, thickness has to be set per fragment.
|
||||
*
|
||||
* Room walls already collapse like this — `normalizeWallIntervals` compacts
|
||||
* "every maximal solid run of one thickness". Independent partitions never
|
||||
* did. This module gives them the same rule.
|
||||
*
|
||||
* Everything here is pure: the rules are provable by unit tests, without a
|
||||
* browser and without a config store. docs/specs/229-merge-collinear-partitions.md
|
||||
*/
|
||||
|
||||
import type { OpeningCfg, PartitionCfg, WallColumnCfg } from './types';
|
||||
import { materializePartitionOpening, resolvePartitionOpeningCompat } from './partition-openings';
|
||||
|
||||
/** Collinearity, as a fraction of one grid pitch (spec §8.3). */
|
||||
export const EPS_ANGLE = 0.02;
|
||||
/** Two ends count as one point below this, also in grid pitches (spec §8.3). */
|
||||
export const EPS_JOIN = 0.05;
|
||||
|
||||
export interface MergeGeometry {
|
||||
/** Every room outline, in the same coordinates as the partitions. */
|
||||
roomPolygons?: readonly (readonly (readonly number[])[])[];
|
||||
columns?: readonly WallColumnCfg[];
|
||||
/** Saved unfinished contours: only their endpoints anchor a junction. */
|
||||
draftEnds?: readonly (readonly number[])[];
|
||||
}
|
||||
|
||||
export interface MergeOptions {
|
||||
/** One grid pitch in the coordinates used by the partitions. */
|
||||
pitch: number;
|
||||
geometry?: MergeGeometry;
|
||||
/**
|
||||
* Ids of the partitions the caller has just drawn. When given, merging is
|
||||
* confined to the connected component that contains at least one of them —
|
||||
* drawing fixes its own seam, `Optimise plans` sweeps what has piled up
|
||||
* (spec §8.6). Omit to consider every partition, which is what the
|
||||
* optimiser does.
|
||||
*/
|
||||
seedIds?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* How a position along one merged partition maps onto the survivor.
|
||||
*
|
||||
* Both are straight and collinear, so the mapping is linear: a point at `t`
|
||||
* of the old record sits at `base + t * span` of the new one. `span` is
|
||||
* negative when the survivor runs the other way — the old record's start
|
||||
* became the far end.
|
||||
*/
|
||||
export interface MergedOpeningMove {
|
||||
/** Host before the merge. */
|
||||
fromId: string;
|
||||
/** Host after the merge; equal to `fromId` when that record survived. */
|
||||
toId: string;
|
||||
base: number;
|
||||
span: number;
|
||||
}
|
||||
|
||||
/** Position of `t` (0..1 along the old host) on the surviving partition. */
|
||||
export function remapHostT(t: number, move: MergedOpeningMove): number {
|
||||
const mapped = move.base + t * move.span;
|
||||
return Math.min(1, Math.max(0, mapped));
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
partitions: PartitionCfg[];
|
||||
/** How many records disappeared into a neighbour. */
|
||||
merged: number;
|
||||
/** What has to happen to every opening hosted by a merged partition. */
|
||||
openingMoves: MergedOpeningMove[];
|
||||
}
|
||||
|
||||
const finite = (p: unknown): p is number[] => Array.isArray(p) && p.length >= 2
|
||||
&& Number.isFinite(p[0]) && Number.isFinite(p[1]);
|
||||
const dist = (a: readonly number[], b: readonly number[]) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
|
||||
/** Distance from a point to a segment — a junction is a side, not just a corner. */
|
||||
function distToSegment(p: readonly number[], a: readonly number[], b: readonly number[]): number {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 <= 0) return dist(p, a);
|
||||
let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
return Math.hypot(p[0] - (a[0] + dx * t), p[1] - (a[1] + dy * t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does anything else meet this point, so the node has a reason to stay?
|
||||
*
|
||||
* The four reasons of spec §8.2, all measured with the same EPS_JOIN: a third
|
||||
* partition, a room edge, a column, the end of a saved draft. A room counts by
|
||||
* its SIDE, not only by its corners — a partition meeting the middle of a room
|
||||
* wall is an ordinary T-junction (docs/specs/141-wall-junctions.md).
|
||||
*/
|
||||
export function junctionAt(
|
||||
point: readonly number[],
|
||||
partitions: readonly PartitionCfg[],
|
||||
exclude: ReadonlySet<string>,
|
||||
geometry: MergeGeometry | undefined,
|
||||
join: number,
|
||||
): boolean {
|
||||
for (const other of partitions) {
|
||||
if (!other || exclude.has(other.id) || !finite(other.a) || !finite(other.b)) continue;
|
||||
if (dist(point, other.a) <= join || dist(point, other.b) <= join) return true;
|
||||
}
|
||||
for (const polygon of geometry?.roomPolygons || []) {
|
||||
for (let i = 0; i < polygon.length; i++) {
|
||||
const a = polygon[i], b = polygon[(i + 1) % polygon.length];
|
||||
if (finite(a) && finite(b) && distToSegment(point, a, b) <= join) return true;
|
||||
}
|
||||
}
|
||||
for (const column of geometry?.columns || []) {
|
||||
if (finite(column?.center) && dist(point, column.center) <= join) return true;
|
||||
}
|
||||
for (const end of geometry?.draftEnds || []) {
|
||||
if (finite(end) && dist(point, end) <= join) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface Pair { i: number; j: number; at: number[]; }
|
||||
|
||||
/** The two records share an end, run the same way and carry the same thickness. */
|
||||
function pairAt(
|
||||
p: PartitionCfg, q: PartitionCfg, angle: number, join: number,
|
||||
): number[] | null {
|
||||
if (p.cm !== q.cm) return null;
|
||||
const pd = [p.b[0] - p.a[0], p.b[1] - p.a[1]];
|
||||
const qd = [q.b[0] - q.a[0], q.b[1] - q.a[1]];
|
||||
const pl = Math.hypot(pd[0], pd[1]), ql = Math.hypot(qd[0], qd[1]);
|
||||
if (!(pl > 0) || !(ql > 0)) return null;
|
||||
const cross = Math.abs((pd[0] / pl) * (qd[1] / ql) - (pd[1] / pl) * (qd[0] / ql));
|
||||
if (cross > angle) return null;
|
||||
for (const [u, v] of [[p.a, q.a], [p.a, q.b], [p.b, q.a], [p.b, q.b]] as const) {
|
||||
if (dist(u, v) <= join) return [(u[0] + v[0]) / 2, (u[1] + v[1]) / 2];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge every collinear neighbour pair that has no reason to keep its node.
|
||||
*
|
||||
* Runs to a fixed point: three segments in a row become one record, not two.
|
||||
* The surviving id is the one that comes first in the array — any rule would
|
||||
* do as long as it is deterministic and independent of when a piece was drawn.
|
||||
*/
|
||||
export function mergeCollinearPartitions(
|
||||
input: readonly PartitionCfg[], options: MergeOptions,
|
||||
): MergeResult {
|
||||
const pitch = Number(options.pitch) > 0 ? Number(options.pitch) : 1;
|
||||
const join = EPS_JOIN * pitch;
|
||||
const angle = EPS_ANGLE;
|
||||
const moves = new Map<string, MergedOpeningMove>();
|
||||
let list = (input || []).filter((p): p is PartitionCfg =>
|
||||
!!p && typeof p.id === 'string' && finite(p.a) && finite(p.b));
|
||||
if (list.length < 2) return { partitions: [...(input || [])], merged: 0, openingMoves: [] };
|
||||
|
||||
const seeds = options.seedIds && options.seedIds.length
|
||||
? new Set(options.seedIds) : null;
|
||||
let merged = 0;
|
||||
|
||||
for (let guard = 0; guard < list.length + 1; guard++) {
|
||||
let pair: Pair | null = null;
|
||||
for (let i = 0; i < list.length && !pair; i++) {
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
const at = pairAt(list[i], list[j], angle, join);
|
||||
if (!at) continue;
|
||||
// Confine the sweep to what the new chain touches (spec §8.6).
|
||||
if (seeds && !seeds.has(list[i].id) && !seeds.has(list[j].id)) continue;
|
||||
if (junctionAt(at, list, new Set([list[i].id, list[j].id]), options.geometry, join)) continue;
|
||||
pair = { i, j, at };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!pair) break;
|
||||
|
||||
const first = list[pair.i], second = list[pair.j];
|
||||
// Far ends: the two points that are NOT the shared joint.
|
||||
const ends = [first.a, first.b, second.a, second.b]
|
||||
.filter((p) => dist(p, pair!.at) > join);
|
||||
let a = ends[0] ?? first.a;
|
||||
let b = ends[ends.length - 1] ?? second.b;
|
||||
if (ends.length < 2) { a = first.a; b = second.b; }
|
||||
// Canonical direction: the lexicographically smaller end starts the record.
|
||||
// Without it the same physical wall comes out as a->b or b->a depending on
|
||||
// the order of the input, and every host.t along it flips with it.
|
||||
if (b[0] < a[0] || (b[0] === a[0] && b[1] < a[1])) { const t = a; a = b; b = t; }
|
||||
const survivor: PartitionCfg = { ...first, a: [a[0], a[1]], b: [b[0], b[1]] };
|
||||
const newLength = dist(a, b);
|
||||
|
||||
// An opening keeps its place in the plan, not its fraction. Both records
|
||||
// are collinear with the survivor, so projecting their ends onto its axis
|
||||
// gives the exact linear map (spec §8.4).
|
||||
const ux = newLength > 0 ? (b[0] - a[0]) / newLength : 0;
|
||||
const uy = newLength > 0 ? (b[1] - a[1]) / newLength : 0;
|
||||
const alongAxis = (p: readonly number[]) =>
|
||||
newLength > 0 ? ((p[0] - a[0]) * ux + (p[1] - a[1]) * uy) / newLength : 0;
|
||||
for (const source of [first, second]) {
|
||||
const base = alongAxis(source.a);
|
||||
const span = alongAxis(source.b) - base;
|
||||
// A record already carrying moves from an earlier round is re-based, so
|
||||
// an opening never has to be walked through the chain of merges.
|
||||
for (const [id, previous] of moves) {
|
||||
if (previous.toId !== source.id) continue;
|
||||
moves.set(id, {
|
||||
fromId: previous.fromId, toId: survivor.id,
|
||||
base: base + previous.base * span, span: previous.span * span,
|
||||
});
|
||||
}
|
||||
moves.set(source.id, { fromId: source.id, toId: survivor.id, base, span });
|
||||
}
|
||||
if (seeds) { seeds.add(survivor.id); }
|
||||
list = list.filter((_, index) => index !== pair!.i && index !== pair!.j);
|
||||
list.splice(pair.i, 0, survivor);
|
||||
merged++;
|
||||
}
|
||||
|
||||
return { partitions: list, merged, openingMoves: [...moves.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Move every opening whose host disappeared onto the record that survived.
|
||||
*
|
||||
* Two things have to happen together, which is why they live in one function:
|
||||
* the authoritative `host` reference and the legacy `x/y/angle` projection an
|
||||
* older reader still draws from (docs/CONFIG-COMPATIBILITY.md, #132). The
|
||||
* projection is not merely a cache here: merging canonicalises the direction
|
||||
* of the survivor, so a wall stored right-to-left comes back left-to-right and
|
||||
* the stored angle turns 180 degrees with it.
|
||||
*
|
||||
* Returns how many openings were re-hosted.
|
||||
*/
|
||||
export function applyOpeningMoves(
|
||||
openings: readonly OpeningCfg[] | null | undefined,
|
||||
partitions: readonly PartitionCfg[],
|
||||
openingMoves: readonly MergedOpeningMove[],
|
||||
ctx: { coordScale: number; cellCm: number; gridPitch: number },
|
||||
): number {
|
||||
if (!openings?.length || !openingMoves.length) return 0;
|
||||
const moves = new Map(openingMoves.map((move) => [move.fromId, move]));
|
||||
let moved = 0;
|
||||
for (const opening of openings) {
|
||||
const host = (opening as any)?.host;
|
||||
if (!host || host.kind !== 'partition') continue;
|
||||
const move = moves.get(host.id);
|
||||
if (!move) continue;
|
||||
(opening as any).host = { ...host, id: move.toId, t: remapHostT(host.t, move) };
|
||||
moved++;
|
||||
const resolved = resolvePartitionOpeningCompat(
|
||||
opening, partitions, ctx.coordScale, ctx.cellCm, ctx.gridPitch,
|
||||
).resolved;
|
||||
if (resolved) Object.assign(
|
||||
opening, materializePartitionOpening(opening, resolved, ctx.coordScale),
|
||||
);
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
+4
-2
@@ -30,13 +30,15 @@ test('i18n: placeholders match between languages', () => {
|
||||
test('Optimize distinguishes updated spaces from cleaned coordinate noise', () => {
|
||||
assert.equal(
|
||||
en['gs.optimize_changes'],
|
||||
'Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}.',
|
||||
'Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.',
|
||||
);
|
||||
assert.equal(
|
||||
ru['gs.optimize_changes'],
|
||||
'Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}.',
|
||||
'Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.',
|
||||
);
|
||||
assert.match(cardSource, /p: String\(r\.coordsCanonicalized\)/);
|
||||
// #229: the independent-wall counter is reported, not silently accumulated
|
||||
assert.match(cardSource, /i: String\(r\.partitionsMerged\)/);
|
||||
assert.match(cardSource, /d\.report\.coordsCanonicalized \+ d\.report\.wallsMerged/);
|
||||
});
|
||||
|
||||
|
||||
@@ -405,3 +405,92 @@ test('optimizer canonicalises only an explicitly stored square-column angle', ()
|
||||
assert.equal('angle' in implicit, false);
|
||||
assert.equal(explicit.angle, 5);
|
||||
});
|
||||
|
||||
// --- issue #229: сращивание независимых стен в «Оптимизировать планы» --------
|
||||
|
||||
const partition = (id, ax, ay, bx, by, cm = 15) => ({ id, a: [ax, ay], b: [bx, by], cm });
|
||||
|
||||
test('issue 229 Optimize collapses the seams an older plan has piled up', () => {
|
||||
const config = {
|
||||
spaces: [{
|
||||
id: 'f1', title: 'Floor', cell_cm: 5, view_box: [0, 0, 1, 1], rooms: [],
|
||||
partitions: [
|
||||
partition('p1', 0.1, 0.1, 0.3, 0.1),
|
||||
partition('p2', 0.3, 0.1, 0.5, 0.1),
|
||||
partition('p3', 0.5, 0.1, 0.7, 0.1),
|
||||
// a lone wall elsewhere: nothing to merge with, must survive untouched
|
||||
partition('lonely', 0.1, 0.6, 0.4, 0.6),
|
||||
],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const result = optimizePlans(config, {});
|
||||
const space = result.config.spaces[0];
|
||||
assert.equal(result.report.partitionsMerged, 2, 'three collinear pieces are one wall');
|
||||
assert.equal(space.partitions.length, 2);
|
||||
const merged = space.partitions.find((p) => p.id !== 'lonely');
|
||||
assert.deepEqual([merged.a, merged.b], [[0.1, 0.1], [0.7, 0.1]]);
|
||||
assert.ok(space.partitions.some((p) => p.id === 'lonely'), 'an unrelated wall is left alone');
|
||||
|
||||
// Idempotent: the sweep has nothing left to do on its own output.
|
||||
const again = optimizePlans(result.config, result.layout);
|
||||
assert.equal(again.report.partitionsMerged, 0);
|
||||
assert.equal(again.config.spaces[0].partitions.length, 2);
|
||||
});
|
||||
|
||||
test('issue 229 Optimize keeps a door where it was when its host is merged', () => {
|
||||
const config = {
|
||||
spaces: [{
|
||||
id: 'f1', title: 'Floor', cell_cm: 5, view_box: [0, 0, 1, 1], rooms: [],
|
||||
partitions: [
|
||||
partition('p1', 0.1, 0.1, 0.3, 0.1),
|
||||
partition('p2', 0.3, 0.1, 0.5, 0.1),
|
||||
],
|
||||
// door in the middle of p2 → absolute x = 0.4
|
||||
openings: [{
|
||||
id: 'door', type: 'door', x: 0.4, y: 0.1, angle: 0, length: 0.05,
|
||||
host: { kind: 'partition', id: 'p2', t: 0.5 },
|
||||
}],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const result = optimizePlans(config, {});
|
||||
const space = result.config.spaces[0];
|
||||
assert.equal(result.report.partitionsMerged, 1);
|
||||
const [wall] = space.partitions;
|
||||
const [door] = space.openings;
|
||||
assert.equal(door.host.id, wall.id, 'the host that vanished is not referenced any more');
|
||||
const x = wall.a[0] + (wall.b[0] - wall.a[0]) * door.host.t;
|
||||
assert.ok(Math.abs(x - 0.4) < 1e-9, `door moved to ${x}`);
|
||||
// …and the legacy projection follows, because that is all an older reader sees
|
||||
assert.ok(Math.abs(door.x - 0.4) < 1e-9, `stale projection: ${door.x}`);
|
||||
});
|
||||
|
||||
test('issue 229 Optimize rewrites the legacy projection when the merged wall turns around', () => {
|
||||
// Both pieces are stored right-to-left; the survivor is canonicalised
|
||||
// left-to-right, so the angle an older reader draws from must be rewritten.
|
||||
const config = {
|
||||
spaces: [{
|
||||
id: 'f1', title: 'Floor', cell_cm: 5, view_box: [0, 0, 1, 1], rooms: [],
|
||||
partitions: [
|
||||
partition('p1', 0.5, 0.1, 0.3, 0.1),
|
||||
partition('p2', 0.3, 0.1, 0.1, 0.1),
|
||||
],
|
||||
openings: [{
|
||||
id: 'door', type: 'door', x: 0.4, y: 0.1, angle: 180, length: 0.05,
|
||||
host: { kind: 'partition', id: 'p1', t: 0.5 },
|
||||
}],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
const result = optimizePlans(config, {});
|
||||
const space = result.config.spaces[0];
|
||||
const [wall] = space.partitions;
|
||||
const [door] = space.openings;
|
||||
assert.equal(result.report.partitionsMerged, 1);
|
||||
assert.deepEqual([wall.a, wall.b], [[0.1, 0.1], [0.5, 0.1]], 'survivor points one way');
|
||||
const x = wall.a[0] + (wall.b[0] - wall.a[0]) * door.host.t;
|
||||
assert.ok(Math.abs(x - 0.4) < 1e-9, `door moved to ${x}`);
|
||||
assert.ok(Math.abs(door.x - 0.4) < 1e-9, `stale projection x: ${door.x}`);
|
||||
assert.equal(door.angle, 0, 'stale projection angle');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Issue #229: a straight run of one thickness is one record; a node stays only
|
||||
// where something else meets it. docs/specs/229-merge-collinear-partitions.md
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyOpeningMoves, EPS_JOIN, mergeCollinearPartitions, junctionAt, remapHostT,
|
||||
} from '../test-build/wall-merge.js';
|
||||
|
||||
const PITCH = 10; // one grid pitch in test coordinates
|
||||
const seg = (id, ax, ay, bx, by, cm = 15) => ({ id, a: [ax, ay], b: [bx, by], cm });
|
||||
const merge = (list, options = {}) => mergeCollinearPartitions(list, { pitch: PITCH, ...options });
|
||||
const geometryOf = (result) => result.partitions
|
||||
.map((p) => `${p.a.join()}→${p.b.join()}@${p.cm}`).sort();
|
||||
|
||||
// --- AC1: прямая цепочка становится одной записью ----------------------------
|
||||
|
||||
test('issue 229 a straight chain of one thickness collapses into a single record', () => {
|
||||
const result = merge([
|
||||
seg('a', 0, 0, 100, 0), seg('b', 100, 0, 220, 0), seg('c', 220, 0, 300, 0),
|
||||
]);
|
||||
assert.equal(result.partitions.length, 1);
|
||||
assert.equal(result.merged, 2);
|
||||
const [only] = result.partitions;
|
||||
assert.deepEqual([only.a, only.b], [[0, 0], [300, 0]]);
|
||||
assert.equal(only.cm, 15);
|
||||
});
|
||||
|
||||
test('issue 229 a corner is not a straight run', () => {
|
||||
const result = merge([seg('a', 0, 0, 100, 0), seg('b', 100, 0, 100, 90)]);
|
||||
assert.equal(result.partitions.length, 2);
|
||||
assert.equal(result.merged, 0);
|
||||
});
|
||||
|
||||
// --- AC2: узел с причиной остаётся -------------------------------------------
|
||||
|
||||
test('issue 229 a third partition at the joint keeps the node', () => {
|
||||
const result = merge([
|
||||
seg('a', 0, 0, 100, 0), seg('b', 100, 0, 200, 0), seg('t', 100, 0, 100, 80),
|
||||
]);
|
||||
assert.equal(result.merged, 0, 'a T of three walls is a junction');
|
||||
});
|
||||
|
||||
test('issue 229 a room side keeps the node, corner or not', () => {
|
||||
// The room's nearest CORNER is far away; its SIDE runs right through the
|
||||
// joint. A partition meeting the middle of a room wall is an ordinary
|
||||
// T-junction and must survive the merge.
|
||||
// The two segments meet at (100, -100). The room's top side runs along
|
||||
// y = -100 from x = 0 to x = 400, so the joint sits in the MIDDLE of that
|
||||
// side — 100 and 300 units away from either corner of the room.
|
||||
const room = [[[0, -100], [400, -100], [400, 300], [0, 300]]];
|
||||
const chain = [seg('a', 100, -300, 100, -100), seg('b', 100, -100, 100, 0)];
|
||||
assert.equal(merge(chain).merged, 1, 'nothing meets this joint yet');
|
||||
const withRoom = merge(chain, { geometry: { roomPolygons: room } });
|
||||
assert.equal(withRoom.merged, 0, 'the joint sits on a room side, not on a corner');
|
||||
});
|
||||
|
||||
test('issue 229 a column or a draft end keeps the node', () => {
|
||||
const chain = [seg('a', 0, 0, 100, 0), seg('b', 100, 0, 200, 0)];
|
||||
assert.equal(merge(chain, {
|
||||
geometry: { columns: [{ id: 'c', shape: 'circle', center: [100, 0], cm: 30 }] },
|
||||
}).merged, 0);
|
||||
assert.equal(merge(chain, { geometry: { draftEnds: [[100, 0]] } }).merged, 0);
|
||||
});
|
||||
|
||||
// --- AC4: разная толщина ------------------------------------------------------
|
||||
|
||||
test('issue 229 different thickness is a real break', () => {
|
||||
const result = merge([seg('a', 0, 0, 100, 0, 15), seg('b', 100, 0, 200, 0, 25)]);
|
||||
assert.equal(result.merged, 0);
|
||||
assert.equal(result.partitions.length, 2);
|
||||
});
|
||||
|
||||
// --- AC5: допуски -------------------------------------------------------------
|
||||
|
||||
test('issue 229 tolerance forgives float noise and refuses a real gap', () => {
|
||||
const ulp = merge([seg('a', 0, 0, 100, 0), seg('b', 100.0000000001, 0, 200, 0)]);
|
||||
assert.equal(ulp.merged, 1, 'a last-bit difference is the same point');
|
||||
const gap = merge([seg('a', 0, 0, 100, 0), seg('b', 100 + EPS_JOIN * PITCH * 4, 0, 200, 0)]);
|
||||
assert.equal(gap.merged, 0, 'a deliberate gap stays a gap');
|
||||
});
|
||||
|
||||
// --- AC6: детерминизм ---------------------------------------------------------
|
||||
|
||||
test('issue 229 the result does not depend on the order of the input', () => {
|
||||
const parts = [seg('a', 0, 0, 100, 0), seg('b', 100, 0, 200, 0), seg('c', 200, 0, 300, 0)];
|
||||
const forward = geometryOf(merge(parts));
|
||||
const backward = geometryOf(merge([...parts].reverse()));
|
||||
const shuffled = geometryOf(merge([parts[1], parts[2], parts[0]]));
|
||||
assert.deepEqual(backward, forward);
|
||||
assert.deepEqual(shuffled, forward);
|
||||
});
|
||||
|
||||
// --- AC3: проём не двигается --------------------------------------------------
|
||||
|
||||
test('issue 229 an opening keeps its place in the plan, not its fraction', () => {
|
||||
// Door in the middle of the second segment: absolute position 150.
|
||||
const parts = [seg('a', 0, 0, 100, 0), seg('b', 100, 0, 200, 0)];
|
||||
const result = merge(parts);
|
||||
assert.equal(result.merged, 1);
|
||||
const move = result.openingMoves.find((m) => m.fromId === 'b');
|
||||
assert.ok(move, 'the merged host reports how to remap its openings');
|
||||
const survivor = result.partitions[0];
|
||||
const length = Math.hypot(survivor.b[0] - survivor.a[0], survivor.b[1] - survivor.a[1]);
|
||||
const t = remapHostT(0.5, move);
|
||||
const x = survivor.a[0] + (survivor.b[0] - survivor.a[0]) * t;
|
||||
assert.equal(length, 200);
|
||||
assert.ok(Math.abs(x - 150) < 1e-9, `door moved to ${x}`);
|
||||
});
|
||||
|
||||
test('issue 229 an opening survives a chain of merges and a reversed survivor', () => {
|
||||
// Three segments merged in two rounds; the door sits on the last one.
|
||||
const result = merge([
|
||||
seg('a', 300, 0, 200, 0), seg('b', 200, 0, 100, 0), seg('c', 100, 0, 0, 0),
|
||||
]);
|
||||
assert.equal(result.partitions.length, 1);
|
||||
const survivor = result.partitions[0];
|
||||
const move = result.openingMoves.find((m) => m.fromId === 'c');
|
||||
assert.ok(move, 'every merged record reports a move');
|
||||
assert.equal(move.toId, survivor.id, 'the move points at the record that exists');
|
||||
const t = remapHostT(0.5, move); // middle of "c" — absolute x = 50
|
||||
const x = survivor.a[0] + (survivor.b[0] - survivor.a[0]) * t;
|
||||
assert.ok(Math.abs(x - 50) < 1e-9, `door moved to ${x}`);
|
||||
});
|
||||
|
||||
// --- §8.6: область слияния при завершении цепочки -----------------------------
|
||||
|
||||
test('issue 229 finishing a chain does not sweep unrelated seams', () => {
|
||||
const parts = [
|
||||
seg('new1', 0, 0, 100, 0), seg('new2', 100, 0, 200, 0), // the chain just drawn
|
||||
seg('old1', 0, 500, 100, 500), seg('old2', 100, 500, 200, 500), // an old seam
|
||||
];
|
||||
const scoped = merge(parts, { seedIds: ['new1', 'new2'] });
|
||||
assert.equal(scoped.merged, 1, 'only the chain is merged');
|
||||
assert.equal(scoped.partitions.length, 3);
|
||||
assert.ok(scoped.partitions.some((p) => p.id === 'old1'), 'the old seam is left alone');
|
||||
// The optimiser passes no seeds and sweeps everything.
|
||||
assert.equal(merge(parts).merged, 2);
|
||||
});
|
||||
|
||||
test('issue 229 a chain merges into what it was drawn onto', () => {
|
||||
const result = merge(
|
||||
[seg('old', 0, 0, 100, 0), seg('new', 100, 0, 200, 0)],
|
||||
{ seedIds: ['new'] },
|
||||
);
|
||||
assert.equal(result.merged, 1, 'the seam belongs to the new chain');
|
||||
});
|
||||
|
||||
// --- junctionAt: прямой контракт ---------------------------------------------
|
||||
|
||||
test('issue 229 junctionAt measures a room by its side, not by its vertices', () => {
|
||||
const room = [[[0, 0], [400, 0], [400, 300], [0, 300]]];
|
||||
const middle = [200, 0];
|
||||
assert.equal(junctionAt(middle, [], new Set(), { roomPolygons: room }, EPS_JOIN * PITCH), true);
|
||||
const away = [200, -50];
|
||||
assert.equal(junctionAt(away, [], new Set(), { roomPolygons: room }, EPS_JOIN * PITCH), false);
|
||||
});
|
||||
|
||||
test('issue 229 a door moved onto the survivor keeps its place in the plan', () => {
|
||||
const partitions = [seg('p1', 0, 0, 200, 0), seg('p2', 200, 0, 400, 0)];
|
||||
// Middle of p2 → absolute x = 300.
|
||||
const openings = [{
|
||||
id: 'door', type: 'door', x: 300, y: 0, angle: 0, length: 40,
|
||||
host: { kind: 'partition', id: 'p2', t: 0.5 },
|
||||
}];
|
||||
const result = merge(partitions);
|
||||
const moved = applyOpeningMoves(openings, result.partitions, result.openingMoves, {
|
||||
coordScale: 1, cellCm: 5, gridPitch: PITCH,
|
||||
});
|
||||
const [wall] = result.partitions;
|
||||
const [door] = openings;
|
||||
assert.equal(moved, 1);
|
||||
assert.equal(door.host.id, wall.id, 'the host that vanished is not referenced any more');
|
||||
const x = wall.a[0] + (wall.b[0] - wall.a[0]) * door.host.t;
|
||||
assert.ok(Math.abs(x - 300) < 1e-9, `door moved to ${x}`);
|
||||
assert.ok(Math.abs(door.x - 300) < 1e-9, `stale projection x: ${door.x}`);
|
||||
});
|
||||
|
||||
test('issue 229 the legacy projection turns around with the survivor', () => {
|
||||
// Both pieces are stored right-to-left; the survivor is canonicalised
|
||||
// left-to-right, so the angle an older reader draws from has to be rewritten.
|
||||
const partitions = [seg('p1', 400, 0, 200, 0), seg('p2', 200, 0, 0, 0)];
|
||||
const openings = [{
|
||||
id: 'door', type: 'door', x: 300, y: 0, angle: 180, length: 40,
|
||||
host: { kind: 'partition', id: 'p1', t: 0.5 },
|
||||
}];
|
||||
const result = merge(partitions);
|
||||
const [wall] = result.partitions;
|
||||
assert.deepEqual([wall.a, wall.b], [[0, 0], [400, 0]], 'survivor points one way');
|
||||
applyOpeningMoves(openings, result.partitions, result.openingMoves, {
|
||||
coordScale: 1, cellCm: 5, gridPitch: PITCH,
|
||||
});
|
||||
const [door] = openings;
|
||||
const x = wall.a[0] + (wall.b[0] - wall.a[0]) * door.host.t;
|
||||
assert.ok(Math.abs(x - 300) < 1e-9, `door moved to ${x}`);
|
||||
assert.ok(Math.abs(door.x - 300) < 1e-9, `stale projection x: ${door.x}`);
|
||||
assert.equal(door.angle, 0, 'stale projection angle');
|
||||
});
|
||||
|
||||
test('issue 229 openings hosted elsewhere are left alone', () => {
|
||||
const partitions = [seg('p1', 0, 0, 200, 0), seg('p2', 200, 0, 400, 0), seg('far', 0, 500, 200, 500)];
|
||||
const openings = [
|
||||
{ id: 'w', type: 'window', x: 100, y: 500, angle: 0, length: 40, host: { kind: 'partition', id: 'far', t: 0.5 } },
|
||||
{ id: 'room', type: 'door', x: 10, y: 10, angle: 0, length: 40, host: { kind: 'room', id: 'r1', t: 0.5 } },
|
||||
];
|
||||
const result = merge(partitions);
|
||||
const before = JSON.stringify(openings);
|
||||
const moved = applyOpeningMoves(openings, result.partitions, result.openingMoves, {
|
||||
coordScale: 1, cellCm: 5, gridPitch: PITCH,
|
||||
});
|
||||
assert.equal(moved, 0);
|
||||
assert.equal(JSON.stringify(openings), before);
|
||||
});
|
||||
@@ -32,6 +32,7 @@
|
||||
"src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts",
|
||||
"src/opening-placement.ts", "src/partition-openings.ts", "src/plan-snap-overlay.ts", "src/wall-face-graph.ts", "src/render/opening-symbol.ts",
|
||||
"src/wall-thickness.ts",
|
||||
"src/wall-merge.ts",
|
||||
"src/physical-geometry.ts",
|
||||
"src/labs.ts", "src/iso-projection.ts", "src/iso-walls.ts", "src/iso-openings.ts",
|
||||
"src/open-spans.ts",
|
||||
|
||||
Reference in New Issue
Block a user