fix+docs v1.21.1: audit of v1.16-v1.21

- fix: Split snaps the click to the room's nearest wall instead of the grid, so
  it works on non-grid-aligned rooms (imported/legacy polygons); the pull is
  capped (~6 cells) so accidental mid-room clicks stay a miss; splitRoom() still
  rejects a bad cut. closestPointOnBoundary() + unit test.
- docs: README (en+ru) documents Merge/Split/ruler/per-space scale (were undocumented);
  TESTING.md merge/split rows + fresh self-run; new smokes smoke_merge_split,
  smoke_split_nonsnap (incl. the capped-pull case).
- 73 frontend + 12 backend tests green; module eval + both cards register.
This commit is contained in:
Matysh
2026-07-16 11:43:15 +03:00
parent b706ad4b49
commit 9288d74f6f
17 changed files with 414 additions and 145 deletions
+12 -4
View File
@@ -14,7 +14,7 @@ import {
import {
lqiColor, snapToGrid, samePoint, pointInPolygon, markerIdForBinding,
segmentCm, formatLength, roomEdges, roomPoly, pointStrictlyInside, roomsOverlap,
pointOnBoundary, mergeRooms, splitRoom, polygonArea,
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
spaceDisplayOf, roomFillColor, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
@@ -28,7 +28,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.21.0';
const CARD_VERSION = '1.21.1';
const LS_KEY = 'houseplan_card_layout_v1';
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -1171,9 +1171,17 @@ class HouseplanCard extends LitElement {
this._splitSel = null;
return;
}
// A split point lands on the room's nearest wall — the user aims at a wall,
// and rooms need not be grid-aligned (imported/legacy polygons), so snapping
// to the grid would miss the outline. The pull is capped: a click far from
// any wall (e.g. an accidental one in the middle of the room) is a miss and
// gets the toast, not a wall the user never meant. splitRoom() still rejects
// any cut that is not a clean wall-to-wall chord.
const eps = this._gridPitch * 0.02;
const pt = this._snap(raw);
if (!pointOnBoundary(pt, poly, eps)) {
const pull = this._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional
const near = closestPointOnBoundary(raw, poly);
const pt = near && Math.hypot(near[0] - raw[0], near[1] - raw[1]) <= pull ? near : null;
if (!pt || !pointOnBoundary(pt, poly, eps)) {
this._showToast(this._t('toast.split_pick_wall'));
return;
}
+23
View File
@@ -105,6 +105,29 @@ function distToSeg(p: number[], a: number[], b: number[]): number {
* neighbouring rooms share walls, so their vertices sit on each other's outlines
* including mid-span, since real walls overlap collinearly rather than match exactly.
*/
/**
* Project a point onto the nearest edge of a polygon and return that point,
* or null when the polygon has no edges. Used to snap a Split click onto the
* actual wall (rooms may not be grid-aligned imported polygons, older configs),
* so cutting no longer requires hitting a grid node exactly on the outline.
*/
export function closestPointOnBoundary(p: number[], poly: number[][]): number[] | null {
if (!poly || poly.length < 2) return null;
let best: number[] | null = null;
let bestD = Infinity;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length];
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
let t = len2 ? ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
const q = [a[0] + t * dx, a[1] + t * dy];
const d = Math.hypot(p[0] - q[0], p[1] - q[1]);
if (d < bestD) { bestD = d; best = q; }
}
return best;
}
export function pointOnBoundary(p: number[], poly: number[][], eps = 1e-6): boolean {
if (!poly || poly.length < 2) return false;
for (let i = 0; i < poly.length; i++)