mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
feat: support openings in independent walls
Issue: #132 User-Visible: yes
This commit is contained in:
+35
-1
@@ -240,7 +240,20 @@ export function alignAllToGrid(
|
||||
const b = [snapN(p.b[0]), snapN(p.b[1])];
|
||||
let d = Math.max(dist(p.a[0], p.a[1], a[0], a[1]),
|
||||
dist(p.b[0], p.b[1], b[0], b[1]));
|
||||
if (dist(a[0], a[1], b[0], b[1]) > EPS) { p.a = a; p.b = b; }
|
||||
const snappedLength = dist(a[0], a[1], b[0], b[1]);
|
||||
const hostedFit = (sp.openings || [])
|
||||
.filter((opening: any) => opening.host?.kind === 'partition'
|
||||
&& opening.host.id === p.id)
|
||||
.every((opening: any) => {
|
||||
const length = Number(opening.length);
|
||||
const t = Number(opening.host.t);
|
||||
const along = t * snappedLength;
|
||||
return Number.isFinite(length) && length > 0
|
||||
&& Number.isFinite(t) && t >= 0 && t <= 1
|
||||
&& along - length / 2 >= -EPS
|
||||
&& along + length / 2 <= snappedLength + EPS;
|
||||
});
|
||||
if (snappedLength > EPS && hostedFit) { p.a = a; p.b = b; }
|
||||
else d = 0;
|
||||
note(d, cell, sid);
|
||||
}
|
||||
@@ -281,6 +294,27 @@ export function alignAllToGrid(
|
||||
// exactly where it is rather than teleported across the plan.
|
||||
for (const o of sp.openings || []) {
|
||||
total++;
|
||||
if (o.host?.kind === 'partition') {
|
||||
const partition = (sp.partitions || []).find((item: any) => item.id === o.host.id);
|
||||
const t = Number(o.host.t);
|
||||
if (!partition || !Number.isFinite(t) || t < 0 || t > 1) continue;
|
||||
const dx = partition.b[0] - partition.a[0];
|
||||
const dy = partition.b[1] - partition.a[1];
|
||||
if (Math.hypot(dx, dy) <= EPS) continue;
|
||||
const nx = partition.a[0] + dx * t;
|
||||
const ny = partition.a[1] + dy * t;
|
||||
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||||
if (angle >= 90) angle -= 180;
|
||||
else if (angle < -90) angle += 180;
|
||||
const raw = Number(o.angle);
|
||||
const turned = !(Number.isFinite(raw) && raw === angle);
|
||||
const d = openingShift(o.x, o.y, Number.isFinite(raw) ? raw : angle,
|
||||
Number(o.length) || 0, nx, ny, angle);
|
||||
o.x = nx; o.y = ny; o.angle = angle;
|
||||
if (turned) rotated++;
|
||||
note(d, cell, sid, turned);
|
||||
continue;
|
||||
}
|
||||
const q = snapToWall([o.x, o.y], sp.rooms || [], WALL_TOL,
|
||||
{ step: GRID_STEP_N, length: Number(o.length) || 0 });
|
||||
if (!q) continue;
|
||||
|
||||
+414
-96
@@ -124,7 +124,7 @@ import {
|
||||
type VirtualLightSnapshot,
|
||||
} from './virtual-light-state';
|
||||
import type {
|
||||
OpeningCfg,
|
||||
OpeningCfg, PartitionOpeningHost,
|
||||
RoomCfg, RoomDraftCfg, PartitionCfg, WallColumnCfg,
|
||||
SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig,
|
||||
MarkerValueBadge, ValueBadgePosition, ValueBadgeSource,
|
||||
@@ -134,8 +134,15 @@ import {
|
||||
directionalOccluders, floorMinusBodies, geometryArea, geometryOuterRings,
|
||||
geometryAllRings, intersectionPaths, partitionBody, polyclipPathD,
|
||||
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement,
|
||||
physicalBodies, physicalBodyParts,
|
||||
physicalBodyParts,
|
||||
type PartitionOpeningCut,
|
||||
} from './physical-geometry';
|
||||
import {
|
||||
hostedOpeningIntervalsOverlap, materializePartitionOpening,
|
||||
partitionOpeningCut, partitionOpeningFace, partitionOpeningHasCompositeRoomWall,
|
||||
partitionPlacementIntervals, resolvePartitionOpening,
|
||||
type PartitionOpeningOrphanReason, type ResolvedPartitionOpening,
|
||||
} from './partition-openings';
|
||||
import {
|
||||
buildPlanSnapGeometry, resolvePlanSnap,
|
||||
type PlanSnapCandidate, type PlanSnapGeometry, type PlanSnapSegment,
|
||||
@@ -656,6 +663,12 @@ type OpeningPlacementCandidate = Omit<OpeningPlacementCore, 'measure'> & {
|
||||
measure: OpMeasure;
|
||||
};
|
||||
|
||||
type RenderOpening = OpeningCfg & {
|
||||
rx: number; ry: number; rlen: number;
|
||||
partitionHost?: ResolvedPartitionOpening;
|
||||
orphanReason?: PartitionOpeningOrphanReason;
|
||||
};
|
||||
|
||||
class HouseplanCard extends LitElement {
|
||||
public hass?: any;
|
||||
private _config?: CardConfig;
|
||||
@@ -1196,6 +1209,7 @@ class HouseplanCard extends LitElement {
|
||||
kind: 'partition' | 'column' | 'draft'; id: string; cm: string;
|
||||
segment?: number; shape?: 'square' | 'circle'; angle?: string; length?: string;
|
||||
} | null = null;
|
||||
private _partitionDeleteDialog: { id: string; openings: OpeningCfg[] } | null = null;
|
||||
/** Drag preview is render-only; config is committed once on pointerup. */
|
||||
private _physicalDrag: {
|
||||
pid: number; kind: 'partition' | 'column'; id: string;
|
||||
@@ -1237,6 +1251,9 @@ class HouseplanCard extends LitElement {
|
||||
key: string; drafts: number[][][]; partitions: number[][][];
|
||||
columns: number[][][]; patches: number[][][]; all: number[][][];
|
||||
} | null = null;
|
||||
/** Light cuts are type/floor-specific and differ from drawn masonry, but HA
|
||||
* state ticks must not rebuild independent-wall topology. */
|
||||
private _lightPhysicalBodiesCache: { key: string; all: number[][][] } | null = null;
|
||||
private _cleanFloorCache = new Map<string, {
|
||||
floor: number[][]; geom: any; path: string; area: number;
|
||||
}>();
|
||||
@@ -1290,6 +1307,8 @@ class HouseplanCard extends LitElement {
|
||||
private _mergeSel: string | null = null;
|
||||
/** Session-only explicit type/width chosen in the Opening sub-panel. */
|
||||
private _openingPreset: OpeningPlacementPreset | null = null;
|
||||
/** Existing orphan whose next valid placement replaces only its host. */
|
||||
private _openingRebindId: string | null = null;
|
||||
private _openingPresetRevision = 0;
|
||||
/** Last painted hover candidate. Click may reuse it only for the same input epoch. */
|
||||
private _openingHoverCandidate: OpeningPlacementCandidate | null = null;
|
||||
@@ -1302,6 +1321,7 @@ class HouseplanCard extends LitElement {
|
||||
invert: boolean;
|
||||
flipH: boolean;
|
||||
flipV: boolean;
|
||||
host?: PartitionOpeningHost;
|
||||
x: number; y: number; angle: number; // render units (from the wall snap)
|
||||
} | null = null;
|
||||
private _openingInfo: OpeningCfg | null = null;
|
||||
@@ -1811,6 +1831,7 @@ class HouseplanCard extends LitElement {
|
||||
_activeDraftId: { state: true },
|
||||
_physicalSel: { state: true },
|
||||
_physicalDialog: { state: true },
|
||||
_partitionDeleteDialog: { state: true },
|
||||
_physicalDrag: { state: true },
|
||||
_physicalRotate: { state: true },
|
||||
_duplicateColumnId: { state: true },
|
||||
@@ -4688,10 +4709,14 @@ class HouseplanCard extends LitElement {
|
||||
return {
|
||||
key,
|
||||
build: () => {
|
||||
const extras = physicalBodies(space, this._cellCm, this._gridPitch);
|
||||
const extras = physicalBodyParts(
|
||||
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
|
||||
this._partitionOpeningCuts(space),
|
||||
).all;
|
||||
const roomOpenings = this._roomWallOpeningInputs(this._openingsR, space);
|
||||
const united = walls.length || extras.length
|
||||
? wallBodiesGeometry(
|
||||
space.rooms, walls, openCuts, openings,
|
||||
space.rooms, walls, openCuts, roomOpenings,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
|
||||
)
|
||||
: null;
|
||||
@@ -4707,15 +4732,18 @@ class HouseplanCard extends LitElement {
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
);
|
||||
const openingBases = openings.map((opening) => {
|
||||
const rendered = this._openingsR[opening.sourceIndex];
|
||||
const faceFlipV = opening.type === 'gate' ? !opening.flipV : opening.flipV;
|
||||
const face = walls.length || opening.type === 'gate'
|
||||
? openingInnerFaceOffsetFromIndex(openingIndex, {
|
||||
x: opening.x,
|
||||
y: opening.y,
|
||||
angle: opening.angle,
|
||||
length: opening.length,
|
||||
flip_v: faceFlipV,
|
||||
})
|
||||
const face = rendered?.partitionHost
|
||||
? partitionOpeningFace(rendered.partitionHost, faceFlipV)
|
||||
: walls.length || opening.type === 'gate'
|
||||
? openingInnerFaceOffsetFromIndex(openingIndex, {
|
||||
x: opening.x,
|
||||
y: opening.y,
|
||||
angle: opening.angle,
|
||||
length: opening.length,
|
||||
flip_v: faceFlipV,
|
||||
})
|
||||
: { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 };
|
||||
return buildIsoOpeningBasis({ ...opening, face });
|
||||
});
|
||||
@@ -6108,28 +6136,11 @@ class HouseplanCard extends LitElement {
|
||||
return this._snap(candidate);
|
||||
}
|
||||
|
||||
/** Canonical physical opening slots on room-wall centrelines. */
|
||||
private _planSnapOpeningCuts(space: SpaceModel, openCuts: number[][]): number[][] {
|
||||
if (!this._openingsR.length) return [];
|
||||
const index = this._openingWallIndexFor(space, openCuts).value;
|
||||
const cuts: number[][] = [];
|
||||
for (const opening of this._openingsR) {
|
||||
const input = {
|
||||
x: opening.rx, y: opening.ry,
|
||||
angle: Number(opening.angle) || 0,
|
||||
length: opening.rlen,
|
||||
};
|
||||
const association = resolveOpeningWallAssociation(index, input);
|
||||
if (!association.negative && !association.positive) continue;
|
||||
const rad = input.angle * Math.PI / 180;
|
||||
const dx = Math.cos(rad) * input.length / 2;
|
||||
const dy = Math.sin(rad) * input.length / 2;
|
||||
cuts.push([input.x - dx, input.y - dy, input.x + dx, input.y + dy]);
|
||||
}
|
||||
return cuts;
|
||||
}
|
||||
|
||||
/** Static architectural axes are rebuilt only when structural editor state changes. */
|
||||
/**
|
||||
* Static structural axes are rebuilt only when wall state changes. Openings
|
||||
* cut masonry, not room-face connectivity (#185); only real open_spans cut
|
||||
* this graph.
|
||||
*/
|
||||
private _planSnapGeometrySnapshot(): { key: string; value: PlanSnapGeometry } {
|
||||
const space = this._spaceModel();
|
||||
const key = [
|
||||
@@ -6141,7 +6152,7 @@ class HouseplanCard extends LitElement {
|
||||
const value = buildPlanSnapGeometry({
|
||||
space,
|
||||
activeDraftId: this._activeDraftId,
|
||||
roomCuts: [...openCuts, ...this._planSnapOpeningCuts(space, openCuts)],
|
||||
roomCuts: openCuts,
|
||||
epsilon: this._gridPitch * 0.0002,
|
||||
});
|
||||
this._planSnapGeometryCache = { key, value };
|
||||
@@ -7086,6 +7097,19 @@ class HouseplanCard extends LitElement {
|
||||
if (!sel || !sp) return;
|
||||
if (sel.kind === 'draft') { this._deleteDraftWhole(); return; }
|
||||
const before = this._geometrySnapshot();
|
||||
if (sel.kind === 'partition') {
|
||||
const hosted = (sp.openings || [])
|
||||
.filter((opening: OpeningCfg) => opening.host?.kind === 'partition'
|
||||
&& opening.host.id === sel.id)
|
||||
.sort((a: OpeningCfg, b: OpeningCfg) => (a.host?.t || 0) - (b.host?.t || 0));
|
||||
if (hosted.length) {
|
||||
this._partitionDeleteDialog = {
|
||||
id: sel.id,
|
||||
openings: hosted.map((opening: OpeningCfg) => JSON.parse(JSON.stringify(opening))),
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
const key = sel.kind === 'partition' ? 'partitions'
|
||||
: sel.kind === 'column' ? 'wall_columns' : 'room_drafts';
|
||||
sp[key] = (sp[key] || []).filter((x: any) => x.id !== sel.id);
|
||||
@@ -7097,6 +7121,24 @@ class HouseplanCard extends LitElement {
|
||||
this._saveConfig();
|
||||
};
|
||||
|
||||
private _confirmPartitionDelete = (): void => {
|
||||
const dialog = this._partitionDeleteDialog;
|
||||
const sp = this._curSpaceCfg as any;
|
||||
if (!dialog || !sp) return;
|
||||
const before = this._geometrySnapshot();
|
||||
sp.partitions = (sp.partitions || []).filter((partition: PartitionCfg) =>
|
||||
partition.id !== dialog.id);
|
||||
if (!sp.partitions.length) delete sp.partitions;
|
||||
sp.openings = (sp.openings || []).filter((opening: OpeningCfg) =>
|
||||
opening.host?.kind !== 'partition' || opening.host.id !== dialog.id);
|
||||
if (!sp.openings.length) delete sp.openings;
|
||||
this._partitionDeleteDialog = null;
|
||||
this._physicalSel = null;
|
||||
this._physicalDialog = null;
|
||||
this._recordGeometry(this._t('history.physical_delete'), before);
|
||||
this._saveConfig();
|
||||
};
|
||||
|
||||
private _deleteDraftWhole = (): void => {
|
||||
const id = this._physicalDialog?.kind === 'draft'
|
||||
? this._physicalDialog.id
|
||||
@@ -7236,8 +7278,22 @@ class HouseplanCard extends LitElement {
|
||||
const p = (sp.partitions || []).find((x: any) => x.id === drag.id);
|
||||
const base = drag.base as PartitionCfg;
|
||||
if (p) {
|
||||
p.a = [(base.a[0] + drag.delta[0]) / NORM_W, (base.a[1] + drag.delta[1]) / NORM_W];
|
||||
p.b = [(base.b[0] + drag.delta[0]) / NORM_W, (base.b[1] + drag.delta[1]) / NORM_W];
|
||||
const moved: PartitionCfg = {
|
||||
...base,
|
||||
a: [base.a[0] + drag.delta[0], base.a[1] + drag.delta[1]],
|
||||
b: [base.b[0] + drag.delta[0], base.b[1] + drag.delta[1]],
|
||||
};
|
||||
p.a = [moved.a[0] / NORM_W, moved.a[1] / NORM_W];
|
||||
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(
|
||||
opening, [moved], NORM_W, this._cellCm, this._gridPitch,
|
||||
).resolved;
|
||||
if (resolved) Object.assign(
|
||||
opening, materializePartitionOpening(opening, resolved, NORM_W),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const c = (sp.wall_columns || []).find((x: any) => x.id === drag.id);
|
||||
@@ -7683,12 +7739,91 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Openings of the current space in render units. */
|
||||
private get _openingsR(): (OpeningCfg & { rx: number; ry: number; rlen: number })[] {
|
||||
private get _openingsR(): RenderOpening[] {
|
||||
const sp = this._curSpaceCfg;
|
||||
const H = this._spaceH;
|
||||
return (sp?.openings || []).map((o: OpeningCfg) => ({
|
||||
...o, rx: o.x * NORM_W, ry: o.y * H, rlen: o.length * NORM_W,
|
||||
}));
|
||||
const space = this._spaceModel();
|
||||
return (sp?.openings || []).flatMap((o: OpeningCfg) => {
|
||||
const fallback: RenderOpening = {
|
||||
...o, rx: o.x * NORM_W, ry: o.y * H, rlen: o.length * NORM_W,
|
||||
};
|
||||
if (!o.host) return [fallback];
|
||||
const resolution = resolvePartitionOpening(
|
||||
o, space.partitions, NORM_W, this._cellCm, this._gridPitch,
|
||||
);
|
||||
if (!resolution.resolved) {
|
||||
return this._mode === 'plan'
|
||||
? [{ ...fallback, orphanReason: resolution.reason || 'invalid-host' }]
|
||||
: [];
|
||||
}
|
||||
let [rx, ry] = resolution.resolved.center;
|
||||
const drag = this._physicalDrag;
|
||||
if (drag?.moved && drag.kind === 'partition' && drag.id === o.host.id) {
|
||||
rx += drag.delta[0];
|
||||
ry += drag.delta[1];
|
||||
}
|
||||
return [{
|
||||
...o,
|
||||
rx, ry,
|
||||
rlen: resolution.resolved.length,
|
||||
angle: resolution.resolved.angle,
|
||||
partitionHost: resolution.resolved,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
private _partitionOpeningCuts(
|
||||
space = this._spaceModel(),
|
||||
accept: (opening: OpeningCfg) => boolean = () => true,
|
||||
): PartitionOpeningCut[] {
|
||||
const raw = this._curSpaceCfg?.id === space.id ? this._curSpaceCfg?.openings || [] : [];
|
||||
const cuts: PartitionOpeningCut[] = [];
|
||||
for (const opening of raw) {
|
||||
if (!opening.host || !accept(opening)) continue;
|
||||
const resolution = resolvePartitionOpening(
|
||||
opening, space.partitions, NORM_W, this._cellCm, this._gridPitch,
|
||||
);
|
||||
if (resolution.resolved) cuts.push(partitionOpeningCut(resolution.resolved));
|
||||
}
|
||||
return cuts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Room masonry may be cut by a hosted opening only for an exact collinear
|
||||
* composite. Nearby/crossing walls never inherit the partition's cut.
|
||||
*/
|
||||
private _roomWallOpeningInputs(
|
||||
openings: readonly RenderOpening[] = this._openingsR,
|
||||
space = this._spaceModel(),
|
||||
): Array<{ x: number; y: number; angle: number; length: number }> {
|
||||
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
|
||||
const intervals = wallIntervals(
|
||||
space.rooms, this._spaceWalls, openCuts,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
);
|
||||
return openings.flatMap((opening) => {
|
||||
const input = {
|
||||
x: opening.rx, y: opening.ry,
|
||||
angle: Number(opening.angle) || 0,
|
||||
length: opening.rlen,
|
||||
};
|
||||
if (!opening.host) return [input];
|
||||
if (!opening.partitionHost) return [];
|
||||
return partitionOpeningHasCompositeRoomWall(
|
||||
opening.partitionHost, intervals, this._gridPitch * 0.0002,
|
||||
) ? [input] : [];
|
||||
});
|
||||
}
|
||||
|
||||
private _openingFace(
|
||||
opening: RenderOpening, index: OpeningWallIndex, flipV: boolean,
|
||||
): OpeningFaceOffset {
|
||||
return opening.partitionHost
|
||||
? partitionOpeningFace(opening.partitionHost, flipV)
|
||||
: openingInnerFaceOffsetFromIndex(index, {
|
||||
x: opening.rx, y: opening.ry,
|
||||
angle: opening.angle, length: opening.rlen, flip_v: flipV,
|
||||
});
|
||||
}
|
||||
|
||||
/** cm → render units via the space scale (cm per grid cell). */
|
||||
@@ -10215,11 +10350,7 @@ class HouseplanCard extends LitElement {
|
||||
const extras = this._physicalBodiesR();
|
||||
if (!walls.length && !extras.length) return null;
|
||||
const openCuts = this._openPairs().flatMap((p) => p.segs);
|
||||
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
|
||||
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
|
||||
angle: Number(o.angle) || 0,
|
||||
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
|
||||
}));
|
||||
const openings = this._roomWallOpeningInputs();
|
||||
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
|
||||
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
|
||||
this._wallUnionCache = {
|
||||
@@ -10763,11 +10894,11 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
// A hidden opening symbol is still a physical opening: never bridge a
|
||||
// doorway/window with the hover stroke merely because its symbol is off.
|
||||
const openingCuts = this._openingsR.map((o) => {
|
||||
const openingCuts = this._roomWallOpeningInputs(this._openingsR, space).map((o) => {
|
||||
const rad = (o.angle * Math.PI) / 180;
|
||||
const dx = (Math.cos(rad) * o.rlen) / 2;
|
||||
const dy = (Math.sin(rad) * o.rlen) / 2;
|
||||
return [o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy];
|
||||
const dx = (Math.cos(rad) * o.length) / 2;
|
||||
const dy = (Math.sin(rad) * o.length) / 2;
|
||||
return [o.x - dx, o.y - dy, o.x + dx, o.y + dy];
|
||||
});
|
||||
const eps = this._gridPitch * 0.02;
|
||||
|
||||
@@ -10890,7 +11021,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
|
||||
private _openingAt(raw: readonly number[]): (OpeningCfg & { rx: number; ry: number; rlen: number }) | null {
|
||||
private _openingAt(raw: readonly number[]): RenderOpening | null {
|
||||
if (!this._openingsR.length) return null;
|
||||
const roughHits = this._openingsR.flatMap((o) => {
|
||||
const rad = o.angle * Math.PI / 180;
|
||||
@@ -10904,11 +11035,8 @@ class HouseplanCard extends LitElement {
|
||||
const wallIndex = this._openingWallIndexFor(this._spaceModel(), openCuts).value;
|
||||
return roughHits.find(({ o, localY }) => {
|
||||
const faceFlipV = o.type === 'gate' ? !o.flip_v : o.flip_v;
|
||||
const face = this._spaceWalls.length || o.type === 'gate'
|
||||
? openingInnerFaceOffsetFromIndex(
|
||||
wallIndex,
|
||||
{ x: o.rx, y: o.ry, angle: o.angle, length: o.rlen, flip_v: faceFlipV },
|
||||
)
|
||||
const face = o.partitionHost || this._spaceWalls.length || o.type === 'gate'
|
||||
? this._openingFace(o, wallIndex, !!faceFlipV)
|
||||
: { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 };
|
||||
const metrics = openingVisibleMetrics({
|
||||
type: o.type,
|
||||
@@ -10935,14 +11063,18 @@ class HouseplanCard extends LitElement {
|
||||
const space = this._spaceModel();
|
||||
const openCuts = this._openCuts();
|
||||
const wallIndex = this._openingWallIndexFor(space, openCuts);
|
||||
const placementKey = `${wallIndex.key}|partitions:${this._cfgEpoch}`;
|
||||
if (!this._openingPlacementIntervalsCache
|
||||
|| this._openingPlacementIntervalsCache.key !== wallIndex.key) {
|
||||
|| this._openingPlacementIntervalsCache.key !== placementKey) {
|
||||
this._openingPlacementIntervalsCache = {
|
||||
key: wallIndex.key,
|
||||
value: wallIntervals(
|
||||
space.rooms, this._spaceWalls, openCuts,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
),
|
||||
key: placementKey,
|
||||
value: [
|
||||
...wallIntervals(
|
||||
space.rooms, this._spaceWalls, openCuts,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
),
|
||||
...partitionPlacementIntervals(space.partitions, this._cellCm, this._gridPitch),
|
||||
],
|
||||
};
|
||||
}
|
||||
const core = resolveOpeningPlacement({
|
||||
@@ -10957,10 +11089,23 @@ class HouseplanCard extends LitElement {
|
||||
});
|
||||
if (!core) return null;
|
||||
const faceFlipV = core.type === 'gate' ? !core.flipV : core.flipV;
|
||||
const face = openingInnerFaceOffsetFromIndex(
|
||||
wallIndex.value,
|
||||
{ x: core.x, y: core.y, angle: core.angle, length: core.renderedLength, flip_v: faceFlipV },
|
||||
);
|
||||
let face: OpeningFaceOffset;
|
||||
if (core.host) {
|
||||
const hosted = resolvePartitionOpening({
|
||||
id: 'preview', type: core.type,
|
||||
x: core.x / NORM_W, y: core.y / NORM_W,
|
||||
angle: core.angle, length: core.renderedLength / NORM_W,
|
||||
host: core.host,
|
||||
}, space.partitions, NORM_W, this._cellCm, this._gridPitch).resolved;
|
||||
face = hosted
|
||||
? partitionOpeningFace(hosted, faceFlipV)
|
||||
: { ox: 0, oy: 0, cm: 0, side: -1 };
|
||||
} else {
|
||||
face = openingInnerFaceOffsetFromIndex(
|
||||
wallIndex.value,
|
||||
{ x: core.x, y: core.y, angle: core.angle, length: core.renderedLength, flip_v: faceFlipV },
|
||||
);
|
||||
}
|
||||
const imperial = this.hass?.config?.unit_system?.length === 'mi';
|
||||
// Placement validity and magnetism belong to the selected atomic physical
|
||||
// interval. Ruler copy keeps the established UX contract, however: it
|
||||
@@ -10993,12 +11138,17 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _clearOpeningPlacement(clearPreset: boolean): void {
|
||||
this._openingHoverCandidate = null;
|
||||
if (clearPreset) this._openingPreset = null;
|
||||
if (clearPreset) {
|
||||
this._openingPreset = null;
|
||||
this._openingRebindId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
|
||||
private _openingClick(raw: number[]): void {
|
||||
const hit = this._openingAt(raw);
|
||||
// Rebind is an explicit one-shot placement: the orphan's stale fallback
|
||||
// position must not win hit testing when the replacement wall crosses it.
|
||||
const hit = this._openingRebindId ? null : this._openingAt(raw);
|
||||
if (hit) {
|
||||
this._editOpening(hit);
|
||||
return;
|
||||
@@ -11019,18 +11169,31 @@ class HouseplanCard extends LitElement {
|
||||
this._showToast(this._t('toast.opening_no_wall'));
|
||||
return;
|
||||
}
|
||||
const rebound = this._openingRebindId
|
||||
? this._curSpaceCfg?.openings?.find((item: OpeningCfg) => item.id === this._openingRebindId)
|
||||
: null;
|
||||
this._openingDialog = {
|
||||
type: place.type, lengthCm: place.lengthCm, contact: '', lock: '',
|
||||
invert: false, flipH: place.flipH, flipV: place.flipV,
|
||||
...(rebound ? {
|
||||
id: rebound.id,
|
||||
type: rebound.type,
|
||||
lengthCm: Math.round((rebound.length * NORM_W / this._gridPitch) * this._cellCm),
|
||||
contact: rebound.contact || '', lock: rebound.lock || '',
|
||||
invert: !!rebound.invert, flipH: !!rebound.flip_h, flipV: !!rebound.flip_v,
|
||||
} : {
|
||||
type: place.type, lengthCm: place.lengthCm, contact: '', lock: '',
|
||||
invert: false, flipH: place.flipH, flipV: place.flipV,
|
||||
}),
|
||||
...(place.host ? { host: place.host } : {}),
|
||||
x: place.x, y: place.y, angle: place.angle,
|
||||
};
|
||||
this._openingRebindId = null;
|
||||
// rulers, tick and ghost live only through the placement gesture
|
||||
this._openingHoverCandidate = null;
|
||||
this._cursorPt = null;
|
||||
}
|
||||
|
||||
/** Open the properties dialog for an existing opening. */
|
||||
private _editOpening(o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
|
||||
private _editOpening(o: RenderOpening): void {
|
||||
this._openingDialog = {
|
||||
id: o.id,
|
||||
type: o.type,
|
||||
@@ -11040,6 +11203,7 @@ class HouseplanCard extends LitElement {
|
||||
invert: !!o.invert,
|
||||
flipH: !!o.flip_h,
|
||||
flipV: !!o.flip_v,
|
||||
...(o.host ? { host: { ...o.host } } : {}),
|
||||
x: o.rx, y: o.ry, angle: o.angle,
|
||||
};
|
||||
}
|
||||
@@ -11072,12 +11236,44 @@ class HouseplanCard extends LitElement {
|
||||
// event behind the L2 data loss).
|
||||
if (Math.abs(ev.clientX - this._opDrag.sx) + Math.abs(ev.clientY - this._opDrag.sy) <= 3) return;
|
||||
const raw = this._svgPoint(ev);
|
||||
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
|
||||
if (!snap) return; // too far from any wall: the opening stays where it was
|
||||
this._opDrag.moved = true;
|
||||
const sp = this._curSpaceCfg;
|
||||
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
|
||||
if (!cfg) return;
|
||||
if (cfg.host?.kind === 'partition') {
|
||||
const partition = this._spaceModel().partitions.find((item) => item.id === cfg.host!.id);
|
||||
if (!partition) return;
|
||||
const dx = partition.b[0] - partition.a[0], dy = partition.b[1] - partition.a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!(length > 1e-9)) return;
|
||||
const ux = dx / length, uy = dy / length;
|
||||
const perpendicular = Math.abs(
|
||||
(raw[0] - partition.a[0]) * uy - (raw[1] - partition.a[1]) * ux,
|
||||
);
|
||||
if (perpendicular > this._gridPitch * 4) return;
|
||||
const half = cfg.length * NORM_W / 2;
|
||||
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;
|
||||
const t = along / length;
|
||||
const nx = (partition.a[0] + ux * along) / NORM_W;
|
||||
const ny = (partition.a[1] + uy * along) / this._spaceH;
|
||||
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||||
if (angle >= 90) angle -= 180;
|
||||
else if (angle < -90) angle += 180;
|
||||
const changed = cfg.host.t !== t || cfg.x !== nx || cfg.y !== ny || cfg.angle !== angle;
|
||||
this._opDrag.moved = true;
|
||||
if (changed) this._opDrag.dirty = true;
|
||||
cfg.host = { ...cfg.host, t };
|
||||
cfg.x = nx; cfg.y = ny; cfg.angle = angle;
|
||||
this._opMeasure = null;
|
||||
if (changed) this._cfgEpoch++;
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
const snap = snapToWall(raw, this._spaceModel().rooms, this._gridPitch * 4);
|
||||
if (!snap) return; // too far from any wall: the opening stays where it was
|
||||
this._opDrag.moved = true;
|
||||
// ruler badges on both shoulders + soft magnet to the wall's center
|
||||
// (owner 2026-08-03) — the very same helper the PLACEMENT preview uses
|
||||
const r = this._opRuler(snap, cfg.length * NORM_W);
|
||||
@@ -11165,7 +11361,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Click: the status card (delayed so a double click can cancel it). */
|
||||
private _opClick(ev: MouseEvent, o: OpeningCfg & { rx: number; ry: number; rlen: number }): void {
|
||||
private _opClick(ev: MouseEvent, o: RenderOpening): void {
|
||||
// HP-1550-04: in the resize tool a click over an opening falls through to
|
||||
// the stage (room picking) instead of opening the editor dialog
|
||||
if (this._mode === 'plan' && this._tool === 'resize') return;
|
||||
@@ -11191,7 +11387,31 @@ class HouseplanCard extends LitElement {
|
||||
y: d.y / H,
|
||||
angle: d.angle,
|
||||
length: this._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W,
|
||||
...(d.host ? { host: { ...d.host } } : {}),
|
||||
};
|
||||
if (o.host) {
|
||||
const resolution = resolvePartitionOpening(
|
||||
o, this._spaceModel().partitions, NORM_W, this._cellCm, this._gridPitch,
|
||||
);
|
||||
if (!resolution.resolved) {
|
||||
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(
|
||||
item, this._spaceModel().partitions, NORM_W, this._cellCm, this._gridPitch,
|
||||
).resolved;
|
||||
return sibling ? [sibling] : [];
|
||||
});
|
||||
if (hostedOpeningIntervalsOverlap(resolution.resolved, siblings)) {
|
||||
this._showToast(this._t('toast.opening_no_wall'));
|
||||
return;
|
||||
}
|
||||
Object.assign(o, materializePartitionOpening(o, resolution.resolved, NORM_W));
|
||||
} else {
|
||||
delete o.host;
|
||||
}
|
||||
if (d.type === 'passage') {
|
||||
// The canonical passage record contains geometry only. Delete keys
|
||||
// instead of writing null/false so imports and old broken records have
|
||||
@@ -11230,6 +11450,27 @@ class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _rebindPartitionOpening = (): void => {
|
||||
const d = this._openingDialog;
|
||||
if (!d?.id) return;
|
||||
const openingId = d.id;
|
||||
this._activateMarkupTool('opening');
|
||||
if (this._tool !== 'opening') return;
|
||||
// Tool activation clears every stale placement session. Publish the
|
||||
// rebind identity only after that gate so the next click cannot degrade
|
||||
// into creating a second opening.
|
||||
this._openingRebindId = openingId;
|
||||
this._openingPreset = {
|
||||
type: d.type,
|
||||
lengthCm: d.lengthCm,
|
||||
flipH: d.flipH,
|
||||
flipV: d.flipV,
|
||||
revision: ++this._openingPresetRevision,
|
||||
};
|
||||
this._openingDialog = null;
|
||||
this._openingHoverCandidate = null;
|
||||
};
|
||||
|
||||
/** Contact-sensor candidates: door/window/gate-like classes first, then the rest. */
|
||||
private _contactCandidates(): { value: string; label: string }[] {
|
||||
const out: [string, string, number][] = [];
|
||||
@@ -12175,6 +12416,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._physicalBodiesCache?.key === key) return this._physicalBodiesCache.all;
|
||||
const frame = physicalBodyParts(
|
||||
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
|
||||
this._partitionOpeningCuts(space),
|
||||
);
|
||||
this._physicalBodiesCache = { key, ...frame };
|
||||
return frame.all;
|
||||
@@ -13300,7 +13542,8 @@ class HouseplanCard extends LitElement {
|
||||
.map((r) => ({ id: r.id || '', poly: roomPoly(r) }))
|
||||
.filter((r): r is { id: string; poly: number[][] } => !!r.id && !!r.poly);
|
||||
const windows = this._openingsR
|
||||
.filter((o) => o.type === 'window')
|
||||
// An independent-wall window is not an exterior sun source (#132).
|
||||
.filter((o) => o.type === 'window' && !o.host)
|
||||
.map((o) => ({ id: o.id, x: o.rx, y: o.ry, angle: o.angle, length: o.rlen }));
|
||||
const walls = this._spaceWalls;
|
||||
const openCuts = this._openPairs().flatMap((p) => p.segs);
|
||||
@@ -14179,15 +14422,33 @@ class HouseplanCard extends LitElement {
|
||||
return onFloor([o.rx + nx * probe, o.ry + ny * probe])
|
||||
&& onFloor([o.rx - nx * probe, o.ry - ny * probe]);
|
||||
});
|
||||
const roomPassages = this._roomWallOpeningInputs(passages, space);
|
||||
// Openings omitted from `passages` remain part of light's opaque masonry.
|
||||
// Consequently a source centred in an exterior door/gate or any window is
|
||||
// rejected below exactly like a source centred inside the wall itself.
|
||||
for (const o of passages) {
|
||||
const rad = (o.angle * Math.PI) / 180;
|
||||
const dx = (Math.cos(rad) * o.rlen) / 2;
|
||||
const dy = (Math.sin(rad) * o.rlen) / 2;
|
||||
cuts.push([o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy]);
|
||||
for (const opening of roomPassages) {
|
||||
const rad = (opening.angle * Math.PI) / 180;
|
||||
const dx = (Math.cos(rad) * opening.length) / 2;
|
||||
const dy = (Math.sin(rad) * opening.length) / 2;
|
||||
cuts.push([opening.x - dx, opening.y - dy, opening.x + dx, opening.y + dy]);
|
||||
}
|
||||
const transparentHostedIds = new Set(
|
||||
passages.filter((opening) => opening.partitionHost).map((opening) => opening.id),
|
||||
);
|
||||
const lightPhysicalKey = [
|
||||
space.id, this._cfgEpoch, this._cellCm, this._gridPitch,
|
||||
[...transparentHostedIds].sort().join(','),
|
||||
].join('|');
|
||||
if (this._lightPhysicalBodiesCache?.key !== lightPhysicalKey) {
|
||||
this._lightPhysicalBodiesCache = {
|
||||
key: lightPhysicalKey,
|
||||
all: physicalBodyParts(
|
||||
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
|
||||
this._partitionOpeningCuts(space, (opening) => transparentHostedIds.has(opening.id)),
|
||||
).all,
|
||||
};
|
||||
}
|
||||
const lightPhysical = this._lightPhysicalBodiesCache.all;
|
||||
// Keyed by what it is made of, never by `_cfgEpoch`: geometry edited in
|
||||
// place leaves the epoch behind, and a stale barrier set is invisible —
|
||||
// the plan simply keeps lighting through a wall that now exists.
|
||||
@@ -14202,7 +14463,7 @@ class HouseplanCard extends LitElement {
|
||||
mix(this._wallKeyPitch);
|
||||
for (const { poly } of polys) { mix(poly.length); for (const p of poly) { mix(p[0]); mix(p[1]); } }
|
||||
for (const cut of cuts) for (const value of cut) mix(value);
|
||||
for (const body of physical) {
|
||||
for (const body of lightPhysical) {
|
||||
mix(body.length);
|
||||
for (const point of body) { mix(point[0]); mix(point[1]); }
|
||||
}
|
||||
@@ -14220,11 +14481,11 @@ class HouseplanCard extends LitElement {
|
||||
// jamb faces. Treating a wall as its centreline let light bleed half a wall
|
||||
// deep (a bright bar at every opening) and started every shadow half a wall
|
||||
// away from the corner that casts it.
|
||||
const masonry = walls.length || physical.length
|
||||
const masonry = walls.length || lightPhysical.length
|
||||
? wallBodiesGeometry(
|
||||
space.rooms, walls, openCuts,
|
||||
passages.map((o) => ({ x: o.rx, y: o.ry, angle: o.angle, length: o.rlen })),
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, physical,
|
||||
roomPassages,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, lightPhysical,
|
||||
)
|
||||
: null;
|
||||
if (masonry) {
|
||||
@@ -14232,7 +14493,7 @@ class HouseplanCard extends LitElement {
|
||||
} else {
|
||||
// Malformed legacy geometry must remain opaque even when the canonical
|
||||
// boolean pass cannot produce a joined result.
|
||||
for (const body of physical) occluders.push(...polygonSegments(body));
|
||||
for (const body of lightPhysical) occluders.push(...polygonSegments(body));
|
||||
}
|
||||
// Edges without any thickness are still walls; so is a room outline when
|
||||
// the boolean pass above could not run at all.
|
||||
@@ -15326,6 +15587,7 @@ class HouseplanCard extends LitElement {
|
||||
${this._mergeDialog ? this._renderMergeDialog() : nothing}
|
||||
${this._openingDialog ? this._renderOpeningDialog() : nothing}
|
||||
${this._physicalDialog ? this._renderPhysicalDialog() : nothing}
|
||||
${this._partitionDeleteDialog ? this._renderPartitionDeleteDialog() : nothing}
|
||||
${this._openingInfo ? this._renderOpeningInfoCard() : nothing}
|
||||
${this._decorTextDialog ? this._renderDecorTextDialog() : nothing}
|
||||
${this._decorShapeDialog ? this._renderDecorShapeDialog() : nothing}
|
||||
@@ -16867,6 +17129,14 @@ class HouseplanCard extends LitElement {
|
||||
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
|
||||
const openingWallIndex = this._openingWallIndexFor(this._spaceModel(), openCuts).value;
|
||||
return svg`${items.map((o) => {
|
||||
if (o.orphanReason) return svg`<g class="opening orphan" data-hp="opening-orphan"
|
||||
data-id=${o.id} role="button" tabindex="0"
|
||||
aria-label=${this._t('opening.partition_orphan')}
|
||||
transform="translate(${o.rx} ${o.ry})"
|
||||
@click=${(event: MouseEvent) => { event.stopPropagation(); this._editOpening(o); }}>
|
||||
<circle r=${this._gridPitch * 0.55}></circle>
|
||||
<text text-anchor="middle" dominant-baseline="central">!</text>
|
||||
</g>`;
|
||||
const amt = this._openingAmt(o);
|
||||
const active = amt > 0 && !!o.contact && this._renderOpeningEntityAvailable(o.contact);
|
||||
const tone = active ? 'var(--hp-open)' : base;
|
||||
@@ -16877,11 +17147,8 @@ class HouseplanCard extends LitElement {
|
||||
const faceFlipV = o.type === 'gate' ? !o.flip_v : o.flip_v;
|
||||
// Resolve the side even with zero-thickness walls for gates, while
|
||||
// preserving the cheap classic path for ordinary line-plan openings.
|
||||
const face = walls.length || o.type === 'gate'
|
||||
? openingInnerFaceOffsetFromIndex(
|
||||
openingWallIndex,
|
||||
{ x: o.rx, y: o.ry, angle: o.angle, length: o.rlen, flip_v: faceFlipV },
|
||||
)
|
||||
const face = o.partitionHost || walls.length || o.type === 'gate'
|
||||
? this._openingFace(o, openingWallIndex, !!faceFlipV)
|
||||
: { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 };
|
||||
const visibleSpec: OpeningVisibleSpec = {
|
||||
type: o.type,
|
||||
@@ -16914,7 +17181,7 @@ class HouseplanCard extends LitElement {
|
||||
/** Padlock badges for door-like openings with a lock entity. */
|
||||
private _renderOpeningLocks(view: { x: number; y: number; w: number; h: number }): TemplateResult {
|
||||
const items = this._openingsR.filter(
|
||||
(o) => (o.type === 'door' || o.type === 'gate')
|
||||
(o) => !o.orphanReason && (o.type === 'door' || o.type === 'gate')
|
||||
&& o.lock && this._renderOpeningEntityAvailable(o.lock),
|
||||
);
|
||||
if (!items.length) return html``;
|
||||
@@ -16929,10 +17196,7 @@ class HouseplanCard extends LitElement {
|
||||
// angle, so resolve that face explicitly.
|
||||
const rad = ((o.angle + 90) * Math.PI) / 180;
|
||||
const gateFace = o.type === 'gate'
|
||||
? openingInnerFaceOffsetFromIndex(
|
||||
openingWallIndex,
|
||||
{ x: o.rx, y: o.ry, angle: o.angle, length: o.rlen, flip_v: !o.flip_v },
|
||||
)
|
||||
? this._openingFace(o, openingWallIndex, !o.flip_v)
|
||||
: null;
|
||||
const off = gateFace ? -16 * gateFace.side : 16 * (o.flip_v ? -1 : 1);
|
||||
const px = o.rx + Math.cos(rad) * off;
|
||||
@@ -17034,6 +17298,13 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _renderOpeningDialog(): TemplateResult {
|
||||
const d = this._openingDialog!;
|
||||
const hostResolution = d.host ? resolvePartitionOpening({
|
||||
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,
|
||||
}, this._spaceModel().partitions, NORM_W, this._cellCm, this._gridPitch) : null;
|
||||
const orphan = !!d.host && !hostResolution?.resolved;
|
||||
const icon = d.type === 'gate' ? 'mdi:gate'
|
||||
: d.type === 'window' ? 'mdi:window-closed-variant'
|
||||
: d.type === 'passage' ? 'mdi:arch' : 'mdi:door';
|
||||
@@ -17046,6 +17317,13 @@ class HouseplanCard extends LitElement {
|
||||
.title=${d.id ? this._t('opening.edit') : this._t('opening.new')} icon=${icon}
|
||||
@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}
|
||||
<span>${orphan
|
||||
? this._t('opening.partition_orphan')
|
||||
: 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'}
|
||||
@change=${() => (this._openingDialog = {
|
||||
@@ -17114,6 +17392,11 @@ class HouseplanCard extends LitElement {
|
||||
</button>
|
||||
</div>`
|
||||
: nothing}
|
||||
${orphan
|
||||
? html`<button class="btn ghost" @click=${this._rebindPartitionOpening}>
|
||||
${this._t('opening.rebind_partition')}
|
||||
</button>`
|
||||
: nothing}
|
||||
<div class="dialog-action-group dialog-action-commit">
|
||||
<button class="btn ghost" @click=${() => (this._openingDialog = null)}>${this._t('btn.cancel')}</button>
|
||||
<button class="btn on" @click=${this._saveOpening}>
|
||||
@@ -17455,6 +17738,41 @@ class HouseplanCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPartitionDeleteDialog(): TemplateResult {
|
||||
const dialog = this._partitionDeleteDialog!;
|
||||
const imperial = this.hass?.config?.unit_system?.length === 'mi';
|
||||
return html`<hp-dialog .hass=${this.hass}
|
||||
.title=${this._t('confirm.delete_partition_openings_title')}
|
||||
icon="mdi:wall" dismiss-on-scrim
|
||||
@hp-close=${() => (this._partitionDeleteDialog = null)}>
|
||||
<div class="body">
|
||||
<p>${this._t('confirm.delete_partition_openings_body', {
|
||||
count: dialog.openings.length,
|
||||
})}</p>
|
||||
<ul aria-label=${this._t('confirm.delete_partition_openings_title')}>
|
||||
${dialog.openings.map((opening) => html`<li>${this._t(
|
||||
'confirm.delete_partition_openings_item', {
|
||||
type: this._t(`opening.${opening.type}` as I18nKey),
|
||||
length: formatLength(
|
||||
(opening.length * NORM_W / this._gridPitch) * this._cellCm,
|
||||
imperial,
|
||||
),
|
||||
},
|
||||
)}</li>`)}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="row" slot="footer">
|
||||
<button class="btn ghost" @click=${() => (this._partitionDeleteDialog = null)}>
|
||||
${this._t('btn.cancel')}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn danger" @click=${this._confirmPartitionDelete}>
|
||||
<ha-icon icon="mdi:delete-outline"></ha-icon>${this._t('btn.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</hp-dialog>`;
|
||||
}
|
||||
|
||||
private _renderPhysicalDialog(): TemplateResult {
|
||||
const d = this._physicalDialog!;
|
||||
const column = d.kind === 'column';
|
||||
|
||||
+7
-1
@@ -131,7 +131,13 @@
|
||||
"opening.unlocked": "Unlocked",
|
||||
"opening.state_unknown": "unavailable",
|
||||
"opening.no_entities": "No sensors bound — a static symbol on the plan.",
|
||||
"toast.opening_no_wall": "Click next to a room wall — openings sit on walls",
|
||||
"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.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.",
|
||||
"confirm.delete_partition_openings_item": "• {type}, {length}",
|
||||
"markup.delete": "Delete",
|
||||
"markup.hint_points": "points: {n} · Shift — 45° steps · Esc/Ctrl+Z — undo a dot · closing an area offers a room",
|
||||
"markup.hint_start": "click a grid dot to start a wall chain",
|
||||
|
||||
+7
-1
@@ -131,7 +131,13 @@
|
||||
"opening.unlocked": "Не заперто",
|
||||
"opening.state_unknown": "недоступно",
|
||||
"opening.no_entities": "Датчики не привязаны — статичный символ на плане.",
|
||||
"toast.opening_no_wall": "Кликните рядом со стеной комнаты — проёмы ставятся на стены",
|
||||
"toast.opening_no_wall": "Кликните рядом со стеной комнаты или независимой стеной",
|
||||
"opening.host_partition": "Независимая стена",
|
||||
"opening.partition_orphan": "Независимая стена этого проёма больше не существует",
|
||||
"opening.rebind_partition": "Привязать к другой независимой стене",
|
||||
"confirm.delete_partition_openings_title": "Удалить стену и проёмы?",
|
||||
"confirm.delete_partition_openings_body": "В стене есть проёмы: {count}. Они будут удалены вместе со стеной.",
|
||||
"confirm.delete_partition_openings_item": "• {type}, {length}",
|
||||
"markup.delete": "Удалить",
|
||||
"markup.hint_points": "точек: {n} · Shift — шаг 45° · Esc/Ctrl+Z — убрать точку · при замыкании будет предложена комната",
|
||||
"markup.hint_start": "кликните точку сетки, чтобы начать цепочку стен",
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface OpeningPlacementTarget {
|
||||
b: [number, number];
|
||||
physicalHalfWidth: number;
|
||||
sourceOrder: number;
|
||||
/** Explicit independent-wall owner; room-wall targets leave it absent. */
|
||||
partitionHost?: { kind: 'partition'; id: string };
|
||||
/** More than one coincident independent wall cannot be selected safely. */
|
||||
ambiguousPartitionHost?: boolean;
|
||||
}
|
||||
|
||||
export interface OpeningPlacementMeasureGeometry {
|
||||
@@ -39,6 +43,7 @@ export interface OpeningPlacementCore {
|
||||
angle: number;
|
||||
renderedLength: number;
|
||||
target: OpeningPlacementTarget;
|
||||
host?: { kind: 'partition'; id: string; t: number };
|
||||
measure: OpeningPlacementMeasureGeometry;
|
||||
}
|
||||
|
||||
@@ -102,7 +107,9 @@ function atomicSegmentKey(a: readonly number[], b: readonly number[]): string {
|
||||
* into OpeningCfg, whose compatibility contract remains absolute x/y/angle.
|
||||
*/
|
||||
export function openingPlacementTargets(
|
||||
intervals: readonly WallInterval[],
|
||||
intervals: readonly (WallInterval & {
|
||||
partitionHost?: { kind: 'partition'; id: string };
|
||||
})[],
|
||||
): OpeningPlacementTarget[] {
|
||||
const targets = new Map<string, OpeningPlacementTarget>();
|
||||
intervals.forEach((interval, sourceOrder) => {
|
||||
@@ -114,6 +121,11 @@ export function openingPlacementTargets(
|
||||
if (previous) {
|
||||
previous.physicalHalfWidth = Math.max(previous.physicalHalfWidth, interval.half || 0);
|
||||
previous.sourceOrder = Math.min(previous.sourceOrder, sourceOrder);
|
||||
if (interval.partitionHost) {
|
||||
if (previous.partitionHost && previous.partitionHost.id !== interval.partitionHost.id)
|
||||
previous.ambiguousPartitionHost = true;
|
||||
else previous.partitionHost = interval.partitionHost;
|
||||
}
|
||||
return;
|
||||
}
|
||||
targets.set(segmentKey, {
|
||||
@@ -122,6 +134,7 @@ export function openingPlacementTargets(
|
||||
b: ends.b,
|
||||
physicalHalfWidth: Math.max(0, interval.half || 0),
|
||||
sourceOrder,
|
||||
...(interval.partitionHost ? { partitionHost: interval.partitionHost } : {}),
|
||||
});
|
||||
});
|
||||
return [...targets.values()];
|
||||
@@ -218,6 +231,10 @@ 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,
|
||||
@@ -226,9 +243,42 @@ export function resolveOpeningPlacement(
|
||||
Math.max(1e-9, Math.min(input.baseTolerance, input.gridStep * 0.04)),
|
||||
))
|
||||
.sort(targetCompare);
|
||||
const picked = eligible[0];
|
||||
let picked = eligible[0];
|
||||
if (!picked) return null;
|
||||
|
||||
// A room wall and an independently persisted wall may cover the same axis
|
||||
// without sharing identical endpoints. In that composite case the stable
|
||||
// partition id is the only useful persisted owner. A genuinely crossing
|
||||
// partition tie is not a composite and must not be resolved by lexical key.
|
||||
const tied = eligible.filter((item) =>
|
||||
Math.abs(item.distance - picked.distance) <= 1e-9
|
||||
&& Math.abs(item.perpendicular - picked.perpendicular) <= 1e-9);
|
||||
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;
|
||||
const hostPick = hosted[0];
|
||||
const hx = hostPick.target.b[0] - hostPick.target.a[0];
|
||||
const hy = hostPick.target.b[1] - hostPick.target.a[1];
|
||||
const hLength = Math.hypot(hx, hy);
|
||||
const hux = hx / hLength, huy = hy / hLength;
|
||||
const composite = tied.every((item) => {
|
||||
const tx = item.target.b[0] - item.target.a[0];
|
||||
const ty = item.target.b[1] - item.target.a[1];
|
||||
const tLength = Math.hypot(tx, ty);
|
||||
const tux = tx / tLength, tuy = ty / tLength;
|
||||
const parallel = Math.abs(hux * tuy - huy * tux) <= 1e-6;
|
||||
const offset = Math.abs(
|
||||
(item.target.a[0] - hostPick.target.a[0]) * huy
|
||||
- (item.target.a[1] - hostPick.target.a[1]) * hux,
|
||||
);
|
||||
return parallel && offset <= 1e-9;
|
||||
});
|
||||
if (!composite) return null;
|
||||
picked = hostPick;
|
||||
}
|
||||
if (picked.target.ambiguousPartitionHost) return 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;
|
||||
@@ -273,6 +323,9 @@ export function resolveOpeningPlacement(
|
||||
angle,
|
||||
renderedLength: input.renderedLength,
|
||||
target,
|
||||
...(target.partitionHost ? {
|
||||
host: { ...target.partitionHost, t: length > 0 ? along / length : 0 },
|
||||
} : {}),
|
||||
measure: {
|
||||
labels: [
|
||||
{ distance: sideA, midpoint: midpointA },
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/** Pure host resolution shared by editor, renderers and backend-facing projections. */
|
||||
import { wallCmToUnits, type WallInterval } from './wall-thickness';
|
||||
import type {
|
||||
OpeningCfg, PartitionCfg, PartitionOpeningHost,
|
||||
} from './types';
|
||||
|
||||
export type PartitionOpeningOrphanReason =
|
||||
| 'invalid-host'
|
||||
| 'missing-partition'
|
||||
| 'invalid-position'
|
||||
| 'invalid-length'
|
||||
| 'does-not-fit';
|
||||
|
||||
export interface ResolvedPartitionOpening {
|
||||
opening: OpeningCfg;
|
||||
host: PartitionOpeningHost;
|
||||
partition: PartitionCfg;
|
||||
center: [number, number];
|
||||
angle: number;
|
||||
length: number;
|
||||
depth: number;
|
||||
t: number;
|
||||
axis: { a: [number, number]; b: [number, number]; ux: number; uy: number; length: number };
|
||||
}
|
||||
|
||||
export interface PartitionOpeningResolution {
|
||||
resolved: ResolvedPartitionOpening | null;
|
||||
reason: PartitionOpeningOrphanReason | null;
|
||||
}
|
||||
|
||||
const finitePoint = (point: readonly number[] | null | undefined): point is readonly [number, number] =>
|
||||
!!point && point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
|
||||
|
||||
/** Resolve explicit host identity. There is intentionally no nearest-wall fallback. */
|
||||
export function resolvePartitionOpening(
|
||||
opening: OpeningCfg,
|
||||
partitions: readonly PartitionCfg[],
|
||||
lengthScale = 1,
|
||||
cellCm = 5,
|
||||
gridPitch = 5,
|
||||
jambMargin = 0,
|
||||
): PartitionOpeningResolution {
|
||||
const host = opening.host;
|
||||
if (!host || host.kind !== 'partition' || typeof host.id !== 'string' || !host.id)
|
||||
return { resolved: null, reason: 'invalid-host' };
|
||||
const partition = partitions.find((item) => item.id === host.id);
|
||||
if (!partition) return { resolved: null, reason: 'missing-partition' };
|
||||
if (!finitePoint(partition.a) || !finitePoint(partition.b) || !Number.isFinite(host.t)
|
||||
|| host.t < 0 || host.t > 1) return { resolved: null, reason: 'invalid-position' };
|
||||
const dx = partition.b[0] - partition.a[0];
|
||||
const dy = partition.b[1] - partition.a[1];
|
||||
const axisLength = Math.hypot(dx, dy);
|
||||
const length = Number(opening.length) * lengthScale;
|
||||
if (!(axisLength > 1e-9) || !(length > 0) || !Number.isFinite(length))
|
||||
return { resolved: null, reason: 'invalid-length' };
|
||||
const along = host.t * axisLength;
|
||||
if (along - length / 2 < jambMargin - 1e-9
|
||||
|| along + length / 2 > axisLength - jambMargin + 1e-9)
|
||||
return { resolved: null, reason: 'does-not-fit' };
|
||||
const ux = dx / axisLength, uy = dy / axisLength;
|
||||
let angle = Math.atan2(dy, dx) * 180 / Math.PI;
|
||||
if (angle >= 90) angle -= 180;
|
||||
else if (angle < -90) angle += 180;
|
||||
return {
|
||||
reason: null,
|
||||
resolved: {
|
||||
opening,
|
||||
host,
|
||||
partition,
|
||||
center: [partition.a[0] + dx * host.t, partition.a[1] + dy * host.t],
|
||||
angle,
|
||||
length,
|
||||
depth: wallCmToUnits(partition.cm, cellCm, gridPitch),
|
||||
t: host.t,
|
||||
axis: {
|
||||
a: [partition.a[0], partition.a[1]],
|
||||
b: [partition.b[0], partition.b[1]],
|
||||
ux, uy, length: axisLength,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function partitionOpeningCut(resolved: ResolvedPartitionOpening): {
|
||||
hostId: string; a: [number, number]; b: [number, number]; depth: number;
|
||||
} {
|
||||
const { center, length, axis, host } = resolved;
|
||||
const half = length / 2;
|
||||
return {
|
||||
hostId: host.id,
|
||||
a: [center[0] - axis.ux * half, center[1] - axis.uy * half],
|
||||
b: [center[0] + axis.ux * half, center[1] + axis.uy * half],
|
||||
depth: resolved.depth,
|
||||
};
|
||||
}
|
||||
|
||||
export function partitionOpeningFace(
|
||||
resolved: ResolvedPartitionOpening,
|
||||
flipV = false,
|
||||
): { ox: number; oy: number; cm: number; side: -1 | 1 } {
|
||||
const side = (flipV ? 1 : -1) as -1 | 1;
|
||||
const half = resolved.depth / 2;
|
||||
return {
|
||||
ox: -resolved.axis.uy * side * half,
|
||||
oy: resolved.axis.ux * side * half,
|
||||
cm: resolved.partition.cm,
|
||||
side,
|
||||
};
|
||||
}
|
||||
|
||||
/** Candidate intervals for the existing room-wall placement resolver. */
|
||||
export function partitionPlacementIntervals(
|
||||
partitions: readonly PartitionCfg[], cellCm: number, gridPitch: number,
|
||||
): Array<WallInterval & { partitionHost: { kind: 'partition'; id: string } }> {
|
||||
return partitions.flatMap((partition) => {
|
||||
if (!finitePoint(partition.a) || !finitePoint(partition.b)
|
||||
|| Math.hypot(partition.b[0] - partition.a[0], partition.b[1] - partition.a[1]) <= 1e-9)
|
||||
return [];
|
||||
return [{
|
||||
roomId: '',
|
||||
a: [partition.a[0], partition.a[1]],
|
||||
b: [partition.b[0], partition.b[1]],
|
||||
key: `partition:${partition.id}`,
|
||||
kind: 'outer' as const,
|
||||
cm: partition.cm,
|
||||
open: false,
|
||||
half: wallCmToUnits(partition.cm, cellCm, gridPitch) / 2,
|
||||
partitionHost: { kind: 'partition' as const, id: partition.id },
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
/** Exact collinear coverage, used only for a computed composite room-wall cut. */
|
||||
export function partitionOpeningHasCompositeRoomWall(
|
||||
resolved: ResolvedPartitionOpening,
|
||||
intervals: readonly WallInterval[],
|
||||
epsilon: number,
|
||||
): boolean {
|
||||
const { center, length, axis } = resolved;
|
||||
const half = length / 2;
|
||||
const coverage = intervals.flatMap((interval) => {
|
||||
if (!interval.kind || interval.open) return [];
|
||||
const dx = interval.b[0] - interval.a[0], dy = interval.b[1] - interval.a[1];
|
||||
const span = Math.hypot(dx, dy);
|
||||
if (!(span > epsilon)) return [];
|
||||
const ux = dx / span, uy = dy / span;
|
||||
if (Math.abs(axis.ux * uy - axis.uy * ux) > 1e-6) return [];
|
||||
const lineDistance = Math.abs(
|
||||
(center[0] - interval.a[0]) * uy - (center[1] - interval.a[1]) * ux,
|
||||
);
|
||||
if (lineDistance > epsilon) return [];
|
||||
const along = (point: readonly number[]) =>
|
||||
(point[0] - center[0]) * axis.ux + (point[1] - center[1]) * axis.uy;
|
||||
const a = along(interval.a), b = along(interval.b);
|
||||
const lo = Math.max(-half, Math.min(a, b));
|
||||
const hi = Math.min(half, Math.max(a, b));
|
||||
return hi >= lo - epsilon ? [[lo, hi] as [number, number]] : [];
|
||||
});
|
||||
if (!coverage.length) return false;
|
||||
coverage.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
let reached = -half;
|
||||
for (const [lo, hi] of coverage) {
|
||||
if (lo > reached + epsilon) return false;
|
||||
reached = Math.max(reached, hi);
|
||||
if (reached >= half - epsilon) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hostedOpeningIntervalsOverlap(
|
||||
candidate: ResolvedPartitionOpening,
|
||||
others: readonly ResolvedPartitionOpening[],
|
||||
epsilon = 1e-9,
|
||||
): boolean {
|
||||
const half = candidate.length / (2 * candidate.axis.length);
|
||||
const lo = candidate.t - half, hi = candidate.t + half;
|
||||
return others.some((other) => {
|
||||
if (other.host.id !== candidate.host.id || other.opening.id === candidate.opening.id) return false;
|
||||
const otherHalf = other.length / (2 * other.axis.length);
|
||||
return Math.max(lo, other.t - otherHalf) < Math.min(hi, other.t + otherHalf) - epsilon;
|
||||
});
|
||||
}
|
||||
|
||||
/** Update compatibility fields after a host move without changing host semantics. */
|
||||
export function materializePartitionOpening(
|
||||
opening: OpeningCfg, resolved: ResolvedPartitionOpening, coordScale: number,
|
||||
): OpeningCfg {
|
||||
return {
|
||||
...opening,
|
||||
x: resolved.center[0] / coordScale,
|
||||
y: resolved.center[1] / coordScale,
|
||||
angle: resolved.angle,
|
||||
};
|
||||
}
|
||||
@@ -105,6 +105,49 @@ export interface PhysicalBodySet extends PhysicalBodyParts {
|
||||
geometry: any | null;
|
||||
}
|
||||
|
||||
export interface PartitionOpeningCut {
|
||||
hostId: string;
|
||||
a: [number, number];
|
||||
b: [number, number];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut only the explicitly hosted independent-wall body. Boolean failure keeps
|
||||
* the original body opaque (fail-dark) instead of manufacturing a light leak.
|
||||
*/
|
||||
export function cutPartitionBody(
|
||||
body: number[][], cuts: readonly PartitionOpeningCut[], epsilon = 1e-9,
|
||||
): number[][][] {
|
||||
if (!cuts.length) return [body];
|
||||
let geometry: any = [closedRing(body)];
|
||||
try {
|
||||
for (const cut of cuts) {
|
||||
const dx = cut.b[0] - cut.a[0], dy = cut.b[1] - cut.a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!(length > epsilon)) continue;
|
||||
const ux = dx / length, uy = dy / length;
|
||||
const nx = -uy, ny = ux;
|
||||
const pad = Math.max(Number(cut.depth) || 0, epsilon * 4) * 1.25;
|
||||
const longitudinalPad = Math.max(epsilon * 2, length * 1e-9);
|
||||
const slot = [
|
||||
[cut.a[0] - ux * longitudinalPad - nx * pad,
|
||||
cut.a[1] - uy * longitudinalPad - ny * pad],
|
||||
[cut.b[0] + ux * longitudinalPad - nx * pad,
|
||||
cut.b[1] + uy * longitudinalPad - ny * pad],
|
||||
[cut.b[0] + ux * longitudinalPad + nx * pad,
|
||||
cut.b[1] + uy * longitudinalPad + ny * pad],
|
||||
[cut.a[0] - ux * longitudinalPad + nx * pad,
|
||||
cut.a[1] - uy * longitudinalPad + ny * pad],
|
||||
];
|
||||
geometry = difference(geometry, closedRing(slot) as any);
|
||||
}
|
||||
return geometryOuterRings(geometry);
|
||||
} catch {
|
||||
return [body];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw editable bodies plus their computed, order-independent junction volumes.
|
||||
* Most runtime consumers need these polygons directly and must not pay for an
|
||||
@@ -115,11 +158,19 @@ export function physicalBodyParts(
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
epsilon = Math.max(gridPitch * 0.0002, 1e-9),
|
||||
partitionCuts: readonly PartitionOpeningCut[] = [],
|
||||
): PhysicalBodyParts {
|
||||
const draftSegments: LinearWallSegment[] = [];
|
||||
const partitionSegments: LinearWallSegment[] = [];
|
||||
const cutsByPartition = new Map<string, PartitionOpeningCut[]>();
|
||||
for (const cut of partitionCuts) {
|
||||
const list = cutsByPartition.get(cut.hostId) || [];
|
||||
list.push(cut);
|
||||
cutsByPartition.set(cut.hostId, list);
|
||||
}
|
||||
const drafts: number[][][] = [];
|
||||
const partitions: number[][][] = [];
|
||||
const presentedPartitions: number[][][] = [];
|
||||
for (const draft of space.room_drafts || []) {
|
||||
for (let i = 0; i + 1 < draft.points.length; i++) {
|
||||
const halfDepth = wallCmToUnits(
|
||||
@@ -142,13 +193,20 @@ export function physicalBodyParts(
|
||||
if (!body) continue;
|
||||
partitionSegments.push(segment);
|
||||
partitions.push(body);
|
||||
presentedPartitions.push(...cutPartitionBody(
|
||||
body, cutsByPartition.get(partition.id) || [], epsilon,
|
||||
));
|
||||
}
|
||||
const columns = (space.wall_columns || []).map((column) =>
|
||||
columnBody(column, cellCm, gridPitch));
|
||||
// Join volumes are presentation masonry too. Leaving them uncut can bridge
|
||||
// an opening placed close to a T/endpoint even though its raw host body was
|
||||
// correctly split. Other crossing walls remain opaque through their own raw
|
||||
// bodies; only the extra shared mitre/bevel volume is trimmed here.
|
||||
const patches = linearWallJoinPatches(
|
||||
[...draftSegments, ...partitionSegments], epsilon,
|
||||
);
|
||||
const all = [...drafts, ...partitions, ...patches, ...columns];
|
||||
).flatMap((body) => cutPartitionBody(body, partitionCuts, epsilon));
|
||||
const all = [...drafts, ...presentedPartitions, ...patches, ...columns];
|
||||
return { drafts, partitions, columns, patches, all };
|
||||
}
|
||||
|
||||
|
||||
@@ -214,8 +214,9 @@ function pointOnSnapSegment(
|
||||
|
||||
/**
|
||||
* Return the stable completed-room solid interval that contains both points.
|
||||
* Because room openings and open spans are cut while the snapshot is built,
|
||||
* points on opposite sides of a gap can never share a returned segment.
|
||||
* Real open spans are cut while the snapshot is built, so points across one
|
||||
* cannot share a segment. Door/window/gate/passage openings remain structural
|
||||
* axes for room-face topology (#185) even though masonry is cut elsewhere.
|
||||
*/
|
||||
export function findSharedRoomSnapSegment(
|
||||
geometry: PlanSnapGeometry,
|
||||
|
||||
+78
-9
@@ -11,17 +11,26 @@ import { buildDevices, areaLqi, areaTemp, resolvedLightSources, resolvedLightSta
|
||||
import {
|
||||
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
|
||||
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
|
||||
openingAmount,
|
||||
type ResolvedRoomFill,
|
||||
} from './logic';
|
||||
import {
|
||||
openingTunnelGeometries, wallBodiesUnionPath, wallBodyNeedsSolid, type WallEntry,
|
||||
openingTunnelGeometries, wallBodiesUnionPath, wallBodyNeedsSolid, wallIntervals,
|
||||
type WallEntry,
|
||||
} from './wall-thickness';
|
||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import { bgModeOf, resolveDayCycle } from './sun';
|
||||
import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render';
|
||||
import type { DevItem, ServerConfig } from './types';
|
||||
import { physicalBodies } from './physical-geometry';
|
||||
import type { DevItem, OpeningCfg, ServerConfig } from './types';
|
||||
import { physicalBodyParts } from './physical-geometry';
|
||||
import {
|
||||
materializePartitionOpening, partitionOpeningCut,
|
||||
partitionOpeningFace, partitionOpeningHasCompositeRoomWall, resolvePartitionOpening,
|
||||
} from './partition-openings';
|
||||
import {
|
||||
renderOpeningVisibleGeometry, type OpeningVisibleSpec,
|
||||
} from './render/opening-symbol';
|
||||
import { activeRegistryHass, fullRegistryHass, type HaRegistrySnapshot } from './ha-binding-status';
|
||||
import {
|
||||
resolveDevicePresentation, type PresentationActivityRuntime, type ResolvedDevicePresentation,
|
||||
@@ -200,15 +209,29 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const spCfg: any = o.cfg.spaces.find((s: any) => s.id === o.spaceId) || {};
|
||||
const walls: WallEntry[] = Array.isArray(spCfg.walls) ? spCfg.walls : [];
|
||||
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(
|
||||
opening, space.partitions, NORM_W, cellCm, GRID_PITCH,
|
||||
).resolved;
|
||||
return resolved ? [resolved] : [];
|
||||
});
|
||||
const physicalFingerprint = contentFingerprint({
|
||||
partitions: space.partitions,
|
||||
roomDrafts: space.room_drafts,
|
||||
columns: space.wall_columns,
|
||||
cellCm,
|
||||
hostedOpenings: resolvedHosted.map((resolved) => ({
|
||||
id: resolved.opening.id, host: resolved.host,
|
||||
length: resolved.length, type: resolved.opening.type,
|
||||
})),
|
||||
});
|
||||
const extras = cachedStaticPhysicalBodies(
|
||||
o.cfg, space.id, physicalFingerprint,
|
||||
() => physicalBodies(space, cellCm, GRID_PITCH),
|
||||
() => physicalBodyParts(
|
||||
space, cellCm, GRID_PITCH, GRID_PITCH * 0.0002,
|
||||
resolvedHosted.map(partitionOpeningCut),
|
||||
).all,
|
||||
);
|
||||
for (const body of extras) {
|
||||
const xs = body.map((p) => p[0]), ys = body.map((p) => p[1]);
|
||||
@@ -244,7 +267,23 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
}
|
||||
// Static intentionally keeps the historical door/window/gate wall output.
|
||||
// Only the new negative-space type participates in its masonry fingerprint.
|
||||
const staticPassages = staticPassageOpenings(spCfg.openings, NORM_W);
|
||||
const resolvedRawOpenings = (spCfg.openings || []).flatMap((opening: OpeningCfg) => {
|
||||
if (!opening.host) return [opening];
|
||||
const resolved = resolvedHosted.find((item) => item.opening.id === opening.id);
|
||||
return resolved ? [materializePartitionOpening(opening, resolved, NORM_W)] : [];
|
||||
});
|
||||
const staticPassages = staticPassageOpenings(resolvedRawOpenings, NORM_W);
|
||||
const roomIntervals = wallIntervals(
|
||||
space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W,
|
||||
);
|
||||
const hostedCompositeOpenings = resolvedHosted
|
||||
.filter((resolved) => partitionOpeningHasCompositeRoomWall(
|
||||
resolved, roomIntervals, GRID_PITCH * 0.0002,
|
||||
))
|
||||
.map((resolved) => ({
|
||||
x: resolved.center[0], y: resolved.center[1],
|
||||
angle: resolved.angle, length: resolved.length,
|
||||
}));
|
||||
|
||||
const roomShapes = space.rooms
|
||||
.filter((r) => r.area || disp.showBorders || roomFillModeOf(disp.fill, r) !== 'none')
|
||||
@@ -381,14 +420,17 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
? contentFingerprint(staticPassages.length
|
||||
? { rooms: space.rooms, walls, extras, cellCm, passages: staticPassages.map((opening) => ({
|
||||
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
|
||||
})) }
|
||||
})), hostedCompositeOpenings }
|
||||
: { rooms: space.rooms, walls, extras, cellCm })
|
||||
: '';
|
||||
const canonicalWallGeometry = needsCanonicalWallGeometry
|
||||
? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => wallBodiesUnionPath(
|
||||
space.rooms, walls, [], staticPassages.map((opening) => ({
|
||||
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
|
||||
})), GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
|
||||
space.rooms, walls, [], [
|
||||
...staticPassages.filter((opening) => !opening.host).map((opening) => ({
|
||||
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
|
||||
})),
|
||||
...hostedCompositeOpenings,
|
||||
], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
|
||||
))
|
||||
: null;
|
||||
const passageTunnelGeometry = staticPassages.length && walls.length
|
||||
@@ -427,6 +469,32 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const pxPerUnit = o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1;
|
||||
const solidWall = !!wallUnion && wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit);
|
||||
const wallStroke = disp.color || '#607d8b';
|
||||
const hostedOpeningSymbols = disp.hideOpenings ? [] : resolvedHosted.map((resolved) => {
|
||||
const opening = resolved.opening;
|
||||
const state = opening.type === 'passage' || !opening.contact
|
||||
? null : planHass.states?.[opening.contact]?.state;
|
||||
const amount = openingAmount(opening.type, state, !!opening.invert);
|
||||
const active = amount > 0 && !!opening.contact;
|
||||
const faceFlipV = opening.type === 'gate' ? !opening.flip_v : !!opening.flip_v;
|
||||
const spec: OpeningVisibleSpec = {
|
||||
type: opening.type,
|
||||
length: resolved.length,
|
||||
angle: resolved.angle,
|
||||
amount,
|
||||
flipH: !!opening.flip_h,
|
||||
flipV: !!opening.flip_v,
|
||||
base: wallStroke,
|
||||
tone: active ? 'var(--hp-open)' : wallStroke,
|
||||
cellCm,
|
||||
gridPitch: GRID_PITCH,
|
||||
face: partitionOpeningFace(resolved, faceFlipV),
|
||||
};
|
||||
return svg`<g class="opening static-opening" data-hp="opening"
|
||||
data-id=${opening.id} data-kind=${opening.type} pointer-events="none"
|
||||
transform="translate(${resolved.center[0]} ${resolved.center[1]}) rotate(${resolved.angle})">
|
||||
${renderOpeningVisibleGeometry(spec)}
|
||||
</g>`;
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="hp-static-stage${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}"
|
||||
@@ -472,6 +540,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
stroke="${wallStroke}" stroke-width="0.6" pointer-events="none"></path>
|
||||
</g>`
|
||||
: nothing}
|
||||
${hostedOpeningSymbols}
|
||||
</svg>
|
||||
${''/* docs/CANVAS.md §6: the same expression as the full card. The
|
||||
static card has no zoom, but its frame is the CONTENT now, so a
|
||||
|
||||
@@ -721,6 +721,22 @@ export const cardStyles = css`
|
||||
.stage.markup .op-hit:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.stage.markup .opening.orphan {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: var(--error-color, #db4437);
|
||||
}
|
||||
.stage.markup .opening.orphan circle {
|
||||
fill: var(--hp-bg);
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.stage.markup .opening.orphan text {
|
||||
fill: currentColor;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* HP-1550-04: in the resize tool the wall handles own the hit test — the
|
||||
transparent .op-hit of a door at the midpoint of a wall used to sit ON
|
||||
TOP of the handle and made that wall ungrabbable for both rooms.
|
||||
|
||||
@@ -166,6 +166,14 @@ export interface Marker {
|
||||
}
|
||||
|
||||
/** A door, window, gate or open passage: plan geometry (normalized coords). */
|
||||
export interface PartitionOpeningHost {
|
||||
kind: 'partition';
|
||||
/** Stable id of one saved independent wall segment in the same space. */
|
||||
id: string;
|
||||
/** Centre position along the directed partition a -> b. */
|
||||
t: number;
|
||||
}
|
||||
|
||||
export interface OpeningCfg {
|
||||
id: string;
|
||||
type: 'door' | 'window' | 'gate' | 'passage';
|
||||
@@ -178,6 +186,8 @@ export interface OpeningCfg {
|
||||
invert?: boolean;
|
||||
flip_h?: boolean; // hinge on the other jamb
|
||||
flip_v?: boolean; // opens to the other side of the wall
|
||||
/** Explicit owner for an opening cut into an independent wall. */
|
||||
host?: PartitionOpeningHost;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
|
||||
Reference in New Issue
Block a user