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.
Files changed (193) hide show
  1. ff9mapkit/__init__.py +18 -0
  2. ff9mapkit/__main__.py +36 -0
  3. ff9mapkit/_animdb.py +2994 -0
  4. ff9mapkit/_animdb_all.py +14125 -0
  5. ff9mapkit/_fieldtable.py +1516 -0
  6. ff9mapkit/_fieldtext.py +845 -0
  7. ff9mapkit/_held_poses.py +44 -0
  8. ff9mapkit/_itemdb.py +65 -0
  9. ff9mapkit/_modeldb.py +725 -0
  10. ff9mapkit/_narrowmap_data.py +10 -0
  11. ff9mapkit/_npcparams.py +634 -0
  12. ff9mapkit/_regen_animdb.py +72 -0
  13. ff9mapkit/_regen_animdb_all.py +66 -0
  14. ff9mapkit/_regen_fieldtable.py +95 -0
  15. ff9mapkit/_regen_fieldtext.py +66 -0
  16. ff9mapkit/_regen_modeldb.py +67 -0
  17. ff9mapkit/_regen_npcparams.py +123 -0
  18. ff9mapkit/_regen_scenedb.py +57 -0
  19. ff9mapkit/_scenedb.py +869 -0
  20. ff9mapkit/abilities.py +225 -0
  21. ff9mapkit/animations.py +120 -0
  22. ff9mapkit/archetypes.py +218 -0
  23. ff9mapkit/areatitle.py +76 -0
  24. ff9mapkit/battle/__init__.py +21 -0
  25. ff9mapkit/battle/abilityfeatures.py +294 -0
  26. ff9mapkit/battle/actiondelta.py +441 -0
  27. ff9mapkit/battle/aiauthor.py +305 -0
  28. ff9mapkit/battle/ailint.py +140 -0
  29. ff9mapkit/battle/aipatch.py +175 -0
  30. ff9mapkit/battle/battleai.py +148 -0
  31. ff9mapkit/battle/battlecsv.py +390 -0
  32. ff9mapkit/battle/battlepatch.py +395 -0
  33. ff9mapkit/battle/build.py +558 -0
  34. ff9mapkit/battle/camera_codec.py +332 -0
  35. ff9mapkit/battle/camera_data.py +128 -0
  36. ff9mapkit/battle/characterdelta.py +789 -0
  37. ff9mapkit/battle/event_data.py +72 -0
  38. ff9mapkit/battle/extract.py +540 -0
  39. ff9mapkit/battle/fbx.py +223 -0
  40. ff9mapkit/battle/reskin.py +149 -0
  41. ff9mapkit/battle/scene_codec.py +314 -0
  42. ff9mapkit/battle/scene_data.py +369 -0
  43. ff9mapkit/battle/scenelint.py +125 -0
  44. ff9mapkit/battle/seqasm.py +131 -0
  45. ff9mapkit/battle/seqauthor.py +220 -0
  46. ff9mapkit/battle/seqcodec.py +300 -0
  47. ff9mapkit/battle/seqdis.py +106 -0
  48. ff9mapkit/battle/seqpatch.py +137 -0
  49. ff9mapkit/battle_bgm.py +133 -0
  50. ff9mapkit/binutils.py +60 -0
  51. ff9mapkit/build.py +5445 -0
  52. ff9mapkit/campaign.py +1276 -0
  53. ff9mapkit/catalog.py +316 -0
  54. ff9mapkit/chain.py +358 -0
  55. ff9mapkit/cli.py +3114 -0
  56. ff9mapkit/config.py +360 -0
  57. ff9mapkit/content/__init__.py +13 -0
  58. ff9mapkit/content/areatitle.py +36 -0
  59. ff9mapkit/content/ate.py +118 -0
  60. ff9mapkit/content/camera.py +123 -0
  61. ff9mapkit/content/chest.py +186 -0
  62. ff9mapkit/content/choice.py +163 -0
  63. ff9mapkit/content/conductor.py +217 -0
  64. ff9mapkit/content/cutscene.py +290 -0
  65. ff9mapkit/content/encounter.py +41 -0
  66. ff9mapkit/content/entry_settle.py +50 -0
  67. ff9mapkit/content/equipment.py +93 -0
  68. ff9mapkit/content/event.py +191 -0
  69. ff9mapkit/content/gateway.py +101 -0
  70. ff9mapkit/content/inventory.py +59 -0
  71. ff9mapkit/content/itemdata.py +644 -0
  72. ff9mapkit/content/itemtext.py +168 -0
  73. ff9mapkit/content/jump.py +114 -0
  74. ff9mapkit/content/ladder.py +633 -0
  75. ff9mapkit/content/movement.py +53 -0
  76. ff9mapkit/content/music.py +97 -0
  77. ff9mapkit/content/npc.py +348 -0
  78. ff9mapkit/content/object.py +340 -0
  79. ff9mapkit/content/onentry.py +135 -0
  80. ff9mapkit/content/party.py +111 -0
  81. ff9mapkit/content/pathfind.py +138 -0
  82. ff9mapkit/content/platform.py +314 -0
  83. ff9mapkit/content/player.py +168 -0
  84. ff9mapkit/content/prop.py +75 -0
  85. ff9mapkit/content/region.py +340 -0
  86. ff9mapkit/content/reinit.py +59 -0
  87. ff9mapkit/content/savepoint.py +90 -0
  88. ff9mapkit/content/shop.py +178 -0
  89. ff9mapkit/content/sps_trigger.py +66 -0
  90. ff9mapkit/content/startup.py +71 -0
  91. ff9mapkit/content/synthesis.py +106 -0
  92. ff9mapkit/content/text.py +183 -0
  93. ff9mapkit/content/textcarry.py +290 -0
  94. ff9mapkit/content/verbatim.py +86 -0
  95. ff9mapkit/content/walkmesh_hotfix.py +38 -0
  96. ff9mapkit/data/__init__.py +48 -0
  97. ff9mapkit/data/_regen_provenance.py +142 -0
  98. ff9mapkit/data/provenance/blank.es.patch +1 -0
  99. ff9mapkit/data/provenance/blank.fr.patch +1 -0
  100. ff9mapkit/data/provenance/blank.gr.patch +1 -0
  101. ff9mapkit/data/provenance/blank.it.patch +1 -0
  102. ff9mapkit/data/provenance/blank.jp.patch +1 -0
  103. ff9mapkit/data/provenance/blank.uk.patch +1 -0
  104. ff9mapkit/data/provenance/blank.us.patch +1 -0
  105. ff9mapkit/data/provenance/manifest.json +65 -0
  106. ff9mapkit/data/provenance/region_template.patch +1 -0
  107. ff9mapkit/data/reference_arcs.toml +89 -0
  108. ff9mapkit/data/region_catalog.toml +593 -0
  109. ff9mapkit/deploystack.py +358 -0
  110. ff9mapkit/dialogue.py +803 -0
  111. ff9mapkit/eb/__init__.py +12 -0
  112. ff9mapkit/eb/_exprtable.py +59 -0
  113. ff9mapkit/eb/_membertable.py +38 -0
  114. ff9mapkit/eb/_optables.py +537 -0
  115. ff9mapkit/eb/_regen_optables.py +76 -0
  116. ff9mapkit/eb/cmdasm.py +323 -0
  117. ff9mapkit/eb/disasm.py +332 -0
  118. ff9mapkit/eb/edit.py +439 -0
  119. ff9mapkit/eb/exprasm.py +158 -0
  120. ff9mapkit/eb/model.py +178 -0
  121. ff9mapkit/eb/opcodes.py +463 -0
  122. ff9mapkit/eblint.py +177 -0
  123. ff9mapkit/editor/__init__.py +20 -0
  124. ff9mapkit/editor/app.py +950 -0
  125. ff9mapkit/editor/battle_forms.py +240 -0
  126. ff9mapkit/editor/breadcrumb.py +89 -0
  127. ff9mapkit/editor/dialogs.py +116 -0
  128. ff9mapkit/editor/feedback.py +208 -0
  129. ff9mapkit/editor/forms.py +632 -0
  130. ff9mapkit/editor/graphview.py +350 -0
  131. ff9mapkit/editor/jobs.py +342 -0
  132. ff9mapkit/editor/model.py +243 -0
  133. ff9mapkit/editor/picker.py +120 -0
  134. ff9mapkit/editor/theme.py +212 -0
  135. ff9mapkit/eventscan.py +1441 -0
  136. ff9mapkit/extract.py +2279 -0
  137. ff9mapkit/flags.py +693 -0
  138. ff9mapkit/forkreport.py +1383 -0
  139. ff9mapkit/hub.py +477 -0
  140. ff9mapkit/idgated.py +101 -0
  141. ff9mapkit/infohub.py +580 -0
  142. ff9mapkit/items.py +63 -0
  143. ff9mapkit/itemstats.py +346 -0
  144. ff9mapkit/journey.py +1902 -0
  145. ff9mapkit/keyitems.py +93 -0
  146. ff9mapkit/logic_add.py +632 -0
  147. ff9mapkit/logic_edit.py +728 -0
  148. ff9mapkit/logic_map.py +526 -0
  149. ff9mapkit/pack.py +175 -0
  150. ff9mapkit/playerswap.py +231 -0
  151. ff9mapkit/prop_archetypes.py +228 -0
  152. ff9mapkit/provision.py +282 -0
  153. ff9mapkit/refarc.py +825 -0
  154. ff9mapkit/save.py +337 -0
  155. ff9mapkit/save_items.py +1673 -0
  156. ff9mapkit/scene/__init__.py +11 -0
  157. ff9mapkit/scene/arena.py +63 -0
  158. ff9mapkit/scene/bgart.py +140 -0
  159. ff9mapkit/scene/bgi.py +732 -0
  160. ff9mapkit/scene/bgs.py +174 -0
  161. ff9mapkit/scene/bgx.py +185 -0
  162. ff9mapkit/scene/cam.py +345 -0
  163. ff9mapkit/scene/guide.py +311 -0
  164. ff9mapkit/scene/paint.py +506 -0
  165. ff9mapkit/scene/placeholder.py +107 -0
  166. ff9mapkit/sjbinary.py +285 -0
  167. ff9mapkit/sps/__init__.py +17 -0
  168. ff9mapkit/sps/author.py +294 -0
  169. ff9mapkit/sps/catalog.py +88 -0
  170. ff9mapkit/sps/codec.py +264 -0
  171. ff9mapkit/sps/edit.py +184 -0
  172. ff9mapkit/sps/lint.py +58 -0
  173. ff9mapkit/sps/render.py +116 -0
  174. ff9mapkit/sps/templates.py +47 -0
  175. ff9mapkit/sps/texture.py +131 -0
  176. ff9mapkit/walkmesh_hotfixes.py +163 -0
  177. ff9mapkit/workspace/__init__.py +18 -0
  178. ff9mapkit/workspace/battledoc.py +985 -0
  179. ff9mapkit/workspace/builddoc.py +607 -0
  180. ff9mapkit/workspace/forms_qt.py +586 -0
  181. ff9mapkit/workspace/importdoc.py +665 -0
  182. ff9mapkit/workspace/mapview.py +131 -0
  183. ff9mapkit/workspace/palette.py +85 -0
  184. ff9mapkit/workspace/savedoc.py +664 -0
  185. ff9mapkit/workspace/shell.py +6907 -0
  186. ff9mapkit/workspace/style.py +105 -0
  187. ff9mapkit/workspace/tuningdialog.py +223 -0
  188. ff9mapkit-1.0.0b3.dist-info/METADATA +155 -0
  189. ff9mapkit-1.0.0b3.dist-info/RECORD +193 -0
  190. ff9mapkit-1.0.0b3.dist-info/WHEEL +5 -0
  191. ff9mapkit-1.0.0b3.dist-info/entry_points.txt +5 -0
  192. ff9mapkit-1.0.0b3.dist-info/licenses/LICENSE +31 -0
  193. ff9mapkit-1.0.0b3.dist-info/top_level.txt +1 -0
@@ -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