refactor: one function owns the junction geometry of a space (#229 r2 M1)

Дефект High-1 был одинаковым в двух местах — и в живом рисовании, и в
«Оптимизировать планы», — потому что каждый вызывающий собирал геометрию
примыканий сам. Ревью r2 справедливо заметило, что и защита получилась
однобокой: юнит и мутант сторожили только оптимизатор, а путь карты — тот, где
дефект и был виден пользователю, — не сторожил никто. Заплатка в виде второго
мутанта-близнеца оставила бы причину на месте: два списка координат, которые
обязаны совпадать, но ничем не связаны.

Поэтому геометрия переехала в `spaceMergeGeometry(space, { excludeDraftId })`:
один источник комнат, колонн и концов черновиков, одни координаты, одно место,
где можно ошибиться. Оба вызывающих теперь строчка вызова.

Покрытие идёт за причиной, а не за симптомом: три юнита в
`test/wall-merge.test.mjs` проверяют масштаб полигонов (включая комнаты в форме
x/y/w/h и комнату без геометрии), исключение активного черновика и сам T-стык к
середине стороны комнаты. Мутанты `partition-merge-rescales-rooms` и
`chain-merge-sees-own-draft` перенацелены на общий модуль и теперь краснеют для
обоих путей сразу: 2 и 1 падение, проверено применением патча.

Сценарий с комнатой в смоке пробовал — не взлетел: рисование в комнату
поднимает `_offerWallFaces`, и цепочка не завершается штатно. Ломать смок под
тест не стал, юниты общего модуля покрывают оба пути честнее.

Issue: #229
User-Visible: no
This commit is contained in:
Codex
2026-08-21 13:25:16 +03:00
committed by Codex
parent fe2fcef735
commit fab0c38ca9
10 changed files with 243 additions and 188 deletions
+2 -18
View File
@@ -265,7 +265,7 @@ import {
applySpaceOrder, canStartTabDrag, markersNeedingPlacement, passedDragThreshold,
reorderSpaceIds,
} from './space-order';
import { applyOpeningMoves, mergeCollinearPartitions } from './wall-merge';
import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge';
const CARD_VERSION = '1.66.0';
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
@@ -6548,26 +6548,10 @@ class HouseplanCard extends LitElement {
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: {
// Same coordinates as the partitions — `roomPoly` returns the raw
// config polygon (review CODE-REVIEW-229-r1, High-1).
roomPolygons: rooms
.map((room) => roomPoly(room))
.filter((poly): poly is number[][] => !!poly),
columns: sp.wall_columns || [],
// The chain being finished is still persisted as a draft at this
// point, and its own ends must not pass for someone else's junction —
// the same exclusion plan-snap-overlay makes (review r1, High-2).
draftEnds: (sp.room_drafts || []).flatMap((draft: any) => {
if (draft?.id && draft.id === this._activeDraftId) return [];
const points = draft?.points || [];
return points.length ? [points[0], points[points.length - 1]] : [];
}),
},
geometry: spaceMergeGeometry(sp, { excludeDraftId: this._activeDraftId }),
});
if (!result.merged) return 0;
sp.partitions = result.partitions;
+2 -15
View File
@@ -24,7 +24,7 @@ import {
degradeWalls, normalizeWallIntervals, rekeyWallsAfterMove, roomWallProfile,
setWallThickness, type WallEntry,
} from './wall-thickness';
import { applyOpeningMoves, mergeCollinearPartitions } from './wall-merge';
import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge';
/** Bump when a new lossless maintenance pass is added. */
export const PLAN_MODEL_VERSION = 6;
@@ -502,20 +502,7 @@ export function optimizePlans(configIn: any, layoutIn: Record<string, any>): Opt
// plan finally loses them — explicitly, with a report and an undo (#229).
const partitionMerge = mergeCollinearPartitions(space.partitions || [], {
pitch: GRID_STEP_N,
geometry: {
// Rooms are stored in the same coordinates as partitions: `roomPoly`
// hands back the raw config polygon, so scaling it here would push
// every room into a corner and no junction would ever be found
// (review CODE-REVIEW-229-r1, High-1).
roomPolygons: (space.rooms || [])
.map((room: any) => roomPoly(room))
.filter((poly: number[][] | null): poly is number[][] => !!poly),
columns: space.wall_columns || [],
draftEnds: (space.room_drafts || []).flatMap((draft: any) => {
const points = draft?.points || [];
return points.length ? [points[0], points[points.length - 1]] : [];
}),
},
geometry: spaceMergeGeometry(space),
});
if (partitionMerge.merged) {
partitionsMerged += partitionMerge.merged;
+33
View File
@@ -16,6 +16,7 @@
import type { OpeningCfg, PartitionCfg, WallColumnCfg } from './types';
import { materializePartitionOpening, resolvePartitionOpeningCompat } from './partition-openings';
import { roomPoly } from './logic';
/** Collinearity, as a fraction of one grid pitch (spec §8.3). */
export const EPS_ANGLE = 0.02;
@@ -260,3 +261,35 @@ export function applyOpeningMoves(
}
return moved;
}
/**
* The junction geometry of one space, in the coordinates the partitions use.
*
* Both callers — the live editor and “Optimize plans” — need exactly the same
* three things, and when each built them for itself the identical mistake was
* made twice: room polygons were scaled a second time and no room junction was
* ever found (review CODE-REVIEW-229-r1/r2). One function, one set of
* coordinates, one place to get it wrong.
*
* `excludeDraftId` is the chain being finished right now: it is already
* persisted in `room_drafts`, and its own ends are not a foreign junction —
* the same exclusion `plan-snap-overlay` makes.
*/
export function spaceMergeGeometry(
space: any, options?: { excludeDraftId?: string | null },
): MergeGeometry {
const exclude = options?.excludeDraftId || null;
return {
// `roomPoly` hands back the raw config polygon: rooms are stored in the
// same normalised coordinates as partitions, so nothing is rescaled here.
roomPolygons: (space?.rooms || [])
.map((room: any) => roomPoly(room))
.filter((poly: number[][] | null): poly is number[][] => !!poly),
columns: space?.wall_columns || [],
draftEnds: (space?.room_drafts || []).flatMap((draft: any) => {
if (exclude && draft?.id === exclude) return [];
const points = draft?.points || [];
return points.length ? [points[0], points[points.length - 1]] : [];
}),
};
}