ff9mapkit 1.0.0b3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ff9mapkit/__init__.py +18 -0
- ff9mapkit/__main__.py +36 -0
- ff9mapkit/_animdb.py +2994 -0
- ff9mapkit/_animdb_all.py +14125 -0
- ff9mapkit/_fieldtable.py +1516 -0
- ff9mapkit/_fieldtext.py +845 -0
- ff9mapkit/_held_poses.py +44 -0
- ff9mapkit/_itemdb.py +65 -0
- ff9mapkit/_modeldb.py +725 -0
- ff9mapkit/_narrowmap_data.py +10 -0
- ff9mapkit/_npcparams.py +634 -0
- ff9mapkit/_regen_animdb.py +72 -0
- ff9mapkit/_regen_animdb_all.py +66 -0
- ff9mapkit/_regen_fieldtable.py +95 -0
- ff9mapkit/_regen_fieldtext.py +66 -0
- ff9mapkit/_regen_modeldb.py +67 -0
- ff9mapkit/_regen_npcparams.py +123 -0
- ff9mapkit/_regen_scenedb.py +57 -0
- ff9mapkit/_scenedb.py +869 -0
- ff9mapkit/abilities.py +225 -0
- ff9mapkit/animations.py +120 -0
- ff9mapkit/archetypes.py +218 -0
- ff9mapkit/areatitle.py +76 -0
- ff9mapkit/battle/__init__.py +21 -0
- ff9mapkit/battle/abilityfeatures.py +294 -0
- ff9mapkit/battle/actiondelta.py +441 -0
- ff9mapkit/battle/aiauthor.py +305 -0
- ff9mapkit/battle/ailint.py +140 -0
- ff9mapkit/battle/aipatch.py +175 -0
- ff9mapkit/battle/battleai.py +148 -0
- ff9mapkit/battle/battlecsv.py +390 -0
- ff9mapkit/battle/battlepatch.py +395 -0
- ff9mapkit/battle/build.py +558 -0
- ff9mapkit/battle/camera_codec.py +332 -0
- ff9mapkit/battle/camera_data.py +128 -0
- ff9mapkit/battle/characterdelta.py +789 -0
- ff9mapkit/battle/event_data.py +72 -0
- ff9mapkit/battle/extract.py +540 -0
- ff9mapkit/battle/fbx.py +223 -0
- ff9mapkit/battle/reskin.py +149 -0
- ff9mapkit/battle/scene_codec.py +314 -0
- ff9mapkit/battle/scene_data.py +369 -0
- ff9mapkit/battle/scenelint.py +125 -0
- ff9mapkit/battle/seqasm.py +131 -0
- ff9mapkit/battle/seqauthor.py +220 -0
- ff9mapkit/battle/seqcodec.py +300 -0
- ff9mapkit/battle/seqdis.py +106 -0
- ff9mapkit/battle/seqpatch.py +137 -0
- ff9mapkit/battle_bgm.py +133 -0
- ff9mapkit/binutils.py +60 -0
- ff9mapkit/build.py +5445 -0
- ff9mapkit/campaign.py +1276 -0
- ff9mapkit/catalog.py +316 -0
- ff9mapkit/chain.py +358 -0
- ff9mapkit/cli.py +3114 -0
- ff9mapkit/config.py +360 -0
- ff9mapkit/content/__init__.py +13 -0
- ff9mapkit/content/areatitle.py +36 -0
- ff9mapkit/content/ate.py +118 -0
- ff9mapkit/content/camera.py +123 -0
- ff9mapkit/content/chest.py +186 -0
- ff9mapkit/content/choice.py +163 -0
- ff9mapkit/content/conductor.py +217 -0
- ff9mapkit/content/cutscene.py +290 -0
- ff9mapkit/content/encounter.py +41 -0
- ff9mapkit/content/entry_settle.py +50 -0
- ff9mapkit/content/equipment.py +93 -0
- ff9mapkit/content/event.py +191 -0
- ff9mapkit/content/gateway.py +101 -0
- ff9mapkit/content/inventory.py +59 -0
- ff9mapkit/content/itemdata.py +644 -0
- ff9mapkit/content/itemtext.py +168 -0
- ff9mapkit/content/jump.py +114 -0
- ff9mapkit/content/ladder.py +633 -0
- ff9mapkit/content/movement.py +53 -0
- ff9mapkit/content/music.py +97 -0
- ff9mapkit/content/npc.py +348 -0
- ff9mapkit/content/object.py +340 -0
- ff9mapkit/content/onentry.py +135 -0
- ff9mapkit/content/party.py +111 -0
- ff9mapkit/content/pathfind.py +138 -0
- ff9mapkit/content/platform.py +314 -0
- ff9mapkit/content/player.py +168 -0
- ff9mapkit/content/prop.py +75 -0
- ff9mapkit/content/region.py +340 -0
- ff9mapkit/content/reinit.py +59 -0
- ff9mapkit/content/savepoint.py +90 -0
- ff9mapkit/content/shop.py +178 -0
- ff9mapkit/content/sps_trigger.py +66 -0
- ff9mapkit/content/startup.py +71 -0
- ff9mapkit/content/synthesis.py +106 -0
- ff9mapkit/content/text.py +183 -0
- ff9mapkit/content/textcarry.py +290 -0
- ff9mapkit/content/verbatim.py +86 -0
- ff9mapkit/content/walkmesh_hotfix.py +38 -0
- ff9mapkit/data/__init__.py +48 -0
- ff9mapkit/data/_regen_provenance.py +142 -0
- ff9mapkit/data/provenance/blank.es.patch +1 -0
- ff9mapkit/data/provenance/blank.fr.patch +1 -0
- ff9mapkit/data/provenance/blank.gr.patch +1 -0
- ff9mapkit/data/provenance/blank.it.patch +1 -0
- ff9mapkit/data/provenance/blank.jp.patch +1 -0
- ff9mapkit/data/provenance/blank.uk.patch +1 -0
- ff9mapkit/data/provenance/blank.us.patch +1 -0
- ff9mapkit/data/provenance/manifest.json +65 -0
- ff9mapkit/data/provenance/region_template.patch +1 -0
- ff9mapkit/data/reference_arcs.toml +89 -0
- ff9mapkit/data/region_catalog.toml +593 -0
- ff9mapkit/deploystack.py +358 -0
- ff9mapkit/dialogue.py +803 -0
- ff9mapkit/eb/__init__.py +12 -0
- ff9mapkit/eb/_exprtable.py +59 -0
- ff9mapkit/eb/_membertable.py +38 -0
- ff9mapkit/eb/_optables.py +537 -0
- ff9mapkit/eb/_regen_optables.py +76 -0
- ff9mapkit/eb/cmdasm.py +323 -0
- ff9mapkit/eb/disasm.py +332 -0
- ff9mapkit/eb/edit.py +439 -0
- ff9mapkit/eb/exprasm.py +158 -0
- ff9mapkit/eb/model.py +178 -0
- ff9mapkit/eb/opcodes.py +463 -0
- ff9mapkit/eblint.py +177 -0
- ff9mapkit/editor/__init__.py +20 -0
- ff9mapkit/editor/app.py +950 -0
- ff9mapkit/editor/battle_forms.py +240 -0
- ff9mapkit/editor/breadcrumb.py +89 -0
- ff9mapkit/editor/dialogs.py +116 -0
- ff9mapkit/editor/feedback.py +208 -0
- ff9mapkit/editor/forms.py +632 -0
- ff9mapkit/editor/graphview.py +350 -0
- ff9mapkit/editor/jobs.py +342 -0
- ff9mapkit/editor/model.py +243 -0
- ff9mapkit/editor/picker.py +120 -0
- ff9mapkit/editor/theme.py +212 -0
- ff9mapkit/eventscan.py +1441 -0
- ff9mapkit/extract.py +2279 -0
- ff9mapkit/flags.py +693 -0
- ff9mapkit/forkreport.py +1383 -0
- ff9mapkit/hub.py +477 -0
- ff9mapkit/idgated.py +101 -0
- ff9mapkit/infohub.py +580 -0
- ff9mapkit/items.py +63 -0
- ff9mapkit/itemstats.py +346 -0
- ff9mapkit/journey.py +1902 -0
- ff9mapkit/keyitems.py +93 -0
- ff9mapkit/logic_add.py +632 -0
- ff9mapkit/logic_edit.py +728 -0
- ff9mapkit/logic_map.py +526 -0
- ff9mapkit/pack.py +175 -0
- ff9mapkit/playerswap.py +231 -0
- ff9mapkit/prop_archetypes.py +228 -0
- ff9mapkit/provision.py +282 -0
- ff9mapkit/refarc.py +825 -0
- ff9mapkit/save.py +337 -0
- ff9mapkit/save_items.py +1673 -0
- ff9mapkit/scene/__init__.py +11 -0
- ff9mapkit/scene/arena.py +63 -0
- ff9mapkit/scene/bgart.py +140 -0
- ff9mapkit/scene/bgi.py +732 -0
- ff9mapkit/scene/bgs.py +174 -0
- ff9mapkit/scene/bgx.py +185 -0
- ff9mapkit/scene/cam.py +345 -0
- ff9mapkit/scene/guide.py +311 -0
- ff9mapkit/scene/paint.py +506 -0
- ff9mapkit/scene/placeholder.py +107 -0
- ff9mapkit/sjbinary.py +285 -0
- ff9mapkit/sps/__init__.py +17 -0
- ff9mapkit/sps/author.py +294 -0
- ff9mapkit/sps/catalog.py +88 -0
- ff9mapkit/sps/codec.py +264 -0
- ff9mapkit/sps/edit.py +184 -0
- ff9mapkit/sps/lint.py +58 -0
- ff9mapkit/sps/render.py +116 -0
- ff9mapkit/sps/templates.py +47 -0
- ff9mapkit/sps/texture.py +131 -0
- ff9mapkit/walkmesh_hotfixes.py +163 -0
- ff9mapkit/workspace/__init__.py +18 -0
- ff9mapkit/workspace/battledoc.py +985 -0
- ff9mapkit/workspace/builddoc.py +607 -0
- ff9mapkit/workspace/forms_qt.py +586 -0
- ff9mapkit/workspace/importdoc.py +665 -0
- ff9mapkit/workspace/mapview.py +131 -0
- ff9mapkit/workspace/palette.py +85 -0
- ff9mapkit/workspace/savedoc.py +664 -0
- ff9mapkit/workspace/shell.py +6907 -0
- ff9mapkit/workspace/style.py +105 -0
- ff9mapkit/workspace/tuningdialog.py +223 -0
- ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
- ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
- ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
- ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
- ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
- ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
ff9mapkit/scene/paint.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
"""Project a field's CONTENT onto the painted canvas for the paint template (Phase B).
|
|
2
|
+
|
|
3
|
+
The floor template (``guide.py``) shows where the FLOOR lands; this adds where each piece of CONTENT
|
|
4
|
+
lands -- NPCs, props, gateways, events, save points, ladders, jumps, dialogue-choice zones, cutscene
|
|
5
|
+
waypoints, camera zones, the player spawn -- as per-type marker geometry (a footprint + a height pole
|
|
6
|
+
for point content, a quad outline for zone content) plus a numbered legend pin. So the artist can see
|
|
7
|
+
exactly where each thing sits and how tall to paint around it.
|
|
8
|
+
|
|
9
|
+
Driven by a parsed ``field.toml`` (+ optional sibling ``scene.toml``), so it covers EVERY content type
|
|
10
|
+
regardless of what the Blender add-on can place spatially. bpy-free + stdlib-only (projects through
|
|
11
|
+
:mod:`ff9mapkit.scene.cam`); the rasterizer / CLI / Blender front-ends consume the geometry + legend
|
|
12
|
+
this returns. There is NO model-height metadata in the game data, so heights are an authored table
|
|
13
|
+
(:data:`HEIGHT_BY_NAME` / :data:`HEIGHT_BY_TYPE`, calibrated by screenshot inversion) that any block
|
|
14
|
+
overrides with ``height = N``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from . import cam as _cam
|
|
20
|
+
|
|
21
|
+
# --- height table (world units) -----------------------------------------------------------
|
|
22
|
+
# Calibrated 2026-06-16 by screenshot inversion in a known-camera scene: place a model on a known
|
|
23
|
+
# floor anchor, read its on-screen top, invert through the exact camera. ADVISORY (manual reads,
|
|
24
|
+
# ~±15%); every value is overridable per-block via ``height = N``. The human anchor (~560) is the
|
|
25
|
+
# reliable one -- two independent models agreed (Zidane 567 / townsman 558) and it matches the kit's
|
|
26
|
+
# earlier ~550 estimate. Heights are to the model's HIGHEST point (e.g. a moogle's pom, a tent's
|
|
27
|
+
# finial), which is what a paint pole should reserve.
|
|
28
|
+
HUMAN_HEIGHT = 560
|
|
29
|
+
|
|
30
|
+
HEIGHT_BY_NAME = { # archetype / preset / prop name -> height (the most specific lookup)
|
|
31
|
+
"moogle": 440, "mog": 440,
|
|
32
|
+
"save_point": 440, "savepoint": 440,
|
|
33
|
+
"chest": 300,
|
|
34
|
+
"barrel": 400, "cask": 400,
|
|
35
|
+
"tent": 680,
|
|
36
|
+
"scroll": 150, "map": 150,
|
|
37
|
+
"sign": 400,
|
|
38
|
+
"feather": 200,
|
|
39
|
+
"ladder": 700,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
HEIGHT_BY_TYPE = { # marker type -> fallback height (0 = a flat marker, no vertical pole)
|
|
43
|
+
"npc": HUMAN_HEIGHT, "spawn": HUMAN_HEIGHT,
|
|
44
|
+
"prop": 300, "savepoint": 440,
|
|
45
|
+
"gateway": 0, "event": 0, "camzone": 0, "choice": 0, "waypoint": 0,
|
|
46
|
+
"ladder": 0, "jump": 0,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Footprint glyph per point type (the rasterizer interprets these); zone types draw an outline.
|
|
50
|
+
FOOTPRINT_SHAPE = {"npc": "circle", "prop": "square", "spawn": "star",
|
|
51
|
+
"waypoint": "cross", "savepoint": "circle"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_height(item: dict) -> int:
|
|
55
|
+
"""World-unit height for a content item: explicit ``height`` > name (archetype/prop) > type."""
|
|
56
|
+
if item.get("height") is not None:
|
|
57
|
+
return int(item["height"])
|
|
58
|
+
name = item.get("subtype")
|
|
59
|
+
if name and str(name) in HEIGHT_BY_NAME:
|
|
60
|
+
return HEIGHT_BY_NAME[str(name)]
|
|
61
|
+
return HEIGHT_BY_TYPE.get(item["type"], 0)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --- normalize a parsed field.toml (+ scene.toml) into a flat content list ----------------
|
|
65
|
+
def _xz(p):
|
|
66
|
+
"""A [x, z] (or [x, z, y]) point -> (int x, int z), dropping any height component."""
|
|
67
|
+
return (int(round(p[0])), int(round(p[1])))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _zone(z):
|
|
71
|
+
"""A list of [x, z] corners -> [(x, z), ...] ints (drops height); [] if not a point list."""
|
|
72
|
+
if not isinstance(z, (list, tuple)):
|
|
73
|
+
return []
|
|
74
|
+
return [_xz(p) for p in z if isinstance(p, (list, tuple)) and len(p) >= 2]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _merge_by_name(field_list, scene_list, key):
|
|
78
|
+
"""Yield (entry, spatial) pairs: each field entry joined to its scene.toml twin by ``name`` for the
|
|
79
|
+
spatial ``key`` ('pos' or 'zone'). Scene-only entries (named, not in the field) are included too."""
|
|
80
|
+
scene_list = scene_list or []
|
|
81
|
+
by_name = {e.get("name"): e for e in scene_list if e.get("name")}
|
|
82
|
+
seen = set()
|
|
83
|
+
for e in field_list or []:
|
|
84
|
+
sc = by_name.get(e.get("name"))
|
|
85
|
+
spatial = e.get(key)
|
|
86
|
+
if spatial is None and sc is not None:
|
|
87
|
+
spatial = sc.get(key)
|
|
88
|
+
seen.add(e.get("name"))
|
|
89
|
+
yield e, spatial
|
|
90
|
+
for e in scene_list:
|
|
91
|
+
if e.get("name") not in seen:
|
|
92
|
+
yield e, e.get(key)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def normalize_content(field_cfg: dict, scene_cfg: dict | None = None) -> list:
|
|
96
|
+
"""Flatten a parsed ``field.toml`` (+ optional ``scene.toml`` for the two-file split) into content
|
|
97
|
+
items the projector understands. Each item:
|
|
98
|
+
|
|
99
|
+
{type, footprint: "point"|"zone", pos|zone, height: int|None, subtype: str|None, label: str}
|
|
100
|
+
|
|
101
|
+
``subtype`` is the archetype/preset/prop name used for the height lookup; ``label`` is the display
|
|
102
|
+
name for the legend. Positions are merged from the scene file by ``name`` (Godot-style split) when
|
|
103
|
+
the field entry omits them. Order is stable so pin numbers are deterministic.
|
|
104
|
+
"""
|
|
105
|
+
scene_cfg = scene_cfg or {}
|
|
106
|
+
items: list = []
|
|
107
|
+
|
|
108
|
+
for n, pos in _merge_by_name(field_cfg.get("npc", []), scene_cfg.get("npc", []), "pos"):
|
|
109
|
+
if pos is None:
|
|
110
|
+
continue
|
|
111
|
+
sub = n.get("preset") or n.get("archetype") or n.get("model")
|
|
112
|
+
items.append({"type": "npc", "footprint": "point", "pos": _xz(pos),
|
|
113
|
+
"height": n.get("height"), "subtype": sub,
|
|
114
|
+
"label": n.get("name") or (str(sub) if sub else "npc")})
|
|
115
|
+
|
|
116
|
+
for pr, pos in _merge_by_name(field_cfg.get("prop", []), scene_cfg.get("prop", []), "pos"):
|
|
117
|
+
if pos is None:
|
|
118
|
+
continue
|
|
119
|
+
sub = pr.get("prop") or pr.get("model")
|
|
120
|
+
items.append({"type": "prop", "footprint": "point", "pos": _xz(pos),
|
|
121
|
+
"height": pr.get("height"), "subtype": sub,
|
|
122
|
+
"label": pr.get("name") or (str(sub) if sub else "prop")})
|
|
123
|
+
|
|
124
|
+
for m, pos in _merge_by_name(field_cfg.get("marker", []), scene_cfg.get("marker", []), "pos"):
|
|
125
|
+
if pos is None:
|
|
126
|
+
continue
|
|
127
|
+
items.append({"type": "waypoint", "footprint": "point", "pos": _xz(pos),
|
|
128
|
+
"height": m.get("height"), "subtype": None, "label": m.get("name") or "waypoint"})
|
|
129
|
+
|
|
130
|
+
player = field_cfg.get("player") or scene_cfg.get("player")
|
|
131
|
+
if isinstance(player, dict) and player.get("spawn") is not None:
|
|
132
|
+
items.append({"type": "spawn", "footprint": "point", "pos": _xz(player["spawn"]),
|
|
133
|
+
"height": player.get("height"), "subtype": None, "label": "spawn"})
|
|
134
|
+
|
|
135
|
+
for gw, zone in _merge_by_name(field_cfg.get("gateway", []), scene_cfg.get("gateway", []), "zone"):
|
|
136
|
+
z = _zone(zone)
|
|
137
|
+
if z:
|
|
138
|
+
to = gw.get("to")
|
|
139
|
+
items.append({"type": "gateway", "footprint": "zone", "zone": z, "height": None,
|
|
140
|
+
"subtype": None, "label": gw.get("name") or (f"-> {to}" if to is not None else "gateway")})
|
|
141
|
+
|
|
142
|
+
for ev, zone in _merge_by_name(field_cfg.get("event", []), scene_cfg.get("event", []), "zone"):
|
|
143
|
+
z = _zone(zone)
|
|
144
|
+
if z:
|
|
145
|
+
items.append({"type": "event", "footprint": "zone", "zone": z, "height": None,
|
|
146
|
+
"subtype": None, "label": ev.get("name") or "event"})
|
|
147
|
+
|
|
148
|
+
for cz in field_cfg.get("camera_zone", []):
|
|
149
|
+
z = _zone(cz.get("zone"))
|
|
150
|
+
if z:
|
|
151
|
+
items.append({"type": "camzone", "footprint": "zone", "zone": z, "height": None,
|
|
152
|
+
"subtype": None, "label": f"cam {cz.get('to_camera', '?')}"})
|
|
153
|
+
|
|
154
|
+
for ch, zone in _merge_by_name(field_cfg.get("choice", []), scene_cfg.get("choice", []), "zone"):
|
|
155
|
+
z = _zone(zone)
|
|
156
|
+
if z:
|
|
157
|
+
items.append({"type": "choice", "footprint": "zone", "zone": z, "height": None,
|
|
158
|
+
"subtype": None, "label": ch.get("name") or (f"choice @ {ch.get('npc')}" if ch.get("npc") else "choice")})
|
|
159
|
+
|
|
160
|
+
for sp, zone in _merge_by_name(field_cfg.get("savepoint", []), scene_cfg.get("savepoint", []), "zone"):
|
|
161
|
+
z = _zone(zone)
|
|
162
|
+
if z:
|
|
163
|
+
items.append({"type": "savepoint", "footprint": "zone", "zone": z,
|
|
164
|
+
"height": sp.get("height"), "subtype": "save_point", "label": sp.get("name") or "save point"})
|
|
165
|
+
|
|
166
|
+
for la in field_cfg.get("ladder", []):
|
|
167
|
+
z = _zone(la.get("zone"))
|
|
168
|
+
climb = _xz(la["bottom"]) if isinstance(la.get("bottom"), (list, tuple)) else None
|
|
169
|
+
climb_top = _xz(la["top"]) if isinstance(la.get("top"), (list, tuple)) else None
|
|
170
|
+
if not z and climb is None:
|
|
171
|
+
continue
|
|
172
|
+
items.append({"type": "ladder", "footprint": "zone", "zone": z, "height": None,
|
|
173
|
+
"subtype": "ladder", "label": la.get("name") or "ladder",
|
|
174
|
+
"climb": climb, "climb_top": climb_top})
|
|
175
|
+
|
|
176
|
+
for jp in field_cfg.get("jump", []):
|
|
177
|
+
z = _zone(jp.get("zone"))
|
|
178
|
+
if z:
|
|
179
|
+
items.append({"type": "jump", "footprint": "zone", "zone": z, "height": None,
|
|
180
|
+
"subtype": None, "label": jp.get("name") or "jump"})
|
|
181
|
+
|
|
182
|
+
return items
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def markers_to_field_cfg(npcs=(), gateways=(), events=(), camzones=(), waypoints=(), spawn=None) -> dict:
|
|
186
|
+
"""Assemble the Blender add-on's collected marker dicts into a ``field_cfg`` that
|
|
187
|
+
:func:`normalize_content` understands -- so the live modeling loop projects content the same way
|
|
188
|
+
the field.toml-driven CLI does. The add-on can only place these 6 kinds; props/ladders/jumps/save
|
|
189
|
+
points live only in the field.toml (and project via the CLI ``paint-template`` command)."""
|
|
190
|
+
cfg = {"npc": list(npcs), "gateway": list(gateways), "event": list(events),
|
|
191
|
+
"camera_zone": list(camzones), "marker": list(waypoints)}
|
|
192
|
+
if spawn is not None:
|
|
193
|
+
cfg["player"] = {"spawn": [int(spawn[0]), int(spawn[1])]}
|
|
194
|
+
return cfg
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --- project the content list onto the canvas (px) ----------------------------------------
|
|
198
|
+
def _canvas_wh(cam: _cam.Cam, scale: int) -> tuple:
|
|
199
|
+
w = int(cam.range[0]) if cam.range and cam.range[0] else 384
|
|
200
|
+
h = int(cam.range[1]) if cam.range and cam.range[1] else 448
|
|
201
|
+
return (w * scale, h * scale)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _centroid(pts):
|
|
205
|
+
return (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def project_content(items: list, cam: _cam.Cam, scale: int = 4, *, footprint_px: int = 7) -> dict:
|
|
209
|
+
"""Project a normalized content list onto the canvas (top-left px, scaled). Returns::
|
|
210
|
+
|
|
211
|
+
{"size": (W, H),
|
|
212
|
+
"types": {type: {"footprints": [{shape, c:(cx,cy), r}],
|
|
213
|
+
"poles": [((cx0,cy0),(cx1,cy1))],
|
|
214
|
+
"zones": [[(cx,cy), ...]], # closed outline (last connects to first)
|
|
215
|
+
"pins": [{n, c:(cx,cy)}]}},
|
|
216
|
+
"legend": [{pin, type, label, subtype, height, canvas:[cx,cy], off_canvas}]}
|
|
217
|
+
|
|
218
|
+
Point content gets a footprint glyph + (if its resolved height > 0) a vertical pole projected from
|
|
219
|
+
``y=0`` to ``y=height`` -- foreshortened correctly at any pitch/yaw, the same machinery as the
|
|
220
|
+
floor wall guides. Zone content gets a closed outline. Pins are numbered in list order. A pin whose
|
|
221
|
+
anchor falls outside the canvas is flagged ``off_canvas`` (real for tunnels / off-screen content).
|
|
222
|
+
"""
|
|
223
|
+
W, H = _canvas_wh(cam, scale)
|
|
224
|
+
S = scale
|
|
225
|
+
|
|
226
|
+
def px(x, y, z):
|
|
227
|
+
cx, cy = _cam.to_canvas((x, y, z), cam)
|
|
228
|
+
return (cx * S, cy * S)
|
|
229
|
+
|
|
230
|
+
types: dict = {}
|
|
231
|
+
legend: list = []
|
|
232
|
+
|
|
233
|
+
def bucket(t):
|
|
234
|
+
return types.setdefault(t, {"footprints": [], "poles": [], "zones": [], "pins": []})
|
|
235
|
+
|
|
236
|
+
for n, item in enumerate(items, start=1):
|
|
237
|
+
t = item["type"]
|
|
238
|
+
b = bucket(t)
|
|
239
|
+
h = resolve_height(item)
|
|
240
|
+
if item["footprint"] == "point":
|
|
241
|
+
x, z = item["pos"]
|
|
242
|
+
feet = px(x, 0, z)
|
|
243
|
+
b["footprints"].append({"shape": FOOTPRINT_SHAPE.get(t, "circle"), "c": feet,
|
|
244
|
+
"r": footprint_px})
|
|
245
|
+
if h > 0:
|
|
246
|
+
b["poles"].append((feet, px(x, h, z)))
|
|
247
|
+
anchor = feet
|
|
248
|
+
else: # zone
|
|
249
|
+
zone = item["zone"]
|
|
250
|
+
ring = [px(x, 0, z) for (x, z) in zone]
|
|
251
|
+
if ring:
|
|
252
|
+
b["zones"].append(ring)
|
|
253
|
+
if zone:
|
|
254
|
+
cx0, cz0 = _centroid(zone)
|
|
255
|
+
anchor = px(cx0, 0, cz0)
|
|
256
|
+
if t == "savepoint" and h > 0: # the moogle stands in the save zone
|
|
257
|
+
b["poles"].append((anchor, px(cx0, h, cz0)))
|
|
258
|
+
elif item.get("climb"): # a ladder with no trigger zone, just a climb
|
|
259
|
+
anchor = px(item["climb"][0], 0, item["climb"][1])
|
|
260
|
+
else:
|
|
261
|
+
anchor = px(0, 0, 0)
|
|
262
|
+
if t == "ladder" and item.get("climb") and item.get("climb_top"):
|
|
263
|
+
bx, bz = item["climb"]
|
|
264
|
+
tx, tz = item["climb_top"]
|
|
265
|
+
b["poles"].append((px(bx, 0, bz), px(tx, 0, tz)))
|
|
266
|
+
b["pins"].append({"n": n, "c": anchor})
|
|
267
|
+
off = not (0 <= anchor[0] < W and 0 <= anchor[1] < H)
|
|
268
|
+
legend.append({"pin": n, "type": t, "label": item.get("label", t),
|
|
269
|
+
"subtype": item.get("subtype"), "height": h,
|
|
270
|
+
"canvas": [round(anchor[0], 1), round(anchor[1], 1)], "off_canvas": off})
|
|
271
|
+
|
|
272
|
+
return {"size": (W, H), "types": types, "legend": legend}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --- rasterize the projected content to per-type PNGs (+ legend + manifest), pure stdlib --------
|
|
276
|
+
# Distinct color per content type (RGBA 0-255); zone colors match the Blender viewport where it has one
|
|
277
|
+
# (event amber, camera-zone blue) so the template reads the same as the add-on.
|
|
278
|
+
TYPE_COLOR = {
|
|
279
|
+
"npc": (90, 210, 255, 255), "prop": (120, 220, 130, 255), "spawn": (90, 255, 120, 255),
|
|
280
|
+
"waypoint": (160, 200, 255, 255), "gateway": (255, 100, 220, 255), "event": (255, 215, 40, 255),
|
|
281
|
+
"camzone": (90, 160, 255, 255), "choice": (70, 220, 200, 255), "savepoint": (255, 230, 70, 255),
|
|
282
|
+
"ladder": (255, 150, 40, 255), "jump": (255, 90, 40, 255),
|
|
283
|
+
}
|
|
284
|
+
TYPE_DESC = {
|
|
285
|
+
"npc": "NPCs (footprint + height pole)", "prop": "Props (footprint + height pole)",
|
|
286
|
+
"spawn": "Player spawn", "waypoint": "Cutscene waypoints", "gateway": "Gateway exit zones",
|
|
287
|
+
"event": "Event trigger zones", "camzone": "Camera-switch zones", "choice": "Dialogue-choice zones",
|
|
288
|
+
"savepoint": "Save points (zone + moogle pole)", "ladder": "Ladders (zone + climb)",
|
|
289
|
+
"jump": "Jump take-off zones",
|
|
290
|
+
}
|
|
291
|
+
# draw order: flat zones underneath, point content on top, the player spawn topmost
|
|
292
|
+
CONTENT_ORDER = ["gateway", "event", "camzone", "choice", "jump", "ladder", "savepoint",
|
|
293
|
+
"prop", "waypoint", "npc", "spawn"]
|
|
294
|
+
# unit-circle offsets for a small octagon footprint (no math import needed)
|
|
295
|
+
_OCT = [(1.0, 0.0), (0.707, 0.707), (0.0, 1.0), (-0.707, 0.707), (-1.0, 0.0),
|
|
296
|
+
(-0.707, -0.707), (0.0, -1.0), (0.707, -0.707)]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _draw_footprint(buf, W, H, shape, c, r, color):
|
|
300
|
+
"""Draw a small OUTLINE footprint glyph (so the artist still sees the art under it)."""
|
|
301
|
+
from . import placeholder as _ph
|
|
302
|
+
cx, cy = c
|
|
303
|
+
if shape == "square":
|
|
304
|
+
p = [(cx - r, cy - r), (cx + r, cy - r), (cx + r, cy + r), (cx - r, cy + r)]
|
|
305
|
+
for i in range(4):
|
|
306
|
+
_ph.draw_line(buf, W, H, p[i], p[(i + 1) % 4], color, 2)
|
|
307
|
+
elif shape in ("cross", "star"):
|
|
308
|
+
_ph.draw_line(buf, W, H, (cx - r, cy), (cx + r, cy), color, 2)
|
|
309
|
+
_ph.draw_line(buf, W, H, (cx, cy - r), (cx, cy + r), color, 2)
|
|
310
|
+
if shape == "star": # spawn: add the diagonals
|
|
311
|
+
_ph.draw_line(buf, W, H, (cx - r, cy - r), (cx + r, cy + r), color, 2)
|
|
312
|
+
_ph.draw_line(buf, W, H, (cx - r, cy + r), (cx + r, cy - r), color, 2)
|
|
313
|
+
else: # circle (octagon outline)
|
|
314
|
+
ring = [(cx + r * ox, cy + r * oy) for ox, oy in _OCT]
|
|
315
|
+
for i in range(len(ring)):
|
|
316
|
+
_ph.draw_line(buf, W, H, ring[i], ring[(i + 1) % len(ring)], color, 2)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _rasterize_type(d: dict, color, W: int, H: int) -> bytearray:
|
|
320
|
+
"""One transparent buffer with a content type's geometry: zone outlines, height poles, footprints,
|
|
321
|
+
and a small locator cross at each zone pin (point content already has a footprint there)."""
|
|
322
|
+
from . import placeholder as _ph
|
|
323
|
+
buf = bytearray(W * H * 4)
|
|
324
|
+
for ring in d["zones"]:
|
|
325
|
+
for i in range(len(ring)):
|
|
326
|
+
_ph.draw_line(buf, W, H, ring[i], ring[(i + 1) % len(ring)], color, 2)
|
|
327
|
+
for p0, p1 in d["poles"]:
|
|
328
|
+
_ph.draw_line(buf, W, H, p0, p1, color, 2)
|
|
329
|
+
for f in d["footprints"]:
|
|
330
|
+
_draw_footprint(buf, W, H, f["shape"], f["c"], f["r"], color)
|
|
331
|
+
if not d["footprints"]: # zone type: mark its pin (centroid)
|
|
332
|
+
for pin in d["pins"]:
|
|
333
|
+
_draw_footprint(buf, W, H, "cross", pin["c"], 4, color)
|
|
334
|
+
return buf
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
WALKMESH_RGBA = (235, 235, 245, 255) # the REAL walkable-floor boundary (neutral, distinct from content)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def walkmesh_outline_segments(ff9_verts, tris, cam: _cam.Cam, scale: int) -> list:
|
|
341
|
+
"""Boundary edges of a walkmesh (its real floor OUTLINE) projected to canvas px. ``ff9_verts`` are
|
|
342
|
+
world (x, y, z) points in the FF9 frame; ``tris`` are (a, b, c) vertex-index triples. The boundary =
|
|
343
|
+
edges belonging to exactly one triangle (the perimeter + any holes / disjoint-floor outlines), so an
|
|
344
|
+
arbitrary forked or hand-modeled walkmesh draws as its true shape, not a synthesized rectangle. The
|
|
345
|
+
interior triangulation (shared edges) is dropped. bpy-free."""
|
|
346
|
+
count: dict = {}
|
|
347
|
+
for tri in tris:
|
|
348
|
+
if len(tri) < 3:
|
|
349
|
+
continue
|
|
350
|
+
a, b, cc = int(tri[0]), int(tri[1]), int(tri[2])
|
|
351
|
+
for u, v in ((a, b), (b, cc), (cc, a)):
|
|
352
|
+
if u == v:
|
|
353
|
+
continue # doubled vertex (degenerate fan edge)
|
|
354
|
+
key = (v, u) if u > v else (u, v)
|
|
355
|
+
count[key] = count.get(key, 0) + 1
|
|
356
|
+
|
|
357
|
+
def px(i):
|
|
358
|
+
# Project in the engine's RENDER frame: the engine negates the walkmesh Y before the GTE
|
|
359
|
+
# (Memoria WalkMesh.cs), so flip Y here too (matches extract.compose_background). Without it a
|
|
360
|
+
# DEEP floor -- a vertical shaft / multi-level field where world Y spans thousands of units
|
|
361
|
+
# (e.g. the PDMN elevator) -- drifts symmetrically off the painting; a flat floor (Y~0) is
|
|
362
|
+
# unaffected, which is why only large scrollers showed it.
|
|
363
|
+
x, y, z = ff9_verts[i][0], ff9_verts[i][1], ff9_verts[i][2]
|
|
364
|
+
cx, cy = _cam.to_canvas((x, -y, z), cam)
|
|
365
|
+
return (cx * scale, cy * scale)
|
|
366
|
+
|
|
367
|
+
n = len(ff9_verts)
|
|
368
|
+
return [(px(a), px(b)) for (a, b), c in count.items()
|
|
369
|
+
if c == 1 and 0 <= a < n and 0 <= b < n]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
_PS_JSX_TEMPLATE = '''\
|
|
373
|
+
// FF9 Map Kit -- paint-template layer importer for Adobe Photoshop (auto-generated).
|
|
374
|
+
// In Photoshop: File > Scripts > Browse... and pick this file. It builds ONE layered document from
|
|
375
|
+
// the PNGs beside it -- correct bottom-to-top order, each layer's opacity + name, already aligned --
|
|
376
|
+
// so you don't drag each PNG or reorder by hand. (Photoshop can't read the manifest.json directly;
|
|
377
|
+
// this script is the bridge.)
|
|
378
|
+
#target photoshop
|
|
379
|
+
(function () {
|
|
380
|
+
var here = new File($.fileName).parent;
|
|
381
|
+
var W = %(W)d, H = %(H)d;
|
|
382
|
+
var L = [
|
|
383
|
+
%(rows)s
|
|
384
|
+
];
|
|
385
|
+
var ru = app.preferences.rulerUnits;
|
|
386
|
+
app.preferences.rulerUnits = Units.PIXELS;
|
|
387
|
+
try {
|
|
388
|
+
var doc = app.documents.add(W, H, 72, "%(base)s", NewDocumentMode.RGB, DocumentFill.TRANSPARENT);
|
|
389
|
+
var starter = doc.artLayers[0];
|
|
390
|
+
for (var i = 0; i < L.length; i++) {
|
|
391
|
+
var f = new File(here + "/" + L[i].file);
|
|
392
|
+
if (!f.exists) { continue; }
|
|
393
|
+
var src = app.open(f);
|
|
394
|
+
var sl = src.activeLayer;
|
|
395
|
+
if (sl.isBackgroundLayer) { sl.isBackgroundLayer = false; } // unlock an opaque image so it duplicates
|
|
396
|
+
// DUPLICATE keeps the layer's absolute pixel position -- every PNG fills its canvas from (0,0), so
|
|
397
|
+
// the layers land pixel-aligned. (paste() centers on the current VIEW and can drift, which made
|
|
398
|
+
// the walkmesh look "centered" / off the art.)
|
|
399
|
+
var dup = sl.duplicate(doc, ElementPlacement.PLACEATBEGINNING);
|
|
400
|
+
src.close(SaveOptions.DONOTSAVECHANGES);
|
|
401
|
+
app.activeDocument = doc;
|
|
402
|
+
dup.name = L[i].name;
|
|
403
|
+
dup.opacity = L[i].opacity;
|
|
404
|
+
}
|
|
405
|
+
// remove the initial blank layer -- but ONLY if it's still empty. Some Photoshop versions paste
|
|
406
|
+
// the FIRST layer ONTO the empty starter (no new layer), so a blind remove would delete it.
|
|
407
|
+
try {
|
|
408
|
+
var bb = starter.bounds;
|
|
409
|
+
if (String(bb[0]) == String(bb[2]) || String(bb[1]) == String(bb[3])) { starter.remove(); }
|
|
410
|
+
} catch (e) {}
|
|
411
|
+
} finally {
|
|
412
|
+
app.preferences.rulerUnits = ru;
|
|
413
|
+
}
|
|
414
|
+
})();
|
|
415
|
+
'''
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _photoshop_jsx(basename: str, W: int, H: int, entries: list) -> str:
|
|
419
|
+
"""An Adobe Photoshop ExtendScript that rebuilds the layered template from the per-layer PNGs beside
|
|
420
|
+
it (bottom-to-top order + opacity + names, pre-aligned). The bridge from the generic manifest to a
|
|
421
|
+
one-click 'File > Scripts > Browse...' import."""
|
|
422
|
+
rows = ",\n".join(
|
|
423
|
+
' {file:"%s", name:"%s", opacity:%d}' % (e["file"], e["type"], int(round(e["opacity"] * 100)))
|
|
424
|
+
for e in entries)
|
|
425
|
+
return _PS_JSX_TEMPLATE % {"W": W, "H": H, "base": basename, "rows": rows}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def render_full_template(cam: _cam.Cam, frame, items: list, out_dir, *, basename: str = "paint_template",
|
|
429
|
+
scale: int = 4, nx: int = 8, nz: int = 8, walkmesh=None, base_image=None) -> list:
|
|
430
|
+
"""Write the FULL paint template for a field: the floor layers (grid / outline / height -- only when
|
|
431
|
+
a ``frame`` is given, i.e. a synth field or a borrow with ``[camera.frame]``); the REAL walkmesh
|
|
432
|
+
outline if ``walkmesh=(ff9_verts, tris)`` is passed (a fork's / modeled floor's true shape, not the
|
|
433
|
+
synthesized rectangle); PLUS one transparent PNG per content type present (npcs/props/gateways/...),
|
|
434
|
+
a ``<basename>.legend.json`` (pin -> name / height / canvas px / off-canvas), and ONE
|
|
435
|
+
``<basename>.manifest.json`` listing every layer bottom-to-top with its opacity. Returns the written
|
|
436
|
+
paths (legend + manifest last). bpy-free + stdlib."""
|
|
437
|
+
import json
|
|
438
|
+
import os
|
|
439
|
+
|
|
440
|
+
from . import guide as _guide
|
|
441
|
+
from . import placeholder as _ph
|
|
442
|
+
|
|
443
|
+
W, H = _canvas_wh(cam, scale)
|
|
444
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
445
|
+
written, entries = [], []
|
|
446
|
+
|
|
447
|
+
def _write_png(fn, buf):
|
|
448
|
+
path = os.path.join(out_dir, fn)
|
|
449
|
+
with open(path, "wb") as fh:
|
|
450
|
+
fh.write(_ph._png_rgba(W, H, buf))
|
|
451
|
+
written.append(path)
|
|
452
|
+
|
|
453
|
+
if frame is not None: # floor layers (single-source the drawing)
|
|
454
|
+
wall_h = abs(frame.zb - frame.zf)
|
|
455
|
+
for layer, opacity, desc in _guide.PAINT_TEMPLATE_LAYERS:
|
|
456
|
+
buf = bytearray(W * H * 4)
|
|
457
|
+
_guide._draw_template_layer(buf, W, H, layer, cam, frame, scale, nx, nz, wall_h)
|
|
458
|
+
fn = f"{basename}_{layer}.png"
|
|
459
|
+
_write_png(fn, buf)
|
|
460
|
+
entries.append({"file": fn, "type": layer, "opacity": opacity, "blend": "normal",
|
|
461
|
+
"description": desc})
|
|
462
|
+
|
|
463
|
+
if walkmesh is not None: # the REAL walkmesh outline (forks / modeled)
|
|
464
|
+
wm_verts, wm_tris = walkmesh
|
|
465
|
+
segs = walkmesh_outline_segments(wm_verts, wm_tris, cam, scale)
|
|
466
|
+
if segs:
|
|
467
|
+
buf = bytearray(W * H * 4)
|
|
468
|
+
for p0, p1 in segs:
|
|
469
|
+
_ph.draw_line(buf, W, H, p0, p1, WALKMESH_RGBA, 2)
|
|
470
|
+
_write_png(f"{basename}_walkmesh.png", buf)
|
|
471
|
+
entries.append({"file": f"{basename}_walkmesh.png", "type": "walkmesh", "opacity": 0.9,
|
|
472
|
+
"blend": "normal", "description": "Walkable floor boundary (the real walkmesh)"})
|
|
473
|
+
|
|
474
|
+
proj = project_content(items, cam, scale) # content layers, one PNG per present type
|
|
475
|
+
for t in CONTENT_ORDER:
|
|
476
|
+
d = proj["types"].get(t)
|
|
477
|
+
if not d:
|
|
478
|
+
continue
|
|
479
|
+
_write_png(f"{basename}_{t}.png", _rasterize_type(d, TYPE_COLOR[t], W, H))
|
|
480
|
+
entries.append({"file": f"{basename}_{t}.png", "type": t, "opacity": 1.0, "blend": "normal",
|
|
481
|
+
"description": TYPE_DESC.get(t, t)})
|
|
482
|
+
|
|
483
|
+
legend = {"version": 1, "canvas_size": [W, H], "items": proj["legend"]}
|
|
484
|
+
lfn = f"{basename}.legend.json"
|
|
485
|
+
with open(os.path.join(out_dir, lfn), "w", encoding="utf-8", newline="\n") as fh:
|
|
486
|
+
json.dump(legend, fh, indent=2)
|
|
487
|
+
fh.write("\n")
|
|
488
|
+
written.append(os.path.join(out_dir, lfn))
|
|
489
|
+
|
|
490
|
+
if base_image and os.path.isfile(os.path.join(out_dir, base_image)): # the REAL art, as the base layer
|
|
491
|
+
entries.insert(0, {"file": base_image, "type": "background", "opacity": 1.0, "blend": "normal",
|
|
492
|
+
"description": "the field's real background art -- paint/trace over it"})
|
|
493
|
+
|
|
494
|
+
jfn = f"{basename}.import.jsx" # one-click Photoshop layered import
|
|
495
|
+
with open(os.path.join(out_dir, jfn), "w", encoding="utf-8", newline="\n") as fh:
|
|
496
|
+
fh.write(_photoshop_jsx(basename, W, H, entries))
|
|
497
|
+
written.append(os.path.join(out_dir, jfn))
|
|
498
|
+
|
|
499
|
+
manifest = {"version": 1, "canvas_size": [W, H], "scale": scale, "layers": entries,
|
|
500
|
+
"legend": lfn, "importer": jfn}
|
|
501
|
+
mfn = f"{basename}.manifest.json"
|
|
502
|
+
with open(os.path.join(out_dir, mfn), "w", encoding="utf-8", newline="\n") as fh:
|
|
503
|
+
json.dump(manifest, fh, indent=2)
|
|
504
|
+
fh.write("\n")
|
|
505
|
+
written.append(os.path.join(out_dir, mfn))
|
|
506
|
+
return written
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Pure-stdlib PLACEHOLDER background art for a scaffolded field -- NO PIL, NO painting.
|
|
2
|
+
|
|
3
|
+
`ff9mapkit new` writes these so a fresh project BUILDS and is walkable immediately (a perspective
|
|
4
|
+
checkerboard floor + a solid backdrop), giving an end-to-end smoke test before any art exists. They
|
|
5
|
+
are obvious placeholders in-game (flat colours); the human REPLACES back.png/floor.png with real
|
|
6
|
+
painted art (Hard Constraint S2 -- the kit only tells you where the floor lands). Not art authoring,
|
|
7
|
+
just scaffolding (like the calibration grids).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import struct
|
|
13
|
+
import zlib
|
|
14
|
+
|
|
15
|
+
from . import cam as _cam
|
|
16
|
+
|
|
17
|
+
BACKDROP = (45, 57, 71, 255) # #2d3947 muted slate
|
|
18
|
+
CHECKER_LIGHT = (210, 170, 90, 255) # warm tan
|
|
19
|
+
CHECKER_DARK = (150, 110, 55, 255)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _png_rgba(w: int, h: int, buf: bytearray) -> bytes:
|
|
23
|
+
"""Encode a w*h RGBA buffer (row-major, top-down) as PNG bytes (filter 0, zlib level 6)."""
|
|
24
|
+
stride = w * 4
|
|
25
|
+
rows = bytearray()
|
|
26
|
+
for y in range(h):
|
|
27
|
+
rows.append(0)
|
|
28
|
+
rows += buf[y * stride:(y + 1) * stride]
|
|
29
|
+
|
|
30
|
+
def chunk(typ, data):
|
|
31
|
+
body = typ + data
|
|
32
|
+
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xffffffff)
|
|
33
|
+
|
|
34
|
+
return (b"\x89PNG\r\n\x1a\n"
|
|
35
|
+
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
|
|
36
|
+
+ chunk(b"IDAT", zlib.compress(bytes(rows), 6))
|
|
37
|
+
+ chunk(b"IEND", b""))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _fill_quad(buf: bytearray, W: int, H: int, pts, rgba) -> None:
|
|
41
|
+
"""Scanline-fill a convex quad (4 (x,y) float pixel corners) into the RGBA buffer."""
|
|
42
|
+
r, g, b, a = rgba
|
|
43
|
+
ys = [p[1] for p in pts]
|
|
44
|
+
y0, y1 = max(0, int(min(ys))), min(H - 1, int(max(ys)))
|
|
45
|
+
for y in range(y0, y1 + 1):
|
|
46
|
+
yc = y + 0.5
|
|
47
|
+
xs = []
|
|
48
|
+
for i in range(4):
|
|
49
|
+
(xa, ya), (xb, yb) = pts[i], pts[(i + 1) % 4]
|
|
50
|
+
if (ya <= yc < yb) or (yb <= yc < ya):
|
|
51
|
+
xs.append(xa + (yc - ya) * (xb - xa) / (yb - ya))
|
|
52
|
+
if len(xs) < 2:
|
|
53
|
+
continue
|
|
54
|
+
xlo, xhi = max(0, int(min(xs))), min(W - 1, int(max(xs)))
|
|
55
|
+
o = (y * W + xlo) * 4
|
|
56
|
+
for _ in range(xlo, xhi + 1):
|
|
57
|
+
buf[o], buf[o + 1], buf[o + 2], buf[o + 3] = r, g, b, a
|
|
58
|
+
o += 4
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def draw_line(buf: bytearray, W: int, H: int, p0, p1, rgba, thick: int = 1) -> None:
|
|
62
|
+
"""Draw a thick straight line into the RGBA buffer (square brush, overwrite -- no blend)."""
|
|
63
|
+
x0, y0 = p0
|
|
64
|
+
x1, y1 = p1
|
|
65
|
+
n = int(max(abs(x1 - x0), abs(y1 - y0)))
|
|
66
|
+
r, g, b, a = rgba
|
|
67
|
+
half = max(0, thick - 1)
|
|
68
|
+
for i in range(n + 1):
|
|
69
|
+
t = i / n if n else 0.0
|
|
70
|
+
cx, cy = int(round(x0 + (x1 - x0) * t)), int(round(y0 + (y1 - y0) * t))
|
|
71
|
+
for oy in range(-half, half + 1):
|
|
72
|
+
yi = cy + oy
|
|
73
|
+
if not (0 <= yi < H):
|
|
74
|
+
continue
|
|
75
|
+
row = yi * W
|
|
76
|
+
for ox in range(-half, half + 1):
|
|
77
|
+
xi = cx + ox
|
|
78
|
+
if 0 <= xi < W:
|
|
79
|
+
o = (row + xi) * 4
|
|
80
|
+
buf[o], buf[o + 1], buf[o + 2], buf[o + 3] = r, g, b, a
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_placeholders(camera: _cam.Cam, frame, back_path, floor_path, *,
|
|
84
|
+
scale: int = 4, nx: int = 12, nz: int = 12):
|
|
85
|
+
"""Write a solid backdrop (`back_path`, z behind) + a perspective checkerboard floor
|
|
86
|
+
(`floor_path`, transparent off the floor) matched to the camera/frame. Returns (W, H) in px.
|
|
87
|
+
|
|
88
|
+
The floor cells are projected through ``cam.to_canvas`` (exact), so the checkerboard sits where
|
|
89
|
+
the painted floor should -- the placeholder doubles as an alignment sanity check in-game.
|
|
90
|
+
"""
|
|
91
|
+
W, H = int(camera.range[0] * scale), int(camera.range[1] * scale)
|
|
92
|
+
back = bytearray(bytes(BACKDROP)) * (W * H) # opaque slate, full canvas
|
|
93
|
+
with open(back_path, "wb") as fh:
|
|
94
|
+
fh.write(_png_rgba(W, H, back))
|
|
95
|
+
|
|
96
|
+
floor = bytearray(W * H * 4) # transparent
|
|
97
|
+
fx, zb, zf = frame.half_width, frame.zb, frame.zf
|
|
98
|
+
for iz in range(nz):
|
|
99
|
+
for ix in range(nx):
|
|
100
|
+
x0, x1 = -fx + 2 * fx * ix / nx, -fx + 2 * fx * (ix + 1) / nx
|
|
101
|
+
z0, z1 = zb + (zf - zb) * iz / nz, zb + (zf - zb) * (iz + 1) / nz
|
|
102
|
+
pts = [tuple(c * scale for c in _cam.to_canvas((x, 0.0, z), camera))
|
|
103
|
+
for (x, z) in ((x0, z0), (x1, z0), (x1, z1), (x0, z1))]
|
|
104
|
+
_fill_quad(floor, W, H, pts, CHECKER_LIGHT if (ix + iz) % 2 == 0 else CHECKER_DARK)
|
|
105
|
+
with open(floor_path, "wb") as fh:
|
|
106
|
+
fh.write(_png_rgba(W, H, floor))
|
|
107
|
+
return W, H
|