fix(plan): keep wall axes across tools

Issue: #304
User-Visible: yes
This commit is contained in:
Matysh
2026-08-25 16:36:55 +03:00
parent 9c5e1e2749
commit a2b4d32b2d
11 changed files with 186 additions and 20 deletions
File diff suppressed because one or more lines are too long
+37
View File
@@ -675,6 +675,43 @@ export async function prepareGoldenScenario(page, scenario) {
await card.updateComplete;
await settleMode(card);
}
// #304: the two existing hidden-wall golden scenes also guard the static
// architectural overlay in every Plan tool. Finish in draw so the reviewed
// pixels stay stable; a missing/changed tool projection fails before capture.
if (scenario.hiddenWallDiagnostics) {
const tools = [
'select', 'draw', 'column', 'merge', 'split', 'resize',
'opening', 'boundary', 'wallthick', 'delroom',
];
const snapshot = () => {
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
if (!overlay || overlay.getAttribute('pointer-events') !== 'none') return null;
return JSON.stringify({
lines: [...overlay.querySelectorAll('.plan-snap-line')].map((line) => [
line.getAttribute('data-key'), line.getAttribute('x1'), line.getAttribute('y1'),
line.getAttribute('x2'), line.getAttribute('y2'),
]),
endpoints: [...overlay.querySelectorAll('.plan-snap-node[data-kind="endpoint"]')]
.map((node) => [node.getAttribute('data-key'), node.getAttribute('cx'), node.getAttribute('cy')]),
});
};
let expected = null;
for (const tool of tools) {
card._tool = tool;
card._clearPlanSnapHover();
card.requestUpdate();
await card.updateComplete;
const current = snapshot();
if (expected == null) expected = current;
if (!current || current !== expected) {
throw new Error(`golden plan-axis parity failed for ${tool}: ${scenario.id}`);
}
}
card._tool = 'draw';
card.requestUpdate();
await card.updateComplete;
await frame();
}
if (scenario.wallUnionIsolation) {
const result = card._wallUnionGeometry?.();
const paths = [...card.renderRoot.querySelectorAll('[data-hp="wall"]')];
+89 -2
View File
@@ -236,7 +236,7 @@ const out = await page.evaluate(async () => {
card._activateMarkupTool('select');
await update();
const diagnostic = root().querySelector('[data-hp="hidden-wall-diagnostic"]');
result.otherPlanToolsHaveHiddenDiagnostic = !overlay() && !!diagnostic
result.otherPlanToolsKeepFullOverlayAndHiddenDiagnostic = !!overlay() && !!diagnostic
&& diagnostic.querySelectorAll('.hidden-wall-line').length === 1
&& diagnostic.querySelectorAll('.hidden-wall-node').length === 2;
const editorVirtualWalls = root().querySelector('.openwalls');
@@ -256,9 +256,96 @@ const out = await page.evaluate(async () => {
result.hiddenDiagnosticPaintsBeforeTransientSnap = !!(
drawDiagnostic.compareDocumentPosition(drawSnap) & Node.DOCUMENT_POSITION_FOLLOWING
);
// #304 regression fixture: one room plus two independent walls which overlap
// its left/bottom axes and continue beyond them. The six source endpoints are
// the exact topology from the field report, reduced to deterministic data.
const parityCfg = {
model_version: 7,
spaces: [{
id: 'axis-parity', title: 'Axis parity', cell_cm: 5, view_box: [0, 0, 1, 0.75],
rooms: [{
id: 'room', name: 'Room', area: null,
poly: [[0.2, 0.2], [0.6, 0.2], [0.6, 0.6], [0.2, 0.6]],
}],
room_drafts: [], openings: [], open_spans: [],
partitions: [
{ id: 'vertical', a: [0.2, 0.05], b: [0.2, 0.6], cm: 20 },
{ id: 'horizontal', a: [0.2, 0.6], b: [0.9, 0.6], cm: 20 },
],
settings: { fill_mode: 'none', show_borders: true, show_names: false },
}],
markers: [], settings: {},
};
card._serverCfg = JSON.parse(JSON.stringify(parityCfg));
card._layout = {};
card._space = 'axis-parity';
card._modelCache = null;
card._frame = null;
card._activeDraftId = null;
card._path = [];
card._cfgEpoch++;
card._setMode('plan');
await update();
const staticSnapshot = () => {
const current = overlay();
return JSON.stringify({
lines: [...current.querySelectorAll('.plan-snap-line')].map((line) => [
line.getAttribute('data-key'), line.getAttribute('data-source-kind'),
line.getAttribute('x1'), line.getAttribute('y1'),
line.getAttribute('x2'), line.getAttribute('y2'),
]),
endpoints: [...current.querySelectorAll('.plan-snap-node[data-kind="endpoint"]')]
.map((node) => [
node.getAttribute('data-key'), node.getAttribute('cx'), node.getAttribute('cy'),
]),
});
};
const tools = [
'select', 'draw', 'column', 'merge', 'split', 'resize',
'opening', 'boundary', 'wallthick', 'delroom',
];
let expectedSnapshot = null;
let everyToolMatches = true;
let everyToolIsPassive = true;
let everyToolPaintsAboveWalls = true;
let thicknessHasSixNodes = false;
for (const tool of tools) {
card._tool = tool;
card._clearPlanSnapHover();
await update();
const current = overlay();
const bodies = root().querySelector('.wallbodies');
const snapshot = current ? staticSnapshot() : null;
if (expectedSnapshot == null) expectedSnapshot = snapshot;
everyToolMatches &&= !!current && snapshot === expectedSnapshot;
everyToolIsPassive &&= current?.getAttribute('pointer-events') === 'none'
&& getComputedStyle(current).pointerEvents === 'none'
&& !current.querySelector('.plan-snap-node[data-active="true"]');
everyToolPaintsAboveWalls &&= !!bodies && !!current
&& !!(bodies.compareDocumentPosition(current) & Node.DOCUMENT_POSITION_FOLLOWING);
if (tool === 'wallthick') {
thicknessHasSixNodes = current.querySelectorAll(
'.plan-snap-node[data-kind="endpoint"]',
).length === 6 && current.querySelectorAll('.plan-snap-line').length === 6;
}
}
result.allPlanToolsShareStaticAxesAndNodes = everyToolMatches;
result.allPlanToolOverlaysStayPointerTransparent = everyToolIsPassive;
result.allPlanToolOverlaysPaintAboveWallBodies = everyToolPaintsAboveWalls;
result.thicknessShowsCompleteSixNodeFixture = thicknessHasSixNodes;
card._setMode('devices');
await update();
const devicesHaveNoPlanOverlay = !overlay();
card._setMode('decor');
await update();
const decorHasNoPlanOverlay = !overlay();
card._setMode('view');
await update();
result.viewHasNoOverlay = !overlay()
result.nonPlanModesHaveNoOverlay = devicesHaveNoPlanOverlay && decorHasNoPlanOverlay
&& !overlay()
&& !root().querySelector('[data-hp="hidden-wall-diagnostic"]');
return result;
+4 -3
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- Every Plan editor tool now shows the same complete set of wall axes and
structural endpoints. Switching from Walls to Thickness no longer hides room
axes, while each tool keeps its own hints and live preview
([#304](https://github.com/Matysh/houseplan-card/issues/304)).
- Re-importing an already imported space no longer grows nested internal-ID
prefixes. Preview and Apply now share one immutable candidate, safely repair
only uniquely proven references to earlier copies, and preserve ambiguous
+4
View File
@@ -8,6 +8,10 @@
## Не выпущено
- Во всех инструментах редактора Плана теперь отображается один и тот же полный
набор осевых линий и узлов стен. При переходе из «Стены» в «Толщина» оси
комнат больше не исчезают, а подсказки и предпросмотр активного инструмента
остаются независимыми ([#304](https://github.com/Matysh/houseplan-card/issues/304)).
- Повторный импорт уже импортированного пространства больше не наращивает
служебные префиксы внутренних ID. Предпросмотр и применение теперь используют
один и тот же неизменяемый кандидат, безопасно восстанавливают единственные
+1 -1
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.67.0-rc.2 candidate** everywhere (manifest, const.py, package.json, CARD_VERSION) |
| Current local cycle | v1.67.0-rc.1 is published. Current `dev` makes `Esc` detach an accepted Walls chain without deleting it (#294), removes only proven-redundant hidden wall geometry during Optimize (#296), preserves neighbouring thickness records through Resize (#298), keeps wall-thickness records split at shared/outer role boundaries (#299), and limits Resize labels, highlights and room-area readouts to the geometry affected by the gesture (#300). v1.67.0-rc.2 packages these S8 changes and remains gated by the full exact-SHA Validate before publication. |
| Current local cycle | v1.67.0-rc.1 is published. Current `dev` makes `Esc` detach an accepted Walls chain without deleting it (#294), removes only proven-redundant hidden wall geometry during Optimize (#296), preserves neighbouring thickness records through Resize (#298), keeps wall-thickness records split at shared/outer role boundaries (#299), and limits Resize labels, highlights and room-area readouts to the geometry affected by the gesture (#300). v1.67.0-rc.2 packages these S8 changes. The next candidate also keeps the complete wall-axis/endpoint overlay identical across every Plan tool (#304) after normal issue review. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+7 -5
View File
@@ -716,11 +716,13 @@ when every one of its segments is fully redundant; a free, partly covered or
thicker chain remains byte-for-byte unchanged. The report counts absorbed
independent-wall sections and removed whole chains, not source records.
In the Plan editor, an independent wall or saved chain hidden under other wall
bodies retains a thin centre axis and its original endpoint nodes above the
masonry. These pointer-transparent diagnostics do not change snapping or
selection and are absent from View. They disappear after Apply only when the
corresponding independent geometry was safely absorbed or removed.
Every Plan editor tool draws room, independent-wall and saved-chain centre axes
and endpoint nodes through the same layer above wall bodies. An independent
wall or saved chain hidden under other masonry additionally retains its source
diagnostic axis and nodes. These pointer-transparent layers do not change
snapping or selection and are absent outside the Plan editor. The diagnostic
disappears after Apply only when the corresponding independent geometry was
safely absorbed or removed.
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
+6 -4
View File
@@ -1500,10 +1500,12 @@ show_signal: true
Отчёт считает поглощённые максимальные участки независимых стен, а не число
исходных записей, и отдельно — только целиком удалённые сохранённые цепочки.
В редакторе Плана ось и исходные узлы независимой стены или цепочки, скрытой под
другой кладкой, рисуются поверх всех стен. Этот диагностический слой не
перехватывает указатель, не меняет привязку и отсутствует в режиме просмотра;
после Apply он исчезает только вместе с доказанно избыточной геометрией.
Во всех инструментах редактора Плана оси и конечные узлы комнат, независимых
стен и сохранённых цепочек рисуются одним и тем же слоем поверх тел стен. Для
независимой стены или цепочки, скрытой под другой кладкой, дополнительно
сохраняется её исходная диагностическая ось и узлы. Эти слои не перехватывают
указатель, не меняют привязку и отсутствуют вне редактора Плана; после Apply
диагностика исчезает только вместе с доказанно избыточной геометрией.
<!-- docs-section: multiple-cards -->
+5 -2
View File
@@ -17575,7 +17575,10 @@ class HouseplanCard extends LitElement {
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderOpeningPlacementPreview()}</g>` : nothing}
${opMeasure ? this._renderOpeningDimensionGuides(opMeasure) : nothing}
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
${this._markup && this._tool === 'draw' ? svg`<g class="hp-editor-only-layer"
${''/* Static architectural axes and endpoints are a Plan-editor
invariant. Tool-specific hover/snap state is resolved inside
the overlay and remains exclusive to Walls. */}
${this._markup ? svg`<g class="hp-editor-only-layer"
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderPlanSnapOverlay()}</g>` : nothing}
${disp.hideOpenings && !this._markup
? nothing
@@ -19838,7 +19841,7 @@ class HouseplanCard extends LitElement {
}
private _renderPlanSnapOverlay(): TemplateResult {
if (!this._markup || this._tool !== 'draw') {
if (!this._markup) {
return svg`` as unknown as TemplateResult;
}
const geometry = this._planSnapGeometrySnapshot().value;
+25
View File
@@ -33,6 +33,31 @@ test('collector includes room rectangles, polygons, saved drafts and partitions'
assert.ok(geometry.endpoints.some((entry) => entry.point[0] === 150 && entry.point[1] === 100));
});
test('room with two coincident extended partitions keeps six structural endpoints', () => {
const geometry = buildPlanSnapGeometry({
space: space({
rooms: [{
id: 'room', area: null,
poly: [[200, 200], [600, 200], [600, 600], [200, 600]],
}],
partitions: [
{ id: 'vertical', a: [200, 50], b: [200, 600], cm: 20 },
{ id: 'horizontal', a: [200, 600], b: [900, 600], cm: 20 },
],
}),
});
assert.equal(geometry.segments.length, 6);
assert.deepEqual(geometry.endpoints.map((entry) => entry.point)
.sort((left, right) => left[0] - right[0] || left[1] - right[1]), [
[200, 50],
[200, 200],
[200, 600],
[600, 200],
[600, 600],
[900, 600],
]);
});
test('coincident endpoints and axes are deduplicated independently of direction', () => {
const geometry = buildPlanSnapGeometry({
space: space({