mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -168,12 +168,18 @@ def _safe_optimize_partition_rehost(
|
||||
return False
|
||||
|
||||
owners: set[str] = set()
|
||||
collinear_rooms: set[str] = set()
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
if any(_segment_covers(poly[index], poly[(index + 1) % len(poly)], a, b)
|
||||
for index in range(len(poly))):
|
||||
owners.add(str(room.get("id", "")))
|
||||
if len(owners) != 2:
|
||||
room_id = str(room.get("id", ""))
|
||||
edges = [(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))]
|
||||
if any(_segment_covers(edge_a, edge_b, a, b) for edge_a, edge_b in edges):
|
||||
owners.add(room_id)
|
||||
if any(_segments_overlap_on_axis(a, b, edge_a, edge_b)
|
||||
for edge_a, edge_b in edges):
|
||||
collinear_rooms.add(room_id)
|
||||
if len(owners) not in (1, 2) or collinear_rooms != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(a, b, span["a"], span["b"])
|
||||
for span in space.get("open_spans") or []):
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// #281: an exact outer-wall partition blocks Resize, explicit Optimize safely
|
||||
// rehosts its windows, and the production pointer path then moves two rooms.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const source = JSON.parse(readFileSync(
|
||||
new URL('../test/fixtures/281-resize-outer-partitions.json', import.meta.url),
|
||||
'utf8',
|
||||
));
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
const { page, browser } = await launch({ width: 900, height: 820 });
|
||||
const out = await page.evaluate(async (fixture) => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const copy = (value) => JSON.parse(JSON.stringify(value));
|
||||
let serverConfig = copy(fixture);
|
||||
let serverLayout = {};
|
||||
const sent = [];
|
||||
const baseCall = card.hass.callWS.bind(card.hass);
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/plan/optimize') {
|
||||
sent.push(message.type);
|
||||
serverConfig = copy(message.config);
|
||||
serverLayout = copy(message.layout);
|
||||
return { ok: true, config_rev: 2, layout_rev: 2, can_undo: true };
|
||||
}
|
||||
if (message.type === 'houseplan/config/get')
|
||||
return { config: copy(serverConfig), rev: 2, can_write: true, can_optimize_undo: true };
|
||||
if (message.type === 'houseplan/layout/get')
|
||||
return { layout: copy(serverLayout), rev: 2 };
|
||||
return baseCall(message);
|
||||
},
|
||||
};
|
||||
|
||||
const resetResize = async () => {
|
||||
card._setMode('plan');
|
||||
card._tool = 'resize';
|
||||
card._rszDrag = null;
|
||||
card._rszPreview = null;
|
||||
card._rszLive = null;
|
||||
card._rszEligibilityCache = null;
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
};
|
||||
const targetHandles = () => [...card.renderRoot.querySelectorAll('.rszhandle')]
|
||||
.filter((handle) => Math.abs(Number(handle.getAttribute('cx')) - 500) < 1
|
||||
&& Math.abs(Number(handle.getAttribute('cy')) - 500) < 1);
|
||||
const screenPoint = (x, y) => {
|
||||
const stage = card.renderRoot.querySelector('.stage');
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const svg = stage.querySelector('svg');
|
||||
const [vx, vy, vw, vh] = svg.getAttribute('viewBox').split(' ').map(Number);
|
||||
return [rect.left + ((x - vx) / vw) * rect.width,
|
||||
rect.top + ((y - vy) / vh) * rect.height];
|
||||
};
|
||||
const dispatch = (target, type, x, y, pointerId = 281) => target.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
bubbles: true, cancelable: true, pointerId,
|
||||
clientX: x, clientY: y, pointerType: 'mouse',
|
||||
buttons: type === 'pointerup' ? 0 : 1,
|
||||
}),
|
||||
);
|
||||
|
||||
card._serverCfg = copy(fixture);
|
||||
card._layout = {};
|
||||
card._space = 'resize-outer';
|
||||
await resetResize();
|
||||
const beforeHandles = targetHandles();
|
||||
result.beforeIsExplainedDisabled = beforeHandles.length === 2
|
||||
&& beforeHandles.every((handle) => handle.getAttribute('aria-disabled') === 'true'
|
||||
&& /independent wall|partition|перегород/i.test(handle.getAttribute('aria-label') || ''));
|
||||
const [beforeX, beforeY] = screenPoint(500, 500);
|
||||
dispatch(beforeHandles[0], 'pointerdown', beforeX, beforeY);
|
||||
result.disabledCapturesNothing = card._rszDrag == null
|
||||
&& card._geometryHistory.size === 0;
|
||||
|
||||
card._openAlignDialog();
|
||||
await card.updateComplete;
|
||||
result.previewProvesOuterRewrite = card._alignDialog?.changed === true
|
||||
&& card._alignDialog?.preflight?.ok === true
|
||||
&& card._alignDialog?.report?.partitionsReconciled === 3
|
||||
&& card._alignDialog?.report?.openingsRehosted === 2
|
||||
&& card._alignDialog?.config?.spaces?.[0]?.partitions == null
|
||||
&& card._alignDialog?.config?.spaces?.[0]?.openings?.every((opening) => opening.host == null);
|
||||
await card._runAlignToGrid();
|
||||
await card.updateComplete;
|
||||
result.optimizeUsesOneWrite = sent.filter((type) => type === 'houseplan/plan/optimize').length === 1;
|
||||
const optimized = card._serverCfg.spaces[0];
|
||||
result.optimizePreservesOpeningFields = optimized.partitions == null
|
||||
&& optimized.openings[0].host == null
|
||||
&& optimized.openings[0].cover === 'cover.left'
|
||||
&& optimized.openings[0].future_field?.keep === 'left'
|
||||
&& optimized.openings[1].host == null
|
||||
&& optimized.openings[1].contact === 'binary_sensor.right';
|
||||
|
||||
await resetResize();
|
||||
const afterHandles = targetHandles();
|
||||
result.afterIsEnabled = afterHandles.length === 2
|
||||
&& afterHandles.every((handle) => handle.getAttribute('aria-disabled') === 'false');
|
||||
const [startX, startY] = screenPoint(500, 500);
|
||||
const [moveX] = screenPoint(550, 500);
|
||||
dispatch(afterHandles[0], 'pointerdown', startX, startY);
|
||||
result.dragStarts = card._rszDrag != null;
|
||||
dispatch(afterHandles[0], 'pointermove', moveX, startY);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
dispatch(afterHandles[0], 'pointerup', moveX, startY);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const left = card._serverCfg.spaces[0].rooms.find((room) => room.id === 'left');
|
||||
const right = card._serverCfg.spaces[0].rooms.find((room) => room.id === 'right');
|
||||
result.gestureMovesExactlySharedEndpoints = Math.abs(left.poly[1][0] - 0.55) < 0.006
|
||||
&& Math.abs(left.poly[2][0] - 0.55) < 0.006
|
||||
&& Math.abs(right.poly[0][0] - 0.55) < 0.006
|
||||
&& Math.abs(right.poly[3][0] - 0.55) < 0.006
|
||||
&& card._serverCfg.spaces[0].rooms.length === 2
|
||||
&& card._geometryHistory.size === 1;
|
||||
return result;
|
||||
}, clone(source));
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- “Optimize plans” now safely removes exact redundant partitions over outer
|
||||
room walls, including lossless reattachment of their hosted windows. Resize
|
||||
then actually moves an eligible shared wall; a handle with no valid grid step
|
||||
no longer looks enabled and explains the blocking reason
|
||||
([#281](https://github.com/Matysh/houseplan-card/issues/281)).
|
||||
- The backend now accepts a proved Optimize reconciliation of a coincident
|
||||
partition: its opening remains on the same shared wall through Apply, reload
|
||||
and Undo. Arbitrary host stripping by an ordinary write or crafted candidate
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- «Оптимизировать планы» теперь безопасно убирает точные лишние перегородки
|
||||
поверх внешних стен комнат, включая неизменное перепривязывание размещённых
|
||||
на них окон. После этого «Изменить размер» действительно двигает доступную
|
||||
общую стену; ручка без единого допустимого шага больше не выглядит активной
|
||||
и объясняет причину блокировки
|
||||
([#281](https://github.com/Matysh/houseplan-card/issues/281)).
|
||||
- Backend теперь принимает доказанное преобразование совпадающей перегородки
|
||||
из Optimize: её проём сохраняется на той же общей стене и корректно проходит
|
||||
Apply/reload/Undo. Произвольное снятие host обычной записью или поддельным
|
||||
|
||||
@@ -137,10 +137,11 @@ explicit rebind.
|
||||
|
||||
The sole host-removal exception is the explicit Optimize reconciliation from
|
||||
#276/#280. The server does not trust a client counter: it independently proves
|
||||
that the old partition was removed, its complete segment is a solid shared
|
||||
boundary of exactly two rooms, the replacement wall envelope is not narrower,
|
||||
the materialized centre/angle and every unrelated opening field are unchanged,
|
||||
and no new slot overlaps. This capability is enabled only by
|
||||
that the old partition was removed, its complete segment is either one solid
|
||||
outer boundary owned by exactly one room or one solid shared boundary owned by
|
||||
exactly two rooms, the replacement wall envelope is not narrower, the
|
||||
materialized centre/angle and every unrelated opening field are unchanged, and
|
||||
no new slot overlaps. This capability is enabled only by
|
||||
`houseplan/plan/optimize`; ordinary config writes and crafted candidates keep
|
||||
the fail-closed `invalid_partition_opening_host` result.
|
||||
|
||||
|
||||
+16
-2
@@ -78,8 +78,17 @@ The closest stop in either direction wins:
|
||||
- moving-wall opening that would no longer fit;
|
||||
- loss of exact shared endpoints or any structural candidate failure.
|
||||
|
||||
The final persisted position is the last safe grid node. A safe range containing
|
||||
only zero yields an explained no-op and no write.
|
||||
The final persisted position is the last safe grid node. Eligibility probes one
|
||||
grid step in both directions through the same exact validator. If neither step
|
||||
is safe, the handle remains visible and focusable but is disabled with the
|
||||
stable reason that blocks the move; it never starts a no-op gesture or write.
|
||||
|
||||
An exact independent partition over a room boundary remains a physical blocker.
|
||||
The explicit **Optimize plans** action may remove that blocker first only when
|
||||
the complete partition is provably identical to one solid outer wall or one
|
||||
solid wall shared by exactly two rooms. Any hosted openings must be materialized
|
||||
without changing their centre, angle or fields, and the backend independently
|
||||
proves the complete rewrite. Resize itself never moves or ignores partitions.
|
||||
|
||||
## Preview and commit
|
||||
|
||||
@@ -133,6 +142,11 @@ building the preview frame itself.
|
||||
- `demo/smoke_room_resize.mjs`: production bundle pointer handlers,
|
||||
preview/commit/Undo, disabled accessibility, real fixture topology,
|
||||
production-preflight failure and cancellation;
|
||||
- `test/resize-optimize.test.mjs` and
|
||||
`demo/smoke_resize_outer_reconciliation.mjs`: an exact outer-wall partition
|
||||
blocks a zero-range handle, Optimize safely rehosts its windows and removes
|
||||
the blocker, then the same production Resize gesture changes exactly two
|
||||
rooms;
|
||||
- `demo/benchmark_safe_resize.mjs`: same-run pointer and cached pointerup budgets;
|
||||
- `demo/benchmark_safe_resize_render.mjs`: warm 20-room/80-handle layer p95
|
||||
and exactly one geometry snapshot per rendered frame;
|
||||
|
||||
@@ -2868,6 +2868,19 @@ require hands on real hardware — they remain for the human pass.
|
||||
`optimize-preflight-bypassed`; performance:
|
||||
`npm run benchmark:coincident-partitions`; golden:
|
||||
`coincident-partition-{before,thin,thick,virtual}-dark`].
|
||||
- [ ] **Optimize unlocks only proved zero-range Resize handles (#281)**: three
|
||||
exact independent partitions over solid one-room outer boundaries block
|
||||
the affected shared-wall Resize before maintenance. Optimize removes all
|
||||
three, materializes both hosted windows without changing their fields,
|
||||
passes independent backend proof and is idempotent. Afterwards the target
|
||||
handle has a non-zero grid step in both directions and one production
|
||||
pointer gesture changes exactly the two adjacent rooms. Partial, unknown
|
||||
or opening-overlapped outer candidates remain untouched. Every handle
|
||||
reported enabled on the anonymized `44.json` fixture has a non-zero
|
||||
contiguous range; a zero-range handle stays visible/focusable but disabled
|
||||
and captures no pointer [unit: test/resize-optimize.test.mjs; backend:
|
||||
tests_backend/test_validation.py + tests_backend/test_ha_websocket.py;
|
||||
auto: smoke_resize_outer_reconciliation].
|
||||
- [ ] **Unit + backend**: inset/mitre/bevel, key from either end, degrade,
|
||||
rekey, cm↔inches; `walls` schema bounds
|
||||
[auto: test/wall-thickness.test.mjs + tests_backend/test_validation.py]
|
||||
|
||||
+10
-9
@@ -385,15 +385,16 @@ delete requires cascade confirmation, and malformed/orphan hosts remain opaque.
|
||||
Opening cuts change physical masonry, not the structural wall axes used for
|
||||
room-face detection (#185).
|
||||
|
||||
Explicit Optimize has one stricter reconciliation pass (#276). It may remove a
|
||||
partition only when its axis is endpoint-to-endpoint identical to one uniform,
|
||||
solid shared interval owned by exactly two rooms and no draft, column, second
|
||||
partition or conflicting opening makes the result ambiguous. Hosted openings
|
||||
are materialised at the same centre/angle as ordinary room-wall openings. The
|
||||
canonical shared thickness is `max(roomCm, partitionCm)`, which is exactly the
|
||||
union envelope of two centred coincident bodies. The pass is immutable,
|
||||
idempotent and followed by the common whole-plan geometry preflight; rendering
|
||||
and ordinary Save never perform it implicitly.
|
||||
Explicit Optimize has one stricter reconciliation pass (#276/#281). It may
|
||||
remove a partition only when its axis is endpoint-to-endpoint identical to one
|
||||
uniform, solid outer interval owned by one room or shared interval owned by
|
||||
exactly two rooms, and no draft, column, second partition or conflicting
|
||||
opening makes the result ambiguous. Hosted openings are materialised at the
|
||||
same centre/angle as ordinary room-wall openings. The canonical thickness is
|
||||
`max(roomCm, partitionCm)`, which is exactly the union envelope of the centred
|
||||
coincident bodies. The pass is immutable, idempotent and followed by the common
|
||||
whole-plan geometry preflight; rendering, Resize and ordinary Save never
|
||||
perform it implicitly.
|
||||
|
||||
`physicalBodySet()` separates raw draft/partition/column bodies from computed
|
||||
junction patches and their joined geometry. Raw bodies remain authoritative for
|
||||
|
||||
@@ -153,9 +153,11 @@ Disabled handle:
|
||||
6. Openings moving shared wall смещаются один раз и остаются на той же shared
|
||||
boundary. Side-wall openings остаются на месте и ограничивают range.
|
||||
|
||||
Если safe range после grid snap содержит только исходную позицию, handle
|
||||
остаётся visible enabled для объяснимости, но попытка drag даёт stop/no-op и не
|
||||
создаёт запись.
|
||||
Если safe range после grid snap содержит только исходную позицию, resolver
|
||||
проверяет один grid step в обе стороны тем же exact validator. Когда оба
|
||||
направления запрещены, handle остаётся visible/focusable, но disabled с
|
||||
приоритетной причиной; pointer capture, preview, history и write не создаются.
|
||||
Это уточнение принято в #281 и заменяет прежний enabled no-op контракт.
|
||||
|
||||
## 7. Stops и live clamp
|
||||
|
||||
|
||||
@@ -953,20 +953,14 @@ export const MUTANTS = [
|
||||
+ 'be deleted just because its hosted opening happens to fit the longer room boundary (#276)',
|
||||
patches: [{
|
||||
file: 'src/coincident-partitions.ts',
|
||||
find: ' .filter((interval) => sameSegment(\n'
|
||||
find: ' const owners = (solidByKey.get(segmentKey(partition.a, partition.b)) || [])\n'
|
||||
+ ' .filter((interval) => sameSegment(\n'
|
||||
+ ' interval.a, interval.b, partition.a, partition.b, eps,\n'
|
||||
+ ' ));',
|
||||
replace: ' .filter((interval) => collinearOverlap(\n'
|
||||
replace: ' const owners = (solidByKey.get(segmentKey(partition.a, partition.b)) || [])\n'
|
||||
+ ' .filter((interval) => collinearOverlap(\n'
|
||||
+ ' interval.a, interval.b, partition.a, partition.b, eps,\n'
|
||||
+ ' ) > eps);',
|
||||
}, {
|
||||
file: 'src/coincident-partitions.ts',
|
||||
find: ' if (shared.some((interval) => !sameSegment(\n'
|
||||
+ ' interval.a, interval.b, partition.a, partition.b, eps,\n'
|
||||
+ ' ))) continue;',
|
||||
replace: ' if (false && shared.some((interval) => !sameSegment(\n'
|
||||
+ ' interval.a, interval.b, partition.a, partition.b, eps,\n'
|
||||
+ ' ))) continue;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Lossless explicit-Optimize reconciliation for an independent partition that
|
||||
* is exactly the same centred physical wall as one solid shared room edge.
|
||||
* is exactly the same centred physical wall as one solid room edge.
|
||||
* Runtime renderers never call this module and it never mutates its inputs.
|
||||
*/
|
||||
import {
|
||||
@@ -180,13 +180,14 @@ export function reconcileCoincidentPartitions(
|
||||
[b[0] / keyScale, b[1] / keyScale],
|
||||
options.pitch,
|
||||
);
|
||||
const sharedByKey = new Map<string, WallInterval[]>();
|
||||
const solidByKey = new Map<string, WallInterval[]>();
|
||||
for (const interval of intervals) {
|
||||
if (interval.kind !== 'shared' || interval.open || !(interval.cm > 0)) continue;
|
||||
if ((interval.kind !== 'shared' && interval.kind !== 'outer')
|
||||
|| interval.open || !(interval.cm > 0)) continue;
|
||||
const key = segmentKey(interval.a, interval.b);
|
||||
const bucket = sharedByKey.get(key);
|
||||
const bucket = solidByKey.get(key);
|
||||
if (bucket) bucket.push(interval);
|
||||
else sharedByKey.set(key, [interval]);
|
||||
else solidByKey.set(key, [interval]);
|
||||
}
|
||||
const modelById = new Map(model.partitions.map((partition) => [partition.id, partition]));
|
||||
let walls = walls0;
|
||||
@@ -204,21 +205,25 @@ export function reconcileCoincidentPartitions(
|
||||
partition.b[0] - partition.a[0], partition.b[1] - partition.a[1],
|
||||
) <= eps) continue;
|
||||
|
||||
const owners = (sharedByKey.get(segmentKey(partition.a, partition.b)) || [])
|
||||
const owners = (solidByKey.get(segmentKey(partition.a, partition.b)) || [])
|
||||
.filter((interval) => sameSegment(
|
||||
interval.a, interval.b, partition.a, partition.b, eps,
|
||||
));
|
||||
if (owners.length !== 2) continue;
|
||||
if (!owners.length) continue;
|
||||
const byRoom = new Map<string, WallInterval>();
|
||||
for (const owner of owners) byRoom.set(owner.roomId, owner);
|
||||
if (byRoom.size !== 2) continue;
|
||||
const shared = [...byRoom.values()];
|
||||
if (shared.some((interval) => !sameSegment(
|
||||
const solid = [...byRoom.values()];
|
||||
const kinds = new Set(solid.map((interval) => interval.kind));
|
||||
if (kinds.size !== 1) continue;
|
||||
const kind = solid[0]?.kind;
|
||||
if ((kind === 'shared' && byRoom.size !== 2)
|
||||
|| (kind === 'outer' && byRoom.size !== 1)) continue;
|
||||
if (solid.some((interval) => !sameSegment(
|
||||
interval.a, interval.b, partition.a, partition.b, eps,
|
||||
))) continue;
|
||||
const sharedCms = new Set(shared.map((interval) => interval.cm));
|
||||
if (sharedCms.size !== 1) continue;
|
||||
const roomCm = shared[0].cm;
|
||||
const solidCms = new Set(solid.map((interval) => interval.cm));
|
||||
if (solidCms.size !== 1) continue;
|
||||
const roomCm = solid[0].cm;
|
||||
|
||||
const anotherPartition = model.partitions.some((other) => (
|
||||
other.id !== partition.id
|
||||
@@ -244,7 +249,7 @@ export function reconcileCoincidentPartitions(
|
||||
}));
|
||||
if (resolvedHosted.some((item) => !item.resolved)) continue;
|
||||
if (resolvedHosted.some((item) => !partitionOpeningHasCompositeRoomWall(
|
||||
item.resolved!, shared, eps,
|
||||
item.resolved!, solid, eps,
|
||||
))) continue;
|
||||
|
||||
const finalCm = Math.max(roomCm, partition.cm);
|
||||
@@ -282,7 +287,7 @@ export function reconcileCoincidentPartitions(
|
||||
model.rooms, nextWalls, openCuts,
|
||||
options.pitch, options.cellCm, options.gridPitch, options.coordScale,
|
||||
);
|
||||
const roomIds = new Set(shared.map((interval) => interval.roomId));
|
||||
const roomIds = new Set(solid.map((interval) => interval.roomId));
|
||||
const associationsOk = nextHosted.every((opening) => {
|
||||
const association = resolveOpeningWallAssociation(index, {
|
||||
x: opening.x * options.coordScale,
|
||||
@@ -290,10 +295,11 @@ export function reconcileCoincidentPartitions(
|
||||
angle: opening.angle,
|
||||
length: opening.length * options.coordScale,
|
||||
}, true);
|
||||
return !!association.negative?.full && !!association.positive?.full
|
||||
&& roomIds.has(association.negative.roomId)
|
||||
&& roomIds.has(association.positive.roomId)
|
||||
&& association.negative.roomId !== association.positive.roomId;
|
||||
const full = [association.negative, association.positive]
|
||||
.filter((side): side is NonNullable<typeof association.negative> => !!side?.full);
|
||||
const associated = new Set(full.map((side) => side.roomId));
|
||||
return full.length === roomIds.size && associated.size === roomIds.size
|
||||
&& [...roomIds].every((roomId) => associated.has(roomId));
|
||||
});
|
||||
if (!associationsOk) continue;
|
||||
|
||||
|
||||
@@ -8403,6 +8403,7 @@ class HouseplanCard extends LitElement {
|
||||
return {
|
||||
minDim: this._cmToUnits(MIN_ROOM_CM),
|
||||
eps: this._gridPitch * 0.05,
|
||||
step: this._gridPitch,
|
||||
movingHalf: cm > 0 ? wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2 : 0,
|
||||
obstacles: this._rszObstacles(),
|
||||
thicknessConflict: exactCms.size > 1,
|
||||
|
||||
@@ -46,6 +46,8 @@ export type SafeResizeObstacle =
|
||||
| { kind: 'circle'; center: number[]; radius: number };
|
||||
|
||||
export interface SafeResizeOptions extends StopOpts {
|
||||
/** One snapped editor step; eligibility must permit a non-zero neighbour. */
|
||||
step?: number;
|
||||
/** Physical half-depth of the moving wall in render units. */
|
||||
movingHalf?: number;
|
||||
/** Independent walls, drafts and columns. They are immutable hard stops. */
|
||||
@@ -706,6 +708,22 @@ export function resolveSafeResize(
|
||||
if (!validateSafeResize(rooms, openings, plan, 0, opts)) {
|
||||
return { enabled: false, reason: 'invalid-geometry' };
|
||||
}
|
||||
const step = Math.abs(Number(opts.step));
|
||||
if (Number.isFinite(step) && step > eps) {
|
||||
const neighbours = [-step, step];
|
||||
if (!neighbours.some((delta) => validateSafeResize(
|
||||
rooms, openings, plan, delta, opts,
|
||||
))) {
|
||||
const withoutObstacles = { ...opts, obstacles: [] };
|
||||
if (neighbours.some((delta) => validateSafeResize(
|
||||
rooms, openings, plan, delta, withoutObstacles,
|
||||
))) return { enabled: false, reason: 'duplicate-physical-wall' };
|
||||
if (openings.length && neighbours.some((delta) => validateSafeResize(
|
||||
rooms, [], plan, delta, opts,
|
||||
))) return { enabled: false, reason: 'opening-conflict' };
|
||||
return { enabled: false, reason: 'invalid-geometry' };
|
||||
}
|
||||
}
|
||||
return { enabled: true, plan };
|
||||
}
|
||||
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"model_version": 7,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "resize-outer",
|
||||
"title": "Resize outer",
|
||||
"view_box": [0, 0, 1, 1],
|
||||
"cell_cm": 5,
|
||||
"rooms": [
|
||||
{ "id": "left", "name": "Left", "area": null,
|
||||
"poly": [[0, 0], [0.5, 0], [0.5, 1], [0, 1]] },
|
||||
{ "id": "right", "name": "Right", "area": null,
|
||||
"poly": [[0.5, 0], [1, 0], [1, 1], [0.5, 1]] }
|
||||
],
|
||||
"walls": [
|
||||
{ "key": "0.500000,0.500000@1.5706", "cm": 20, "a": [0.5, 0], "b": [0.5, 1] },
|
||||
{ "key": "0.250000,0.000000@0.0000", "cm": 30, "a": [0, 0], "b": [0.5, 0] },
|
||||
{ "key": "0.750000,0.000000@0.0000", "cm": 30, "a": [0.5, 0], "b": [1, 0] },
|
||||
{ "key": "0.750000,1.000000@0.0000", "cm": 20, "a": [1, 1], "b": [0.5, 1] }
|
||||
],
|
||||
"openings": [
|
||||
{
|
||||
"id": "left-window", "type": "window",
|
||||
"x": 0.25, "y": 0, "angle": 0, "length": 0.2,
|
||||
"cover": "cover.left", "future_field": { "keep": "left" }
|
||||
},
|
||||
{
|
||||
"id": "right-window", "type": "window",
|
||||
"x": 0.75, "y": 0, "angle": 0, "length": 0.2,
|
||||
"contact": "binary_sensor.right"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"model_version": 7,
|
||||
"spaces": [
|
||||
{
|
||||
"id": "resize-outer",
|
||||
"title": "Resize outer",
|
||||
"view_box": [0, 0, 1, 1],
|
||||
"cell_cm": 5,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "left",
|
||||
"name": "Left",
|
||||
"area": null,
|
||||
"poly": [[0, 0], [0.5, 0], [0.5, 1], [0, 1]]
|
||||
},
|
||||
{
|
||||
"id": "right",
|
||||
"name": "Right",
|
||||
"area": null,
|
||||
"poly": [[0.5, 0], [1, 0], [1, 1], [0.5, 1]]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{ "key": "0.500000,0.500000@1.5706", "a": [0.5, 0], "b": [0.5, 1], "cm": 20 },
|
||||
{ "key": "0.250000,0.000000@0.0000", "a": [0, 0], "b": [0.5, 0], "cm": 30 },
|
||||
{ "key": "0.750000,0.000000@0.0000", "a": [0.5, 0], "b": [1, 0], "cm": 30 },
|
||||
{ "key": "0.750000,1.000000@0.0000", "a": [0.5, 1], "b": [1, 1], "cm": 20 }
|
||||
],
|
||||
"partitions": [
|
||||
{ "id": "top-left", "a": [0, 0], "b": [0.5, 0], "cm": 30 },
|
||||
{ "id": "top-right", "a": [0.5, 0], "b": [1, 0], "cm": 30 },
|
||||
{ "id": "bottom-right", "a": [0.5, 1], "b": [1, 1], "cm": 20 }
|
||||
],
|
||||
"openings": [
|
||||
{
|
||||
"id": "left-window", "type": "window",
|
||||
"x": 0, "y": 0, "angle": 0, "length": 0.2,
|
||||
"cover": "cover.left", "future_field": { "keep": "left" },
|
||||
"host": { "kind": "partition", "id": "top-left", "t": 0.5 }
|
||||
},
|
||||
{
|
||||
"id": "right-window", "type": "window",
|
||||
"x": 0, "y": 0, "angle": 0, "length": 0.2,
|
||||
"contact": "binary_sensor.right",
|
||||
"host": { "kind": "partition", "id": "top-right", "t": 0.5 }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"settings": {}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import {
|
||||
applySafeResize, clampSafeResize, resolveSafeResize,
|
||||
} from '../test-build/resize.js';
|
||||
import { GRID_PITCH, NORM_W } from '../test-build/space-geometry.js';
|
||||
import { thicknessCmAt, wallCmToUnits } from '../test-build/wall-thickness.js';
|
||||
|
||||
const source = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/281-resize-outer-partitions.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const expected = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/281-resize-outer-candidate.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const clone = (value) => structuredClone(value);
|
||||
|
||||
const resizeInputs = (space) => ({
|
||||
rooms: space.rooms.map((room) => ({
|
||||
id: room.id,
|
||||
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
|
||||
})),
|
||||
openings: (space.openings || []).map((opening) => ({
|
||||
id: opening.id,
|
||||
x: opening.x * NORM_W,
|
||||
y: opening.y * NORM_W,
|
||||
length: opening.length * NORM_W,
|
||||
angle: opening.angle,
|
||||
hosted: !!opening.host,
|
||||
})),
|
||||
obstacles: (space.partitions || []).map((partition) => ({
|
||||
kind: 'segment',
|
||||
a: partition.a.map((value) => value * NORM_W),
|
||||
b: partition.b.map((value) => value * NORM_W),
|
||||
half: wallCmToUnits(partition.cm, space.cell_cm, GRID_PITCH) / 2,
|
||||
})),
|
||||
});
|
||||
|
||||
const options = (obstacles) => ({
|
||||
minDim: wallCmToUnits(30, 5, GRID_PITCH),
|
||||
eps: GRID_PITCH * 0.05,
|
||||
step: GRID_PITCH,
|
||||
movingHalf: wallCmToUnits(20, 5, GRID_PITCH) / 2,
|
||||
obstacles,
|
||||
});
|
||||
|
||||
test('issue 281 Optimize removes exact outer duplicates and rehosts their windows', () => {
|
||||
const input = clone(source);
|
||||
const before = clone(input);
|
||||
const result = optimizePlans(input, {});
|
||||
assert.deepEqual(input, before, 'Optimize preview must stay immutable');
|
||||
assert.equal(result.report.partitionsReconciled, 3);
|
||||
assert.equal(result.report.openingsRehosted, 2);
|
||||
assert.deepEqual(result.config, expected);
|
||||
assert.ok(result.config.spaces[0].openings.every((opening) => !opening.host));
|
||||
const second = optimizePlans(result.config, result.layout);
|
||||
assert.equal(second.changed, false);
|
||||
assert.deepEqual(second.config, result.config);
|
||||
});
|
||||
|
||||
test('issue 281 disables the old zero-range handle and enables it after Optimize', () => {
|
||||
const before = resizeInputs(source.spaces[0]);
|
||||
const blocked = resolveSafeResize(
|
||||
before.rooms, before.openings, 'left', 1, options(before.obstacles),
|
||||
);
|
||||
assert.deepEqual(blocked, { enabled: false, reason: 'duplicate-physical-wall' });
|
||||
|
||||
const optimized = optimizePlans(clone(source), {}).config.spaces[0];
|
||||
const after = resizeInputs(optimized);
|
||||
const opts = options(after.obstacles);
|
||||
const resolution = resolveSafeResize(after.rooms, after.openings, 'left', 1, opts);
|
||||
assert.equal(resolution.enabled, true);
|
||||
const plan = resolution.plan;
|
||||
assert.equal(clampSafeResize(
|
||||
after.rooms, after.openings, plan, GRID_PITCH, GRID_PITCH, opts,
|
||||
), GRID_PITCH);
|
||||
assert.equal(clampSafeResize(
|
||||
after.rooms, after.openings, plan, -GRID_PITCH, GRID_PITCH, opts,
|
||||
), -GRID_PITCH);
|
||||
const moved = applySafeResize(after.rooms, after.openings, plan, GRID_PITCH);
|
||||
assert.deepEqual(Object.keys(moved.polys).sort(), ['left', 'right']);
|
||||
assert.ok(Object.values(moved.polys).every((poly) => poly.length === 4));
|
||||
});
|
||||
|
||||
test('issue 281 leaves unsafe outer duplicates as hard stops', () => {
|
||||
const variants = [];
|
||||
const partial = clone(source);
|
||||
partial.spaces[0].partitions[0].b = [0.4, 0];
|
||||
variants.push(partial);
|
||||
const unknown = clone(source);
|
||||
unknown.spaces[0].partitions[0].future_semantics = true;
|
||||
variants.push(unknown);
|
||||
const overlap = clone(source);
|
||||
overlap.spaces[0].openings.push({
|
||||
id: 'ordinary-overlap', type: 'window',
|
||||
x: 0.25, y: 0, angle: 0, length: 0.2,
|
||||
});
|
||||
variants.push(overlap);
|
||||
|
||||
for (const input of variants) {
|
||||
const result = optimizePlans(input, {});
|
||||
assert.ok(
|
||||
result.config.spaces[0].partitions?.some((partition) => partition.id === 'top-left'),
|
||||
'unsafe outer partition must remain explicit',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue 281 private exact fixture has no enabled zero-range handle', (t) => {
|
||||
const path = 'C:\\Temp\\44.json';
|
||||
if (!existsSync(path)) return t.skip('private #281 fixture is not present');
|
||||
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
||||
const previous = raw.payload?.config || raw.config || raw;
|
||||
const optimized = optimizePlans(previous, {}).config.spaces[0];
|
||||
const inputs = resizeInputs(optimized);
|
||||
const openings = inputs.openings;
|
||||
const obstacles = inputs.obstacles;
|
||||
let enabled = 0;
|
||||
let zeroRange = 0;
|
||||
let target = null;
|
||||
for (const room of inputs.rooms) {
|
||||
for (let edge = 0; edge < room.poly.length; edge++) {
|
||||
const a = room.poly[edge], b = room.poly[(edge + 1) % room.poly.length];
|
||||
const cm = thicknessCmAt(optimized.walls, a, b, 1 / 240, NORM_W) || 15;
|
||||
const opts = {
|
||||
minDim: wallCmToUnits(30, optimized.cell_cm, GRID_PITCH),
|
||||
eps: GRID_PITCH * 0.05,
|
||||
step: GRID_PITCH,
|
||||
movingHalf: wallCmToUnits(cm, optimized.cell_cm, GRID_PITCH) / 2,
|
||||
obstacles,
|
||||
};
|
||||
const resolution = resolveSafeResize(inputs.rooms, openings, room.id, edge, opts);
|
||||
if (!resolution.enabled) continue;
|
||||
enabled++;
|
||||
const negative = clampSafeResize(
|
||||
inputs.rooms, openings, resolution.plan, -GRID_PITCH, GRID_PITCH, opts,
|
||||
);
|
||||
const positive = clampSafeResize(
|
||||
inputs.rooms, openings, resolution.plan, GRID_PITCH, GRID_PITCH, opts,
|
||||
);
|
||||
if (negative === 0 && positive === 0) zeroRange++;
|
||||
const configA = a.map((value) => value / NORM_W);
|
||||
const configB = b.map((value) => value / NORM_W);
|
||||
if ([configA, configB].every((point) =>
|
||||
Math.abs(point[0] - 0.4) < 1e-8
|
||||
&& point[1] >= -0.208333334 && point[1] <= 1.266666668)) {
|
||||
target = { negative, positive };
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(zeroRange, 0, `${zeroRange}/${enabled} enabled handles cannot move`);
|
||||
assert.ok(target && target.negative < 0 && target.positive > 0,
|
||||
'the reported shared wall must move at least one step both ways');
|
||||
});
|
||||
@@ -534,10 +534,15 @@ async def test_optimize_undo_restores_geometry_but_not_legacy_noisy_bits(
|
||||
assert restored_layout["lamp"]["x"] == -0.12345679
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("source_name", "candidate_name", "partition_id"), [
|
||||
("276-coincident-partition.json", "280-optimize-rehost-candidate.json", "redundant"),
|
||||
("281-resize-outer-partitions.json", "281-resize-outer-candidate.json", "top-left"),
|
||||
])
|
||||
async def test_optimize_accepts_proved_rehost_and_undo_restores_host(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator,
|
||||
source_name: str, candidate_name: str, partition_id: str,
|
||||
) -> None:
|
||||
"""#280: the real WS boundary accepts exactly the #276 candidate."""
|
||||
"""#280/#281: the real WS boundary accepts shared and outer proof."""
|
||||
from custom_components.houseplan.store import get_data
|
||||
|
||||
await _setup(hass)
|
||||
@@ -545,10 +550,10 @@ async def test_optimize_accepts_proved_rehost_and_undo_restores_host(
|
||||
runtime = get_data(hass)
|
||||
fixture_dir = Path(__file__).parents[1] / "test" / "fixtures"
|
||||
previous = json.loads(
|
||||
(fixture_dir / "276-coincident-partition.json").read_text(encoding="utf-8")
|
||||
(fixture_dir / source_name).read_text(encoding="utf-8")
|
||||
)
|
||||
candidate = json.loads(
|
||||
(fixture_dir / "280-optimize-rehost-candidate.json").read_text(encoding="utf-8")
|
||||
(fixture_dir / candidate_name).read_text(encoding="utf-8")
|
||||
)
|
||||
await runtime.config_store.async_save({"config": previous, "rev": 1})
|
||||
await runtime.store.async_save({"layout": {}, "rev": 1})
|
||||
@@ -575,9 +580,9 @@ async def test_optimize_accepts_proved_rehost_and_undo_restores_host(
|
||||
assert undone["success"] and undone["result"]["can_undo"] is False
|
||||
restored = (await runtime.config_store.async_load())["config"]
|
||||
restored_space = restored["spaces"][0]
|
||||
assert restored_space["partitions"][0]["id"] == "redundant"
|
||||
assert any(item["id"] == partition_id for item in restored_space["partitions"])
|
||||
assert restored_space["openings"][0]["host"] == {
|
||||
"kind": "partition", "id": "redundant", "t": 0.5,
|
||||
"kind": "partition", "id": partition_id, "t": 0.5,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1764,7 +1764,7 @@ def test_optimize_accepts_only_proved_partition_to_room_wall_rehost():
|
||||
"b": [0.504166667, 0.995833333],
|
||||
"cm": 20,
|
||||
}]))
|
||||
rejected(lambda space: space["rooms"].pop())
|
||||
rejected(lambda space: space.update(rooms=[]))
|
||||
rejected(lambda space: space["walls"][0].update(cm=10))
|
||||
rejected(lambda space: space["openings"][0].update(x=0.51))
|
||||
rejected(lambda space: space["openings"][0].update(angle=-89))
|
||||
@@ -1782,6 +1782,43 @@ def test_optimize_accepts_only_proved_partition_to_room_wall_rehost():
|
||||
}]))
|
||||
|
||||
|
||||
def test_optimize_accepts_proved_outer_rehost_for_resize_preparation():
|
||||
fixture_dir = os.path.join(_ROOT, "test", "fixtures")
|
||||
previous = json.load(open(
|
||||
os.path.join(fixture_dir, "281-resize-outer-partitions.json"),
|
||||
encoding="utf-8",
|
||||
))
|
||||
candidate = json.load(open(
|
||||
os.path.join(fixture_dir, "281-resize-outer-candidate.json"),
|
||||
encoding="utf-8",
|
||||
))
|
||||
with pytest.raises(v.PartitionOpeningHostError):
|
||||
v.validate_partition_opening_hosts(candidate, previous)
|
||||
v.validate_partition_opening_hosts(
|
||||
candidate, previous, allow_optimize_rehost=True
|
||||
)
|
||||
|
||||
conflict = json.loads(json.dumps(candidate))
|
||||
conflict["spaces"][0]["openings"].append({
|
||||
"id": "overlap", "type": "window",
|
||||
"x": 0.25, "y": 0, "angle": 0, "length": 0.1,
|
||||
})
|
||||
with pytest.raises(v.PartitionOpeningHostError):
|
||||
v.validate_partition_opening_hosts(
|
||||
conflict, previous, allow_optimize_rehost=True
|
||||
)
|
||||
|
||||
partial_owner = json.loads(json.dumps(candidate))
|
||||
partial_owner["spaces"][0]["rooms"].append({
|
||||
"id": "partial", "name": "Partial", "area": None,
|
||||
"poly": [[0.2, 0], [0.3, 0], [0.3, -0.2], [0.2, -0.2]],
|
||||
})
|
||||
with pytest.raises(v.PartitionOpeningHostError):
|
||||
v.validate_partition_opening_hosts(
|
||||
partial_owner, previous, allow_optimize_rehost=True
|
||||
)
|
||||
|
||||
|
||||
def test_optimize_rehost_validation_is_atomic_across_the_batch():
|
||||
root = os.path.dirname(os.path.dirname(__file__))
|
||||
fixture_dir = os.path.join(root, "test", "fixtures")
|
||||
|
||||
Reference in New Issue
Block a user