commit c8e9597228143530b98e9a984139dc1b0c193d0e Author: Sergey Matyunin Date: Fri Aug 21 16:47:27 2026 +0300 fix: resolve chain segment thickness in one place Issue: #234 User-Visible: yes diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index a3d21d6..48e3e87 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -158,7 +158,7 @@ import { } from './plan-snap-overlay'; import { atomizeWallSegments, buildWallFaceGraph, findNewWallFacesInGraphs, - normalizeUnifiedWallTool, wallChainSegments, + normalizeUnifiedWallTool, wallChainSegments, chainSegmentCms, type WallFaceGraph, type WallGraphFace, type WallGraphSourceSegment, } from './wall-face-graph'; import { @@ -2617,7 +2617,9 @@ class HouseplanCard extends LitElement { if (draft) { this._activeDraftId = draft.id; this._path = draft.points.map((point) => [...point]); - this._draftSegmentCms = draft.segments.map((segment) => segment.cm); + this._wallChainSegmentCms = this._adoptDraftCms( + this._path, draft.segments.map((segment: any) => segment.cm), draft.id, + ); this._resumeDraftBySpace[this._space] = draft.id; } else { this._activeDraftId = null; @@ -6574,7 +6576,11 @@ class HouseplanCard extends LitElement { return true; } const segments = wallChainSegments( - this._path, this._draftSegmentCms, DRAW_WALL_DEFAULT_CM, + this._path, + chainSegmentCms( + this._path.length - 1, this._draftSegmentCms, + this._drawWallCm, DRAW_WALL_DEFAULT_CM, + ), ); const segmentCount = segments.length; if ((sp.partitions || []).length + segmentCount > MAX_PARTITIONS) { @@ -7268,9 +7274,13 @@ class HouseplanCard extends LitElement { this._path = endHit.reverse ? [...endHit.draft.points].reverse().map((p) => [...p]) : endHit.draft.points.map((p) => [...p]); - this._draftSegmentCms = endHit.reverse - ? [...endHit.draft.segments].reverse().map((s) => s.cm) - : endHit.draft.segments.map((s) => s.cm); + this._draftSegmentCms = this._adoptDraftCms( + this._path, + endHit.reverse + ? [...endHit.draft.segments].reverse().map((s) => s.cm) + : endHit.draft.segments.map((s) => s.cm), + endHit.draft.id, + ); return; } this._activeDraftId = null; @@ -7289,8 +7299,16 @@ class HouseplanCard extends LitElement { return; } if (!this._canAppendRoomDraftPoint()) return; + // Точка и толщина её отрезка пишутся вместе (#234). Раньше запись жила в + // отдельном методе, который молча выходил при невалидном поле толщины, и + // тогда `_draftSegmentCms` становился короче числа отрезков: превью + // показывало текущее поле, а запись — 15 см. Инвариант читается прямо + // здесь, а не выводится из двух проверок в разных местах. + const cm = this._drawWallCm; + if (cm == null) { this._showPhysicalRange(100); return; } const beforePath = this._path.map((point) => [...point]); this._path = [...this._path, pt]; + this._draftSegmentCms = [...this._draftSegmentCms, cm]; this._persistActiveDraftSegment(); this._offerWallFaces(beforePath); } @@ -7395,8 +7413,10 @@ class HouseplanCard extends LitElement { sp.room_drafts.push(saved); this._activeDraftId = id; this._resumeDraftBySpace[this._space] = id; - this._draftSegmentCms = persistedSegments.map((s: any) => Number(s.cm)); this._path = persistedPoints; + this._draftSegmentCms = this._adoptDraftCms( + persistedPoints, persistedSegments.map((s: any) => Number(s.cm)), id, + ); this._physicalSel = null; this._recordGeometry(this._t('history.draft_merge'), before); this._saveConfig(); @@ -7406,12 +7426,35 @@ class HouseplanCard extends LitElement { } } - /** Persist every completed draft segment immediately. */ + /** + * Thickness array adopted from storage, brought to the length of the path. + * + * A record written before #234 may be shorter than the path: the resolver + * fills the gaps by the same rule the preview and the writers use, so a + * resumed draft cannot carry a hidden 15 cm into the next save. Reported to + * the console rather than to the user: the person did not cause it and cannot + * fix it. + */ + private _adoptDraftCms(path: readonly (readonly number[])[], recorded: readonly (number | null | undefined)[], id?: string): number[] { + const count = Math.max(0, path.length - 1); + const resolved = chainSegmentCms(count, recorded, this._drawWallCm, DRAW_WALL_DEFAULT_CM); + if (recorded.length !== count) { + console.debug( + `[houseplan] draft ${id ?? '?'}: восстановлено толщин ${count - recorded.length} (#234)`, + ); + } + return resolved; + } + + /** + * Persist every completed draft segment immediately. + * + * The thickness of the new segment is already recorded by the caller (#234): + * this method must not decide whether to record it, or the array and the path + * drift apart the moment the toolbar field is mid-edit. + */ private _persistActiveDraftSegment(): void { if (this._path.length < 2 || !this._curSpaceCfg) return; - const cm = this._drawWallCm; - if (cm == null) return; - this._draftSegmentCms = [...this._draftSegmentCms, cm]; const before = this._geometrySnapshot(); const sp = this._curSpaceCfg as any; sp.room_drafts ||= []; @@ -12383,10 +12426,17 @@ class HouseplanCard extends LitElement { return validCm(model.room_drafts.find((item) => item.id === draftId)?.segments[index]?.cm) ?? DRAW_WALL_DEFAULT_CM; } + // Толщина отрезка активной цепочки решается тем же резолвером (#234): + // именно это значение подсвечивает инструмент «Толщина», и расхождение с + // записью здесь было тем способом, которым дефект и обнаружился. + const resolved = chainSegmentCms( + Math.max(0, activePath.length - 1), activeCms, + this._drawWallCm, DRAW_WALL_DEFAULT_CM, + ); for (let i = 0; i + 1 < activePath.length; i++) { const a = activePath[i], b = activePath[i + 1]; if (distToSegment(point, [a[0], a[1], b[0], b[1]]) <= epsilon) { - return validCm(activeCms[i]) ?? DRAW_WALL_DEFAULT_CM; + return resolved[i] ?? DRAW_WALL_DEFAULT_CM; } } return DRAW_WALL_DEFAULT_CM; @@ -12441,7 +12491,11 @@ class HouseplanCard extends LitElement { const partitions: Array<{ a: number[]; b: number[]; cm: number }> = []; if (!accepted.length) { partitions.push(...wallChainSegments( - batch.activePath, batch.activeCms, DRAW_WALL_DEFAULT_CM, + batch.activePath, + chainSegmentCms( + batch.activePath.length - 1, batch.activeCms, + this._drawWallCm, DRAW_WALL_DEFAULT_CM, + ), )); } else { const consumed = new Set(accepted.flatMap((decision) => decision.candidate.atomKeys)); @@ -12656,8 +12710,12 @@ class HouseplanCard extends LitElement { // Draw-session wall thickness: apply to new edges only; keep neighbour cm // on shared stretches. Split naming does not use the Draw field. if (!wasSplit) { - const edgeCms = [...this._draftSegmentCms, this._closingWallCm || this._drawWallCm || DRAW_WALL_DEFAULT_CM]; - const cm = edgeCms[0] || this._drawWallCm; + const edgeCms = chainSegmentCms( + verts.length, + [...this._draftSegmentCms, this._closingWallCm ?? undefined], + this._drawWallCm, DRAW_WALL_DEFAULT_CM, + ); + const cm = edgeCms[0]; if (cm != null) { this._cfgEpoch++; // the new room must be in the model before keying const openCuts = this._openCuts(); @@ -12681,7 +12739,7 @@ class HouseplanCard extends LitElement { return distToSegment(mid, [a[0], a[1], b[0], b[1]]) <= this._gridPitch * 0.02; }); if (source >= 0) next = setWallThickness( - next, iv.a, iv.b, edgeCms[source] || cm, + next, iv.a, iv.b, edgeCms[source], this._wallKeyPitch, NORM_W, ); } @@ -12759,7 +12817,9 @@ class HouseplanCard extends LitElement { if (!draft) { delete this._resumeDraftBySpace[this._space]; return; } this._activeDraftId = id; this._path = draft.points.map((p) => [...p]); - this._draftSegmentCms = draft.segments.map((s) => s.cm); + this._draftSegmentCms = this._adoptDraftCms( + this._path, draft.segments.map((s: any) => s.cm), draft.id, + ); this._clearPlanSnapHover(); } @@ -13029,8 +13089,14 @@ class HouseplanCard extends LitElement { return; } const before = this._geometrySnapshot(); - const cms = [...this._draftSegmentCms, - this._closingWallCm || this._drawWallCm || DRAW_WALL_DEFAULT_CM]; + // Замкнутый контур: отрезков столько же, сколько вершин, и последний — + // закрывающий. Его известное значение подаётся резолверу как запись, всё + // остальное решает единое правило (#234). + const cms = chainSegmentCms( + verts.length, + [...this._draftSegmentCms, this._closingWallCm ?? undefined], + this._drawWallCm, DRAW_WALL_DEFAULT_CM, + ); sp.partitions ||= []; const seed = Date.now().toString(36); for (let i = 0; i < verts.length; i++) { @@ -13039,7 +13105,7 @@ class HouseplanCard extends LitElement { id: `partition-${seed}-${i}`, a: [a[0] / NORM_W, a[1] / NORM_W], b: [b[0] / NORM_W, b[1] / NORM_W], - cm: cms[i] || DRAW_WALL_DEFAULT_CM, + cm: cms[i], }); } if (this._activeDraftId && Array.isArray(sp.room_drafts)) { @@ -18544,15 +18610,17 @@ class HouseplanCard extends LitElement { if (this._cursorPt) return [...path, this._cursorPt]; return path.length >= 2 ? path : null; })(); + // Превью берёт толщины из того же резолвера, что и запись (#234). Раньше + // здесь была вторая формула, и расходились они ровно на пропуске: на экране + // текущее поле, в конфиге — 15 см. const previewHalfDepths = previewPts - ? previewPts.slice(0, -1).map((_, i) => { - const cm = Number(this._draftSegmentCms[i]) > 0 - ? Number(this._draftSegmentCms[i]) - : this._contourClosed && i === previewPts.length - 2 - ? (this._closingWallCm || drawCm || DRAW_WALL_DEFAULT_CM) - : (drawCm || DRAW_WALL_DEFAULT_CM); - return wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2; - }) + ? chainSegmentCms( + previewPts.length - 1, + this._contourClosed + ? [...this._draftSegmentCms, this._closingWallCm ?? undefined] + : this._draftSegmentCms, + drawCm, DRAW_WALL_DEFAULT_CM, + ).map((cm) => wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2) : []; const previewD = previewPts ? drawWallPreviewD( diff --git a/src/wall-face-graph.ts b/src/wall-face-graph.ts index 202cf92..380ab63 100644 --- a/src/wall-face-graph.ts +++ b/src/wall-face-graph.ts @@ -55,10 +55,60 @@ export function normalizeUnifiedWallTool(value: unknown): unknown { } /** Immutable open-chain projection used by explicit finish and full rejection. */ +/** + * Thickness of every segment in a chain — the single answer to that question. + * + * Issue #234: five call sites decided it independently and disagreed in three + * different ways. The preview filled a gap with the toolbar field, the two + * partition writers with a hard-coded 15 cm, the room writer with the first + * edge's value. So a chain drawn at 30 cm was shown at 30 and stored at 15, and + * the owner discovered it much later by hovering a wall. Two formulas for one + * meaning always drift; there is exactly one here now. + * + * A missing record inherits the previous segment of the same chain, then the + * toolbar field, then the default (owner's decision 2026-08-21): that is what + * the person saw on screen while drawing, and a global default is not. + * + * Strictly positive is the validity boundary. The previous `wallChainSegments` + * accepted a recorded zero, which cannot be drawn through the UI (1..100 cm, + * `docs/WALL-THICKNESS.md`) but can sit in an old draft. + */ +export function chainSegmentCms( + segmentCount: number, + recorded: readonly (number | null | undefined)[] | null | undefined, + activeCm: number | null | undefined, + defaultCm: number, +): number[] { + const count = Number.isFinite(segmentCount) && segmentCount > 0 + ? Math.floor(segmentCount) : 0; + const valid = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null; + // `defaultCm` — ответственность вызывающего: он передаёт + // DRAW_WALL_DEFAULT_CM. Константа сюда не импортируется намеренно — этот + // модуль не зависит ни от чего, и второе место, где живёт число 15, было бы + // ровно тем дублированием, которое задача и убирает. Невалидный default — + // дефект вызывающего, поэтому он приводится к минимальной допустимой + // толщине (1 см, docs/WALL-THICKNESS.md), а не к выдуманному значению. + const fallbackTail = valid(activeCm) ?? valid(defaultCm) ?? 1; + const out: number[] = []; + let previous: number | null = null; + for (let i = 0; i < count; i++) { + const own = valid(recorded?.[i]); + const cm = own ?? previous ?? fallbackTail; + out.push(cm); + previous = cm; + } + return out; +} + +/** + * Drawable segments of a chain. Thickness arrives already resolved (#234): this + * function no longer owns a fallback of its own, because owning one is how the + * disagreement started. + */ export function wallChainSegments( path: readonly (readonly number[])[], cms: readonly number[], - defaultCm: number, ): WallChainSegment[] { const result: WallChainSegment[] = []; for (let i = 0; i + 1 < path.length; i++) { @@ -66,8 +116,9 @@ export function wallChainSegments( const b = path[i + 1]; if (!finitePoint(a) || !finitePoint(b) || Math.hypot(b[0] - a[0], b[1] - a[1]) <= Number.EPSILON) continue; - const cm = Number.isFinite(cms[i]) && cms[i] >= 0 ? cms[i] : defaultCm; - result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm }); + // The resolver guarantees a positive number per index; a caller that skips + // it is a defect, so the value is used as given rather than re-defaulted. + result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm: cms[i] }); } return result; }