Compare commits

..

2 Commits

Author SHA1 Message Date
chrysalis
894c17291d bodies.md: visible-mechanism vocabulary + head/footer cleanup
Adds new §The visible-mechanism vocabulary section between §The
body-caste gradient and §Sumptuary fabrication. Codifies joint-design
and tongue-visibility as paired defining-factor visual axes that
follow the flesh = status spine; principle locked, per-tier specifics
exploration-pending the body-mesh authoring pass.

Reshapes §Open question §"Specific tier-distinguishing visual cues"
to acknowledge the visible-mechanism axis is now named.

Strips the metadata blockquote header and version footer per the new
repo discipline (filename = identity, git = changelog, content =
content). Doc opens directly with §What this is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:46:56 +02:00
chrysalis
ef8bbca9a4 imperial-typewriter v1: compose imperial-tongue words from glyph PNGs
Small Python tool (Pillow) that reads glyph assets from the closed
studio repo and composes Latin words as single PNGs for VLM-decoder
training data generation.

- typewriter.py DAFIT             # single word
- typewriter.py --csv words.csv   # batch from wordlist
- --polarity positive|negative|both (default both)
- glyph-cell-tight composition (no gap), matches in-game inscription layout
- auto-uppercases input — the imperial machine always screams

Sits in the open repo (tools/imperial-typewriter/); reads from the
closed studio repo (default /home/dafit/nimmerverse/studio.nimmerworld.eachpath.local).
Clean cut: assets in studio, code that consumes them in nimmerworld.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:46:38 +02:00
4 changed files with 252 additions and 12 deletions

File diff suppressed because one or more lines are too long

1
tools/imperial-typewriter/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
output/

View File

@@ -0,0 +1,6 @@
# test wordlist for imperial-typewriter — comments and blank lines are skipped
DAFIT
MOIRA
NIMMER
EROS
SOPHROSYNE
1 # test wordlist for imperial-typewriter — comments and blank lines are skipped
2 DAFIT
3 MOIRA
4 NIMMER
5 EROS
6 SOPHROSYNE

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Imperial typewriter — compose imperial-tongue words from glyph PNGs.
Reads glyph assets from the closed studio repo; writes composed word PNGs
to an output directory for VLM-decoder training data generation.
Composition is glyph-cell-tight (no gap between glyphs) — matches the
in-game inscription-typing layout. The imperial machine always screams:
input is auto-uppercased before composition.
"""
import argparse
import csv
import sys
from pathlib import Path
from PIL import Image
DEFAULT_STUDIO_ROOT = "/home/dafit/nimmerverse/studio.nimmerworld.eachpath.local"
DEFAULT_GENERATION = "03"
DEFAULT_RESOLUTION = "512x1024"
VOWELS = set("AEIOUY")
CONSONANTS = set("BCDFGHJKLMNPQRSTVWXZ")
DIGITS = set("0123456789")
CATEGORY_INFO = {
"vowel": ("vowels", "vowel"),
"consonant": ("consonant", "consonant"),
"number": ("numbers", "number"),
}
def category_for(char: str) -> tuple[str, str]:
"""Return (category-dir-name, file-prefix) for a single character."""
if char in VOWELS:
return CATEGORY_INFO["vowel"]
if char in CONSONANTS:
return CATEGORY_INFO["consonant"]
if char in DIGITS:
return CATEGORY_INFO["number"]
raise ValueError(f"No imperial-tongue glyph for character: {char!r}")
def glyph_path(
char: str,
polarity: str,
studio_root: str,
generation: str = DEFAULT_GENERATION,
resolution: str = DEFAULT_RESOLUTION,
) -> Path:
cat_dir, prefix = category_for(char)
suffix = "_neg" if polarity == "negative" else ""
filename = f"{prefix}_{char}_{resolution}{suffix}.png"
return (
Path(studio_root)
/ "imperial-cult"
/ "assets"
/ "imperial-tongue"
/ cat_dir
/ generation
/ f"{prefix}_{char}"
/ filename
)
def compose_word(
word: str,
polarity: str,
studio_root: str,
generation: str = DEFAULT_GENERATION,
resolution: str = DEFAULT_RESOLUTION,
) -> Image.Image:
glyphs = []
for char in word:
path = glyph_path(char, polarity, studio_root, generation, resolution)
if not path.exists():
raise FileNotFoundError(f"Missing glyph asset: {path}")
glyphs.append(Image.open(path).convert("RGBA"))
width = sum(g.width for g in glyphs)
height = max(g.height for g in glyphs)
composed = Image.new("RGBA", (width, height), (0, 0, 0, 0))
x = 0
for g in glyphs:
composed.paste(g, (x, 0), g)
x += g.width
return composed
def words_from_csv(path: Path) -> list[str]:
"""Read words from a CSV file. Takes the first column of each non-empty row.
Skips blank rows and rows whose first cell is empty or starts with '#'."""
words = []
with open(path, newline="") as f:
reader = csv.reader(f)
for row in reader:
if not row:
continue
cell = row[0].strip()
if not cell or cell.startswith("#"):
continue
words.append(cell)
return words
def render_one(
word: str,
output_dir: Path,
polarities: list[str],
studio_root: str,
generation: str,
resolution: str,
) -> None:
word = word.upper()
for pol in polarities:
img = compose_word(word, pol, studio_root, generation, resolution)
suffix = "_neg" if pol == "negative" else ""
out_path = output_dir / f"{word}{suffix}.png"
img.save(out_path)
print(f"wrote {out_path} ({img.width}x{img.height})")
def main() -> None:
parser = argparse.ArgumentParser(
description="Imperial typewriter — compose imperial-tongue words from glyph PNGs.",
)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument(
"word",
nargs="?",
help="A single Latin word to compose. Auto-uppercased (imperial machine always screams).",
)
source.add_argument(
"--csv",
type=Path,
help="Path to a CSV/wordlist file. First column of each non-empty, non-'#'-prefixed row is used as a word.",
)
parser.add_argument(
"--output",
type=Path,
default=Path("./output"),
help="Output directory (created if missing). Default: ./output",
)
parser.add_argument(
"--polarity",
choices=["positive", "negative", "both"],
default="both",
help="Which polarity to render. Default: both",
)
parser.add_argument(
"--studio-root",
default=DEFAULT_STUDIO_ROOT,
help=f"Path to studio repo root. Default: {DEFAULT_STUDIO_ROOT}",
)
parser.add_argument(
"--generation",
default=DEFAULT_GENERATION,
help=f"Resolution generation subdir. Default: {DEFAULT_GENERATION}",
)
parser.add_argument(
"--resolution",
default=DEFAULT_RESOLUTION,
help=f"Glyph resolution. Default: {DEFAULT_RESOLUTION}",
)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
polarities = ["positive", "negative"] if args.polarity == "both" else [args.polarity]
if args.csv:
words = words_from_csv(args.csv)
if not words:
print(f"no words found in {args.csv}", file=sys.stderr)
sys.exit(1)
print(f"composing {len(words)} word(s) from {args.csv}")
for word in words:
try:
render_one(word, args.output, polarities, args.studio_root, args.generation, args.resolution)
except (ValueError, FileNotFoundError) as e:
print(f" skip {word!r}: {e}", file=sys.stderr)
else:
render_one(args.word, args.output, polarities, args.studio_root, args.generation, args.resolution)
if __name__ == "__main__":
main()