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)