mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
File diff suppressed because one or more lines are too long
@@ -170,6 +170,219 @@ def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, ta
|
||||
return False
|
||||
|
||||
|
||||
_OPTIMIZE_PARTITION_KEYS = {"id", "a", "b", "cm"}
|
||||
|
||||
|
||||
def _known_optimize_partition(partition: dict) -> bool:
|
||||
return isinstance(partition, dict) and set(partition).issubset(_OPTIMIZE_PARTITION_KEYS)
|
||||
|
||||
|
||||
def _axis_range(a: list, b: list, axis_a: list, axis_b: list) -> tuple[float, float] | None:
|
||||
"""Project one positive collinear overlap onto ``axis_a -> axis_b``."""
|
||||
if not (isinstance(a, list) and len(a) == 2
|
||||
and isinstance(b, list) and len(b) == 2):
|
||||
return None
|
||||
if (_line_distance(a, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(b, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
return None
|
||||
_, _, length = _segment_metrics(axis_a, axis_b)
|
||||
lo, hi = sorted((_projection(a, axis_a, axis_b),
|
||||
_projection(b, axis_a, axis_b)))
|
||||
lo, hi = max(0.0, lo), min(length, hi)
|
||||
return (lo, hi) if hi - lo > _OPTIMIZE_REHOST_EPSILON else None
|
||||
|
||||
|
||||
def _axis_point(a: list, b: list, along: float) -> list[float]:
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
return [float(a[0]) + dx / length * along,
|
||||
float(a[1]) + dy / length * along]
|
||||
|
||||
|
||||
def _optimize_partition_needs_proof(old_partition: dict, space: dict) -> bool:
|
||||
"""Select #296 removal/splitting, not ordinary Optimize grid alignment."""
|
||||
partition_id = str(old_partition.get("id", ""))
|
||||
current = next((item for item in space.get("partitions") or []
|
||||
if str(item.get("id", "")) == partition_id), None)
|
||||
if current == old_partition:
|
||||
return False
|
||||
if current is None:
|
||||
return True
|
||||
if not (_known_optimize_partition(old_partition)
|
||||
and _known_optimize_partition(current)):
|
||||
return False
|
||||
try:
|
||||
if float(current.get("cm")) != float(old_partition.get("cm")):
|
||||
return False
|
||||
old_a, old_b = old_partition["a"], old_partition["b"]
|
||||
current_range = _axis_range(current["a"], current["b"], old_a, old_b)
|
||||
_, _, old_length = _segment_metrics(old_a, old_b)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
return current_range is not None and (
|
||||
current_range[0] > _OPTIMIZE_REHOST_EPSILON
|
||||
or current_range[1] < old_length - _OPTIMIZE_REHOST_EPSILON
|
||||
)
|
||||
|
||||
|
||||
def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
|
||||
"""Independently prove every removed atom of one old partition axis."""
|
||||
if not _known_optimize_partition(old_partition):
|
||||
return False
|
||||
try:
|
||||
old_a, old_b = old_partition["a"], old_partition["b"]
|
||||
old_cm = float(old_partition["cm"])
|
||||
_, _, length = _segment_metrics(old_a, old_b)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON or old_cm <= 0:
|
||||
return False
|
||||
|
||||
rooms: list[tuple[str, list[tuple[list, list]]]] = []
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
if len(poly) < 3:
|
||||
return False
|
||||
rooms.append((str(room.get("id", "")), [
|
||||
(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))
|
||||
]))
|
||||
|
||||
candidate_partitions = space.get("partitions") or []
|
||||
retained: list[tuple[float, float]] = []
|
||||
breakpoints = [0.0, length]
|
||||
|
||||
def note_segment(a: list, b: list) -> tuple[float, float] | None:
|
||||
projected = _axis_range(a, b, old_a, old_b)
|
||||
if projected:
|
||||
breakpoints.extend(projected)
|
||||
return projected
|
||||
|
||||
for partition in candidate_partitions:
|
||||
try:
|
||||
projected = note_segment(partition["a"], partition["b"])
|
||||
candidate_cm = float(partition["cm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if projected and _known_optimize_partition(partition) and candidate_cm == old_cm:
|
||||
retained.append(projected)
|
||||
for _, edges in rooms:
|
||||
for edge_a, edge_b in edges:
|
||||
note_segment(edge_a, edge_b)
|
||||
for collection in (space.get("walls") or [], space.get("open_spans") or []):
|
||||
for segment in collection:
|
||||
try:
|
||||
note_segment(segment["a"], segment["b"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
|
||||
points = sorted(set(round(point, 12) for point in breakpoints
|
||||
if -_OPTIMIZE_REHOST_EPSILON <= point
|
||||
<= length + _OPTIMIZE_REHOST_EPSILON))
|
||||
for lo, hi in zip(points, points[1:]):
|
||||
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
|
||||
continue
|
||||
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
|
||||
and end >= hi - _OPTIMIZE_REHOST_EPSILON
|
||||
for start, end in retained):
|
||||
continue
|
||||
target_a, target_b = _axis_point(old_a, old_b, lo), _axis_point(old_a, old_b, hi)
|
||||
owners = {room_id for room_id, edges in rooms
|
||||
if _segments_cover_target(edges, target_a, target_b)}
|
||||
collinear = {room_id for room_id, edges in rooms
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, edge_a, edge_b
|
||||
) for edge_a, edge_b in edges)}
|
||||
if len(owners) not in (1, 2) or collinear != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, span["a"], span["b"]
|
||||
) for span in space.get("open_spans") or []):
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(
|
||||
target_a, target_b, partition["a"], partition["b"]
|
||||
) for partition in candidate_partitions):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
try:
|
||||
wall_a, wall_b = wall["a"], wall["b"]
|
||||
if _segment_covers(wall_a, wall_b, target_a, target_b):
|
||||
covering_walls.append((
|
||||
_segment_metrics(wall_a, wall_b)[2], float(wall["cm"]),
|
||||
))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
effective_cm = (min(covering_walls, key=lambda item: item[0])[1]
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM)
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < old_cm:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _hosted_opening_geometry(opening: dict, partition: dict) -> tuple[float, float, float, float] | None:
|
||||
host = opening.get("host")
|
||||
try:
|
||||
if not isinstance(host, dict) or host.get("kind") != "partition":
|
||||
return None
|
||||
t = float(host["t"])
|
||||
a, b = partition["a"], partition["b"]
|
||||
dx, dy, length = _segment_metrics(a, b)
|
||||
opening_length = float(opening["length"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if not 0 <= t <= 1 or length <= _OPTIMIZE_REHOST_EPSILON or opening_length <= 0:
|
||||
return None
|
||||
angle = math.degrees(math.atan2(dy, dx))
|
||||
if angle >= 90:
|
||||
angle -= 180
|
||||
elif angle < -90:
|
||||
angle += 180
|
||||
return float(a[0]) + dx * t, float(a[1]) + dy * t, angle, opening_length
|
||||
|
||||
|
||||
def _safe_optimize_residual_rehost(
|
||||
space: dict, old_space: dict, opening: dict, old_opening: dict,
|
||||
) -> bool:
|
||||
"""Prove a hosted opening rebound to a newly-created residual id."""
|
||||
old_host, new_host = old_opening.get("host"), opening.get("host")
|
||||
if not (isinstance(old_host, dict) and isinstance(new_host, dict)
|
||||
and old_host.get("kind") == new_host.get("kind") == "partition"):
|
||||
return False
|
||||
old_partition = next((item for item in old_space.get("partitions") or []
|
||||
if str(item.get("id", "")) == str(old_host.get("id", ""))), None)
|
||||
new_partition = next((item for item in space.get("partitions") or []
|
||||
if str(item.get("id", "")) == str(new_host.get("id", ""))), None)
|
||||
if old_partition is None or new_partition is None:
|
||||
return False
|
||||
if not _safe_optimize_partition_delta(space, old_partition):
|
||||
return False
|
||||
ignored = {"host", "x", "y", "angle"}
|
||||
if ({key: value for key, value in old_opening.items() if key not in ignored}
|
||||
!= {key: value for key, value in opening.items() if key not in ignored}):
|
||||
return False
|
||||
old_geometry = _hosted_opening_geometry(old_opening, old_partition)
|
||||
new_geometry = _hosted_opening_geometry(opening, new_partition)
|
||||
if old_geometry is None or new_geometry is None:
|
||||
return False
|
||||
old_x, old_y, old_angle, old_length = old_geometry
|
||||
new_x, new_y, new_angle, new_length = new_geometry
|
||||
try:
|
||||
explicit_x, explicit_y = float(opening["x"]), float(opening["y"])
|
||||
explicit_angle = float(opening["angle"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
return (
|
||||
abs(old_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(old_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(old_length - new_length) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and _angle_delta_mod_180(old_angle, new_angle) <= 1e-7
|
||||
and abs(explicit_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and abs(explicit_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
|
||||
and _angle_delta_mod_180(explicit_angle, new_angle) <= 1e-7
|
||||
)
|
||||
|
||||
|
||||
def _safe_optimize_partition_rehost(
|
||||
space: dict, old_space: dict, opening: dict, old_opening: dict,
|
||||
) -> bool:
|
||||
@@ -182,6 +395,8 @@ def _safe_optimize_partition_rehost(
|
||||
if str(item.get("id", "")) == partition_id), None)
|
||||
if old_partition is None:
|
||||
return False
|
||||
if not _safe_optimize_partition_delta(space, old_partition):
|
||||
return False
|
||||
a, b = old_partition.get("a"), old_partition.get("b")
|
||||
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
|
||||
return False
|
||||
@@ -322,6 +537,17 @@ def validate_partition_opening_hosts(
|
||||
continue
|
||||
old_host = (old or {}).get("host")
|
||||
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
|
||||
if (allow_optimize_rehost and old and old_space
|
||||
and isinstance(old_host, dict)
|
||||
and old_host.get("kind") == "partition"
|
||||
and isinstance(host, dict) and host.get("kind") == "partition"
|
||||
and old_host.get("id") != host.get("id")
|
||||
and not _safe_optimize_residual_rehost(
|
||||
space, old_space, opening, old
|
||||
)):
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; opening={opening_id}; residual host changed"
|
||||
)
|
||||
ax, ay = partition["a"]
|
||||
bx, by = partition["b"]
|
||||
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
|
||||
@@ -352,6 +578,14 @@ def validate_partition_opening_hosts(
|
||||
raise PartitionOpeningJambMarginError(
|
||||
space_id, opening_id, margin, margin_cm
|
||||
)
|
||||
if allow_optimize_rehost and old_space:
|
||||
for old_partition in old_space.get("partitions") or []:
|
||||
if (_optimize_partition_needs_proof(old_partition, space)
|
||||
and not _safe_optimize_partition_delta(space, old_partition)):
|
||||
raise PartitionOpeningHostError(
|
||||
f"space={space_id}; partition={old_partition.get('id', '')}; "
|
||||
"optimize delta unproved"
|
||||
)
|
||||
|
||||
|
||||
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
|
||||
|
||||
@@ -57,7 +57,7 @@ const arg = (name, fallback) => {
|
||||
* Когда #296 закроется, число здесь станет нулём, и тест это потребует.
|
||||
*/
|
||||
const PLANS = [
|
||||
{ file: 'real-plan-second-floor.json', debt: 3 },
|
||||
{ file: 'real-plan-second-floor.json', debt: 1 },
|
||||
{ file: 'real-plan-first-floor.json', debt: 0 },
|
||||
];
|
||||
|
||||
|
||||
@@ -246,6 +246,16 @@ const out = await page.evaluate(async () => {
|
||||
|| !!(editorVirtualWalls.compareDocumentPosition(diagnostic) & Node.DOCUMENT_POSITION_FOLLOWING));
|
||||
result.hiddenDiagnosticIsPointerTransparent = diagnostic?.getAttribute('pointer-events') === 'none'
|
||||
&& getComputedStyle(diagnostic).pointerEvents === 'none';
|
||||
card._activateMarkupTool('draw');
|
||||
await update();
|
||||
const drawDiagnostic = root().querySelector('[data-hp="hidden-wall-diagnostic"]');
|
||||
const drawSnap = overlay();
|
||||
result.drawToolKeepsHiddenDiagnosticAndSnapOverlay = !!drawDiagnostic && !!drawSnap
|
||||
&& drawDiagnostic.querySelectorAll('.hidden-wall-line').length === 1
|
||||
&& drawDiagnostic.querySelectorAll('.hidden-wall-node').length === 2;
|
||||
result.hiddenDiagnosticPaintsBeforeTransientSnap = !!(
|
||||
drawDiagnostic.compareDocumentPosition(drawSnap) & Node.DOCUMENT_POSITION_FOLLOWING
|
||||
);
|
||||
card._setMode('view');
|
||||
await update();
|
||||
result.viewHasNoOverlay = !overlay()
|
||||
|
||||
Vendored
+13
-10
File diff suppressed because one or more lines are too long
+4
-2
@@ -3,11 +3,13 @@
|
||||
## Unreleased
|
||||
|
||||
- Hidden independent wall sections and saved wall chains now keep their axes
|
||||
and original endpoints visible above all masonry in the Plan editor. “Optimize
|
||||
and original endpoints visible above all masonry in every Plan tool. “Optimize
|
||||
plans” can absorb every exactly covered section into one or more consecutive
|
||||
room walls, retain any unproven residual and its openings, and remove a saved
|
||||
chain only when all of it is redundant. This clears real invisible Resize
|
||||
blockers without shrinking walls or deleting unfinished work
|
||||
blockers without shrinking walls or deleting unfinished work. The server
|
||||
independently proves the complete removed axis, including walls without
|
||||
openings, and the Optimize report names redundant saved chains separately
|
||||
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
|
||||
- While drawing Walls, `Esc` now finishes all accepted segments as independent
|
||||
walls and releases the last point without deleting geometry or leaving the
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
## Не выпущено
|
||||
|
||||
- Оси и исходные узлы скрытых независимых стен и сохранённых цепочек теперь
|
||||
видны поверх всей кладки в редакторе Плана. «Оптимизировать планы» умеет
|
||||
видны поверх всей кладки во всех инструментах Плана. «Оптимизировать планы» умеет
|
||||
поглощать каждый точно покрытый участок одной или несколькими соседними
|
||||
стенами комнат, сохраняет недоказанный остаток вместе с его проёмами и удаляет
|
||||
цепочку только при полной избыточности. Это устраняет реальные невидимые
|
||||
блокеры Resize, не уменьшая толщину стен и не удаляя незавершённую работу
|
||||
блокеры Resize, не уменьшая толщину стен и не удаляя незавершённую работу.
|
||||
Сервер независимо доказывает всю удаляемую ось, включая стены без проёмов,
|
||||
а отчёт Optimize отдельно называет избыточные сохранённые цепочки
|
||||
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
|
||||
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
|
||||
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
|
||||
|
||||
@@ -1264,6 +1264,51 @@ export const MUTANTS = [
|
||||
replace: ' const residualRuns: PieceRun[] = [];',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'optimizer-coincident-max-thickness-lost',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="max thickness independently" '
|
||||
+ 'test/optimize-hidden-obstacles.test.mjs',
|
||||
because: 'each absorbed piece must retain the thicker of room masonry and the hidden '
|
||||
+ 'partition instead of silently thinning an authored wall (#296)',
|
||||
patches: [{
|
||||
file: 'src/coincident-partitions.ts',
|
||||
find: ' const finalCm = proofOk ? Math.max(roomCm, source.cm) : source.cm;',
|
||||
replace: ' const finalCm = proofOk ? roomCm : source.cm;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'optimizer-backend-trusts-frontend-delta',
|
||||
guard: 'node scripts/backend-test-guard.mjs '
|
||||
+ 'backend_proves_full_partition_delta_without_openings tests_backend/test_validation.py',
|
||||
because: 'the backend must reconstruct every removed partition atom independently; '
|
||||
+ 'trusting the frontend candidate lets a no-opening partition disappear without masonry (#296)',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/validation.py',
|
||||
find: 'def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:\n'
|
||||
+ ' """Independently prove every removed atom of one old partition axis."""\n'
|
||||
+ ' if not _known_optimize_partition(old_partition):',
|
||||
replace: 'def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:\n'
|
||||
+ ' """Independently prove every removed atom of one old partition axis."""\n'
|
||||
+ ' return True\n'
|
||||
+ ' if not _known_optimize_partition(old_partition):',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'hidden-diagnostic-under-virtual-walls',
|
||||
guard: 'node demo/smoke_plan_snap_overlay.mjs',
|
||||
because: 'hidden wall axes and source endpoints must remain visible above every real and '
|
||||
+ 'virtual wall body while staying below transient tool previews (#296)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' ${this._editing ? this._renderOpenWalls(disp) : nothing}\n'
|
||||
+ ' ${this._markup ? svg`<g class="hp-editor-only-layer"\n'
|
||||
+ ' opacity="${modeVisual?.editorWeight ?? 1}">${this._renderHiddenWallDiagnosticOverlay()}</g>` : nothing}',
|
||||
replace: ' ${this._markup ? svg`<g class="hp-editor-only-layer"\n'
|
||||
+ ' opacity="${modeVisual?.editorWeight ?? 1}">${this._renderHiddenWallDiagnosticOverlay()}</g>` : nothing}\n'
|
||||
+ ' ${this._editing ? this._renderOpenWalls(disp) : nothing}',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'atomic-child-thickness-parent-fallback',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
+14
-9
@@ -16423,7 +16423,7 @@ class HouseplanCard extends LitElement {
|
||||
+ r.positionsRemapped + r.markersDetached;
|
||||
const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized
|
||||
+ r.wallsMerged + r.spansMerged + r.partitionsMerged
|
||||
+ r.partitionsReconciled + r.openingsRehosted;
|
||||
+ r.partitionsReconciled + r.openingsRehosted + r.redundantDraftsRemoved;
|
||||
const gridWarning = r.moved + r.rotated + r.removedDrafts
|
||||
+ r.coordsCanonicalized + r.wallsStraightened;
|
||||
const straightenCm = Math.ceil(r.maxStraightenShiftCm * 10) / 10;
|
||||
@@ -16512,6 +16512,11 @@ class HouseplanCard extends LitElement {
|
||||
n: String(r.removedDrafts),
|
||||
})}</p>`
|
||||
: nothing}
|
||||
${r.redundantDraftsRemoved
|
||||
? html`<p class="alignmsg">${this._t('gs.optimize_redundant_drafts', {
|
||||
n: String(r.redundantDraftsRemoved),
|
||||
})}</p>`
|
||||
: nothing}
|
||||
${modelMaintenance ? html`<p class="alignmsg">${this._t('gs.optimize_changes', {
|
||||
m: String(r.migrated), c: String(r.canonicalized),
|
||||
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
|
||||
@@ -17379,19 +17384,19 @@ class HouseplanCard extends LitElement {
|
||||
inside thick jambs without changing the stored span. */}
|
||||
${!this._editing ? this._renderOpenWalls(disp) : nothing}
|
||||
${this._renderWallBodies(disp)}
|
||||
${this._markup ? svg`<g class="hp-editor-only-layer"
|
||||
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderOpeningPlacementPreview()}</g>` : nothing}
|
||||
${opMeasure ? this._renderOpeningDimensionGuides(opMeasure) : nothing}
|
||||
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
|
||||
${this._renderRoomHoverOutline(roomHover)}
|
||||
${''/* Editors: saved virtual boundaries and the live two-click
|
||||
preview deliberately paint AFTER real wall bodies. Their
|
||||
full centreline geometry remains visible for editing. */}
|
||||
${this._editing ? this._renderOpenWalls(disp) : nothing}
|
||||
${this._markup ? svg`<g class="hp-editor-only-layer"
|
||||
opacity="${modeVisual?.editorWeight ?? 1}">${this._tool === 'draw'
|
||||
? this._renderPlanSnapOverlay()
|
||||
: this._renderHiddenWallDiagnosticOverlay()}</g>` : nothing}
|
||||
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderHiddenWallDiagnosticOverlay()}</g>` : nothing}
|
||||
${this._markup ? svg`<g class="hp-editor-only-layer"
|
||||
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderOpeningPlacementPreview()}</g>` : nothing}
|
||||
${opMeasure ? this._renderOpeningDimensionGuides(opMeasure) : nothing}
|
||||
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
|
||||
${this._markup && this._tool === 'draw' ? svg`<g class="hp-editor-only-layer"
|
||||
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderPlanSnapOverlay()}</g>` : nothing}
|
||||
${disp.hideOpenings && !this._markup
|
||||
? nothing
|
||||
: isoLayers && !isoLayers.floorSymbols
|
||||
@@ -19580,7 +19585,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _renderHiddenWallDiagnosticOverlay(): TemplateResult {
|
||||
if (!this._markup || this._tool === 'draw') return svg`` as unknown as TemplateResult;
|
||||
if (!this._markup) return svg`` as unknown as TemplateResult;
|
||||
const geometry = this._hiddenWallDiagnosticSnapshot().value;
|
||||
if (!geometry.segments.length) return svg`` as unknown as TemplateResult;
|
||||
const radius = wallCmToUnits(5, this._cellCm, this._gridPitch);
|
||||
|
||||
@@ -811,6 +811,7 @@
|
||||
"gs.align_where": "The largest shift is in “{s}”.",
|
||||
"gs.align_turned": "Openings whose angle is corrected: {n}.",
|
||||
"gs.align_removed_drafts": "Invalid outlines collapsed by the grid and removed: {n}.",
|
||||
"gs.optimize_redundant_drafts": "Saved wall chains hidden by solid room walls and removed: {n}.",
|
||||
"gs.align_preflight_failed": "Could not safely verify the geometry of the following spaces: {spaces}{more}.",
|
||||
"gs.align_preflight_hint": "Plans were not changed. Update House Plan and try again. If the error persists, attach a space export to the bug report.",
|
||||
"gs.align_preflight_space": "Space {n}",
|
||||
|
||||
@@ -811,6 +811,7 @@
|
||||
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
|
||||
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
|
||||
"gs.align_removed_drafts": "Схлопнувшиеся на сетке некорректные контуры удалены: {n}.",
|
||||
"gs.optimize_redundant_drafts": "Сохранённые цепочки стен, полностью скрытые стенами комнат, удалены: {n}.",
|
||||
"gs.align_preflight_failed": "Не удалось безопасно проверить геометрию следующих пространств: {spaces}{more}.",
|
||||
"gs.align_preflight_hint": "Планы не изменены. Обновите House Plan и повторите. Если ошибка останется, приложите экспорт пространства к отчёту об ошибке.",
|
||||
"gs.align_preflight_space": "Пространство {n}",
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface OptimizeReport extends AlignReport, SpaceReferenceReport {
|
||||
partitionsReconciled: number;
|
||||
/** Hosted openings materialised onto the coincident shared room wall. */
|
||||
openingsRehosted: number;
|
||||
/** Saved wall chains removed because solid room masonry covers every segment. */
|
||||
redundantDraftsRemoved: number;
|
||||
/** Unique physical near-axis walls accepted for explicit straightening. */
|
||||
wallsStraightened: number;
|
||||
/** Near-axis walls found but rejected by structural safety checks. */
|
||||
@@ -498,6 +500,7 @@ export function optimizePlans(
|
||||
let partitionsMerged = 0;
|
||||
let partitionsReconciled = 0;
|
||||
let openingsRehosted = 0;
|
||||
let redundantDraftsRemoved = 0;
|
||||
let canonicalized = 0;
|
||||
for (let i = 0; i < config.spaces.length; i++) {
|
||||
const before = beforeSpaces[i];
|
||||
@@ -632,7 +635,7 @@ export function optimizePlans(
|
||||
else delete space.walls;
|
||||
}
|
||||
if (reconciled.removedDrafts) {
|
||||
alignReport.removedDrafts += reconciled.removedDrafts;
|
||||
redundantDraftsRemoved += reconciled.removedDrafts;
|
||||
if (reconciled.roomDrafts.length) space.room_drafts = reconciled.roomDrafts;
|
||||
else delete space.room_drafts;
|
||||
}
|
||||
@@ -709,6 +712,7 @@ export function optimizePlans(
|
||||
partitionsMerged: changed ? partitionsMerged : 0,
|
||||
partitionsReconciled: changed ? partitionsReconciled : 0,
|
||||
openingsRehosted: changed ? openingsRehosted : 0,
|
||||
redundantDraftsRemoved: changed ? redundantDraftsRemoved : 0,
|
||||
wallsStraightened: changed ? wallsStraightened : 0,
|
||||
wallsStraightenSkipped,
|
||||
maxStraightenShiftCm: changed ? maxStraightenShiftCm : 0,
|
||||
|
||||
@@ -205,7 +205,8 @@ test('issue 296 removes a fully hidden saved chain before reconciling its partit
|
||||
segments: [{ cm: 15 }],
|
||||
}];
|
||||
const result = optimize(input);
|
||||
assert.equal(result.report.removedDrafts, 1);
|
||||
assert.equal(result.report.removedDrafts, 0);
|
||||
assert.equal(result.report.redundantDraftsRemoved, 1);
|
||||
assert.equal(result.report.partitionsReconciled, 1);
|
||||
assert.equal(result.config.spaces[0].room_drafts, undefined);
|
||||
assert.equal(result.config.spaces[0].partitions, undefined);
|
||||
|
||||
-32
@@ -520,38 +520,6 @@
|
||||
3.866666667
|
||||
],
|
||||
"cm": 30
|
||||
},
|
||||
{
|
||||
"id": "partition-room-mt7ijuyq-0",
|
||||
"a": [
|
||||
-0.354166667,
|
||||
2.2875
|
||||
],
|
||||
"b": [
|
||||
-0.354166667,
|
||||
3.866666667
|
||||
],
|
||||
"cm": 20
|
||||
}
|
||||
],
|
||||
"room_drafts": [
|
||||
{
|
||||
"id": "draft-mt7igts5",
|
||||
"points": [
|
||||
[
|
||||
-0.354166667,
|
||||
3.866666667
|
||||
],
|
||||
[
|
||||
1.058333333,
|
||||
3.866666667
|
||||
]
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"cm": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"openings": []
|
||||
|
||||
@@ -643,7 +643,7 @@ test('реальный план: перегородка поверх наруж
|
||||
// Долг фикстуры, а не «допустимо»: `partition-mt2on9ou-0` лежит на наружной
|
||||
// стене трёх комнат и выключает ресайз Мастера с/у и Гардеробной. Число здесь
|
||||
// обязано стать нулём вместе с закрытием #296 — иначе починка пройдёт молча.
|
||||
const debt = { 'real-plan-second-floor.json': 3, 'real-plan-first-floor.json': 0 };
|
||||
const debt = { 'real-plan-second-floor.json': 1, 'real-plan-first-floor.json': 0 };
|
||||
for (const [file, expected] of Object.entries(debt)) {
|
||||
const { config } = readModel(
|
||||
readFileSync(resolve(repoRoot, 'test/fixtures', file), 'utf8'));
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import { reconcileCoincidentPartitions } from '../test-build/coincident-partitions.js';
|
||||
import { GRID_PITCH, GRID_STEP_N as S } from '../test-build/space-geometry.js';
|
||||
import { wallIntervals, wallKey } from '../test-build/wall-thickness.js';
|
||||
|
||||
@@ -10,6 +11,15 @@ const clone = (value) => structuredClone(value);
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/real-plan-second-floor.json', import.meta.url), 'utf8',
|
||||
));
|
||||
fixture.space.partitions.push({
|
||||
id: 'partition-room-mt7ijuyq-0',
|
||||
a: [-0.354166667, 2.2875], b: [-0.354166667, 3.866666667], cm: 20,
|
||||
});
|
||||
fixture.space.room_drafts = [{
|
||||
id: 'draft-mt7igts5',
|
||||
points: [[-0.354166667, 3.866666667], [1.058333333, 3.866666667]],
|
||||
segments: [{ cm: 30 }],
|
||||
}];
|
||||
|
||||
const configOf = (space) => ({ model_version: 7, spaces: [space], markers: [], settings: {} });
|
||||
|
||||
@@ -20,7 +30,8 @@ test('issue 296 real second floor removes all three hidden blockers in one Optim
|
||||
assert.deepEqual(input, before, 'preview must stay immutable');
|
||||
assert.equal(result.changed, true);
|
||||
assert.ok(result.report.partitionsReconciled >= 2);
|
||||
assert.equal(result.report.removedDrafts, 1);
|
||||
assert.equal(result.report.removedDrafts, 0);
|
||||
assert.equal(result.report.redundantDraftsRemoved, 1);
|
||||
const space = result.config.spaces[0];
|
||||
assert.equal(space.partitions, undefined);
|
||||
assert.equal(space.room_drafts, undefined);
|
||||
@@ -38,6 +49,7 @@ test('issue 296 real second floor removes all three hidden blockers in one Optim
|
||||
assert.equal(second.changed, false);
|
||||
assert.equal(second.report.partitionsReconciled, 0);
|
||||
assert.equal(second.report.removedDrafts, 0);
|
||||
assert.equal(second.report.redundantDraftsRemoved, 0);
|
||||
assert.deepEqual(second.config, result.config);
|
||||
});
|
||||
|
||||
@@ -84,7 +96,8 @@ test('issue 296 draft cleanup is all-or-nothing and preserves legal unfinished w
|
||||
],
|
||||
};
|
||||
const result = optimizePlans(configOf(space), {});
|
||||
assert.equal(result.report.removedDrafts, 1);
|
||||
assert.equal(result.report.removedDrafts, 0);
|
||||
assert.equal(result.report.redundantDraftsRemoved, 1);
|
||||
assert.deepEqual(result.config.spaces[0].room_drafts.map((draft) => draft.id), [
|
||||
'free', 'partial', 'thicker',
|
||||
]);
|
||||
@@ -117,3 +130,112 @@ test('issue 296 an opening across a structural breakpoint keeps the source parti
|
||||
assert.equal(result.config.spaces[0].openings[0].host.id, 'host');
|
||||
assert.equal(result.config.spaces[0].openings[0].host.t, 0.5);
|
||||
});
|
||||
|
||||
test('issue 296 keeps an opening wholly on a residual with exact absolute geometry', () => {
|
||||
const space = {
|
||||
id: 'residual-opening', title: 'Residual opening', view_box: [-0.5, -0.5, 2, 2], cell_cm: 5,
|
||||
rooms: [{ id: 'room', name: 'room', area: null,
|
||||
poly: [[0, 0], [1, 0], [1, 1], [0, 1]] }],
|
||||
walls: [{ key: wallKey([0, 0], [1, 0], S), a: [0, 0], b: [1, 0], cm: 20 }],
|
||||
partitions: [{ id: 'host', a: [-0.25, 0], b: [1.25, 0], cm: 20 }],
|
||||
openings: [{
|
||||
id: 'door', type: 'door', x: 0, y: 0, angle: 0, length: 0.1,
|
||||
host: { kind: 'partition', id: 'host', t: 0.9 },
|
||||
}],
|
||||
};
|
||||
const result = optimizePlans(configOf(space), {});
|
||||
const opening = result.config.spaces[0].openings[0];
|
||||
assert.match(opening.host.id, /^host~r-/);
|
||||
assert.equal(opening.x, 1.1);
|
||||
assert.equal(opening.y, 0);
|
||||
assert.equal(opening.angle, 0);
|
||||
assert.equal(result.report.openingsRehosted, 0);
|
||||
});
|
||||
|
||||
test('issue 296 computes max thickness independently for disjoint safe pieces', () => {
|
||||
const space = {
|
||||
id: 'piece-thickness', title: 'Piece thickness', view_box: [-0.2, -0.2, 3.4, 1.4], cell_cm: 5,
|
||||
rooms: [
|
||||
{ id: 'left', name: 'left', area: null, poly: [[0, 0], [1, 0], [1, 1], [0, 1]] },
|
||||
{ id: 'right', name: 'right', area: null, poly: [[2, 0], [3, 0], [3, 1], [2, 1]] },
|
||||
],
|
||||
walls: [
|
||||
{ key: wallKey([0, 0], [1, 0], S), a: [0, 0], b: [1, 0], cm: 15 },
|
||||
{ key: wallKey([2, 0], [3, 0], S), a: [2, 0], b: [3, 0], cm: 25 },
|
||||
],
|
||||
partitions: [{ id: 'split', a: [0, 0], b: [3, 0], cm: 20 }],
|
||||
};
|
||||
const result = optimizePlans(configOf(space), {});
|
||||
assert.equal(result.report.partitionsReconciled, 2);
|
||||
assert.deepEqual(result.config.spaces[0].partitions.map((item) => [item.a, item.b]), [
|
||||
[[1, 0], [2, 0]],
|
||||
]);
|
||||
const bottom = wallIntervals(
|
||||
result.config.spaces[0].rooms, result.config.spaces[0].walls, [], S, 5, GRID_PITCH, 1,
|
||||
).filter((item) => item.a[1] === 0 && item.b[1] === 0);
|
||||
assert.equal(bottom.find((item) => Math.min(item.a[0], item.b[0]) === 0)?.cm, 20);
|
||||
assert.equal(bottom.find((item) => Math.min(item.a[0], item.b[0]) === 2)?.cm, 25);
|
||||
});
|
||||
|
||||
test('issue 296 applies column and unfinished-draft blockers to each piece and preserves work', () => {
|
||||
const base = {
|
||||
id: 'piece-blockers', title: 'Piece blockers', view_box: [-0.2, -0.2, 3.4, 1.4], cell_cm: 5,
|
||||
rooms: [
|
||||
{ id: 'left', name: 'left', area: null, poly: [[0, 0], [1, 0], [1, 1], [0, 1]] },
|
||||
{ id: 'right', name: 'right', area: null, poly: [[2, 0], [3, 0], [3, 1], [2, 1]] },
|
||||
],
|
||||
walls: [], partitions: [{ id: 'split', a: [0, 0], b: [3, 0], cm: 15 }],
|
||||
wall_columns: [{ id: 'column', shape: 'circle', center: [0.5, 0], cm: 20 }],
|
||||
};
|
||||
const columnResult = optimizePlans(configOf(base), {});
|
||||
assert.equal(columnResult.report.partitionsReconciled, 1);
|
||||
assert.deepEqual(columnResult.config.spaces[0].partitions[0].a, [0, 0]);
|
||||
assert.deepEqual(columnResult.config.spaces[0].partitions[0].b, [2, 0]);
|
||||
|
||||
const draftSpace = clone(base);
|
||||
delete draftSpace.wall_columns;
|
||||
draftSpace.rooms = [base.rooms[0]];
|
||||
draftSpace.partitions = [{ id: 'blocked', a: [0, 0], b: [1, 0], cm: 15 }];
|
||||
draftSpace.room_drafts = [{
|
||||
id: 'unfinished', points: [[0, 0], [0.5, 0]], segments: [{ cm: 30 }],
|
||||
}];
|
||||
const draftResult = optimizePlans(configOf(draftSpace), {});
|
||||
assert.equal(draftResult.report.partitionsReconciled, 0);
|
||||
assert.equal(draftResult.report.redundantDraftsRemoved, 0);
|
||||
assert.deepEqual(draftResult.config.spaces[0].room_drafts, draftSpace.room_drafts);
|
||||
});
|
||||
|
||||
test('issue 296 fails closed at MAX_PARTITIONS and MAX_WALLS', () => {
|
||||
const room = { id: 'room', name: 'room', area: null,
|
||||
poly: [[0, 0], [1, 0], [1, 1], [0, 1]] };
|
||||
const target = { id: 'a-target', a: [-0.25, 0], b: [1.25, 0], cm: 15 };
|
||||
const partitions = [target, ...Array.from({ length: 1999 }, (_, index) => ({
|
||||
id: `z-${index}`, a: [10 + index * 2, 10], b: [11 + index * 2, 10], cm: 15,
|
||||
}))];
|
||||
const partitionLimited = reconcileCoincidentPartitions(
|
||||
{ partitions, openings: [], room_drafts: [] },
|
||||
{ rooms: [room], partitions, room_drafts: [], wall_columns: [] },
|
||||
[], [], { pitch: S, cellCm: 5, gridPitch: GRID_PITCH, coordScale: 1 },
|
||||
);
|
||||
assert.equal(partitionLimited.partitionsReconciled, 0);
|
||||
assert.deepEqual(partitionLimited.partitions[0], target);
|
||||
|
||||
const count = 501;
|
||||
const polygon = Array.from({ length: count }, (_, index) => {
|
||||
const angle = 2 * Math.PI * index / count;
|
||||
return [10 + Math.cos(angle), 10 + Math.sin(angle)];
|
||||
});
|
||||
const walls = Array.from({ length: count - 1 }, (_, index) => ({
|
||||
key: wallKey(polygon[index + 1], polygon[(index + 2) % count], S),
|
||||
a: polygon[index + 1], b: polygon[(index + 2) % count], cm: 15,
|
||||
}));
|
||||
const edgePartition = { id: 'edge', a: polygon[0], b: polygon[1], cm: 20 };
|
||||
const wallLimited = reconcileCoincidentPartitions(
|
||||
{ partitions: [edgePartition], openings: [], room_drafts: [] },
|
||||
{ rooms: [{ id: 'many', name: 'many', poly: polygon }],
|
||||
partitions: [edgePartition], room_drafts: [], wall_columns: [] },
|
||||
walls, [], { pitch: S, cellCm: 5, gridPitch: GRID_PITCH, coordScale: 1 },
|
||||
);
|
||||
assert.equal(wallLimited.partitionsReconciled, 0);
|
||||
assert.deepEqual(wallLimited.partitions, [edgePartition]);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ const assertNoPersistedChanges = (result) => {
|
||||
'moved', 'coordsCanonicalized', 'rotated', 'removedDrafts', 'migrated',
|
||||
'glowSpacesMigrated', 'glowRoomsMigrated', 'canonicalized', 'wallsMerged',
|
||||
'spansMerged', 'partitionsMerged', 'partitionsReconciled', 'openingsRehosted',
|
||||
'redundantDraftsRemoved',
|
||||
'wallsStraightened',
|
||||
'spaceRefsRemapped', 'roomRefsRemapped',
|
||||
'positionsRemapped', 'markersDetached', 'orphanRoomLabelsRemoved',
|
||||
|
||||
@@ -1837,8 +1837,8 @@ def test_issue_296_backend_accepts_only_a_composite_room_edge_rehost():
|
||||
candidate = {"spaces": [{
|
||||
"id": "floor", "rooms": [room], "partitions": [],
|
||||
"walls": [{
|
||||
"key": "0.250000,0.000000@0.0000", "cm": 20,
|
||||
"a": [0.2, 0], "b": [0.3, 0],
|
||||
"key": "0.500000,0.000000@0.0000", "cm": 20,
|
||||
"a": [0, 0], "b": [1, 0],
|
||||
}],
|
||||
"openings": [{
|
||||
"id": "window", "type": "window", "x": 0.25, "y": 0,
|
||||
@@ -1871,6 +1871,75 @@ def test_issue_296_backend_accepts_only_a_composite_room_edge_rehost():
|
||||
)
|
||||
|
||||
|
||||
def test_issue_296_backend_proves_full_partition_delta_without_openings_and_residual_rehost():
|
||||
room = {
|
||||
"id": "room", "name": "Room", "area": None,
|
||||
"poly": [[0, 0], [1, 0], [1, 1], [0, 1]],
|
||||
}
|
||||
partition = {"id": "host", "a": [0, 0], "b": [1, 0], "cm": 20}
|
||||
previous = {"spaces": [{
|
||||
"id": "floor", "rooms": [room], "partitions": [partition], "openings": [],
|
||||
}]}
|
||||
candidate = {"spaces": [{
|
||||
"id": "floor", "rooms": [room], "partitions": [], "openings": [],
|
||||
"walls": [{"a": [0, 0], "b": [1, 0], "cm": 20}],
|
||||
}]}
|
||||
v.validate_partition_opening_hosts(candidate, previous, allow_optimize_rehost=True)
|
||||
|
||||
for mutator in (
|
||||
lambda space: space.update(walls=[]),
|
||||
lambda space: space["walls"][0].update(b=[0.5, 0]),
|
||||
):
|
||||
changed = json.loads(json.dumps(candidate))
|
||||
mutator(changed["spaces"][0])
|
||||
with pytest.raises(v.PartitionOpeningHostError, match="partition=host"):
|
||||
v.validate_partition_opening_hosts(
|
||||
changed, previous, allow_optimize_rehost=True
|
||||
)
|
||||
|
||||
unknown_previous = json.loads(json.dumps(previous))
|
||||
unknown_previous["spaces"][0]["partitions"][0]["future_semantics"] = True
|
||||
with pytest.raises(v.PartitionOpeningHostError, match="partition=host"):
|
||||
v.validate_partition_opening_hosts(
|
||||
candidate, unknown_previous, allow_optimize_rehost=True
|
||||
)
|
||||
|
||||
middle_room = {
|
||||
"id": "middle", "name": "Middle", "area": None,
|
||||
"poly": [[0.25, 0], [0.75, 0], [0.75, 1], [0.25, 1]],
|
||||
}
|
||||
hosted = {
|
||||
"id": "door", "type": "door", "x": 0, "y": 0,
|
||||
"angle": 0, "length": 0.1,
|
||||
"host": {"kind": "partition", "id": "host", "t": 0.875},
|
||||
}
|
||||
residual_previous = {"spaces": [{
|
||||
"id": "floor", "cell_cm": 5, "rooms": [middle_room],
|
||||
"partitions": [partition], "openings": [hosted],
|
||||
}]}
|
||||
residual_candidate = {"spaces": [{
|
||||
"id": "floor", "cell_cm": 5, "rooms": [middle_room],
|
||||
"walls": [{"a": [0.25, 0], "b": [0.75, 0], "cm": 20}],
|
||||
"partitions": [
|
||||
{"id": "host", "a": [0, 0], "b": [0.25, 0], "cm": 20},
|
||||
{"id": "host~r-test", "a": [0.75, 0], "b": [1, 0], "cm": 20},
|
||||
],
|
||||
"openings": [{
|
||||
**hosted, "x": 0.875, "y": 0, "angle": 0,
|
||||
"host": {"kind": "partition", "id": "host~r-test", "t": 0.5},
|
||||
}],
|
||||
}]}
|
||||
v.validate_partition_opening_hosts(
|
||||
residual_candidate, residual_previous, allow_optimize_rehost=True
|
||||
)
|
||||
shifted = json.loads(json.dumps(residual_candidate))
|
||||
shifted["spaces"][0]["openings"][0]["x"] = 0.88
|
||||
with pytest.raises(v.PartitionOpeningHostError, match="opening=door"):
|
||||
v.validate_partition_opening_hosts(
|
||||
shifted, residual_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