mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
fix: resolve chain segment thickness in one place
Issue: #234 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Issue #234: a chain stores the thickness it was drawn with.
|
||||||
|
*
|
||||||
|
* The defect: "thickness of segment i" was decided by six independent formulas
|
||||||
|
* with three different fallbacks, so a chain drawn at 30 cm could be previewed
|
||||||
|
* at 30 and written as 15. The check below builds a chain whose last segment has
|
||||||
|
* no recorded thickness — the shape a mid-edit toolbar field used to produce —
|
||||||
|
* and then reads three things that used to disagree: the committed partitions,
|
||||||
|
* what the Thickness tool reports for the same stretch, and what a resumed
|
||||||
|
* legacy draft carries.
|
||||||
|
*/
|
||||||
|
import { launch, checkAll, finish } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const out = {};
|
||||||
|
const c = window.__card;
|
||||||
|
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
|
||||||
|
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||||
|
|
||||||
|
c._setMode('plan');
|
||||||
|
c._tool = 'draw';
|
||||||
|
await upd();
|
||||||
|
|
||||||
|
const space = sp();
|
||||||
|
const savedRooms = JSON.parse(JSON.stringify(space.rooms || []));
|
||||||
|
const savedPartitions = JSON.parse(JSON.stringify(space.partitions || []));
|
||||||
|
const savedDrafts = space.room_drafts ? JSON.parse(JSON.stringify(space.room_drafts)) : null;
|
||||||
|
space.rooms = [];
|
||||||
|
space.partitions = [];
|
||||||
|
delete space.room_drafts;
|
||||||
|
await upd();
|
||||||
|
|
||||||
|
const g = c._gridPitch;
|
||||||
|
const pts = [
|
||||||
|
[100, 100],
|
||||||
|
[100 + 6 * g, 100],
|
||||||
|
[100 + 6 * g, 100 + 6 * g],
|
||||||
|
[100 + 12 * g, 100 + 6 * g],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Two recorded segments at 30 and 25 cm, then a third point whose thickness
|
||||||
|
// is missing: exactly what a field left mid-edit produced before #234.
|
||||||
|
c._drawWallField = '30';
|
||||||
|
await upd();
|
||||||
|
out.fieldAcceptedThirty = c._drawWallCm === 30;
|
||||||
|
c._activeDraftId = null;
|
||||||
|
c._path = [pts[0], pts[1], pts[2], pts[3]];
|
||||||
|
c._draftSegmentCms = [30, 25];
|
||||||
|
c._closingWallCm = null;
|
||||||
|
await upd();
|
||||||
|
|
||||||
|
// Preview and the writer must agree on that third segment.
|
||||||
|
const previewD = (c.shadowRoot || c.renderRoot)
|
||||||
|
?.querySelector('path.drawwall-preview')?.getAttribute('d') || '';
|
||||||
|
out.previewRendered = previewD.length > 0;
|
||||||
|
|
||||||
|
out.committed = c._finishWallChain();
|
||||||
|
await upd();
|
||||||
|
const stored = (sp().partitions || []).map((p) => p.cm);
|
||||||
|
out.storedThreeSegments = stored.length === 3;
|
||||||
|
out.storedFirstThirty = stored[0] === 30;
|
||||||
|
out.storedSecondTwentyFive = stored[1] === 25;
|
||||||
|
// The gap inherits the previous segment, never the global default.
|
||||||
|
out.gapInheritedPrevious = stored[2] === 25;
|
||||||
|
out.nothingFellBackToFifteen = !stored.includes(15);
|
||||||
|
|
||||||
|
// The Thickness tool reads the same value it stored: the mismatch between
|
||||||
|
// highlight and record is how the defect was noticed in the first place.
|
||||||
|
const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||||
|
const path = [pts[0], pts[1], pts[2], pts[3]];
|
||||||
|
const reported = [
|
||||||
|
c._wallSourceCmAt(mid(pts[0], pts[1]), path, [30, 25]),
|
||||||
|
c._wallSourceCmAt(mid(pts[1], pts[2]), path, [30, 25]),
|
||||||
|
c._wallSourceCmAt(mid(pts[2], pts[3]), path, [30, 25]),
|
||||||
|
];
|
||||||
|
out.highlightMatchesStored = reported[0] === stored[0]
|
||||||
|
&& reported[1] === stored[1] && reported[2] === stored[2];
|
||||||
|
|
||||||
|
// A draft saved with fewer records than segments (data written before this
|
||||||
|
// fix) resumes with a full vector instead of carrying a hidden 15 cm.
|
||||||
|
space.partitions = [];
|
||||||
|
space.room_drafts = [{
|
||||||
|
id: 'legacy-234',
|
||||||
|
points: pts.map((p) => [p[0] / 1000, p[1] / 1000]),
|
||||||
|
segments: [{ cm: 30 }],
|
||||||
|
}];
|
||||||
|
c._resumeDraftBySpace[c._space] = 'legacy-234';
|
||||||
|
c._path = [];
|
||||||
|
c._draftSegmentCms = [];
|
||||||
|
c._activeDraftId = null;
|
||||||
|
await upd();
|
||||||
|
c._resumeLastDraft();
|
||||||
|
await upd();
|
||||||
|
out.legacyResumedFullVector = c._draftSegmentCms.length === c._path.length - 1;
|
||||||
|
out.legacyGapsInheritedThirty = c._draftSegmentCms.every((cm) => cm === 30);
|
||||||
|
|
||||||
|
c._path = [];
|
||||||
|
c._draftSegmentCms = [];
|
||||||
|
c._activeDraftId = null;
|
||||||
|
delete c._resumeDraftBySpace[c._space];
|
||||||
|
space.rooms = savedRooms;
|
||||||
|
space.partitions = savedPartitions;
|
||||||
|
if (savedDrafts) space.room_drafts = savedDrafts; else delete space.room_drafts;
|
||||||
|
await upd();
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
checkAll(res);
|
||||||
|
await finish(browser);
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+69
-69
File diff suppressed because one or more lines are too long
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- A chain of walls now stores the thickness it was drawn with. A segment whose
|
||||||
|
thickness was not recorded — which happened while the toolbar field was being
|
||||||
|
edited between segments — used to be previewed at the drawn value and saved at
|
||||||
|
15 cm, and the plan only revealed it later when the Thickness tool reported a
|
||||||
|
number nobody had chosen. Preview, both writers and the Thickness highlight now
|
||||||
|
read one resolver, and a missing record inherits the previous segment of the
|
||||||
|
same chain ([#234](https://github.com/Matysh/houseplan-card/issues/234)).
|
||||||
|
|
||||||
- Wall hatching no longer changes density with the space's grid scale. The same
|
- Wall hatching no longer changes density with the space's grid scale. The same
|
||||||
15 cm wall used to look different on plans with different `cell_cm` — from a
|
15 cm wall used to look different on plans with different `cell_cm` — from a
|
||||||
dense set of stripes to almost none, a 25× spread. The step is now a distance
|
dense set of stripes to almost none, a 25× spread. The step is now a distance
|
||||||
|
|||||||
@@ -8,6 +8,14 @@
|
|||||||
|
|
||||||
## Не выпущено
|
## Не выпущено
|
||||||
|
|
||||||
|
- Цепочка стен сохраняет ту толщину, которой её нарисовали. Отрезок, толщина
|
||||||
|
которого не была записана — это случалось, если поле толщины правили между
|
||||||
|
отрезками, — показывался с нарисованной толщиной, а сохранялся с 15 см, и
|
||||||
|
расхождение обнаруживалось позже: инструмент «Толщина» подсвечивал значение,
|
||||||
|
которого никто не выбирал. Теперь превью, оба пути записи и подсветка читают
|
||||||
|
один резолвер, а пропуск наследует толщину предыдущего отрезка той же цепочки
|
||||||
|
([#234](https://github.com/Matysh/houseplan-card/issues/234)).
|
||||||
|
|
||||||
- Плотность штриховки стен больше не зависит от масштаба пространства. Раньше
|
- Плотность штриховки стен больше не зависит от масштаба пространства. Раньше
|
||||||
одна и та же стена 15 см выглядела на планах с разным `cell_cm` по-разному —
|
одна и та же стена 15 см выглядела на планах с разным `cell_cm` по-разному —
|
||||||
от плотной сетки полос до почти пустой полосы, разброс достигал 25 раз.
|
от плотной сетки полос до почти пустой полосы, разброс достигал 25 раз.
|
||||||
|
|||||||
@@ -40,6 +40,47 @@ import { fileURLToPath } from 'node:url';
|
|||||||
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
|
||||||
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
|
||||||
export const MUTANTS = [
|
export const MUTANTS = [
|
||||||
|
{
|
||||||
|
id: 'chain-thickness-falls-back-to-default',
|
||||||
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
+ '&& node --test --test-name-pattern="chainSegmentCms fills a gap" '
|
||||||
|
+ 'test/wall-face-graph.test.mjs',
|
||||||
|
because: 'a segment whose thickness was not recorded must inherit the previous segment of '
|
||||||
|
+ 'its own chain — the value the person watched on screen — instead of the global default '
|
||||||
|
+ 'that silently replaced 30 cm with 15 cm in the stored plan (#234)',
|
||||||
|
patches: [{
|
||||||
|
file: 'src/wall-face-graph.ts',
|
||||||
|
find: ' const cm = own ?? previous ?? fallbackTail;',
|
||||||
|
replace: ' const cm = own ?? fallbackTail;',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chain-thickness-preview-diverges',
|
||||||
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
+ '&& node --test --test-name-pattern="owns no fallback of its own" '
|
||||||
|
+ 'test/wall-face-graph.test.mjs',
|
||||||
|
because: 'the preview and every writer must read one resolver; two formulas for one meaning '
|
||||||
|
+ 'is what let the editor show 30 cm while the config kept 15 (#234)',
|
||||||
|
patches: [{
|
||||||
|
file: 'src/wall-face-graph.ts',
|
||||||
|
find: ' result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm: cms[i] });',
|
||||||
|
replace: ' result.push({ a: [a[0], a[1]], b: [b[0], b[1]], '
|
||||||
|
+ 'cm: Number.isFinite(cms[i]) && cms[i] >= 0 ? cms[i] : 15 });',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chain-thickness-length-invariant-dropped',
|
||||||
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
+ '&& node --test --test-name-pattern="always returns exactly segmentCount" '
|
||||||
|
+ 'test/wall-face-graph.test.mjs',
|
||||||
|
because: 'the resolver must return one thickness per segment for any input: the array drifting '
|
||||||
|
+ 'shorter than the path is the mechanism that produced the whole defect (#234)',
|
||||||
|
patches: [{
|
||||||
|
file: 'src/wall-face-graph.ts',
|
||||||
|
find: ' for (let i = 0; i < count; i++) {',
|
||||||
|
replace: ' for (let i = 0; i < Math.min(count, (recorded?.length ?? 0)); i++) {',
|
||||||
|
}],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'snapn-returns-input-near-node',
|
id: 'snapn-returns-input-near-node',
|
||||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
|||||||
+96
-28
@@ -158,7 +158,7 @@ import {
|
|||||||
} from './plan-snap-overlay';
|
} from './plan-snap-overlay';
|
||||||
import {
|
import {
|
||||||
atomizeWallSegments, buildWallFaceGraph, findNewWallFacesInGraphs,
|
atomizeWallSegments, buildWallFaceGraph, findNewWallFacesInGraphs,
|
||||||
normalizeUnifiedWallTool, wallChainSegments,
|
normalizeUnifiedWallTool, wallChainSegments, chainSegmentCms,
|
||||||
type WallFaceGraph, type WallGraphFace, type WallGraphSourceSegment,
|
type WallFaceGraph, type WallGraphFace, type WallGraphSourceSegment,
|
||||||
} from './wall-face-graph';
|
} from './wall-face-graph';
|
||||||
import {
|
import {
|
||||||
@@ -2617,7 +2617,9 @@ class HouseplanCard extends LitElement {
|
|||||||
if (draft) {
|
if (draft) {
|
||||||
this._activeDraftId = draft.id;
|
this._activeDraftId = draft.id;
|
||||||
this._path = draft.points.map((point) => [...point]);
|
this._path = draft.points.map((point) => [...point]);
|
||||||
this._draftSegmentCms = draft.segments.map((segment) => segment.cm);
|
this._draftSegmentCms = this._adoptDraftCms(
|
||||||
|
this._path, draft.segments.map((segment: any) => segment.cm), draft.id,
|
||||||
|
);
|
||||||
this._resumeDraftBySpace[this._space] = draft.id;
|
this._resumeDraftBySpace[this._space] = draft.id;
|
||||||
} else {
|
} else {
|
||||||
this._activeDraftId = null;
|
this._activeDraftId = null;
|
||||||
@@ -6574,7 +6576,11 @@ class HouseplanCard extends LitElement {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const segments = wallChainSegments(
|
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;
|
const segmentCount = segments.length;
|
||||||
if ((sp.partitions || []).length + segmentCount > MAX_PARTITIONS) {
|
if ((sp.partitions || []).length + segmentCount > MAX_PARTITIONS) {
|
||||||
@@ -7268,9 +7274,13 @@ class HouseplanCard extends LitElement {
|
|||||||
this._path = endHit.reverse
|
this._path = endHit.reverse
|
||||||
? [...endHit.draft.points].reverse().map((p) => [...p])
|
? [...endHit.draft.points].reverse().map((p) => [...p])
|
||||||
: endHit.draft.points.map((p) => [...p]);
|
: endHit.draft.points.map((p) => [...p]);
|
||||||
this._draftSegmentCms = endHit.reverse
|
this._draftSegmentCms = this._adoptDraftCms(
|
||||||
? [...endHit.draft.segments].reverse().map((s) => s.cm)
|
this._path,
|
||||||
: endHit.draft.segments.map((s) => s.cm);
|
endHit.reverse
|
||||||
|
? [...endHit.draft.segments].reverse().map((s) => s.cm)
|
||||||
|
: endHit.draft.segments.map((s) => s.cm),
|
||||||
|
endHit.draft.id,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._activeDraftId = null;
|
this._activeDraftId = null;
|
||||||
@@ -7289,8 +7299,16 @@ class HouseplanCard extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this._canAppendRoomDraftPoint()) 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]);
|
const beforePath = this._path.map((point) => [...point]);
|
||||||
this._path = [...this._path, pt];
|
this._path = [...this._path, pt];
|
||||||
|
this._draftSegmentCms = [...this._draftSegmentCms, cm];
|
||||||
this._persistActiveDraftSegment();
|
this._persistActiveDraftSegment();
|
||||||
this._offerWallFaces(beforePath);
|
this._offerWallFaces(beforePath);
|
||||||
}
|
}
|
||||||
@@ -7395,8 +7413,10 @@ class HouseplanCard extends LitElement {
|
|||||||
sp.room_drafts.push(saved);
|
sp.room_drafts.push(saved);
|
||||||
this._activeDraftId = id;
|
this._activeDraftId = id;
|
||||||
this._resumeDraftBySpace[this._space] = id;
|
this._resumeDraftBySpace[this._space] = id;
|
||||||
this._draftSegmentCms = persistedSegments.map((s: any) => Number(s.cm));
|
|
||||||
this._path = persistedPoints;
|
this._path = persistedPoints;
|
||||||
|
this._draftSegmentCms = this._adoptDraftCms(
|
||||||
|
persistedPoints, persistedSegments.map((s: any) => Number(s.cm)), id,
|
||||||
|
);
|
||||||
this._physicalSel = null;
|
this._physicalSel = null;
|
||||||
this._recordGeometry(this._t('history.draft_merge'), before);
|
this._recordGeometry(this._t('history.draft_merge'), before);
|
||||||
this._saveConfig();
|
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 {
|
private _persistActiveDraftSegment(): void {
|
||||||
if (this._path.length < 2 || !this._curSpaceCfg) return;
|
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 before = this._geometrySnapshot();
|
||||||
const sp = this._curSpaceCfg as any;
|
const sp = this._curSpaceCfg as any;
|
||||||
sp.room_drafts ||= [];
|
sp.room_drafts ||= [];
|
||||||
@@ -12383,10 +12426,17 @@ class HouseplanCard extends LitElement {
|
|||||||
return validCm(model.room_drafts.find((item) => item.id === draftId)?.segments[index]?.cm)
|
return validCm(model.room_drafts.find((item) => item.id === draftId)?.segments[index]?.cm)
|
||||||
?? DRAW_WALL_DEFAULT_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++) {
|
for (let i = 0; i + 1 < activePath.length; i++) {
|
||||||
const a = activePath[i], b = activePath[i + 1];
|
const a = activePath[i], b = activePath[i + 1];
|
||||||
if (distToSegment(point, [a[0], a[1], b[0], b[1]]) <= epsilon) {
|
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;
|
return DRAW_WALL_DEFAULT_CM;
|
||||||
@@ -12441,7 +12491,11 @@ class HouseplanCard extends LitElement {
|
|||||||
const partitions: Array<{ a: number[]; b: number[]; cm: number }> = [];
|
const partitions: Array<{ a: number[]; b: number[]; cm: number }> = [];
|
||||||
if (!accepted.length) {
|
if (!accepted.length) {
|
||||||
partitions.push(...wallChainSegments(
|
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 {
|
} else {
|
||||||
const consumed = new Set(accepted.flatMap((decision) => decision.candidate.atomKeys));
|
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
|
// Draw-session wall thickness: apply to new edges only; keep neighbour cm
|
||||||
// on shared stretches. Split naming does not use the Draw field.
|
// on shared stretches. Split naming does not use the Draw field.
|
||||||
if (!wasSplit) {
|
if (!wasSplit) {
|
||||||
const edgeCms = [...this._draftSegmentCms, this._closingWallCm || this._drawWallCm || DRAW_WALL_DEFAULT_CM];
|
const edgeCms = chainSegmentCms(
|
||||||
const cm = edgeCms[0] || this._drawWallCm;
|
verts.length,
|
||||||
|
[...this._draftSegmentCms, this._closingWallCm ?? undefined],
|
||||||
|
this._drawWallCm, DRAW_WALL_DEFAULT_CM,
|
||||||
|
);
|
||||||
|
const cm = edgeCms[0];
|
||||||
if (cm != null) {
|
if (cm != null) {
|
||||||
this._cfgEpoch++; // the new room must be in the model before keying
|
this._cfgEpoch++; // the new room must be in the model before keying
|
||||||
const openCuts = this._openCuts();
|
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;
|
return distToSegment(mid, [a[0], a[1], b[0], b[1]]) <= this._gridPitch * 0.02;
|
||||||
});
|
});
|
||||||
if (source >= 0) next = setWallThickness(
|
if (source >= 0) next = setWallThickness(
|
||||||
next, iv.a, iv.b, edgeCms[source] || cm,
|
next, iv.a, iv.b, edgeCms[source],
|
||||||
this._wallKeyPitch, NORM_W,
|
this._wallKeyPitch, NORM_W,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12759,7 +12817,9 @@ class HouseplanCard extends LitElement {
|
|||||||
if (!draft) { delete this._resumeDraftBySpace[this._space]; return; }
|
if (!draft) { delete this._resumeDraftBySpace[this._space]; return; }
|
||||||
this._activeDraftId = id;
|
this._activeDraftId = id;
|
||||||
this._path = draft.points.map((p) => [...p]);
|
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();
|
this._clearPlanSnapHover();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13029,8 +13089,14 @@ class HouseplanCard extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const before = this._geometrySnapshot();
|
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 ||= [];
|
sp.partitions ||= [];
|
||||||
const seed = Date.now().toString(36);
|
const seed = Date.now().toString(36);
|
||||||
for (let i = 0; i < verts.length; i++) {
|
for (let i = 0; i < verts.length; i++) {
|
||||||
@@ -13039,7 +13105,7 @@ class HouseplanCard extends LitElement {
|
|||||||
id: `partition-${seed}-${i}`,
|
id: `partition-${seed}-${i}`,
|
||||||
a: [a[0] / NORM_W, a[1] / NORM_W],
|
a: [a[0] / NORM_W, a[1] / NORM_W],
|
||||||
b: [b[0] / NORM_W, b[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)) {
|
if (this._activeDraftId && Array.isArray(sp.room_drafts)) {
|
||||||
@@ -18544,15 +18610,17 @@ class HouseplanCard extends LitElement {
|
|||||||
if (this._cursorPt) return [...path, this._cursorPt];
|
if (this._cursorPt) return [...path, this._cursorPt];
|
||||||
return path.length >= 2 ? path : null;
|
return path.length >= 2 ? path : null;
|
||||||
})();
|
})();
|
||||||
|
// Превью берёт толщины из того же резолвера, что и запись (#234). Раньше
|
||||||
|
// здесь была вторая формула, и расходились они ровно на пропуске: на экране
|
||||||
|
// текущее поле, в конфиге — 15 см.
|
||||||
const previewHalfDepths = previewPts
|
const previewHalfDepths = previewPts
|
||||||
? previewPts.slice(0, -1).map((_, i) => {
|
? chainSegmentCms(
|
||||||
const cm = Number(this._draftSegmentCms[i]) > 0
|
previewPts.length - 1,
|
||||||
? Number(this._draftSegmentCms[i])
|
this._contourClosed
|
||||||
: this._contourClosed && i === previewPts.length - 2
|
? [...this._draftSegmentCms, this._closingWallCm ?? undefined]
|
||||||
? (this._closingWallCm || drawCm || DRAW_WALL_DEFAULT_CM)
|
: this._draftSegmentCms,
|
||||||
: (drawCm || DRAW_WALL_DEFAULT_CM);
|
drawCm, DRAW_WALL_DEFAULT_CM,
|
||||||
return wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2;
|
).map((cm) => wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2)
|
||||||
})
|
|
||||||
: [];
|
: [];
|
||||||
const previewD = previewPts
|
const previewD = previewPts
|
||||||
? drawWallPreviewD(
|
? drawWallPreviewD(
|
||||||
|
|||||||
+54
-3
@@ -55,10 +55,60 @@ export function normalizeUnifiedWallTool(value: unknown): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Immutable open-chain projection used by explicit finish and full rejection. */
|
/** 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(
|
export function wallChainSegments(
|
||||||
path: readonly (readonly number[])[],
|
path: readonly (readonly number[])[],
|
||||||
cms: readonly number[],
|
cms: readonly number[],
|
||||||
defaultCm: number,
|
|
||||||
): WallChainSegment[] {
|
): WallChainSegment[] {
|
||||||
const result: WallChainSegment[] = [];
|
const result: WallChainSegment[] = [];
|
||||||
for (let i = 0; i + 1 < path.length; i++) {
|
for (let i = 0; i + 1 < path.length; i++) {
|
||||||
@@ -66,8 +116,9 @@ export function wallChainSegments(
|
|||||||
const b = path[i + 1];
|
const b = path[i + 1];
|
||||||
if (!finitePoint(a) || !finitePoint(b)
|
if (!finitePoint(a) || !finitePoint(b)
|
||||||
|| Math.hypot(b[0] - a[0], b[1] - a[1]) <= Number.EPSILON) continue;
|
|| 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;
|
// The resolver guarantees a positive number per index; a caller that skips
|
||||||
result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm });
|
// 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
findNewWallFacesInGraphs,
|
findNewWallFacesInGraphs,
|
||||||
normalizeUnifiedWallTool,
|
normalizeUnifiedWallTool,
|
||||||
wallChainSegments,
|
wallChainSegments,
|
||||||
|
chainSegmentCms,
|
||||||
} from '../test-build/wall-face-graph.js';
|
} from '../test-build/wall-face-graph.js';
|
||||||
|
|
||||||
const edge = (key, a, b) => ({ key, a, b });
|
const edge = (key, a, b) => ({ key, a, b });
|
||||||
@@ -24,14 +25,76 @@ test('legacy Partition token becomes Walls without mutating other tools', () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('open chain converts endpoints and per-segment thickness without mutation', () => {
|
test('open chain converts endpoints and per-segment thickness without mutation', () => {
|
||||||
|
// Толщины приходят уже разрешёнными (#234): своего fallback у функции нет.
|
||||||
const path = [[0, 0], [100, 0], [100, 50]];
|
const path = [[0, 0], [100, 0], [100, 50]];
|
||||||
const before = JSON.stringify(path);
|
const before = JSON.stringify(path);
|
||||||
assert.deepEqual(wallChainSegments(path, [0, 25], 15), [
|
assert.deepEqual(wallChainSegments(path, [30, 25]), [
|
||||||
{ a: [0, 0], b: [100, 0], cm: 0 },
|
{ a: [0, 0], b: [100, 0], cm: 30 },
|
||||||
{ a: [100, 0], b: [100, 50], cm: 25 },
|
{ a: [100, 0], b: [100, 50], cm: 25 },
|
||||||
]);
|
]);
|
||||||
assert.equal(JSON.stringify(path), before);
|
assert.equal(JSON.stringify(path), before);
|
||||||
assert.deepEqual(wallChainSegments([[0, 0]], [], 15), []);
|
assert.deepEqual(wallChainSegments([[0, 0]], []), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chainSegmentCms fills a gap from the previous segment, then the field (#234)', () => {
|
||||||
|
// Симптом задачи: цепочка нарисована 30 см, запись последнего отрезка
|
||||||
|
// потеряна. Раньше запись давала 15, превью — 30; теперь ответ один.
|
||||||
|
assert.deepEqual(chainSegmentCms(3, [30, 30], 30, 15), [30, 30, 30]);
|
||||||
|
// Дырка в середине наследует предыдущий, а не следующий и не дефолт.
|
||||||
|
assert.deepEqual(chainSegmentCms(4, [30, undefined, 20], 12, 15), [30, 30, 20, 20]);
|
||||||
|
// Нет ни одной записи — текущее поле.
|
||||||
|
assert.deepEqual(chainSegmentCms(2, [], 22, 15), [22, 22]);
|
||||||
|
// Нет и поля — дефолт вызывающего.
|
||||||
|
assert.deepEqual(chainSegmentCms(2, [], null, 15), [15, 15]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chainSegmentCms treats only strictly positive records as valid (#234)', () => {
|
||||||
|
// Прежняя wallChainSegments считала записанный 0 валидным; через UI ноль
|
||||||
|
// недостижим (1..100 см), но в старом черновике лежать может.
|
||||||
|
assert.deepEqual(chainSegmentCms(3, [0, 30, 0], 25, 15), [25, 30, 30]);
|
||||||
|
assert.deepEqual(chainSegmentCms(4, [NaN, -5, null, 'x'], 18, 15), [18, 18, 18, 18]);
|
||||||
|
assert.deepEqual(chainSegmentCms(2, [Infinity, 40], 18, 15), [18, 40]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chainSegmentCms always returns exactly segmentCount positive numbers (#234)', () => {
|
||||||
|
// Инвариант: длина результата равна числу отрезков при любом входе — именно
|
||||||
|
// его отсутствие и позволяло массиву разъехаться с путём.
|
||||||
|
for (const count of [0, 1, 5]) {
|
||||||
|
for (const recorded of [[], [30], [30, 30, 30, 30, 30, 30, 30], [null, 0, NaN]]) {
|
||||||
|
const out = chainSegmentCms(count, recorded, 20, 15);
|
||||||
|
assert.equal(out.length, count);
|
||||||
|
assert.ok(out.every((cm) => typeof cm === 'number' && cm > 0), JSON.stringify(out));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Мусор в самих аргументах длины и дефолта не роняет резолвер.
|
||||||
|
assert.deepEqual(chainSegmentCms(-1, [30], 20, 15), []);
|
||||||
|
assert.deepEqual(chainSegmentCms(2, [30], null, NaN), [30, 30]);
|
||||||
|
assert.deepEqual(chainSegmentCms(2, null, null, null), [1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wallChainSegments owns no fallback of its own (#234)', () => {
|
||||||
|
// Свойство, а не курьёз: единственный источник значения — резолвер. Если у
|
||||||
|
// этой функции появится собственный дефолт, вернётся ровно та ситуация, из
|
||||||
|
// которой выросла задача — две формулы для одного смысла. Поэтому пропуск
|
||||||
|
// здесь обязан остаться пропуском, а не превратиться в 15 см.
|
||||||
|
const path = [[0, 0], [100, 0], [100, 50]];
|
||||||
|
const out = wallChainSegments(path, [30]);
|
||||||
|
assert.equal(out.length, 2);
|
||||||
|
assert.equal(out[0].cm, 30);
|
||||||
|
assert.equal(out[1].cm, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the preview and the writers cannot disagree, because the source is one (#234)', () => {
|
||||||
|
// Это ядро задачи, поэтому проверяется как свойство, а не как пример:
|
||||||
|
// вектор для превью и вектор для записи строятся одним вызовом.
|
||||||
|
const path = [[0, 0], [100, 0], [100, 60], [0, 60]];
|
||||||
|
const recorded = [30, 30];
|
||||||
|
const field = 30;
|
||||||
|
const resolved = chainSegmentCms(path.length - 1, recorded, field, 15);
|
||||||
|
const written = wallChainSegments(path, resolved).map((segment) => segment.cm);
|
||||||
|
assert.deepEqual(written, resolved);
|
||||||
|
// И то, что раньше расходилось: последний отрезок больше не 15.
|
||||||
|
assert.equal(written[written.length - 1], 30);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('atomizes endpoint, T, proper X and collinear overlap with provenance', () => {
|
test('atomizes endpoint, T, proper X and collinear overlap with provenance', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user