4;){const e=t.findIndex(t=>!t.live);if(e<0)break;clearTimeout(t[e].evict),t.splice(e,1)}}_warmScheduleEvict(t,e){if(clearTimeout(t.evict),!(t.dlg||t.vp&&"view"!==t.vp.mode))return;const i=t.freed,s=t.owner;t.evict=window.setTimeout(()=>{if(t.evict=0,t.freed!==i||t.owner!==s)return;var n;t.dlg=null,t.vp=(n=t.vp)&&"view"!==n.mode?{...n,mode:"view",zoom:n.snap?.space===n.space?n.snap.zoom:n.zoom,view:null,snap:null,tool:"draw",decorTool:"select",showHidden:!1,selId:null,rszSel:null,decorSel:null}:n,t.frameFingerprint="";const o=ag.get(e);if(!t.live&&o&&o.length>1){const e=o.indexOf(t);e>=0&&o.splice(e,1)}},cg+250)}_warmAdoptViewport(t){const e=this._warmVp;if(!e||this._warmSlot?.path!==location.pathname)return void(this._warmVp=null);const i=this._fixedFloorState();if(this._hashApplied||!this._model.find(t=>t.id===e.space)||"valid"===i.kind&&i.id!==e.space||this._hasFixedFloor&&"valid"!==i.kind)return void(this._warmVp=null);this._commitSpace(e.space,!0),this._navApplied=!0,this._adoptMode("view"!==e.mode&&this._canEdit&&!t.kiosk?e.mode:"view"),this._pendingNavMode="view"===e.mode||this._canEdit||t.kiosk?null:e.mode,this._zoom=e.zoom;const s=this._effectiveProjection(),n=s===e.projection&&this._labsIso===e.activeLabsIso;if(this._view=n&&e.view?{...e.view}:null,this._viewModeSnap=n&&e.snap?{...e.snap}:null,!n&&e.logicalCenter){const t="iso"===s?pm([e.logicalCenter.x,e.logicalCenter.y],0):[e.logicalCenter.x,e.logicalCenter.y];this._applyView(e.zoom,t[0],t[1])}var o;this._tool="openwall"===(o=e.tool)||"closewall"===o?"boundary":"opening"===(o=function(t){return"partition"===t?"draw":t}(o))?"draw":"string"==typeof o&&bg.has(o)?o:"draw",this._decorTool=e.decorTool,this._showHidden=e.showHidden,this._showFar!==e.showFar&&(this._showFar=e.showFar,this._frame=null),this._selId=e.selId,this._rszSel=e.rszSel,this._decorSel=e.decorSel,this._warmVpArmed=!0}_warmPatch(t,e=!1){if(this._config?.kiosk)return;const i=lg(this._config);if(!this._warmSlot||this._warmKey===i){if(!this._warmSlot){if(!e)return;const t=this.parentNode;this._warmKey=i,this._warmSlot={owner:this._warmGen,path:location.pathname,place:t?new WeakRef(t):null,idx:this._warmIdx(t),live:!0,hdrH:this._hdrH,stageH:0,vp:null,frameFingerprint:"",devices:null,dlg:null,freed:0,evict:0};const s=ag.get(i)||[];for(s.push(this._warmSlot),ag.set(i,s),this._warmTrim(s);ag.size>8;){const t=ag.keys().next().value;if(void 0===t||t===i)break;for(const e of ag.get(t)||[])clearTimeout(e.evict);ag.delete(t)}}Object.assign(this._warmSlot,t)}}_warmViewportState(){const t=this._effectiveProjection();return{space:this._space,mode:this._mode,projection:t,activeLabsIso:this._labsIso,logicalCenter:this._logicalViewCenter(t),zoom:this._zoom,view:this._view?{...this._view}:null,snap:this._viewModeSnap?{...this._viewModeSnap}:null,tool:this._tool,decorTool:this._decorTool,showHidden:this._showHidden,showFar:this._showFar,selId:this._selId,rszSel:this._rszSel,decorSel:this._decorSel}}_warmDialogState(){const t=(t,e)=>({kind:t,space:this._space,mode:this._mode,data:e});return this._tapConfirm||this._alignDialog||this._mergeDialog||this._importDialog||this._backupExportDialog||this._backupImportDialog?null:this._openingInfo?t("openingInfo",this._openingInfo.id):this._infoCard?t("info",this._infoCard.id):this._rulesDialog?this._rulesDialog.busy?null:t("rules",this._rulesDialog):this._settingsDialog?this._settingsDialog.busy?null:t("settings",this._settingsDialog):this._markerDialog?this._markerDialog.busy?null:t("marker",this._markerDialog):this._openingDialog?t("opening",this._openingDialog):this._backdropDialog?t("backdrop",this._backdropDialog):this._decorShapeDialog?t("decorShape",this._decorShapeDialog):this._decorTextDialog?t("decorText",this._decorTextDialog):this._roomDialog?t("room",{editId:this._roomEditId,fill:this._roomFill,customFill:this._roomCustomFill,tempSrc:this._roomTempSrc,humSrc:this._roomHumSrc,srcOpen:this._roomSrcOpen,srcFilter:this._roomSrcFilter,nameScale:this._roomNameScale,labelScale:this._roomLabelScale,areaSel:this._areaSel,nameSel:this._nameSel,pendingSplit:this._pendingSplit,wallFaceBatch:this._wallFaceBatch,path:this._path}):this._spaceDialog?this._spaceDialog.busy?null:t("space",this._spaceDialog):null}_warmSnapshot(){if(this._booting||this._config?.kiosk||"steady"!==this._continuity.state)return;const t={vp:this._warmViewportState(),frameFingerprint:this._continuity.frameFingerprint,devices:this._devices};if(this._warmRevivePending||(t.dlg=this._warmDialogState()),this.isConnected&&this._warmSlot?.owner===this._warmGen){const e=this.parentNode;t.place=e?new WeakRef(e):null,t.idx=this._warmIdx(e)}this._warmPatch(t)}_warmReviveDialog(){this._warmRevivePending=!1;const t=this._warmSlot;if(this._warmReviveTimer=void 0,!t||!t.dlg)return;const e=t.dlg,i=t.freed;if(t.dlg=null,t.freed=0,clearTimeout(t.evict),t.evict=0,i&&!(Date.now()-i>cg)&&e.space===this._space&&e.mode===this._mode){switch(e.kind){case"space":this._spaceDialog={...e.data,busy:!1,savedBusy:!1};break;case"marker":this._markerDialog={...e.data,busy:!1};break;case"settings":this._settingsDialog={...e.data,busy:!1};break;case"rules":this._rulesDialog={...e.data,busy:!1};break;case"opening":this._openingDialog={...e.data};break;case"backdrop":this._backdropDialog={...e.data};break;case"decorShape":this._decorShapeDialog={...e.data};break;case"decorText":{this._decorTextDialog={...e.data};const t=String(this._decorTextDialog?.text??"").length;this._decorTextSelection={start:t,end:t};break}case"room":{const t=e.data;this._roomEditId=t.editId,this._roomFill=t.fill,this._roomCustomFill=t.customFill||null,this._roomTempSrc=t.tempSrc,this._roomHumSrc=t.humSrc,this._roomSrcOpen=t.srcOpen,this._roomSrcFilter=t.srcFilter,this._roomNameScale=t.nameScale,this._roomLabelScale=t.labelScale,this._areaSel=t.areaSel,this._nameSel=t.nameSel,this._pendingSplit=t.pendingSplit,this._wallFaceBatch=t.wallFaceBatch||null,this._path=t.path,this._wallFaceBatch&&(this._activeDraftId=this._wallFaceBatch.activeDraftId,this._draftSegmentCms=[...this._wallFaceBatch.activeCms]),this._roomDialog=!0;break}case"info":{const t=this._devices.find(t=>t.id===e.data);t&&(this._infoCard=t);break}case"openingInfo":{const t=(this._curSpaceCfg?.openings||[]).find(t=>t.id===e.data);t&&(this._openingInfo=t);break}}this.requestUpdate()}}_cacheSnapshot(){if(this._serverCfg)try{this._cfgContentFingerprint=Th(this._serverCfg),this._layoutContentFingerprint=Th(this._layout),this._virtualLights=function(t,e,i){if(t.configRev===i)return t;const s=new Set((Array.isArray(e?.markers)?e.markers:[]).filter(yn).map(t=>t.id));return{...t,configRev:i,off:new Set([...t.off].filter(t=>s.has(t)))}}(this._virtualLights,this._serverCfg,this._cfgRev),localStorage.setItem(dg,JSON.stringify({config:this._serverCfg,rev:this._cfgRev,config_fingerprint:this._cfgContentFingerprint,layout:this._layout,layout_rev:this._layoutRev,layout_fingerprint:this._layoutContentFingerprint,virtual_lights:fn(this._virtualLights)}))}catch{}}_beginContinuityCandidate(t,e,i="plan"){return this._booting&&!this._continuity.hasCompleteFrame?this._continuity.token:(this._continuityDataReady=e,this._continuityPaintToken=-1,this._stagedDeviceSnapshotToken=-1,this._resumeSettling=!0,this._continuity.beginCandidate(t,i))}_continuityStageValid(){const t=this._stageEl;return!!t&&t.clientWidth>0&&t.clientHeight>0}_continuityAssetsReady(){if(!this._model.length)return!1;const t=this._model.length?this._spaceModel():null;return!t?.bg?.href||this._signer.isReady(this.hass,t.bg.href)}_initialSpaceSelection(t,e=this._loadOk){const i=this._fixedFloorState(t,e);return"valid"===i.kind?{id:i.id,source:"fixed"}:this._hasFixedFloor?{id:null,source:"none"}:function(t){const e=new Set(t.spaceIds.filter(t=>!!t));if(!e.size)return{id:null,source:"none"};const i=[["hash",!1===t.acceptHash?null:t.hashSpace],["current",t.preserveCurrent?t.currentSpace:null],["saved",t.savedSpace],["default",t.defaultSpace],["first",t.spaceIds[0]]];for(const[t,s]of i)if(s&&e.has(s))return{id:s,source:t};return{id:null,source:"none"}}({spaceIds:t.map(t=>t.id),hashSpace:this._hashSpace(),acceptHash:!this._hashApplied,currentSpace:this._space,preserveCurrent:this._hashApplied||this._navApplied||this._warmVpArmed,savedSpace:this._savedNav()?.space,defaultSpace:this._config?.default_floor})}_adoptInitialSpace(t,e=this._loadOk){const i=this._initialSpaceSelection(t,e);return i.id?(this._commitSpace(i.id,!0),"hash"===i.source&&(this._hashApplied=!0),"saved"===i.source&&(this._navApplied=!0),i):i}_candidateBackdrop(t,e=this._space){const i=wc(t),s=this._fixedFloorState(i,!0);if(this._hasFixedFloor&&"valid"!==s.kind)return"";const n=this._initialSpaceSelection(i,!0).id||(i.some(t=>t.id===e)?e:i[0]?.id);return i.find(t=>t.id===n)?.bg?.href||""}_visualFrameFingerprint(){const t=this._stageEl,e=t?[t.clientWidth,t.clientHeight]:[0,0];return Rh([this._cfgRev,this._cfgContentFingerprint||Th(this._serverCfg),this._layoutRev,this._layoutContentFingerprint||Th(this._layout),this._space,this._mode,this._view,e,this._glowScreenBlend?"screen":"normal",this.hass?.themes?.darkMode??this.hass?.themes?.default_theme??""])}_settleContinuityFrame(){if(this._booting||!this._continuityStageValid())return;if(!this._continuity.hasCompleteFrame&&"steady"===this._continuity.state)return void(this._continuityAssetsReady()&&(this._renderSnapshotAt=Date.now(),this._continuity.markCompleteFrame(this._visualFrameFingerprint())));if(!this._continuityDataReady)return;if(!["holding","offline-stale","overlay-pending","overlay-visible","candidate-ready"].includes(this._continuity.state))return;const t=this._continuity.token;if(this._candidateDeviceSnapshot&&this._candidateDeviceSnapshot!==this._visibleDeviceSnapshot&&this._stagedDeviceSnapshotToken!==t)return this._stagedDeviceSnapshotToken=t,void this.requestUpdate();this._continuityPaintToken!==t&&(this._continuityPaintToken=t,this._continuity.candidateReady(t)&&this._continuity.commitAfterPaint(t,{updateComplete:()=>this.updateComplete,stageValid:()=>this.isConnected&&this._continuityStageValid(),assetsReady:()=>this._continuityAssetsReady(),frameFingerprint:()=>this._visualFrameFingerprint()}).then(e=>{e&&t===this._continuity.token?(this._resumeSettling=!1,this._renderSnapshotAt=Date.now(),this._candidateDeviceSnapshot&&(this._visibleDeviceSnapshot=this._candidateDeviceSnapshot),this._candidateDeviceSnapshot=null,this._stagedDeviceSnapshotToken=-1,this._warmSnapshot()):t===this._continuity.token&&(this._continuityPaintToken=-1,this._stagedDeviceSnapshotToken=-1,this._candidateDeviceSnapshot=null,this.requestUpdate())}))}_onBackdropLoaded(t,e){this._signer.markLoaded(this.hass,t,e),this._continuity.note("asset-ready"),this._continuityPaintToken=-1,"steady"!==this._continuity.state&&this.requestUpdate()}_renderRecoveryOverlay(){if(!this._continuity.overlayVisible&&"recovery-error"!==this._continuity.state)return G;const t="connection"===this._continuity.recoveryReason;return W`t.stopPropagation()}
@click=${t=>t.stopPropagation()}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 5933df7f..992f3dfb 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -2,6 +2,13 @@
## Unreleased
+- “Optimize plans” now removes a microscopic wall-thickness step beside a
+ T-junction when the short fragment is bounded by two equal-thickness pieces
+ of the same straight wall. The T-node and its perpendicular wall stay in
+ place; fragments between two topology nodes or touching an opening boundary
+ remain protected. Preview, Apply, reload and maintenance Undo use the same
+ canonical result ([#273](https://github.com/Matysh/houseplan-card/issues/273)).
+
## v1.67.0-beta.5 — 2026-08-23
- After deleting a complete HA device from the plan, any one of its active
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index 0e88530b..3c285500 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -8,6 +8,14 @@
## Не выпущено
+- «Оптимизировать планы» теперь удаляет микроскопическую ступень толщины рядом
+ с T-стыком, если короткий фрагмент ограничен двумя участками одинаковой
+ толщины одной прямой стены. T-узел и перпендикулярная стена остаются на
+ месте; фрагменты между двумя топологическими узлами или на границе проёма
+ по-прежнему защищены. Preview, Apply, перезагрузка и служебная отмена
+ используют один канонический результат
+ ([#273](https://github.com/Matysh/houseplan-card/issues/273)).
+
## v1.67.0-beta.5 — 2026-08-23
- После удаления целого HA-устройства с плана теперь можно отдельно вернуть
diff --git a/docs/CONFIG-COMPATIBILITY.md b/docs/CONFIG-COMPATIBILITY.md
index c628fddf..e3eacef2 100644
--- a/docs/CONFIG-COMPATIBILITY.md
+++ b/docs/CONFIG-COMPATIBILITY.md
@@ -86,6 +86,17 @@ Explicit Optimize rewrites the compatibility key, retains `cm`, endpoints and
unknown siblings, and its next in-memory or backend storage round-trip is a
no-op. Legacy key-only records continue through the previous midpoint fallback.
+Explicit Optimize also has one deliberately lossy wall-thickness repair. A
+positive interval shorter than half a grid step may inherit its two equal
+positive neighbours only when all three belong to one original straight room
+edge and exact owners are unambiguous. One endpoint may be a room T-node: only
+the interval `cm` changes, so that node and its perpendicular incident geometry
+remain intact. An opening/open-span endpoint, two room topology endpoints,
+unequal neighbours, a half-step-or-longer interval or conflicting owners always
+block the repair. Normal read, render, Save and editor paths remain lossless;
+only confirmed Optimize applies it, with the ordinary preview and server Undo
+(#198, #273).
+
## Open-passage opening type (#157)
`space.openings[].type` additionally accepts the literal `passage`. Its
diff --git a/docs/TESTING.md b/docs/TESTING.md
index d9597023..76e852a8 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -2733,6 +2733,15 @@ require hands on real hardware — they remain for the human pass.
[unit: test/plan-optimizer.test.mjs; auto:
smoke_optimize_micro_interval; mutation:
`optimizer-micro-interval-cleanup-disabled`].
+- [ ] **A single T-node does not preserve an artificial thickness island
+ (#273)**: the minimized beta.5 `22 → 15 → 22` profile has a 1.381904-unit
+ centre beside one perpendicular room edge. Preview/Apply store one 22 cm
+ run, the T coordinate and incident room stay unchanged, render probes
+ see one continuous outer face, reload is idempotent and server Undo
+ restores the exact entries. A second topology endpoint or any open-span
+ endpoint still blocks cleanup [unit: test/plan-optimizer.test.mjs; auto:
+ smoke_optimize_micro_interval; mutation:
+ `optimizer-single-topology-island-blocked`].
- [ ] **Unit + backend**: inset/mitre/bevel, key from either end, degrade,
rekey, cm↔inches; `walls` schema bounds
[auto: test/wall-thickness.test.mjs + tests_backend/test_validation.py]
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
index 39fc25fd..d4d47e2b 100644
--- a/docs/USER-GUIDE.md
+++ b/docs/USER-GUIDE.md
@@ -635,6 +635,14 @@ there is no exact copy, an active real device follows its unambiguous HA Area;
otherwise only its missing placement is detached, so the marker becomes
available on a valid plan without losing its settings.
+Equal neighbouring wall-thickness fragments are compacted. Optimize may also
+remove a different-thickness fragment shorter than half a grid step when equal
+pieces of the same straight wall prove the replacement. This includes a
+fragment touching exactly one room T-junction: the junction and perpendicular
+wall do not move. A fragment between two room vertices or touching an opening
+boundary is preserved. Ordinary opening, rendering, Save and editing never
+perform this cleanup without explicit Optimize confirmation.
+
Old positions are classified before Apply. A position whose room label, device
or light-group owner is proven absent is removed automatically and counted by a
plain-language category. A live owner in a deleted space is named and preserved
diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md
index 7f07e4b8..2415c779 100644
--- a/docs/USER-GUIDE.ru.md
+++ b/docs/USER-GUIDE.ru.md
@@ -1391,7 +1391,7 @@ show_signal: true
| Декор и мебель | Положение и размеры округляются к сетке |
| Устройства и подписи комнат | Позиции округляются к сетке |
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
-| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, только если с обеих сторон находятся участки одной толщины и на его концах нет вершины комнаты или границы проёма |
+| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, если с обеих сторон находятся участки одной толщины той же прямой стены. Допустим один T-узел комнаты: он и перпендикулярная стена не двигаются. Два топологических узла или граница проёма защищают участок |
| Перегородки | Коллинеарные соседние отрезки одинаковой толщины сращиваются в одну запись; узел сохраняется, если в нём сходится третья стена, стена комнаты, колонна или конец черновика. Проёмы остаются на своих местах |
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку; настройки маркера сохраняются |
diff --git a/docs/WALL-THICKNESS.md b/docs/WALL-THICKNESS.md
index 873bc6b9..fb3c57fa 100644
--- a/docs/WALL-THICKNESS.md
+++ b/docs/WALL-THICKNESS.md
@@ -282,7 +282,10 @@ production-scale `0 ↔ 10`, `10 ↔ 20` and `1 ↔ 100` collinear transitions a
their exact endpoint; full 8-room/25-wall/3-cut virtual-junction resilience,
ULP-equivalent patch vertices, per-patch failure isolation and record-order
invariance (#197); explicit Optimize-only collapse of a sub-half-step isolated
-thickness island with strict threshold, topology and ambiguity guards (#198);
+thickness island with strict threshold and ambiguity guards (#198), including
+the proven `equal → micro → equal` case beside exactly one room T-node while
+opening endpoints and spans between two room topology nodes stay protected
+(#273);
exact parent-run thickness inherited by atomic children when
closing a virtual neighbour, without partial-span leakage (#201).
Browser: seamless frame; fill not in hatch; m² drops with thickness; a partial
diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs
index 2c746a05..29c4623d 100644
--- a/scripts/mutation-gate.mjs
+++ b/scripts/mutation-gate.mjs
@@ -719,6 +719,19 @@ export const MUTANTS = [
+ ' const eps = Math.max(pitch * scale * 0.02, 1e-9);',
}],
},
+ {
+ id: 'optimizer-single-topology-island-blocked',
+ guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ + '&& node --test --test-name-pattern="issue 273 Optimize" '
+ + 'test/plan-optimizer.test.mjs',
+ because: 'a proven 22→15→22 island beside exactly one room T-node must not survive '
+ + 'merely because the old #198 guard classified both endpoints identically',
+ patches: [{
+ file: 'src/plan-optimizer.ts',
+ find: ' if (isNode(a, roomNodes) && isNode(b, roomNodes)) continue;',
+ replace: ' if (isNode(a, roomNodes) || isNode(b, roomNodes)) continue;',
+ }],
+ },
{
id: 'atomic-child-thickness-parent-fallback',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
diff --git a/src/plan-optimizer.ts b/src/plan-optimizer.ts
index aff4198f..38360002 100644
--- a/src/plan-optimizer.ts
+++ b/src/plan-optimizer.ts
@@ -92,7 +92,9 @@ const optimizerSpanKey = (a: number[], b: number[], coordScale: number): string
*
* A positive interval shorter than half a grid step may inherit its two equal
* neighbours only when all three pieces belong to one original straight room
- * edge and neither endpoint is a room/opening topology node. Candidates are
+ * edge. Opening endpoints and spans between two room topology nodes stay
+ * protected; one room T-node plus one synthetic endpoint is safe because the
+ * node coordinate and its incident edges are not changed. Candidates are
* collected from the untouched effective profile first, so replacements never
* cascade and input order cannot change the result.
*/
@@ -108,16 +110,17 @@ export function collapseIsolatedWallThicknessIslands(
if (!walls?.length) return [];
const scale = coordScale > 0 ? coordScale : 1;
const eps = Math.max(pitch * scale * 0.02, 1e-9);
- const nodes: number[][] = [];
+ const roomNodes: number[][] = [];
for (const room of rooms || []) {
- for (const point of roomPoly(room) || []) nodes.push([point[0], point[1]]);
+ for (const point of roomPoly(room) || []) roomNodes.push([point[0], point[1]]);
}
+ const openingNodes: number[][] = [];
for (const cut of openCuts || []) {
if (Array.isArray(cut) && cut.length >= 4 && cut.slice(0, 4).every(Number.isFinite)) {
- nodes.push([cut[0], cut[1]], [cut[2], cut[3]]);
+ openingNodes.push([cut[0], cut[1]], [cut[2], cut[3]]);
}
}
- const isTopologyNode = (point: number[]): boolean => nodes.some((node) => (
+ const isNode = (point: number[], nodes: number[][]): boolean => nodes.some((node) => (
Math.hypot(point[0] - node[0], point[1] - node[1]) <= eps * 2
));
@@ -152,7 +155,12 @@ export function collapseIsolatedWallThicknessIslands(
// scale-relative numeric guard instead of accidentally deleting it.
const halfStep = gridPitch * 0.5;
if (!(length > eps) || !(length < halfStep - gridPitch * 1e-9)) continue;
- if (isTopologyNode(a) || isTopologyNode(b)) continue;
+ // An opening boundary is always semantic. Room vertices are different:
+ // a micro interval between two vertices is semantic, while exactly one
+ // vertex is the confirmed T-junction shape from #273. Replacing only
+ // the interval cm keeps that vertex and every incident edge intact.
+ if (isNode(a, openingNodes) || isNode(b, openingNodes)) continue;
+ if (isNode(a, roomNodes) && isNode(b, roomNodes)) continue;
const key = optimizerSpanKey(a, b, scale);
const found = candidates.get(key);
if (found) found.targets.add(leftCm);
diff --git a/test/plan-optimizer.test.mjs b/test/plan-optimizer.test.mjs
index cdad2c10..99382c92 100644
--- a/test/plan-optimizer.test.mjs
+++ b/test/plan-optimizer.test.mjs
@@ -8,9 +8,11 @@ import {
import {
canonicalizeConfigGeometry, canonicalizeLayoutGeometry, canonicalizeNumber,
} from '../test-build/coordinate-canonicalization.js';
-import { unionBodies } from '../test-build/physical-geometry.js';
+import { pointInPhysicalGeometry, unionBodies } from '../test-build/physical-geometry.js';
import { GRID_PITCH, GRID_STEP_N as S, NORM_W } from '../test-build/space-geometry.js';
-import { wallKey } from '../test-build/wall-thickness.js';
+import {
+ wallBodiesGeometry, wallIntervals, wallKey,
+} from '../test-build/wall-thickness.js';
const room = (id, x0, x1, openTo) => ({
id,
@@ -76,6 +78,29 @@ const microIntervalFixture = (length = S / 3, middleCm = 15, rightCm = 22) => {
};
};
+// Privacy-minimised beta.5 topology from #273. The main room keeps one
+// original straight parent edge. The upper room contributes only the
+// perpendicular incident edge at `split`, so it is a real T-node rather than
+// a collinear vertex inserted into the candidate's parent edge.
+const topologyMicroIntervalFixture = () => {
+ const x0 = 0.8, split = 0.8875, x1 = 0.95, y = 0.345833333;
+ const microEnd = split + 0.001381904;
+ return {
+ split,
+ microEnd,
+ rooms: [
+ { id: 'main', poly: [[x0, y], [x1, y], [x1, 0.5], [x0, 0.5]] },
+ { id: 'branch', poly: [[0.845833333, 0.245833333], [split, 0.245833333],
+ [split, y], [0.845833333, y]] },
+ ],
+ walls: [
+ exactWall([x0, y], [split, y], 22),
+ exactWall([split, y], [microEnd, y], 15),
+ exactWall([microEnd, y], [x1, y], 22),
+ ],
+ };
+};
+
test('Optimize collapses one isolated thickness micro-interval and is idempotent', () => {
const fixture = microIntervalFixture();
const config = {
@@ -104,6 +129,53 @@ test('Optimize collapses one isolated thickness micro-interval and is idempotent
assert.deepEqual(second.config, first.config);
});
+test('issue 273 Optimize collapses the beta.5 island beside one T-node', () => {
+ const fixture = topologyMicroIntervalFixture();
+ const config = {
+ model_version: PLAN_MODEL_VERSION,
+ spaces: [{
+ id: 'topology-micro', title: 'Topology micro', view_box: [0, 0, 1, 1], cell_cm: 5,
+ rooms: fixture.rooms, walls: fixture.walls,
+ }],
+ markers: [], settings: {},
+ };
+ const before = structuredClone(config);
+ const rawIntervals = wallIntervals(fixture.rooms, fixture.walls, [], S, 5, S);
+ assert.ok(rawIntervals.some((interval) => interval.cm === 15),
+ 'runtime remains lossless before explicit Optimize');
+
+ const first = optimizePlans(config, {});
+ assert.deepEqual(config, before, 'preview must not mutate the T-node source');
+ assert.equal(first.changed, true);
+ assert.equal(first.report.canonicalized, 1);
+ assert.equal(first.report.wallsMerged, 2);
+ const canonicalBefore = canonicalizeConfigGeometry(before);
+ assert.deepEqual(first.config.spaces[0].rooms, canonicalBefore.spaces[0].rooms,
+ 'T coordinate and perpendicular incident room stay byte-equivalent');
+ assert.equal(first.config.spaces[0].walls.length, 1);
+ assert.equal(first.config.spaces[0].walls[0].cm, 22);
+ assert.deepEqual(first.config.spaces[0].walls[0].a, [0.8, 0.345833333]);
+ assert.deepEqual(first.config.spaces[0].walls[0].b, [0.95, 0.345833333]);
+
+ const afterIntervals = wallIntervals(
+ first.config.spaces[0].rooms, first.config.spaces[0].walls, [], S, 5, S,
+ );
+ assert.equal(afterIntervals.some((interval) => interval.cm === 15), false);
+ const geometry = wallBodiesGeometry(
+ first.config.spaces[0].rooms, first.config.spaces[0].walls, [], [], S, 5, S,
+ );
+ assert.ok(geometry, 'optimized profile must render a masonry body');
+ for (const x of [fixture.split - S, (fixture.split + fixture.microEnd) / 2,
+ fixture.microEnd + S]) {
+ assert.equal(pointInPhysicalGeometry([x, 0.345833333 - 0.008], geometry.geom), true,
+ `22 cm outer face must stay continuous at x=${x}`);
+ }
+
+ const second = optimizePlans(first.config, first.layout);
+ assertNoPersistedChanges(second);
+ assert.deepEqual(second.config, first.config);
+});
+
test('Optimize canonicalizes the six-room ULP source without claiming a visible move', () => {
const config = {
model_version: PLAN_MODEL_VERSION,
@@ -265,6 +337,24 @@ test('micro-interval cleanup preserves ambiguous and topological boundaries', ()
'an open-cut endpoint at the island boundary blocks cleanup',
);
+ const betweenTwoNodes = topologyMicroIntervalFixture();
+ betweenTwoNodes.rooms.push({
+ id: 'branch-2',
+ poly: [
+ [betweenTwoNodes.microEnd, 0.245833333],
+ [betweenTwoNodes.microEnd + 5 * S, 0.245833333],
+ [betweenTwoNodes.microEnd + 5 * S, 0.345833333],
+ [betweenTwoNodes.microEnd, 0.345833333],
+ ],
+ });
+ assert.deepEqual(
+ collapseIsolatedWallThicknessIslands(
+ betweenTwoNodes.rooms, betweenTwoNodes.walls, [], S, 5, S,
+ ),
+ betweenTwoNodes.walls,
+ 'an interval between two room topology nodes remains intentional',
+ );
+
const chain = microIntervalFixture(S / 3, 15, 22);
const y = 0.2, firstEnd = 0.5 + S / 3, secondEnd = firstEnd + S / 3;
chain.walls = [