File size: 4,889 Bytes
f2b4b4e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | """English keyword -> parametric placement spec for the edit path. No model.
Replaces the small-model selector: the edit path is pure Python, instant, and
deterministic. An instruction is parsed into which catalog part to add, how many,
how big, where, whether to rotate, and what color; assembler.assemble_part turns
that into geometry. English-only by design (see CLAUDE.md / wiki).
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from .parts import PARTS, ANCHORS
from .fallback import _COLOR_WORDS, _COLOR_HEX
# Count words. Digits are matched separately. Articles count as 1.
_NUM = {"a": 1, "an": 1, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10}
# Size adjective -> float scale (cube size constant; the block grows).
_SIZE = {"tiny": 0.5, "teeny": 0.5, "mini": 0.6, "small": 0.6, "little": 0.6,
"big": 1.5, "large": 1.5, "bigger": 1.5,
"huge": 2.0, "giant": 2.0, "massive": 2.0, "enormous": 2.0}
# Position word -> anchor override. Single tokens, matched on word boundaries
# (so "back" never fires inside "backpack").
_ANCHOR = {"behind": "rear", "back": "rear", "rear": "rear",
"front": "front", "forward": "front", "ahead": "front",
"top": "top", "above": "top", "head": "top", "atop": "top",
"under": "bottom", "below": "bottom", "beneath": "bottom", "bottom": "bottom",
"left": "left", "right": "right"}
# Rotation phrases -> (axis, quarter_turns). Checked as substrings, longest first.
_ROT = [(("upside down", "upside-down", "inverted"), ("x", 2)),
(("sideways", "on its side", "tilted"), ("z", 1)),
(("rotated", "turned", "spun"), ("y", 1))]
# Tokens that carry no part identity (articles, sizes, verbs, generic nouns).
_STOP = {"a", "an", "the", "of", "with", "and", "to", "its", "it", "on", "in",
"add", "give", "put", "make", "please", "some", "pair", "set",
"big", "small", "tiny", "huge", "giant", "little", "large"}
def _norm(tok: str) -> str:
"""Crude singular: strip a trailing plural 's' so wings/wing, ears/ear match."""
return tok[:-1] if len(tok) > 3 and tok.endswith("s") else tok
def _toks(s: str) -> list[str]:
return [_norm(w) for w in re.findall(r"[a-z]+", s.lower())]
_PART_IDTOKS = {pid: set(_toks(pid.replace("_", " "))) for pid in PARTS}
_PART_DESCTOKS = {pid: (set(_toks(p["desc"])) - _STOP) for pid, p in PARTS.items()}
def match_part(text: str) -> str | None:
"""Best catalog part for an instruction, or None. Ranks by id-token overlap
(primary), then generic-id tie-break (fewer id tokens wins, so "wing" -> the
generic `wings` not `wing_bat`), then desc-token overlap."""
tt = set(_toks(text)) - _STOP
if not tt:
return None
best = None
best_key = None
for pid in PARTS:
ih = len(_PART_IDTOKS[pid] & tt)
dh = len(_PART_DESCTOKS[pid] & tt)
if ih == 0 and dh == 0:
continue
key = (ih, -len(_PART_IDTOKS[pid]), dh)
if best_key is None or key > best_key:
best_key = key
best = pid
return best
@dataclass(frozen=True)
class PlacementSpec:
part: str
anchor: str
scale: float
count: int
rotation: tuple | None
color: str | None
def parse_edit(instruction: str) -> PlacementSpec | None:
"""Parse an English instruction into a PlacementSpec, or None if no catalog
part is recognised (the caller then falls back to the generative model)."""
t = (instruction or "").lower()
part = match_part(t)
if part is None:
return None
# count: explicit digit wins, else a number word, else 1. Clamp 1..8.
count = 1
m = re.search(r"\b(\d+)\b", t)
if m:
count = int(m.group(1))
else:
for w, n in _NUM.items():
if w in ("a", "an", "one"):
continue # articles don't signal a real multi-count
if re.search(rf"\b{w}\b", t):
count = n
break
count = max(1, min(8, count))
scale = 1.0
for w, s in _SIZE.items():
if re.search(rf"\b{w}\b", t):
scale = s
break
anchor = PARTS[part]["default_anchor"]
for w, a in _ANCHOR.items():
if re.search(rf"\b{w}\b", t):
anchor = a
break
rotation = None
for phrases, rot in _ROT:
if any(p in t for p in phrases):
rotation = rot
break
color = None
for col, words in _COLOR_WORDS.items():
if any(w in t for w in words):
color = _COLOR_HEX[col]
break
if anchor not in ANCHORS:
anchor = PARTS[part]["default_anchor"]
return PlacementSpec(part=part, anchor=anchor, scale=scale,
count=count, rotation=rotation, color=color)
|