v1.59.0: release stable plan editing

This commit is contained in:
Matysh
2026-08-06 17:43:53 +03:00
parent 5ca4c7e5c5
commit e7aca9c678
14 changed files with 284 additions and 101 deletions
+86 -16
View File
@@ -47,7 +47,8 @@ import {
degradeWalls, rekeyWallsAfterMove,
setWallThickness, setWallThicknessForRoom, cmToField, fieldToCm, wallCmToUnits,
wallEdgeBodies, wallBodiesUnionPath, paperRoomShapesWithWalls,
innerContourForRoom, openingInnerFaceOffset, applyWallThicknessToNewRoom,
innerContourForRoom, roomWallProfile, outsetContour,
openingInnerFaceOffset, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, wallIntervals, normalizeWallIntervals,
intervalCmAt, wallBodyNeedsSolid, type WallEntry,
} from './wall-thickness';
@@ -90,7 +91,7 @@ import {
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
const CARD_VERSION = '1.59.0-rc.2';
const CARD_VERSION = '1.59.0';
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
* trailing-quiescence requirement (chrome still settling near the cap
@@ -5723,12 +5724,12 @@ class HouseplanCard extends LitElement {
}
/**
* View-only perimeter above real wall bodies.
* View-only clean-floor perimeter above real wall bodies.
*
* The room shape lives below the unioned wall body, so its CSS :hover stroke
* disappears precisely on thick/common walls. Draw the hovered room's real
* boundary once more after the bodies. Open (virtual) spans stay dashed in
* their own layer and are cut out here instead of being painted solid.
* The hover follows the same inner face that defines the displayed room
* area. A nested room is a hole in the hovered room, so its boundary uses the
* opposite (outward) wall face. Open spans stay dashed in their own layer;
* doors and windows remain gaps instead of being bridged by a solid accent.
*/
private _renderRoomHover(space: SpaceModel): TemplateResult {
const hover = this._hoverRoom;
@@ -5740,16 +5741,39 @@ class HouseplanCard extends LitElement {
const poly = roomPoly(room);
if (!poly) return svg`` as unknown as TemplateResult;
// A parent room also owns the walls around rooms nested inside it.
// A parent room also owns the floor-facing side of walls around rooms
// nested inside it. Keep the room together with its cached polygon so a
// hole can use the OUTER face of that nested room's wall.
const others = space.rooms
.filter((r) => r !== room)
.map((r) => roomPoly(r))
.filter((p): p is number[][] => !!p);
const contours = [poly, ...islandsOf(poly, others)];
.map((r) => ({ room: r, poly: roomPoly(r) }))
.filter((v): v is { room: RoomCfg; poly: number[][] } => !!v.poly);
const islandPolys = islandsOf(poly, others.map((v) => v.poly));
const pairs = this._openPairs();
const allOpenCuts = pairs.flatMap((p) => p.segs);
const openCuts = room.id
? pairs.filter((p) => p.a.id === room.id || p.b.id === room.id).flatMap((p) => p.segs)
: pairs.flatMap((p) => p.segs);
const walls = this._spaceWalls;
const floor = walls.length && room.id
? (innerContourForRoom(
space.rooms, room.id, walls, allOpenCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
) || poly)
: poly;
const contours: { axis: number[][]; face: number[][] }[] = [{ axis: poly, face: floor }];
for (const island of islandPolys) {
const owner = others.find((v) => v.poly === island)?.room;
let face = island;
if (walls.length && owner?.id) {
const profile = roomWallProfile(
space.rooms, owner.id, walls, allOpenCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
if (profile) face = outsetContour(profile.poly, profile.offsets) || island;
}
contours.push({ axis: island, face });
}
// 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) => {
@@ -5758,13 +5782,59 @@ class HouseplanCard extends LitElement {
const dy = (Math.sin(rad) * o.rlen) / 2;
return [o.rx - dx, o.ry - dy, o.rx + dx, o.ry + dy];
});
const cuts = openCuts.concat(openingCuts);
const eps = this._gridPitch * 0.02;
const d = contours.map((contour) => {
if (!cuts.length) {
return `M ${contour.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`;
// Cuts are stored on wall centrelines. Project each relevant cut onto the
// clean-floor face before trimming; otherwise a door on a thick wall would
// no longer intersect the inset hover contour.
const cutOnFace = (cut: number[], axis: number[][], face: number[][]): number[] | null => {
const cx = cut[2] - cut[0], cy = cut[3] - cut[1];
const cLen = Math.hypot(cx, cy);
if (cLen < eps) return null;
const cux = cx / cLen, cuy = cy / cLen;
const mx = (cut[0] + cut[2]) / 2, my = (cut[1] + cut[3]) / 2;
let belongs = false;
for (let i = 0; i < axis.length; i++) {
const a = axis[i], b = axis[(i + 1) % axis.length];
const ex = b[0] - a[0], ey = b[1] - a[1];
const eLen = Math.hypot(ex, ey);
if (eLen < eps || Math.abs(cux * (ey / eLen) - cuy * (ex / eLen)) > 0.05) continue;
if (distToSegment([mx, my], [a[0], a[1], b[0], b[1]]) <= eps * 4) {
belongs = true;
break;
}
}
return outlineWithout(contour, cuts, eps)
if (!belongs) return null;
let best: { a: number[]; b: number[]; d: number } | null = null;
for (let i = 0; i < face.length; i++) {
const a = face[i], b = face[(i + 1) % face.length];
const ex = b[0] - a[0], ey = b[1] - a[1];
const eLen = Math.hypot(ex, ey);
if (eLen < eps || Math.abs(cux * (ey / eLen) - cuy * (ex / eLen)) > 0.05) continue;
const d = distToSegment([mx, my], [a[0], a[1], b[0], b[1]]);
if (!best || d < best.d) best = { a, b, d };
}
if (!best) return null;
const fx = best.b[0] - best.a[0], fy = best.b[1] - best.a[1];
const fLen = Math.hypot(fx, fy) || 1;
const nx = -fy / fLen, ny = fx / fLen;
const shift = (best.a[0] - mx) * nx + (best.a[1] - my) * ny;
return [
cut[0] + nx * shift, cut[1] + ny * shift,
cut[2] + nx * shift, cut[3] + ny * shift,
];
};
const rawCuts = openCuts.concat(openingCuts);
const d = contours.map(({ axis, face }) => {
const cuts = rawCuts
.map((cut) => cutOnFace(cut, axis, face))
.filter((cut): cut is number[] => !!cut);
if (!cuts.length) {
return `M ${face.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`;
}
return outlineWithout(face, cuts, eps)
.map((sg) => `M ${sg[0]} ${sg[1]} L ${sg[2]} ${sg[3]}`)
.join(' ');
}).filter(Boolean).join(' ');
+2 -2
View File
@@ -1212,8 +1212,8 @@ export const cardStyles = css`
stroke: rgba(62, 166, 255, 0.55);
stroke-opacity: 1;
}
/* View hover is repeated above the unioned thick-wall body. The original
room stroke remains responsible for the fill and thin-wall fallback. */
/* View hover follows the clean-floor face above the unioned wall body. The
original room stroke remains responsible for fill and thin-wall fallback. */
.room-hover-outline {
fill: none;
stroke: var(--hp-accent);
+16 -18
View File
@@ -1270,8 +1270,13 @@ export function wallBodyRings(
}
/**
* Seamless wall hatch: union of all half-outsets minus union of all half-insets,
* Seamless wall hatch: union of each room's own outset-minus-inset wall ring,
* with opening slots cut as holes. One continuous body across L and T joins.
*
* Do not rewrite this as `(union outsets) - (union insets)`: subtraction does
* not distribute over union. In a nested/complex layout the clean floor of one
* room would then erase a wall owned by another room, leaving half-depth strips
* and tiny holes at junctions.
*/
export function wallBodiesUnionPath(
rooms: any[],
@@ -1284,8 +1289,7 @@ export function wallBodiesUnionPath(
coordScale = 1,
): { d: string; depthUnits: number } | null {
if (!walls?.length) return null;
const outsets: number[][][] = [];
const insets: number[][][] = [];
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
let maxDepth = 0;
for (const room of rooms || []) {
if (!room?.id) continue;
@@ -1294,26 +1298,20 @@ export function wallBodiesUnionPath(
for (const o of pr.offsets) if (o > 0) maxDepth = Math.max(maxDepth, o * 2);
const outC = outsetContour(pr.poly, pr.offsets);
const inC = insetContour(pr.poly, pr.offsets);
if (outC) outsets.push(outC);
if (inC) insets.push(inC);
if (!outC) continue;
roomRings.push({ outset: outC, inset: inC });
}
if (!outsets.length) return null;
if (!roomRings.length) return null;
const junctions = virtualJunctionPatches(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
try {
let Uout: any = closedRing(outsets[0]);
for (let i = 1; i < outsets.length; i++) {
Uout = union(Uout, closedRing(outsets[i]) as any);
}
let body: any = Uout;
if (insets.length) {
let Uin: any = closedRing(insets[0]);
for (let i = 1; i < insets.length; i++) {
Uin = union(Uin, closedRing(insets[i]) as any);
}
body = difference(Uout, Uin);
}
const bodyOf = (ring: typeof roomRings[number]): any => {
const outset: any = closedRing(ring.outset);
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
};
let body: any = bodyOf(roomRings[0]);
for (let i = 1; i < roomRings.length; i++) body = union(body, bodyOf(roomRings[i]));
// The room-ring subtraction above cannot infer a mitre between real arms
// owned by different contours at a virtual T. Add only those missing
// junction pieces, then let physical openings cut through them as usual.