fix: keep jamb margin on partition openings

Issue: #186
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 15:43:23 +03:00
parent 804b282f5f
commit 57ba75b9da
25 changed files with 2125 additions and 1515 deletions
+98 -24
View File
@@ -142,8 +142,9 @@ import {
} from './physical-geometry';
import {
hostedOpeningIntervalsOverlap, materializePartitionOpening,
partitionOpeningJambMargin, partitionOpeningNeedsStrictValidation,
partitionOpeningCut, partitionOpeningFace, partitionOpeningHasCompositeRoomWall,
partitionPlacementIntervals, resolvePartitionOpening,
partitionPlacementIntervals, resolvePartitionOpeningCompat, resolvePartitionOpeningStrict,
type PartitionOpeningOrphanReason, type ResolvedPartitionOpening,
} from './partition-openings';
import {
@@ -250,7 +251,7 @@ import {
} from './render/opening-symbol';
import {
openingDefaultLengthCm, openingPlacementPreset, passagePlacementPreviewGeometry,
resolveOpeningPlacement, sameOpeningPlacementInput,
resolveOpeningPlacementResult, sameOpeningPlacementInput,
type OpeningPlacementCore, type OpeningPlacementPreset, type OpeningPlacementType,
} from './opening-placement';
import { safeStoredColor } from './color';
@@ -1331,10 +1332,14 @@ class HouseplanCard extends LitElement {
private _openingPresetRevision = 0;
/** Last painted hover candidate. Click may reuse it only for the same input epoch. */
private _openingHoverCandidate: OpeningPlacementCandidate | null = null;
/** Last pointer target rejected only because its independent-wall jamb was too small. */
private _openingJambBlockCm: number | null = null;
private _openingDialog: {
id?: string; // editing an existing opening
type: 'door' | 'window' | 'gate' | 'passage';
lengthCm: number;
/** Avoid opting a rounded legacy length into strict geometry on a binding-only edit. */
lengthTouched?: boolean;
contact: string;
lock: string;
invert: boolean;
@@ -7467,7 +7472,7 @@ class HouseplanCard extends LitElement {
p.b = [moved.b[0] / NORM_W, moved.b[1] / NORM_W];
for (const opening of sp.openings || []) {
if (opening.host?.kind !== 'partition' || opening.host.id !== drag.id) continue;
const resolved = resolvePartitionOpening(
const resolved = resolvePartitionOpeningCompat(
opening, [moved], NORM_W, this._cellCm, this._gridPitch,
).resolved;
if (resolved) Object.assign(
@@ -7932,7 +7937,7 @@ class HouseplanCard extends LitElement {
...o, rx: o.x * NORM_W, ry: o.y * H, rlen: o.length * NORM_W,
};
if (!o.host) return [fallback];
const resolution = resolvePartitionOpening(
const resolution = resolvePartitionOpeningCompat(
o, space.partitions, NORM_W, this._cellCm, this._gridPitch,
);
if (!resolution.resolved) {
@@ -7965,7 +7970,7 @@ class HouseplanCard extends LitElement {
const cuts: PartitionOpeningCut[] = [];
for (const opening of raw) {
if (!opening.host || !accept(opening)) continue;
const resolution = resolvePartitionOpening(
const resolution = resolvePartitionOpeningCompat(
opening, space.partitions, NORM_W, this._cellCm, this._gridPitch,
);
if (resolution.resolved) cuts.push(partitionOpeningCut(resolution.resolved));
@@ -11298,7 +11303,7 @@ class HouseplanCard extends LitElement {
],
};
}
const core = resolveOpeningPlacement({
const resolution = resolveOpeningPlacementResult({
pointer: [raw[0], raw[1]],
preset,
geometryRevision: this._cfgEpoch,
@@ -11308,11 +11313,15 @@ class HouseplanCard extends LitElement {
bodyPointerPadding: this._cssPxToRender(this._boundaryCoarse ? 10 : 6),
gridStep: this._gridPitch,
});
this._openingJambBlockCm = resolution.jambBlockedTarget
? (resolution.jambBlockedTarget.physicalHalfWidth / this._gridPitch) * this._cellCm
: null;
const core = resolution.candidate;
if (!core) return null;
const faceFlipV = core.type === 'gate' ? !core.flipV : core.flipV;
let face: OpeningFaceOffset;
if (core.host) {
const hosted = resolvePartitionOpening({
const hosted = resolvePartitionOpeningCompat({
id: 'preview', type: core.type,
x: core.x / NORM_W, y: core.y / NORM_W,
angle: core.angle, length: core.renderedLength / NORM_W,
@@ -11359,6 +11368,7 @@ class HouseplanCard extends LitElement {
private _clearOpeningPlacement(clearPreset: boolean): void {
this._openingHoverCandidate = null;
this._openingJambBlockCm = null;
if (clearPreset) {
this._openingPreset = null;
this._openingRebindId = null;
@@ -11383,6 +11393,12 @@ class HouseplanCard extends LitElement {
cached, [raw[0], raw[1]], preset.revision, this._cfgEpoch,
) ? cached : this._resolveOpeningPlacement(raw);
if (!place) {
if (this._openingJambBlockCm != null) {
this._showToast(this._t('opening.partition_jamb_margin', {
distance: formatLength(this._openingJambBlockCm, this._imperial),
}));
return;
}
const eps = this._gridPitch * 1.5;
const snap = snapToWall(raw, space.rooms, eps);
if (snap && pointOnOpenCut(snap.x, snap.y, snap.angle, this._openCuts(), eps)) {
@@ -11400,6 +11416,7 @@ class HouseplanCard extends LitElement {
id: rebound.id,
type: rebound.type,
lengthCm: Math.round((rebound.length * NORM_W / this._gridPitch) * this._cellCm),
lengthTouched: false,
contact: rebound.contact || '', lock: rebound.lock || '',
invert: !!rebound.invert, flipH: !!rebound.flip_h, flipV: !!rebound.flip_v,
} : {
@@ -11421,6 +11438,7 @@ class HouseplanCard extends LitElement {
id: o.id,
type: o.type,
lengthCm: Math.round((o.rlen / this._gridPitch) * this._cellCm),
lengthTouched: false,
contact: o.contact || '',
lock: o.lock || '',
invert: !!o.invert,
@@ -11476,10 +11494,12 @@ class HouseplanCard extends LitElement {
);
if (perpendicular > this._gridPitch * 4) return;
const half = cfg.length * NORM_W / 2;
const jamb = partitionOpeningJambMargin(partition, this._cellCm, this._gridPitch);
const shoulder = half + jamb;
let along = (raw[0] - partition.a[0]) * ux + (raw[1] - partition.a[1]) * uy;
along = Math.round(along / this._gridPitch) * this._gridPitch;
along = Math.max(half, Math.min(length - half, along));
if (length < half * 2) return;
along = Math.max(shoulder, Math.min(length - shoulder, along));
if (length < shoulder * 2 - 1e-9) return;
const t = along / length;
const nx = (partition.a[0] + ux * along) / NORM_W;
const ny = (partition.a[1] + uy * along) / this._spaceH;
@@ -11612,20 +11632,39 @@ class HouseplanCard extends LitElement {
x: d.x / NORM_W,
y: d.y / H,
angle: d.angle,
length: this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W,
length: previous && !d.lengthTouched
? previous.length
: this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W,
...(d.host ? { host: { ...d.host } } : {}),
};
if (o.host) {
const resolution = resolvePartitionOpening(
o, model.partitions, NORM_W, this._cellCm, this._gridPitch,
);
const strict = partitionOpeningNeedsStrictValidation(previous, o);
const resolution = strict
? resolvePartitionOpeningStrict(
o, model.partitions, NORM_W, this._cellCm, this._gridPitch,
)
: resolvePartitionOpeningCompat(
o, model.partitions, NORM_W, this._cellCm, this._gridPitch,
);
if (!resolution.resolved) {
this._showToast(this._t('opening.partition_orphan'));
const partition = model.partitions.find((item) => item.id === o.host!.id);
if (resolution.reason === 'does-not-fit-jamb' && partition) {
const margin = partitionOpeningJambMargin(
partition, this._cellCm, this._gridPitch,
);
this._showToast(this._t('opening.partition_jamb_margin', {
distance: formatLength(
(margin / this._gridPitch) * this._cellCm, this._imperial,
),
}));
} else {
this._showToast(this._t('opening.partition_orphan'));
}
return;
}
const siblings = (sp.openings || []).flatMap((item: OpeningCfg) => {
if (!item.host || item.id === o.id) return [];
const sibling = resolvePartitionOpening(
const sibling = resolvePartitionOpeningCompat(
item, model.partitions, NORM_W, this._cellCm, this._gridPitch,
).resolved;
return sibling ? [sibling] : [];
@@ -12773,6 +12812,15 @@ class HouseplanCard extends LitElement {
});
}
}
if (e.code === 'invalid_partition_opening_jamb_margin') {
const match = String(e.message || e.error || '').match(/margin_cm=([^;]*)/);
const marginCm = match ? Number(match[1]) : NaN;
if (Number.isFinite(marginCm)) {
return this._t('opening.partition_jamb_margin', {
distance: formatLength(marginCm, this._imperial),
});
}
}
if (e.message) return e.message;
if (e.error) return e.error;
if (e.code != null) return this._t('err.code', { code: e.code });
@@ -17590,13 +17638,36 @@ class HouseplanCard extends LitElement {
private _renderOpeningDialog(): TemplateResult {
const d = this._openingDialog!;
const partitions = this._spaceModel()?.partitions || [];
const hostResolution = d.host ? resolvePartitionOpening({
const previous = d.id
? this._curSpaceCfg?.openings?.find((item: OpeningCfg) => item.id === d.id)
: null;
const dialogOpening: OpeningCfg = {
id: d.id || 'preview', type: d.type,
x: d.x / NORM_W, y: d.y / this._spaceH,
angle: d.angle, length: this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W,
host: d.host,
}, partitions, NORM_W, this._cellCm, this._gridPitch) : null;
const orphan = !!d.host && !hostResolution?.resolved;
angle: d.angle,
length: previous && !d.lengthTouched
? previous.length
: this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W,
...(d.host ? { host: d.host } : {}),
};
const strict = partitionOpeningNeedsStrictValidation(previous, dialogOpening);
const hostResolution = d.host
? strict
? resolvePartitionOpeningStrict(
dialogOpening, partitions, NORM_W, this._cellCm, this._gridPitch,
)
: resolvePartitionOpeningCompat(
dialogOpening, partitions, NORM_W, this._cellCm, this._gridPitch,
)
: null;
const jambInvalid = hostResolution?.reason === 'does-not-fit-jamb';
const hostPartition = d.host
? partitions.find((partition) => partition.id === d.host!.id)
: null;
const jambDistance = hostPartition
? formatLength(hostPartition.cm / 2, this._imperial)
: '';
const orphan = !!d.host && !hostResolution?.resolved && !jambInvalid;
const icon = d.type === 'gate' ? 'mdi:gate'
: d.type === 'window' ? 'mdi:window-closed-variant'
: d.type === 'passage' ? 'mdi:arch' : 'mdi:door';
@@ -17610,11 +17681,14 @@ class HouseplanCard extends LitElement {
@hp-close=${() => (this._openingDialog = null)}>
<div class="body">
${d.host ? html`<label>${this._t('opening.host_partition')}</label>
<div class=${orphan ? 'habindingbanner' : 'rhint'} role=${orphan ? 'status' : nothing}>
${orphan ? html`<ha-icon icon="mdi:alert-outline"></ha-icon>` : nothing}
<div class=${orphan || jambInvalid ? 'habindingbanner' : 'rhint'}
role=${orphan || jambInvalid ? 'status' : nothing}>
${orphan || jambInvalid ? html`<ha-icon icon="mdi:alert-outline"></ha-icon>` : nothing}
<span>${orphan
? this._t('opening.partition_orphan')
: this._t('opening.host_partition')}</span>
: jambInvalid
? this._t('opening.partition_jamb_margin', { distance: jambDistance })
: this._t('opening.host_partition')}</span>
</div>` : nothing}
<label>${this._t('opening.type_label')}</label>
<label class="srcrow"><input type="radio" name="optype" .checked=${d.type === 'window'}
@@ -17643,7 +17717,7 @@ class HouseplanCard extends LitElement {
<input class="namein tempin" type="number" min="20" max="600" step="5" .value=${String(d.lengthCm)}
@input=${(e: Event) => {
const n = strictNumber((e.target as HTMLInputElement).value);
if (n != null) this._openingDialog = { ...d, lengthCm: n };
if (n != null) this._openingDialog = { ...d, lengthCm: n, lengthTouched: true };
}} />
${d.type === 'passage' && (d.contact || d.lock)
+1
View File
@@ -140,6 +140,7 @@
"toast.opening_no_wall": "Click next to a room wall or independent wall",
"opening.host_partition": "Independent wall",
"opening.partition_orphan": "The independent wall for this opening no longer exists",
"opening.partition_jamb_margin": "Leave at least {distance} between the opening and the end of the wall",
"opening.rebind_partition": "Attach to another independent wall",
"confirm.delete_partition_openings_title": "Delete wall and openings?",
"confirm.delete_partition_openings_body": "This wall contains {count} opening(s). They will be deleted together.",
+1
View File
@@ -140,6 +140,7 @@
"toast.opening_no_wall": "Кликните рядом со стеной комнаты или независимой стеной",
"opening.host_partition": "Независимая стена",
"opening.partition_orphan": "Независимая стена этого проёма больше не существует",
"opening.partition_jamb_margin": "Оставьте от края проёма до торца стены минимум {distance}",
"opening.rebind_partition": "Привязать к другой независимой стене",
"confirm.delete_partition_openings_title": "Удалить стену и проёмы?",
"confirm.delete_partition_openings_body": "В стене есть проёмы: {count}. Они будут удалены вместе со стеной.",
+37 -15
View File
@@ -47,6 +47,12 @@ export interface OpeningPlacementCore {
measure: OpeningPlacementMeasureGeometry;
}
export interface OpeningPlacementResolution {
candidate: OpeningPlacementCore | null;
/** Closest independent wall rejected specifically by its jamb reserve. */
jambBlockedTarget: OpeningPlacementTarget | null;
}
export interface PassagePlacementPreviewGeometry {
rect: { x: number; y: number; width: number; height: number };
boundaries: readonly [
@@ -250,11 +256,11 @@ function targetCompare(
/** Pure hover/click resolver. It selects one bounded physical wall interval,
* projects to its canonical axis and applies the established along-wall grid
* plus centre magnet. No config, history or renderer cache is mutated. */
export function resolveOpeningPlacement(
export function resolveOpeningPlacementResult(
input: ResolveOpeningPlacementInput,
): OpeningPlacementCore | null {
): OpeningPlacementResolution {
const targets = openingPlacementTargets(input.intervals);
const eligible = targets.map((target) => {
const pointerEligible = targets.map((target) => {
const p = projection(input.pointer, target);
const envelope = Math.max(
input.baseTolerance,
@@ -262,20 +268,25 @@ export function resolveOpeningPlacement(
);
return { target, ...p, envelope };
}).filter((item) => item.distance <= item.envelope + 1e-9)
// Room-wall compatibility keeps its historical wide-gate behaviour, but
// an explicit partition host must be able to own the complete interval.
.filter((item) => !item.target.partitionHost
|| input.renderedLength <= item.length + 1e-9)
.filter((item) => !pointerInsideCollinearOpenSpan(
input.pointer,
item.target,
input.intervals,
item.envelope,
Math.max(1e-9, Math.min(input.baseTolerance, input.gridStep * 0.04)),
))
));
const jambBlocked = pointerEligible
.filter((item) => !!item.target.partitionHost
&& input.renderedLength + 2 * item.target.physicalHalfWidth > item.length + 1e-9)
.sort(targetCompare);
const eligible = pointerEligible
// Room-wall compatibility keeps its historical wide-gate behaviour. An
// explicit partition host also reserves half its physical depth per end.
.filter((item) => !item.target.partitionHost
|| input.renderedLength + 2 * item.target.physicalHalfWidth <= item.length + 1e-9)
.sort(targetCompare);
let picked = eligible[0];
if (!picked) return null;
if (!picked) return { candidate: null, jambBlockedTarget: jambBlocked[0]?.target || null };
// A room wall and an independently persisted wall may cover the same axis
// without sharing identical endpoints. In that composite case the stable
@@ -287,7 +298,8 @@ export function resolveOpeningPlacement(
const hosted = tied.filter((item) => item.target.partitionHost);
if (hosted.length) {
const hostIds = new Set(hosted.map((item) => item.target.partitionHost!.id));
if (hostIds.size !== 1 || hosted.some((item) => item.target.ambiguousPartitionHost)) return null;
if (hostIds.size !== 1 || hosted.some((item) => item.target.ambiguousPartitionHost))
return { candidate: null, jambBlockedTarget: null };
const hostPick = hosted[0];
const hx = hostPick.target.b[0] - hostPick.target.a[0];
const hy = hostPick.target.b[1] - hostPick.target.a[1];
@@ -305,15 +317,19 @@ export function resolveOpeningPlacement(
);
return parallel && offset <= 1e-9;
});
if (!composite) return null;
if (!composite) return { candidate: null, jambBlockedTarget: null };
picked = hostPick;
}
if (picked.target.ambiguousPartitionHost) return null;
if (picked.target.ambiguousPartitionHost) return { candidate: null, jambBlockedTarget: null };
const { target, length } = picked;
const dx = target.b[0] - target.a[0], dy = target.b[1] - target.a[1];
const ux = dx / length, uy = dy / length;
const half = Math.min(Math.max(0, input.renderedLength) / 2, length / 2);
const jambMargin = target.partitionHost ? target.physicalHalfWidth : 0;
const half = Math.min(
Math.max(0, input.renderedLength) / 2 + jambMargin,
length / 2,
);
let along = picked.along;
const grid = Math.max(input.gridStep, 1e-9);
const wallCenter = length / 2;
@@ -341,7 +357,7 @@ export function resolveOpeningPlacement(
else if (angle < -90) angle += 180;
const isCentered = Math.abs(along - wallCenter) <= 1e-9;
return {
return { candidate: {
presetRevision: input.preset.revision,
geometryRevision: input.geometryRevision,
pointer: [input.pointer[0], input.pointer[1]],
@@ -364,7 +380,13 @@ export function resolveOpeningPlacement(
],
guide: isCentered ? { x, y, angle } : null,
},
};
}, jambBlockedTarget: null };
}
export function resolveOpeningPlacement(
input: ResolveOpeningPlacementInput,
): OpeningPlacementCore | null {
return resolveOpeningPlacementResult(input).candidate;
}
export function sameOpeningPlacementInput(
+57 -1
View File
@@ -9,7 +9,8 @@ export type PartitionOpeningOrphanReason =
| 'missing-partition'
| 'invalid-position'
| 'invalid-length'
| 'does-not-fit';
| 'does-not-fit'
| 'does-not-fit-jamb';
export interface ResolvedPartitionOpening {
opening: OpeningCfg;
@@ -31,6 +32,13 @@ export interface PartitionOpeningResolution {
const finitePoint = (point: readonly number[] | null | undefined): point is readonly [number, number] =>
!!point && point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
/** Physical jamb reserve at either endpoint of one independent wall. */
export function partitionOpeningJambMargin(
partition: PartitionCfg, cellCm = 5, gridPitch = 5,
): number {
return wallCmToUnits(partition.cm, cellCm, gridPitch) / 2;
}
/** Resolve explicit host identity. There is intentionally no nearest-wall fallback. */
export function resolvePartitionOpening(
opening: OpeningCfg,
@@ -81,6 +89,54 @@ export function resolvePartitionOpening(
};
}
/** Explicit zero-margin policy for render, hit-test and legacy round-trip. */
export function resolvePartitionOpeningCompat(
opening: OpeningCfg,
partitions: readonly PartitionCfg[],
lengthScale = 1,
cellCm = 5,
gridPitch = 5,
): PartitionOpeningResolution {
return resolvePartitionOpening(opening, partitions, lengthScale, cellCm, gridPitch, 0);
}
/** Strict write policy. Compat/read callers intentionally keep using the
* zero-margin resolver above so an existing near-end opening never disappears. */
export function resolvePartitionOpeningStrict(
opening: OpeningCfg,
partitions: readonly PartitionCfg[],
lengthScale = 1,
cellCm = 5,
gridPitch = 5,
): PartitionOpeningResolution {
const host = opening.host;
const partition = host?.kind === 'partition'
? partitions.find((item) => item.id === host.id)
: undefined;
if (!partition) return resolvePartitionOpeningCompat(
opening, partitions, lengthScale, cellCm, gridPitch,
);
const result = resolvePartitionOpening(
opening, partitions, lengthScale, cellCm, gridPitch,
partitionOpeningJambMargin(partition, cellCm, gridPitch),
);
return result.reason === 'does-not-fit'
? { resolved: null, reason: 'does-not-fit-jamb' }
: result;
}
/** Only direct geometry edits opt a legacy record into the strict policy. */
export function partitionOpeningNeedsStrictValidation(
previous: OpeningCfg | null | undefined,
candidate: OpeningCfg,
): boolean {
if (!previous) return true;
return previous.length !== candidate.length
|| previous.host?.kind !== candidate.host?.kind
|| previous.host?.id !== candidate.host?.id
|| previous.host?.t !== candidate.host?.t;
}
export function partitionOpeningCut(resolved: ResolvedPartitionOpening): {
hostId: string; a: [number, number]; b: [number, number]; depth: number;
} {
+2 -2
View File
@@ -26,7 +26,7 @@ import type { DevItem, OpeningCfg, ServerConfig } from './types';
import { physicalBodyParts } from './physical-geometry';
import {
materializePartitionOpening, partitionOpeningCut,
partitionOpeningFace, partitionOpeningHasCompositeRoomWall, resolvePartitionOpening,
partitionOpeningFace, partitionOpeningHasCompositeRoomWall, resolvePartitionOpeningCompat,
} from './partition-openings';
import {
renderOpeningVisibleGeometry, type OpeningVisibleSpec,
@@ -211,7 +211,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const cellCm = Number(spCfg.cell_cm) > 0 ? Number(spCfg.cell_cm) : 5;
const resolvedHosted = (spCfg.openings || []).flatMap((opening: OpeningCfg) => {
if (!opening.host) return [];
const resolved = resolvePartitionOpening(
const resolved = resolvePartitionOpeningCompat(
opening, space.partitions, NORM_W, cellCm, GRID_PITCH,
).resolved;
return resolved ? [resolved] : [];