mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
fix: keep jamb margin on partition openings
Issue: #186 User-Visible: yes
This commit is contained in:
+98
-24
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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] : [];
|
||||
|
||||
Reference in New Issue
Block a user