mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
The limits read `wall_segments`, so a document older than the catalogue reports no walls at all — and therefore no violations, whatever its geometry. Comparing that raw baseline against a candidate the card had already migrated counted every inherited violation as new, and a legacy plan could not take an unrelated edit at all: renaming a room was refused with junction_limit_angle. Spec §3 forbids exactly this, and the frontend had already learned the same lesson in 4758767e; the backend mirror simply never got the second half. validate_junction_limits now runs both documents through commit_wall_segment_model before counting. A document that cannot be migrated is not this validator's verdict — the wall-model barrier owns that error and reports it with its own code — so it degrades to "no baseline to inherit". The regression is pinned twice: a test that asserts the legacy baseline reads clean raw and carries the apex once migrated, and the mutant junction-limit-backend-raw-baseline. Both fixtures that exercise the barrier were rebuilt as real documents (rooms plus walls), because the previous ones put walls in wall_segments with no rooms and did not survive migration. Issue: #329 User-Visible: no
317 lines
13 KiB
Python
317 lines
13 KiB
Python
"""Issue #329 — wall junction limits, Python mirror of src/junction-limits.ts.
|
||
|
||
The card refuses a WRITE that would ADD a junction violation; this module is
|
||
the backend half of that contract, so a stale or hostile client cannot post
|
||
what the editor refuses. It mirrors П1–П4 exactly, including the two legal
|
||
incidences of П4 (a shared node and a T-joint into the middle of a foreign
|
||
wall) and the П3 rule that measures the collinear same-thickness WALL RUN
|
||
rather than one catalogue atom.
|
||
|
||
П5 (the room keeps at least 25 cm² of interior) is deliberately NOT mirrored:
|
||
it is a statement about the rendered wall bodies, and reproducing the mitre /
|
||
inset pipeline in Python would be a second geometry implementation whose drift
|
||
is a larger risk than the rule it guards. A document that violates only П5 is
|
||
ugly, not corrupt — see docs/specs/329-junction-limits.md §5.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
|
||
from .wall_segment_model import commit_wall_segment_model
|
||
|
||
MIN_JUNCTION_ANGLE_DEG = 15.0
|
||
MAX_JUNCTION_VALENCE = 6
|
||
MIN_SEGMENT_LENGTH_CM = 20.0
|
||
MIN_NODE_DISTANCE_CM = 5.0
|
||
|
||
GRID_STEP_N = 1 / 240
|
||
|
||
_EPS = 1e-9
|
||
# Below this a node is ON the wall (T-joint), not near it.
|
||
_INCIDENT_EPS = 1e-9
|
||
|
||
|
||
class JunctionLimitError(ValueError):
|
||
"""A write introduced a wall-junction violation (#329)."""
|
||
|
||
def __init__(self, space_id: str, rule: str, subject: str,
|
||
actual: float, limit: float) -> None:
|
||
self.space_id = space_id
|
||
self.rule = rule
|
||
self.subject = subject
|
||
self.actual = actual
|
||
self.limit = limit
|
||
self.code = f"junction_limit_{rule}"
|
||
super().__init__(
|
||
f"space={space_id}; rule={rule}; subject={subject}; "
|
||
f"actual={actual:.12g}; limit={limit:.12g}"
|
||
)
|
||
|
||
|
||
def _finite_point(point: object) -> bool:
|
||
return (
|
||
isinstance(point, (list, tuple))
|
||
and len(point) >= 2
|
||
and all(isinstance(value, (int, float)) and math.isfinite(value)
|
||
for value in point[:2])
|
||
)
|
||
|
||
|
||
def _key(point) -> str:
|
||
return f"{point[0]:.6f},{point[1]:.6f}"
|
||
|
||
|
||
def _length(a, b) -> float:
|
||
return math.hypot(b[0] - a[0], b[1] - a[1])
|
||
|
||
|
||
def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> float:
|
||
return (cm / (cell_cm or 1)) * grid_pitch
|
||
|
||
|
||
def limit_segments(space: dict) -> list[dict]:
|
||
"""Every wall the limits judge: contour atoms, partitions, draft segments."""
|
||
segments: list[dict] = []
|
||
for segment in space.get("wall_segments") or []:
|
||
if _finite_point(segment.get("a")) and _finite_point(segment.get("b")):
|
||
segments.append({
|
||
"id": str(segment.get("id") or ""),
|
||
"a": segment["a"], "b": segment["b"],
|
||
"cm": float(segment.get("cm") or 0),
|
||
})
|
||
for partition in space.get("partitions") or []:
|
||
if _finite_point(partition.get("a")) and _finite_point(partition.get("b")):
|
||
segments.append({
|
||
"id": str(partition.get("id") or ""),
|
||
"a": partition["a"], "b": partition["b"],
|
||
"cm": float(partition.get("cm") or 0),
|
||
})
|
||
for draft in space.get("room_drafts") or []:
|
||
points = draft.get("points") or []
|
||
drafted = draft.get("segments") or []
|
||
for index in range(max(len(points) - 1, 0)):
|
||
if not (_finite_point(points[index]) and _finite_point(points[index + 1])):
|
||
continue
|
||
piece = drafted[index] if index < len(drafted) else {}
|
||
segments.append({
|
||
"id": str((piece or {}).get("id")
|
||
or f"{draft.get('id') or 'draft'}-{index}"),
|
||
"a": points[index], "b": points[index + 1],
|
||
"cm": float((piece or {}).get("cm") or 0),
|
||
})
|
||
return [segment for segment in segments
|
||
if _length(segment["a"], segment["b"]) > _EPS]
|
||
|
||
|
||
def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]:
|
||
"""П1 + П2: valence of a node and the narrowest wedge in it."""
|
||
rays: dict[str, list[float]] = {}
|
||
for segment in segments:
|
||
for start, end in ((segment["a"], segment["b"]), (segment["b"], segment["a"])):
|
||
rays.setdefault(_key(start), []).append(
|
||
math.atan2(end[1] - start[1], end[0] - start[0])
|
||
)
|
||
violations: list[tuple[str, str, float, float]] = []
|
||
for node, angles in rays.items():
|
||
if len(angles) > MAX_JUNCTION_VALENCE:
|
||
violations.append(
|
||
("valence", node, float(len(angles)), float(MAX_JUNCTION_VALENCE))
|
||
)
|
||
if len(angles) < 2:
|
||
continue
|
||
ordered = sorted(angles)
|
||
smallest = math.inf
|
||
for index, value in enumerate(ordered):
|
||
delta = ordered[(index + 1) % len(ordered)] - value
|
||
if index == len(ordered) - 1:
|
||
delta += math.pi * 2
|
||
degrees = (delta * 180) / math.pi
|
||
# Collinear rays of one straight wall are a 180° pair, not a wedge.
|
||
if _EPS < degrees < smallest:
|
||
smallest = degrees
|
||
if smallest < MIN_JUNCTION_ANGLE_DEG - 1e-9:
|
||
violations.append(("angle", node, smallest, MIN_JUNCTION_ANGLE_DEG))
|
||
return violations
|
||
|
||
|
||
def _axis_degrees(segment: dict) -> float:
|
||
degrees = math.degrees(math.atan2(
|
||
segment["b"][1] - segment["a"][1], segment["b"][0] - segment["a"][0]
|
||
))
|
||
return (degrees % 180 + 180) % 180
|
||
|
||
|
||
def _collinear(left: dict, right: dict, tolerance_deg: float = 1.0) -> bool:
|
||
delta = abs(_axis_degrees(left) - _axis_degrees(right))
|
||
return min(delta, 180 - delta) <= tolerance_deg
|
||
|
||
|
||
def collinear_run_length_units(segment: dict, segments: list[dict]) -> float:
|
||
"""Length of the WALL a segment belongs to, not of the atom.
|
||
|
||
Atomisation at a thickness step leaves pieces nobody drew — where a 30 cm
|
||
wall meets a 20 cm one, a (30−20)/2 = 5 cm piece continues the same wall.
|
||
They are collinear continuations at the same thickness, so П3 measures the
|
||
maximal collinear chain through the segment's nodes.
|
||
"""
|
||
by_node: dict[str, list[dict]] = {}
|
||
for item in segments:
|
||
for point in (item["a"], item["b"]):
|
||
by_node.setdefault(_key(point), []).append(item)
|
||
visited = [segment]
|
||
total = _length(segment["a"], segment["b"])
|
||
|
||
def walk(current: dict, node: str) -> None:
|
||
nonlocal total
|
||
for candidate in by_node.get(node, []):
|
||
if any(candidate is seen for seen in visited):
|
||
continue
|
||
if not _collinear(candidate, current):
|
||
continue
|
||
if float(candidate.get("cm") or 0) != float(current.get("cm") or 0):
|
||
continue
|
||
visited.append(candidate)
|
||
total += _length(candidate["a"], candidate["b"])
|
||
walk(candidate, _key(candidate["b"])
|
||
if _key(candidate["a"]) == node else _key(candidate["a"]))
|
||
return
|
||
|
||
walk(segment, _key(segment["a"]))
|
||
walk(segment, _key(segment["b"]))
|
||
return total
|
||
|
||
|
||
def check_segment_lengths(
|
||
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||
) -> list[tuple[str, str, float, float]]:
|
||
"""П3: a wall is at least 20 cm and never shorter than its own thickness."""
|
||
violations: list[tuple[str, str, float, float]] = []
|
||
for segment in segments:
|
||
units = collinear_run_length_units(segment, segments)
|
||
cm = (units / grid_pitch) * (cell_cm or 1)
|
||
thickness = float(segment.get("cm") or 0)
|
||
limit = max(MIN_SEGMENT_LENGTH_CM, thickness if thickness > 0 else 0.0)
|
||
if cm < limit - 1e-9:
|
||
violations.append((
|
||
"length", str(segment.get("id") or _key(segment["a"])), cm, limit,
|
||
))
|
||
return violations
|
||
|
||
|
||
def _distance_to_segment(point, a, b) -> float:
|
||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||
length_sq = dx * dx + dy * dy
|
||
t = 0.0 if length_sq <= _EPS else max(0.0, min(1.0, (
|
||
(point[0] - a[0]) * dx + (point[1] - a[1]) * dy
|
||
) / length_sq))
|
||
return math.hypot(point[0] - (a[0] + dx * t), point[1] - (a[1] + dy * t))
|
||
|
||
|
||
def check_node_distances(
|
||
segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N,
|
||
) -> list[tuple[str, str, float, float]]:
|
||
"""П4: non-incident nodes and node-to-foreign-wall clearance."""
|
||
nodes: dict[str, list] = {}
|
||
for segment in segments:
|
||
nodes[_key(segment["a"])] = segment["a"]
|
||
nodes[_key(segment["b"])] = segment["b"]
|
||
min_units = cm_to_units(MIN_NODE_DISTANCE_CM, cell_cm, grid_pitch)
|
||
violations: list[tuple[str, str, float, float]] = []
|
||
entries = list(nodes.items())
|
||
for i in range(len(entries)):
|
||
for j in range(i + 1, len(entries)):
|
||
distance = _length(entries[i][1], entries[j][1])
|
||
if distance < min_units - 1e-9:
|
||
violations.append((
|
||
"distance", f"{entries[i][0]} ↔ {entries[j][0]}",
|
||
(distance / grid_pitch) * (cell_cm or 1), MIN_NODE_DISTANCE_CM,
|
||
))
|
||
for node_key, node in nodes.items():
|
||
for segment in segments:
|
||
# A node that belongs to the wall is a legal corner or T-joint.
|
||
if _key(segment["a"]) == node_key or _key(segment["b"]) == node_key:
|
||
continue
|
||
distance = _distance_to_segment(node, segment["a"], segment["b"])
|
||
# Sitting exactly ON the wall is the other legal incidence.
|
||
if distance <= _INCIDENT_EPS:
|
||
continue
|
||
if distance < min_units - 1e-9:
|
||
violations.append((
|
||
"distance",
|
||
f"{node_key} → {segment.get('id') or _key(segment['a'])}",
|
||
(distance / grid_pitch) * (cell_cm or 1), MIN_NODE_DISTANCE_CM,
|
||
))
|
||
return violations
|
||
|
||
|
||
def space_violations(space: dict) -> list[tuple[str, str, float, float]]:
|
||
"""П1–П4 over one space, in the frontend's order."""
|
||
segments = limit_segments(space)
|
||
cell_cm = float(space.get("cell_cm") or 1)
|
||
return [
|
||
*check_nodes(segments),
|
||
*check_segment_lengths(segments, cell_cm),
|
||
*check_node_distances(segments, cell_cm),
|
||
]
|
||
|
||
|
||
def _migrated_spaces(config: dict | None) -> dict[str, dict]:
|
||
"""Spaces of one document AFTER the wall-segment migration, by id.
|
||
|
||
The limits read `wall_segments`, so a document that predates the catalogue
|
||
reports NO walls at all — a legacy space would answer "no violations"
|
||
regardless of its geometry. Comparing such a baseline against a candidate
|
||
the client already migrated counts every inherited violation as new and
|
||
refuses an unrelated edit (spec §3 forbids exactly that). Both sides are
|
||
therefore judged after the SAME migration, mirroring the frontend barrier.
|
||
|
||
A document that cannot be migrated is not a reason to refuse the write:
|
||
the wall-model barrier owns that verdict and reports it with its own code.
|
||
Here it simply means there is no baseline to inherit from.
|
||
"""
|
||
if not isinstance(config, dict):
|
||
return {}
|
||
try:
|
||
migrated, _ = commit_wall_segment_model(config)
|
||
except Exception: # noqa: BLE001 — see the docstring: not our verdict
|
||
migrated = config
|
||
return {
|
||
str(space.get("id")): space
|
||
for space in (migrated or {}).get("spaces") or []
|
||
if isinstance(space, dict)
|
||
}
|
||
|
||
|
||
def validate_junction_limits(config: dict, previous: dict | None = None) -> None:
|
||
"""Refuse a write that ADDS a junction violation; inherit the rest.
|
||
|
||
Counted per RULE, not per subject: a structural write re-atomises and
|
||
re-keys contour segments, so subject identity is not stable across the
|
||
barrier and matching by it would report an inherited violation as new
|
||
(the mistake that refused legitimate resizes on the frontend). Spec §3:
|
||
a write may keep existing violations, it may never add one.
|
||
|
||
Both documents go through `commit_wall_segment_model` first, so a legacy
|
||
baseline is compared in the same terms as the candidate.
|
||
"""
|
||
old_spaces = _migrated_spaces(previous)
|
||
new_spaces = _migrated_spaces(config)
|
||
for space_id, space in new_spaces.items():
|
||
old_space = old_spaces.get(space_id)
|
||
if old_space is None:
|
||
# A brand-new space has nothing to inherit from — but neither is a
|
||
# first write allowed to arrive already broken.
|
||
before: dict[str, int] = {}
|
||
else:
|
||
before = {}
|
||
for rule, _subject, _actual, _limit in space_violations(old_space):
|
||
before[rule] = before.get(rule, 0) + 1
|
||
after: dict[str, list[tuple[str, str, float, float]]] = {}
|
||
for violation in space_violations(space):
|
||
after.setdefault(violation[0], []).append(violation)
|
||
for rule, items in after.items():
|
||
if len(items) > before.get(rule, 0):
|
||
_, subject, actual, limit = items[0]
|
||
raise JunctionLimitError(space_id, rule, subject, actual, limit)
|