Scaffold homemaker-py with validated geometry port
Clean-room Python successor to Urb for programme-driven layout search.
This initial commit establishes the .dom bridge format and a faithful
port of Urb's top-down quad geometry, validated byte-identical against
Urb across all 35 programme-house example files (including the wall
inset and multi-storey wall-stacking inheritance).
- dom.py: .dom YAML <-> Node tree, parent/below/position linkage,
wall_outer inset on load, raw-corner stash for round-tripping
- geometry.py: Coordinate/Coordinate_a/_b/Area/Length + Coordinate_Offset
- experiments/dump_areas.{py,pl}: geometry regression harness
This commit is contained in:
commit
0366392da4
10 changed files with 518 additions and 0 deletions
26
.claude/settings.json
Normal file
26
.claude/settings.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"hooks": {
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"command": "bd prime",
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
"matcher": ""
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"command": "bd prime",
|
||||
"type": "command"
|
||||
}
|
||||
],
|
||||
"matcher": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
# scratch output from the Perl oracle during experiments
|
||||
scratch/
|
||||
*.dom.score
|
||||
*.dom.fails
|
||||
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Project Instructions for AI Agents
|
||||
|
||||
This file provides instructions and context for AI coding agents working on this project.
|
||||
|
||||
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->
|
||||
## Beads Issue Tracker
|
||||
|
||||
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
|
||||
|
||||
### Quick Reference
|
||||
|
||||
```bash
|
||||
bd ready # Find available work
|
||||
bd show <id> # View issue details
|
||||
bd update <id> --claim # Claim work
|
||||
bd close <id> # Complete work
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
|
||||
- Run `bd prime` for detailed command reference and session close protocol
|
||||
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
|
||||
|
||||
## Session Completion
|
||||
|
||||
**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds.
|
||||
|
||||
**MANDATORY WORKFLOW:**
|
||||
|
||||
1. **File issues for remaining work** - Create issues for anything that needs follow-up
|
||||
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
||||
3. **Update issue status** - Close finished work, update in-progress items
|
||||
4. **PUSH TO REMOTE** - This is MANDATORY:
|
||||
```bash
|
||||
git pull --rebase
|
||||
bd dolt push
|
||||
git push
|
||||
git status # MUST show "up to date with origin"
|
||||
```
|
||||
5. **Clean up** - Clear stashes, prune remote branches
|
||||
6. **Verify** - All changes committed AND pushed
|
||||
7. **Hand off** - Provide context for next session
|
||||
|
||||
**CRITICAL RULES:**
|
||||
- Work is NOT complete until `git push` succeeds
|
||||
- NEVER stop before pushing - that leaves work stranded locally
|
||||
- NEVER say "ready to push when you are" - YOU must push
|
||||
- If push fails, resolve and retry until it succeeds
|
||||
<!-- END BEADS INTEGRATION -->
|
||||
|
||||
|
||||
## Build & Test
|
||||
|
||||
_Add your build and test commands here_
|
||||
|
||||
```bash
|
||||
# Example:
|
||||
# npm install
|
||||
# npm test
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
_Add a brief overview of your project architecture_
|
||||
|
||||
## Conventions & Patterns
|
||||
|
||||
_Add your project-specific conventions here_
|
||||
36
README.md
Normal file
36
README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# homemaker-py
|
||||
|
||||
Programme-driven building-layout search over slicing trees. A clean-room Python
|
||||
successor to the Perl [Urb](../urb) project, intended to eventually be 100% Python.
|
||||
|
||||
## Why a rewrite
|
||||
|
||||
Urb represents a building as a binary slicing tree where room sizes are derived
|
||||
**top-down** from division ratios. That makes room area an emergent property of
|
||||
every cut above it, which:
|
||||
|
||||
- gives the genome low locality (a cut near the root rescales every descendant),
|
||||
- makes target room sizes nearly impossible to hit, so the gaussian size penalty
|
||||
dominates fitness, and
|
||||
- defeats crossover (transplanted subtrees lose their proportions).
|
||||
|
||||
homemaker inverts this: leaves carry **target dimensions** from the programme and
|
||||
division ratios are **solved bottom-up** for a fixed topology. The evolutionary
|
||||
search then only explores topology + types + adjacency.
|
||||
|
||||
## Phase plan
|
||||
|
||||
1. **Solver experiment** (current): port Urb's geometry, re-solve ratios from
|
||||
programme targets, score the result against the original via the Perl oracle.
|
||||
2. Native Python fitness (retire the Perl oracle).
|
||||
3. Canonical slicing encoding (normalized Polish expression) + memetic search.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/homemaker/dom.py` — read/write Urb `.dom` YAML into a `Node` tree.
|
||||
- `src/homemaker/geometry.py` — faithful port of Urb's top-down geometry.
|
||||
- `src/homemaker/programme.py` — parse `patterns.config` space requirements.
|
||||
- `src/homemaker/solver.py` — bottom-up ratio solve (scipy).
|
||||
- `src/homemaker/oracle.py` — Phase-1 scaffold: score a `.dom` via Urb's `urb-fitness.pl`.
|
||||
|
||||
The Perl oracle is the only throwaway component; everything else is permanent.
|
||||
23
experiments/dump_areas.pl
Normal file
23
experiments/dump_areas.pl
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/perl
|
||||
# Dump per-leaf areas from a .dom using Urb itself (ground truth for validating
|
||||
# the Python geometry port). Run from the urb repo root with -Ilib.
|
||||
# Output matches experiments/dump_areas.py: "level/idpath type area", sorted.
|
||||
use strict;
|
||||
use warnings;
|
||||
use YAML;
|
||||
use Urb::Dom;
|
||||
|
||||
my $dom = Urb::Dom->new;
|
||||
$dom->Deserialise (YAML::LoadFile ($ARGV[0]));
|
||||
|
||||
my @rows;
|
||||
for my $level ($dom, $dom->Levels_Above)
|
||||
{
|
||||
my $lid = scalar $level->Levels_Below;
|
||||
for my $leaf ($level->Leafs)
|
||||
{
|
||||
my $type = $leaf->Type || '?';
|
||||
push @rows, sprintf ("%d/%s %s %.4f", $lid, $leaf->Id, $type, $leaf->Area);
|
||||
}
|
||||
}
|
||||
print join ("\n", sort @rows), "\n";
|
||||
26
experiments/dump_areas.py
Normal file
26
experiments/dump_areas.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""Dump per-leaf areas from a .dom using the Python geometry port.
|
||||
|
||||
Used to validate the port against a Perl dump from Urb (see the sibling
|
||||
``dump_areas.pl``). Output: one line per leaf, ``level/idpath type area``,
|
||||
sorted for a stable diff.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from homemaker import dom, geometry # noqa: E402
|
||||
|
||||
|
||||
def main(path: str) -> None:
|
||||
root = dom.load(path)
|
||||
rows = []
|
||||
for level_idx, lvl in enumerate(dom.levels(root)):
|
||||
for leaf in lvl.leaves():
|
||||
rows.append(f"{level_idx}/{leaf.id} {leaf.type or '?'} {geometry.area(leaf):.4f}")
|
||||
print("\n".join(sorted(rows)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1])
|
||||
25
pyproject.toml
Normal file
25
pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[project]
|
||||
name = "homemaker"
|
||||
version = "0.0.1"
|
||||
description = "Programme-driven building layout search over slicing trees (Python successor to Urb)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
"numpy>=1.26",
|
||||
"scipy>=1.11",
|
||||
"shapely>=2.0",
|
||||
"networkx>=3.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8.0", "ruff>=0.5"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
3
src/homemaker/__init__.py
Normal file
3
src/homemaker/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""homemaker — programme-driven building-layout search over slicing trees."""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
201
src/homemaker/dom.py
Normal file
201
src/homemaker/dom.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""Read/write Urb ``.dom`` YAML into a ``Node`` tree.
|
||||
|
||||
A ``.dom`` file is a YAML serialisation of a binary slicing tree (see
|
||||
``Urb::Quad::Serialise``). The top-level mapping is the *lowest* level's root
|
||||
quad; each level's root carries an ``above`` mapping pointing at the next storey
|
||||
up. Only the lowest root carries plot geometry (``node``); all other coordinates
|
||||
are derived top-down (see :mod:`homemaker.geometry`).
|
||||
|
||||
This module owns *structure and I/O only* — no geometry. Linkage fields
|
||||
(``parent``, ``below``, ``position``) are populated after parsing so the geometry
|
||||
port can mirror ``Urb::Quad`` exactly, including multi-storey wall-stacking where
|
||||
an upper quad inherits its coordinates from the matching quad below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
# Root-level attributes that live only on a level's root quad.
|
||||
_ROOT_FLOATS = ("height", "elevation", "wall_inner", "wall_outer")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Node:
|
||||
rotation: int = 0
|
||||
division: list[float] | None = None
|
||||
type: str | None = None
|
||||
left: "Node | None" = None
|
||||
right: "Node | None" = None
|
||||
above: "Node | None" = None
|
||||
|
||||
# level-root only
|
||||
node: list[list[float]] | None = None # working corners (wall-inset)
|
||||
node_file: list[list[float]] | None = None # raw outer corners as read from disk
|
||||
perimeter: dict | None = None
|
||||
height: float | None = None
|
||||
elevation: float | None = None
|
||||
wall_inner: float | None = None
|
||||
wall_outer: float | None = None
|
||||
|
||||
# runtime linkage (never serialised)
|
||||
parent: "Node | None" = field(default=None, repr=False, compare=False)
|
||||
below: "Node | None" = field(default=None, repr=False, compare=False)
|
||||
position: str = "" # 'l', 'r', or '' for a level root
|
||||
|
||||
@property
|
||||
def divided(self) -> bool:
|
||||
return self.division is not None and self.left is not None and self.right is not None
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
"""Absolute id path within this level, e.g. '' (root), 'l', 'rlr'."""
|
||||
parts: list[str] = []
|
||||
n: Node | None = self
|
||||
while n is not None and n.parent is not None:
|
||||
parts.append(n.position)
|
||||
n = n.parent
|
||||
return "".join(reversed(parts))
|
||||
|
||||
def leaves(self) -> list["Node"]:
|
||||
if not self.divided:
|
||||
return [self]
|
||||
return self.left.leaves() + self.right.leaves()
|
||||
|
||||
def by_id(self, path: str) -> "Node | None":
|
||||
"""Walk an id path from this node; None if the path runs off a leaf."""
|
||||
n: Node | None = self
|
||||
for ch in path:
|
||||
if n is None or not n.divided:
|
||||
return None
|
||||
n = n.left if ch == "l" else n.right
|
||||
return n
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Parsing
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _parse(d: dict) -> Node:
|
||||
n = Node()
|
||||
n.rotation = int(d.get("rotation") or 0)
|
||||
if d.get("type") is not None:
|
||||
n.type = str(d["type"])
|
||||
if d.get("node") is not None:
|
||||
n.node = [[float(p[0]), float(p[1])] for p in d["node"]]
|
||||
if d.get("perimeter") is not None:
|
||||
n.perimeter = dict(d["perimeter"])
|
||||
for k in _ROOT_FLOATS:
|
||||
if d.get(k) is not None:
|
||||
setattr(n, k, float(d[k]))
|
||||
if d.get("division") is not None:
|
||||
n.division = [float(x) for x in d["division"]]
|
||||
n.left = _parse(d["l"])
|
||||
n.right = _parse(d["r"])
|
||||
if d.get("above") is not None:
|
||||
n.above = _parse(d["above"])
|
||||
return n
|
||||
|
||||
|
||||
def _link_subtree(n: Node, parent: Node | None, position: str) -> None:
|
||||
n.parent = parent
|
||||
n.position = position
|
||||
if n.divided:
|
||||
_link_subtree(n.left, n, "l")
|
||||
_link_subtree(n.right, n, "r")
|
||||
|
||||
|
||||
def levels(root: Node) -> list[Node]:
|
||||
"""Level roots, lowest first."""
|
||||
out: list[Node] = []
|
||||
lvl: Node | None = root
|
||||
while lvl is not None:
|
||||
out.append(lvl)
|
||||
lvl = lvl.above
|
||||
return out
|
||||
|
||||
|
||||
def _link(root: Node) -> None:
|
||||
lvls = levels(root)
|
||||
for lvl in lvls:
|
||||
_link_subtree(lvl, None, "") # each level root is parent-less
|
||||
# Below-inheritance: a node links to the same-id node one level down,
|
||||
# if that path exists there (mirrors Urb::Quad::Below via By_Id).
|
||||
for i in range(1, len(lvls)):
|
||||
below_root = lvls[i - 1]
|
||||
|
||||
def _set(n: Node, below_root: Node = below_root) -> None:
|
||||
b = below_root.by_id(n.id)
|
||||
if b is not None:
|
||||
n.below = b
|
||||
if n.divided:
|
||||
_set(n.left)
|
||||
_set(n.right)
|
||||
|
||||
_set(lvls[i])
|
||||
|
||||
|
||||
def load(path: str) -> Node:
|
||||
"""Load a ``.dom`` file and return the fully-linked lowest level root.
|
||||
|
||||
The plot stored on disk is the *outer* boundary; Urb::Dom insets it by
|
||||
``wall_outer`` on load (and offsets back out on save). We mirror that so
|
||||
leaf areas match ``urb-fitness.pl``, stashing the raw corners in
|
||||
``node_file`` for byte-perfect plot round-tripping.
|
||||
"""
|
||||
from . import geometry # local import avoids a module-load cycle
|
||||
|
||||
with open(path) as fh:
|
||||
root = _parse(yaml.safe_load(fh))
|
||||
_link(root)
|
||||
if root.wall_outer is None:
|
||||
root.wall_outer = 0.25 # Urb::Dom::Wall_Outer default
|
||||
if root.wall_inner is None:
|
||||
root.wall_inner = 0.08 # Urb::Dom::Wall_Inner default
|
||||
if root.node is not None:
|
||||
root.node_file = [list(p) for p in root.node]
|
||||
root.node = geometry.offset_quad(root.node, -root.wall_outer)
|
||||
return root
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Emit (mirrors Urb::Quad::Serialise; field order kept close for readability)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _emit(n: Node, is_level_root: bool) -> dict:
|
||||
d: dict = {}
|
||||
if is_level_root and (n.node_file is not None or n.node is not None):
|
||||
# write the outer plot boundary Urb expects on disk
|
||||
if n.node_file is not None:
|
||||
d["node"] = [list(p) for p in n.node_file]
|
||||
else:
|
||||
from . import geometry
|
||||
|
||||
d["node"] = [list(p) for p in geometry.offset_quad(n.node, n.wall_outer or 0.25)]
|
||||
if is_level_root and n.perimeter is not None:
|
||||
d["perimeter"] = dict(n.perimeter)
|
||||
if n.type and not n.divided:
|
||||
d["type"] = n.type
|
||||
d["rotation"] = n.rotation
|
||||
if n.divided:
|
||||
d["division"] = list(n.division)
|
||||
if is_level_root and n.height is not None:
|
||||
d["height"] = n.height
|
||||
if is_level_root and n.elevation is not None:
|
||||
d["elevation"] = n.elevation
|
||||
for k in ("wall_inner", "wall_outer"):
|
||||
if is_level_root and getattr(n, k) is not None:
|
||||
d[k] = getattr(n, k)
|
||||
if n.divided:
|
||||
d["l"] = _emit(n.left, False)
|
||||
d["r"] = _emit(n.right, False)
|
||||
if n.above is not None:
|
||||
d["above"] = _emit(n.above, True)
|
||||
return d
|
||||
|
||||
|
||||
def dump(root: Node, path: str) -> None:
|
||||
with open(path, "w") as fh:
|
||||
yaml.safe_dump(
|
||||
_emit(root, True), fh, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||
)
|
||||
98
src/homemaker/geometry.py
Normal file
98
src/homemaker/geometry.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Faithful port of Urb's top-down quad geometry (``Urb::Quad``).
|
||||
|
||||
A leaf has *no* intrinsic dimensions: its corners are derived by walking up to
|
||||
the level root, and — for upper storeys — across to the matching quad on the
|
||||
level below (wall-stacking). Every function here mirrors the corresponding Perl
|
||||
method so areas computed in Python match ``urb-fitness.pl`` to floating point.
|
||||
|
||||
Corner ids run anti-clockwise and are offset by the node's ``rotation``. A
|
||||
division is a line between a point on edge (0->1), parameter ``division[0]``, and
|
||||
a point on edge (3->2), parameter ``division[1]`` — independent params allow a
|
||||
skewed (non-perpendicular) cut.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from .dom import Node
|
||||
|
||||
Point = list[float]
|
||||
|
||||
|
||||
def _interp(a: Point, b: Point, t: float) -> Point:
|
||||
return [a[0] * (1 - t) + b[0] * t, a[1] * (1 - t) + b[1] * t]
|
||||
|
||||
|
||||
def coordinate(n: Node, idx: int) -> Point:
|
||||
"""Corner ``idx`` (0..3) of ``n``; mirrors ``Urb::Quad::Coordinate``."""
|
||||
if n.below is not None: # upper storey inherits geometry from below
|
||||
return coordinate(n.below, idx)
|
||||
rid = (idx + n.rotation) % 4
|
||||
if n.parent is None: # level root: stored, rotation-adjusted corner
|
||||
return list(n.node[rid])
|
||||
p = n.parent
|
||||
if n.position == "l":
|
||||
return {0: coordinate(p, 0), 1: coord_a(p), 2: coord_b(p), 3: coordinate(p, 3)}[rid]
|
||||
# position == 'r'
|
||||
return {0: coord_a(p), 1: coordinate(p, 1), 2: coordinate(p, 2), 3: coord_b(p)}[rid]
|
||||
|
||||
|
||||
def coord_a(n: Node) -> Point:
|
||||
"""End 'a' of the division line; mirrors ``Urb::Quad::Coordinate_a``."""
|
||||
if n.below is not None and n.below.divided:
|
||||
return coord_a(n.below)
|
||||
return _interp(coordinate(n, 0), coordinate(n, 1), n.division[0])
|
||||
|
||||
|
||||
def coord_b(n: Node) -> Point:
|
||||
"""End 'b' of the division line; mirrors ``Urb::Quad::Coordinate_b``."""
|
||||
if n.below is not None and n.below.divided:
|
||||
return coord_b(n.below)
|
||||
return _interp(coordinate(n, 3), coordinate(n, 2), n.division[1])
|
||||
|
||||
|
||||
def _dist(a: Point, b: Point) -> float:
|
||||
return math.hypot(a[0] - b[0], a[1] - b[1])
|
||||
|
||||
|
||||
def _triangle_area(a: Point, b: Point, c: Point) -> float:
|
||||
# Heron's formula, matching Urb::Math::triangle_area (always >= 0).
|
||||
da, db, dc = _dist(b, c), _dist(a, c), _dist(a, b)
|
||||
s = (da + db + dc) / 2
|
||||
return math.sqrt(max(0.0, s * (s - da) * (s - db) * (s - dc)))
|
||||
|
||||
|
||||
def area(n: Node) -> float:
|
||||
"""Area of quad ``n``; mirrors ``Urb::Quad::Area`` (two Heron triangles)."""
|
||||
c = [coordinate(n, i) for i in range(4)]
|
||||
return _triangle_area(c[0], c[1], c[2]) + _triangle_area(c[0], c[2], c[3])
|
||||
|
||||
|
||||
def edge_length(n: Node, idx: int) -> float:
|
||||
"""Length of edge from corner ``idx`` to ``idx+1`` (``Urb::Quad::Length``)."""
|
||||
return _dist(coordinate(n, idx), coordinate(n, (idx + 1) % 4))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Plot wall inset (Urb::Quad::Coordinate_Offset, used on the root in Urb::Dom).
|
||||
# Positive offset moves a corner outward, negative inward. Computed per corner
|
||||
# from its two neighbours along the interior-angle bisector; independent of
|
||||
# rotation, so it operates directly on the stored corner order.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _corner_offset(prev: Point, b: Point, c: Point, offset: float) -> Point:
|
||||
side_a = _dist(b, c)
|
||||
side_b = _dist(prev, b)
|
||||
side_c = _dist(c, prev)
|
||||
cos_t = (side_a**2 + side_b**2 - side_c**2) / (2 * side_a * side_b)
|
||||
theta2 = math.acos(max(-1.0, min(1.0, cos_t))) / 2
|
||||
angle_new = math.atan2(c[1] - b[1], c[0] - b[0]) + theta2
|
||||
scale = offset / math.sin(theta2)
|
||||
return [b[0] - math.cos(angle_new) * scale, b[1] - math.sin(angle_new) * scale]
|
||||
|
||||
|
||||
def offset_quad(corners: list[Point], offset: float) -> list[Point]:
|
||||
"""Offset a 4-corner plot by ``offset`` (negative = inward)."""
|
||||
n = len(corners)
|
||||
return [_corner_offset(corners[(k - 1) % n], corners[k], corners[(k + 1) % n], offset)
|
||||
for k in range(n)]
|
||||
Loading…
Add table
Reference in a new issue