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:
Codex
2026-08-21 12:40:34 +03:00
parent 483c29cc39
commit e6be43b90f
17 changed files with 997 additions and 190 deletions
File diff suppressed because one or more lines are too long
+61
View File
@@ -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
+61 -61
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -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
View File
@@ -8,6 +8,14 @@
## Не выпущено
- Прямая стена, нарисованная в несколько кликов, теперь хранится одной
перегородкой: швы между отрезками одинаковой толщины и направления
срастаются сразу при завершении цепочки. Узел остаётся там, где есть за что
держаться — примыкание третьей стены, стена комнаты, колонна или конец
сохранённого черновика. Старым планам швы убирает «Оптимизировать планы»
отдельной строкой предпросмотра; двери и окна остаются на своих местах
([#229](https://github.com/Matysh/houseplan-card/issues/229)).
- Порядок вкладок пространств теперь можно менять: в режиме редактора возьмите
вкладку мышью и перетащите на нужное место. Порядок сохраняется, свайп и
карусель следуют ему. Устройства при этом остаются на своих местах
+8
View File
@@ -416,6 +416,13 @@ T-соединение входит в проходящую стену без в
Resize показывает живые длины и чистую площадь по внутреннему контуру толстых стен. Завершённый drag становится одним именованным шагом общего 50-командного Undo/Redo.
Прямая стена, нарисованная в несколько кликов, сохраняется одной перегородкой:
соседние отрезки одинаковой толщины и направления срастаются сразу после
завершения цепочки. Узел остаётся там, где в него приходит третья стена, стена
комнаты, колонна или конец сохранённого черновика — то есть везде, где он
что-то значит. Планы, нарисованные раньше, приводит в порядок «Оптимизировать
планы».
Перегородки и колонны можно перетаскивать по сетке и редактировать двойным
кликом. У перегородки задаётся толщина 1–100 см. У колонны задаются квадратная
или круглая форма и внешний размер 1–150 см; у квадрата доступен поворот, у
@@ -1293,6 +1300,7 @@ show_signal: true
| Устройства и подписи комнат | Позиции округляются к сетке |
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
| Перегородки | Коллинеарные соседние отрезки одинаковой толщины сращиваются в одну запись; узел сохраняется, если в нём сходится третья стена, стена комнаты, колонна или конец черновика. Проёмы остаются на своих местах |
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
Перед записью диалог показывает количество затрагиваемых элементов,
+79
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "Оптимизировать",
+32
View File
@@ -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,
};
+262
View File
@@ -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
View File
@@ -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/);
});
+89
View File
@@ -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');
});
+212
View File
@@ -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);
});
+1
View File
@@ -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",