fix: enforce exact lattice coordinates

Issue: #291
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 17:13:21 +03:00
parent c8a3878568
commit 4ccf4a3ccc
31 changed files with 1213 additions and 316 deletions
@@ -1,7 +1,7 @@
"""Lossless, allow-listed canonicalisation of persisted geometry.
The frontend mirrors this module in src/coordinate-canonicalization.ts.
Keep the precision, scalar formula and field allow-list in lockstep; a shared
Keep the precision, lattice formula and field allow-list in lockstep; a shared
fixture is exercised by both runtimes.
"""
from __future__ import annotations
@@ -13,10 +13,12 @@ from typing import Any
COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
LATTICE_GRID_N = 240
LATTICE_NOISE_STEPS = 1e-4
def canonicalize_number(value: Any) -> Any:
"""Return one stable IEEE-754 representation for an allow-listed number."""
"""Return one stable IEEE-754 representation for an allow-listed scalar."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
@@ -32,6 +34,22 @@ def canonicalize_number(value: Any) -> Any:
return result
def canonicalize_lattice_coordinate(value: Any) -> Any:
"""Collapse near-node noise while preserving authored off-grid values."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return value
number = float(value)
if not math.isfinite(number):
return value
scaled = number * LATTICE_GRID_N
# JavaScript Math.round: ties go toward +infinity, unlike Python round().
nearest = math.floor(scaled + 0.5)
if abs(scaled - nearest) < LATTICE_NOISE_STEPS:
result = nearest / LATTICE_GRID_N
return 0.0 if result == 0 else result
return canonicalize_number(number)
def _record(value: Any) -> dict[str, Any] | None:
return value if isinstance(value, dict) else None
@@ -42,37 +60,43 @@ def _records(value: Any) -> list[dict[str, Any]]:
return [item for item in value if isinstance(item, dict)]
def _fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
def _scalar_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_number(record[name])
def _point(value: Any) -> None:
def _lattice_fields(record: dict[str, Any], names: tuple[str, ...]) -> None:
for name in names:
if name in record:
record[name] = canonicalize_lattice_coordinate(record[name])
def _lattice_point(value: Any) -> None:
if not isinstance(value, list):
return
for index in range(min(2, len(value))):
value[index] = canonicalize_number(value[index])
value[index] = canonicalize_lattice_coordinate(value[index])
def _points(value: Any) -> None:
def _lattice_points(value: Any) -> None:
if not isinstance(value, list):
return
for point in value:
_point(point)
_lattice_point(point)
def canonicalize_position(position: Any) -> Any:
"""Canonicalise x/y in one layout record without touching its metadata."""
"""Canonicalise lattice x/y in one layout record, preserving metadata."""
result = copy.deepcopy(position)
record = _record(result)
if record is not None:
_fields(record, ("x", "y"))
_lattice_fields(record, ("x", "y"))
return result
def canonicalize_layout_geometry(layout: Any) -> Any:
"""Canonicalise x/y in every layout record, preserving future fields."""
"""Canonicalise lattice x/y in every layout record."""
result = copy.deepcopy(layout)
record = _record(result)
if record is None:
@@ -80,7 +104,7 @@ def canonicalize_layout_geometry(layout: Any) -> Any:
for position in record.values():
item = _record(position)
if item is not None:
_fields(item, ("x", "y"))
_lattice_fields(item, ("x", "y"))
return result
@@ -92,7 +116,7 @@ def canonicalize_config_geometry(config: Any) -> Any:
return result
for space in _records(root.get("spaces")):
_fields(
_scalar_fields(
space,
(
"plan_x",
@@ -105,45 +129,48 @@ def canonicalize_config_geometry(config: Any) -> Any:
)
for room in _records(space.get("rooms")):
_fields(room, ("x", "y", "w", "h"))
_points(room.get("poly"))
_lattice_fields(room, ("x", "y", "w", "h"))
_lattice_points(room.get("poly"))
for wall in _records(space.get("walls")):
_point(wall.get("a"))
_point(wall.get("b"))
_lattice_point(wall.get("a"))
_lattice_point(wall.get("b"))
for opening in _records(space.get("openings")):
_fields(opening, ("x", "y", "angle", "length"))
_lattice_fields(opening, ("x", "y"))
_scalar_fields(opening, ("angle", "length"))
host = _record(opening.get("host"))
if host is not None:
_fields(host, ("t",))
_scalar_fields(host, ("t",))
for decor in _records(space.get("decor")):
kind = decor.get("kind")
if kind == "line":
_fields(decor, ("x1", "y1", "x2", "y2"))
_lattice_fields(decor, ("x1", "y1", "x2", "y2"))
elif kind in ("rect", "ellipse", "furniture"):
_fields(decor, ("x", "y", "w", "h", "angle"))
_lattice_fields(decor, ("x", "y", "w", "h"))
_scalar_fields(decor, ("angle",))
elif kind == "text":
_fields(decor, ("x", "y", "scale", "angle"))
_lattice_fields(decor, ("x", "y"))
_scalar_fields(decor, ("scale", "angle"))
for draft in _records(space.get("room_drafts")):
_points(draft.get("points"))
_lattice_points(draft.get("points"))
for partition in _records(space.get("partitions")):
_point(partition.get("a"))
_point(partition.get("b"))
_lattice_point(partition.get("a"))
_lattice_point(partition.get("b"))
for column in _records(space.get("wall_columns")):
_point(column.get("center"))
_lattice_point(column.get("center"))
if column.get("shape") == "square":
_fields(column, ("angle",))
_scalar_fields(column, ("angle",))
for span in _records(space.get("open_spans")):
_point(span.get("a"))
_point(span.get("b"))
_lattice_point(span.get("a"))
_lattice_point(span.get("b"))
for marker in _records(root.get("markers")):
_fields(marker, ("angle",))
_scalar_fields(marker, ("angle",))
return result
File diff suppressed because one or more lines are too long