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
ff9mapkit/scene/cam.py ADDED
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+ # FF9 field CAMERA math library — the "novel camera" toolkit.
3
+ #
4
+ # Goal: faithfully read / decompose / re-synthesize an FF9 .bgx CAMERA block,
5
+ # so we can author a brand-new camera (any angle) instead of borrowing one.
6
+ #
7
+ # Ground truth (verified against Memoria source):
8
+ # * Player/walkmesh screen position = PSX.CalculateGTE_RTPT_POS(worldPos,
9
+ # localRTS=identity, globalRT=GetMatrixRT(), viewDist=proj, offset=centerOffset, useAbsZ=true)
10
+ # (FieldMapActor.cs:121). localRTS is identity for field actors.
11
+ # * GetMatrixRT() row i = (r[i,0]/4096, r[i,1]/4096, r[i,2]/4096 | t[i]); row3=(0,0,0,1).
12
+ # * .bgx OrientationMatrix entries == r[i,j]/4096 (BGSCENE_DEF.ProcessMemoriaCamera / ExportMemoriaBGX).
13
+ #
14
+ # The projection (from PSX.CalculateGTE_RTPT_POS), with F = diag(1,-1,1):
15
+ # v = vertex # localRTS = I
16
+ # v.y = -v.y # flip 1 -> v' = F*vertex
17
+ # result = R_ff9 * v' + t # globalRT = GetMatrixRT
18
+ # result.y= -result.y # flip 2 -> result'' = F*result
19
+ # num = |result.z|
20
+ # screen.x= result''.x * H/num + off.x
21
+ # screen.y= result''.y * H/num + off.y
22
+ # (screen.z = result.z -> used for depth sort)
23
+ #
24
+ # Equivalent clean pinhole form (derived + validated here):
25
+ # R_view = F * R_ff9 * F
26
+ # cs = R_view * (P - C) # camera-space; cs.z > 0 in front
27
+ # screen = (cs.x, cs.y) * H/|cs.z| + offset
28
+ # with t = -R_ff9 * (F*C) <=> C = -F * R_ff9^{-1} * t
29
+ #
30
+ # Key invariant (measured across 6 real cameras): R_ff9 = diag(1, k, 1) * R_ortho
31
+ # where R_ortho is a proper orthonormal rotation and k = 14/15 = 0.933333...
32
+ #
33
+ # Pure stdlib (no numpy) so it runs anywhere the other tools do.
34
+ import math
35
+
36
+ K_VSCALE = 14.0 / 15.0 # 0.93333.. vertical-focal scale baked into row 1
37
+ ROT = 4096.0 # fixed-point factor (BGCAM_DEF.ROTATTION_FACTOR)
38
+
39
+ # Field-screen half-extents (PSX native 4:3). HalfFieldWidth is aspect-dependent under
40
+ # widescreen (PsxFieldWidth/2); HalfFieldHeight is fixed (PsxFieldHeightNative/2 = 112).
41
+ HALF_FIELD_W = 160.0
42
+ HALF_FIELD_H = 112.0
43
+
44
+ # ---------- painted-canvas map (EXACT, scale-1) ----------
45
+ # A painted-canvas pixel (cx, cy) is placed by the engine's overlay system at FieldMap-world
46
+ # (cx - HalfFieldWidth, HalfFieldHeight - cy) (BGSCENE_DEF.CreateScene_OverlayGo, scale 1); the field
47
+ # actor/walkmesh is placed at its GTE-projected (px, py). Both render through the same ortho FieldMap
48
+ # camera, so a world point appears under canvas pixel (cx, cy) exactly when (px, py) == (cx - HFW,
49
+ # HFH - cy). Writing px,py as the RAW GTE projection plus the engine offset (range/2 - HFW in x,
50
+ # -range/2 + HFH in y), the HalfField terms cancel and the map is EXACTLY scale-1, no fudge:
51
+ # canvasX = rawProj.x + range.w/2 ; canvasY = range.h/2 - rawProj.y
52
+ # Verified noise-free against an in-engine projection probe (overlay corners + actor grid, 2026-06-02).
53
+ # The earlier S_CANVAS_X/Y = 0.926/0.889 were an eyeball fit that silently absorbed the player
54
+ # COLLISION_RADIUS_W (below) -- removed; kept here as 1.0 for any external caller.
55
+ S_CANVAS_X = 1.0
56
+ S_CANVAS_Y = 1.0
57
+ S_CANVAS = 1.0
58
+
59
+ # Walking-character collision radius, world units. FieldMap.cs sets the controller radius to
60
+ # bgiRad*4 (bgiRad from the .bgi; flat quads use the default ~12 -> ~48). The player CENTRE cannot
61
+ # reach the painted floor edge -- it stops ~this far inside (most visible at the foreshortened back
62
+ # edge; THIS was the old "back edge a bit short"). Physics, not a map error: extend the walkmesh
63
+ # past the painted floor by ~this much if the player should be able to stand at the visual edge.
64
+ COLLISION_RADIUS_W = 48.0
65
+
66
+ # Object<->object collision radius, world units (DISTINCT from the controller radius above).
67
+ # WalkMesh.Collision blocks one actor against another when their centre distance < 4*collRadA +
68
+ # 4*collRadB (disdif = dx^2+dz^2 - r^2 < 0, r = 4*collRadA + 4*collRadB). The ENGINE default collRad
69
+ # is 16, but the kit's fields are cloned from field 1357, whose player-init sets collRad = 24 via
70
+ # SetObjectLogicalSize (RADIUS, 0x4B) -- and an injected NPC clones the player, so BOTH are 24
71
+ # (verified by disassembling a built script). So 4*24 = 96 per character, and two kit characters
72
+ # collide at ~2*96 = 192u apart. A cutscene walk TO another object (e.g. @player) must stop SHORT of
73
+ # this or the synchronous walk presses into the box forever and stalls. (A custom oversized model with
74
+ # its own RADIUS would need a larger value.)
75
+ OBJECT_COLLISION_W = 96.0
76
+
77
+ # Character GROUND offset, world units. MEASURED IN-GAME = ~0 (Session 18 engine probe + grid, 3
78
+ # spots x 2 pitches): the character MODEL is projected by its vertex shader's GTE (FieldMapActor.txt:
79
+ # _MatrixRT/_ViewDistance/_OffsetX/Y) EXACTLY like the floor/walkmesh, so the feet render at the
80
+ # true world position -- there is NO real character-vs-floor offset. The earlier "3D-perspective-
81
+ # camera, feet sit behind" story (and the per-pitch sx/sy scale) was WRONG; both were fitting an
82
+ # artifact. The artifact: the legacy flat builder (bgi.quad/build_flat) injects orgPos=(0,0,300), so
83
+ # its walkmesh sits +300z off a to_canvas-painted floor; this 298 is the near-cancel that undoes it.
84
+ # So it is NOT a real offset -- it's the partner of the +300 org (the Session-17 double-count). The
85
+ # HONEST model is `[walkmesh] frame = "world"` => org=0 + NO offset (walkmesh in true world coords =
86
+ # the painted floor; exact at any angle). New scaffolds use that. This constant is kept ONLY so the
87
+ # legacy org=300 quad/auto path still cancels (head-on); prefer frame="world" for new work.
88
+ CHARACTER_GROUND_OFFSET_Z = 298.0
89
+
90
+ # ---------- scroll bounds (larger-than-screen fields) ----------
91
+ def scroll_bounds(range_wh, half_w=HALF_FIELD_W, half_h=HALF_FIELD_H):
92
+ """Camera Viewport (vrpMinX, vrpMaxX, vrpMinY, vrpMaxY) that lets the native view window pan
93
+ across the WHOLE painting, so a larger-than-screen field scrolls edge to edge.
94
+
95
+ From Memoria's clamp (FieldMap.cs:1111-1114): vrpMin = HalfNative, vrpMax = size - HalfNative
96
+ (HalfNative = PSX 160 x 112, == HALF_FIELD_W/H here). For a screen-sized painting (w,h == 384,448)
97
+ this is (160, 224, 112, 336) = the kit's DEFAULT_VIEWPORT (min<->max gap is tiny, so no real
98
+ scroll). For a wider painting the gap opens and the engine pans. In-game-proven on the 768x448
99
+ spike (field 4003): Viewport (160, 608, 112, 336) scrolls + clamps cleanly with no over-scroll."""
100
+ w, h = int(range_wh[0]), int(range_wh[1])
101
+ return (int(half_w), int(w - half_w), int(half_h), int(h - half_h))
102
+
103
+
104
+ # ---------- tiny 3x3 / vec3 linear algebra ----------
105
+ def mv(M, v):
106
+ return [M[i][0]*v[0] + M[i][1]*v[1] + M[i][2]*v[2] for i in range(3)]
107
+ def mm(A, B):
108
+ return [[sum(A[i][k]*B[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
109
+ def transpose(M):
110
+ return [[M[j][i] for j in range(3)] for i in range(3)]
111
+ def dot(a, b):
112
+ return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]
113
+ def norm(a):
114
+ return math.sqrt(dot(a, a))
115
+ def sub(a, b):
116
+ return [a[0]-b[0], a[1]-b[1], a[2]-b[2]]
117
+ def scale_rows(M, s):
118
+ return [[M[i][j]*s[i] for j in range(3)] for i in range(3)]
119
+ def det3(M):
120
+ return (M[0][0]*(M[1][1]*M[2][2]-M[1][2]*M[2][1])
121
+ - M[0][1]*(M[1][0]*M[2][2]-M[1][2]*M[2][0])
122
+ + M[0][2]*(M[1][0]*M[2][1]-M[1][1]*M[2][0]))
123
+ def inv3(M):
124
+ d = det3(M)
125
+ c = [[ (M[1][1]*M[2][2]-M[1][2]*M[2][1]), -(M[0][1]*M[2][2]-M[0][2]*M[2][1]), (M[0][1]*M[1][2]-M[0][2]*M[1][1])],
126
+ [-(M[1][0]*M[2][2]-M[1][2]*M[2][0]), (M[0][0]*M[2][2]-M[0][2]*M[2][0]), -(M[0][0]*M[1][2]-M[0][2]*M[1][0])],
127
+ [ (M[1][0]*M[2][1]-M[1][1]*M[2][0]), -(M[0][0]*M[2][1]-M[0][1]*M[2][0]), (M[0][0]*M[1][1]-M[0][1]*M[1][0])]]
128
+ return [[c[i][j]/d for j in range(3)] for i in range(3)]
129
+
130
+ F = [[1,0,0],[0,-1,0],[0,0,1]] # the y-flip diag(1,-1,1)
131
+ def Fapply(v): return [v[0], -v[1], v[2]]
132
+
133
+ # ---------- camera container ----------
134
+ class Cam:
135
+ def __init__(self):
136
+ self.proj = 0 # H = ViewDistance
137
+ self.centerOffset = [0, 0]
138
+ self.t = [0, 0, 0] # RT translation
139
+ self.range = [0, 0] # w, h (canvas size)
140
+ self.depthOffset = 0
141
+ self.viewport = [0, 0, 0, 0] # minX,maxX,minY,maxY
142
+ self.r = [[0,0,0],[0,0,0],[0,0,0]] # r[i][j] = OrientationMatrix * 4096 (Int16)
143
+ def Rf(self):
144
+ "R_ff9 = r/4096 (float 3x3)"
145
+ return [[self.r[i][j]/ROT for j in range(3)] for i in range(3)]
146
+
147
+ # ---------- the exact engine projection ----------
148
+ def project(P, cam, offset=(0.0, 0.0)):
149
+ """Replicates PSX.CalculateGTE_RTPT_POS. Returns (screenX, screenY, depthZ).
150
+ `offset` is the 2D projection offset ADDED after the perspective divide.
151
+ NOTE: the engine does NOT pass raw centerOffset here — it passes `compute_offset(cam)`
152
+ (FieldMap.cs builds projectionOffset = centerOffset +/- range/2 +/- HalfField).
153
+ Use project_screen() for the engine-accurate actor position."""
154
+ Rf = cam.Rf()
155
+ v = Fapply(P) # flip 1
156
+ res = [mv(Rf, v)[i] + cam.t[i] for i in range(3)] # R*v + t
157
+ resz = res[2]
158
+ res = Fapply(res) # flip 2 (y)
159
+ num = abs(resz)
160
+ sx = res[0]*cam.proj/num + offset[0]
161
+ sy = res[1]*cam.proj/num + offset[1]
162
+ return (sx, sy, resz)
163
+
164
+ def compute_offset(cam, half_w=HALF_FIELD_W, half_h=HALF_FIELD_H):
165
+ """The projectionOffset the engine actually passes to the GTE (FieldMap.cs:393-406).
166
+ offset.x = centerOffset.x + w/2 - HalfFieldWidth ; offset.y = -centerOffset.y - h/2 + HalfFieldHeight"""
167
+ return (cam.centerOffset[0] + cam.range[0]/2.0 - half_w,
168
+ -cam.centerOffset[1] - cam.range[1]/2.0 + half_h)
169
+
170
+ def project_screen(P, cam, half_w=HALF_FIELD_W, half_h=HALF_FIELD_H):
171
+ "Engine-accurate actor screen position (FieldMapActor.cs:121): GTE with the real offset."
172
+ return project(P, cam, compute_offset(cam, half_w, half_h))
173
+
174
+ def depth(P, cam):
175
+ "Actor depth for OT sorting (FieldMapActor.cs:122 / shader): result.z/4 + depthOffset."
176
+ _, _, resz = project(P, cam)
177
+ return resz/4.0 + cam.depthOffset
178
+
179
+ def to_canvas(P, cam):
180
+ """Painted-canvas pixel (top-left origin, Y down) where a world point appears.
181
+ EXACT, scale-1 (no calibration fudge):
182
+ canvasX = rawProj.x + range.w/2 ; canvasY = range.h/2 - rawProj.y
183
+ Derived from the engine overlay placement (BGSCENE_DEF) + the GTE actor projection (FieldMap),
184
+ and verified noise-free against an in-engine projection probe.
185
+ NB: this is pure geometry -- the player's COLLISION_RADIUS_W keeps the player CENTRE a constant
186
+ ~48 world units inside any painted edge; account for it in the walkmesh, not here."""
187
+ px, py, _ = project(P, cam) # RAW GTE projection (offset 0,0)
188
+ return (px + cam.range[0]/2.0, cam.range[1]/2.0 - py)
189
+
190
+
191
+ def solve_z_for_canvasY(cam, canvasY, x=0.0, y=0.0, zlo=-30000.0, zhi=30000.0):
192
+ """Inverse: find the world z (at given x,y) whose foot projects to a painted-canvas row.
193
+ Bisection on the monotonic canvasY(z). Returns z, or None if the row is unreachable (the
194
+ floor never projects there at this camera — i.e. above the horizon, or beyond the z search)."""
195
+ def cy(z): return to_canvas((x, y, z), cam)[1]
196
+ a, b = zlo, zhi
197
+ fa, fb = cy(a)-canvasY, cy(b)-canvasY
198
+ if fa == 0: return a
199
+ if fb == 0: return b
200
+ if (fa > 0) == (fb > 0): return None
201
+ for _ in range(80):
202
+ m = 0.5*(a+b); fm = cy(m)-canvasY
203
+ if abs(fm) < 1e-4: return m
204
+ if (fm > 0) == (fa > 0): a, fa = m, fm
205
+ else: b, fb = m, fm
206
+ return 0.5*(a+b)
207
+
208
+ def horizon_canvas_y(cam, x=0.0):
209
+ """The painted-canvas Y the floor (y=0 plane) approaches as z -> +inf: the camera's horizon.
210
+ Floor rows ABOVE this (smaller canvasY) are unreachable — there's no floor point there."""
211
+ return to_canvas((x, 0.0, 1.0e7), cam)[1]
212
+
213
+ # ---------- decomposition: R_ff9 -> (k-per-row, R_ortho, camera pos C, R_view) ----------
214
+ def decompose(cam):
215
+ Rf = cam.Rf()
216
+ row_norms = [norm(Rf[i]) for i in range(3)]
217
+ # divide each row by its norm to recover the orthonormal rotation
218
+ R_ortho = [[Rf[i][j]/row_norms[i] for j in range(3)] for i in range(3)]
219
+ # orthonormality residual: ||R_ortho * R_ortho^T - I||_max
220
+ RRt = mm(R_ortho, transpose(R_ortho))
221
+ ortho_err = max(abs(RRt[i][j] - (1.0 if i == j else 0.0)) for i in range(3) for j in range(3))
222
+ det = det3(R_ortho)
223
+ # camera world position: C = -F * R_ff9^{-1} * t
224
+ Rinv = inv3(Rf)
225
+ C = Fapply([-x for x in mv(Rinv, cam.t)])
226
+ R_view = mm(mm(F, Rf), F)
227
+ return {
228
+ "row_norms": row_norms,
229
+ "k": row_norms[1],
230
+ "R_ortho": R_ortho,
231
+ "ortho_err": ortho_err,
232
+ "det": det,
233
+ "C": C,
234
+ "R_view": R_view,
235
+ "fov_x_deg": 2*math.degrees(math.atan((cam.range[0]/2.0)/cam.proj)) if cam.range[0] else None,
236
+ }
237
+
238
+ # ---------- synthesis: (camera pos C, R_ortho, H) -> r[][], t[] ----------
239
+ def synth_r_t(C, R_ortho, H, k=K_VSCALE):
240
+ "Build FF9 r[][] (Int16) and t[] from a clean camera. Inverse of decompose()."
241
+ Rf = scale_rows(R_ortho, [1.0, k, 1.0]) # R_ff9 = diag(1,k,1)*R_ortho
242
+ r = [[int(round(Rf[i][j]*ROT)) for j in range(3)] for i in range(3)]
243
+ t = [int(round(x)) for x in [-v for v in mv(Rf, Fapply(C))]] # t = -R_ff9*(F*C)
244
+ return r, t
245
+
246
+ # ---------- rotation builders (proper orthonormal, right-handed) ----------
247
+ def rot_x(deg):
248
+ a = math.radians(deg); c, s = math.cos(a), math.sin(a)
249
+ return [[1,0,0],[0,c,-s],[0,s,c]]
250
+ def rot_y(deg):
251
+ a = math.radians(deg); c, s = math.cos(a), math.sin(a)
252
+ return [[c,0,s],[0,1,0],[-s,0,c]]
253
+ def rot_z(deg):
254
+ a = math.radians(deg); c, s = math.cos(a), math.sin(a)
255
+ return [[c,-s,0],[s,c,0],[0,0,1]]
256
+
257
+ # ---------- .bgx CAMERA block I/O ----------
258
+ def parse_bgx_cameras(path):
259
+ "Return list[Cam] parsed from a .bgx file."
260
+ with open(path, encoding="utf-8", errors="replace") as fh:
261
+ return parse_bgx_cameras_text(fh.read())
262
+
263
+
264
+ def parse_bgx_cameras_text(text):
265
+ "Return list[Cam] parsed from .bgx text."
266
+ cams, cur = [], None
267
+ for line in text.splitlines():
268
+ s = line.strip()
269
+ if not s or s.startswith("#") or s.startswith("//"):
270
+ continue
271
+ if s == "CAMERA":
272
+ cur = Cam(); cams.append(cur); continue
273
+ if cur is None or ":" not in s:
274
+ continue
275
+ key, _, val = s.partition(":")
276
+ key = key.strip(); args = [a.strip() for a in val.split(",")]
277
+ try:
278
+ if key == "ViewDistance": cur.proj = int(args[0])
279
+ elif key == "CenterOffset": cur.centerOffset = [int(args[0]), int(args[1])]
280
+ elif key == "Position": cur.t = [int(args[0]), int(args[1]), int(args[2])]
281
+ elif key == "Range": cur.range = [int(args[0]), int(args[1])]
282
+ elif key == "DepthOffset": cur.depthOffset = int(args[0])
283
+ elif key == "Viewport": cur.viewport = [int(a) for a in args[:4]]
284
+ elif key == "OrientationMatrix":
285
+ f = [float(a) for a in args[:9]]
286
+ cur.r = [[int(round(f[i*3+j]*ROT)) for j in range(3)] for i in range(3)]
287
+ except (ValueError, IndexError):
288
+ pass
289
+ return cams
290
+
291
+ def format_bgx_camera(cam):
292
+ Rf = cam.Rf()
293
+ om = ", ".join(_fmt(Rf[i][j]) for i in range(3) for j in range(3))
294
+ return ("CAMERA\n"
295
+ f"ViewDistance: {cam.proj}\n"
296
+ f"CenterOffset: {cam.centerOffset[0]}, {cam.centerOffset[1]}\n"
297
+ f"Position: {cam.t[0]}, {cam.t[1]}, {cam.t[2]}\n"
298
+ f"Range: {cam.range[0]}, {cam.range[1]}\n"
299
+ f"DepthOffset: {cam.depthOffset}\n"
300
+ f"Viewport: {cam.viewport[0]}, {cam.viewport[1]}, {cam.viewport[2]}, {cam.viewport[3]}\n"
301
+ f"OrientationMatrix: {om}\n")
302
+
303
+ def _fmt(x):
304
+ # match Unity's float formatting closely enough for readability
305
+ return f"{x:.7g}"
306
+
307
+
308
+ # ---------- supported camera-pitch range (advisory) ----------
309
+ # Both the camera SYNTHESIS (synth_r_t) and the painted-canvas map (to_canvas) are EXACT at any
310
+ # pitch (the map is pure projection, verified noise-free in-engine 2026-06-02). This range is now
311
+ # only an AUTHENTICITY advisory: the shipped FF9 cameras span ~0-50 deg downward pitch (GRGR steepest
312
+ # ~49.6; most 15-30). Steeper angles render correctly but look non-vanilla, and the constant
313
+ # COLLISION_RADIUS_W inset is more visible at a steep/foreshortened back edge. ADVISORY only.
314
+ SUPPORTED_PITCH_DEG = (0.0, 50.0)
315
+
316
+
317
+ def pitch_deg(cam):
318
+ """Approximate downward pitch (degrees) of a camera, from its orthonormal orientation.
319
+
320
+ Exact for pure-pitch cameras (R_ortho = rot_x(pitch)); a reasonable tilt estimate otherwise.
321
+ """
322
+ R = decompose(cam)["R_ortho"]
323
+ return math.degrees(math.atan2(R[2][1], R[1][1]))
324
+
325
+
326
+ def yaw_deg(cam):
327
+ """Camera yaw (orbit about world-Y), degrees, recovered from R_ortho row 0. 0 = front-facing.
328
+
329
+ Exact for the make_camera form R_ortho = rot_x(pitch)·rot_y(-yaw), whose row 0 is
330
+ (cos yaw, 0, -sin yaw); a reasonable estimate for arbitrary real cameras. Drives the player
331
+ movement control-direction (TWIST): the WASD vector must rotate by the camera yaw so "up"
332
+ stays "up the screen". See content.movement.control_value_for_angle."""
333
+ R = decompose(cam)["R_ortho"]
334
+ return math.degrees(math.atan2(-R[0][2], R[0][0]))
335
+
336
+
337
+ def pitch_warning(pitch, lo_hi=SUPPORTED_PITCH_DEG):
338
+ """Return an advisory message if `pitch` (deg) is outside the supported range, else None."""
339
+ lo, hi = lo_hi
340
+ if lo <= pitch <= hi:
341
+ return None
342
+ return (f"camera pitch {pitch:.1f} deg is outside the typical FF9 range "
343
+ f"[{lo:.0f}-{hi:.0f} deg]: the render and paint guide are still exact, but the angle "
344
+ f"looks non-vanilla and the player's collision-radius inset is more visible at a steep "
345
+ f"back edge. Advisory only.")
@@ -0,0 +1,311 @@
1
+ """Author a camera from a simple spec, frame a flat floor, and emit a paint guide.
2
+
3
+ This is the human-facing half of the scene pipeline. The kit can't paint the background
4
+ (Hard Constraint), but it CAN tell the artist *exactly* where the floor and its edges land
5
+ on the painted canvas for a chosen camera angle, and hand back the matching walkmesh corners.
6
+
7
+ make_camera(pitch, distance, fov_x | proj, yaw) -> a Cam (via the camera math)
8
+ frame_floor(cam, back/front canvas rows) -> the floor quad (world + canvas coords)
9
+ render_paint_guide(cam, frame, png) -> a checkerboard guide image to paint over
10
+ walkmesh_corners(frame) -> 4 (x, z) corners for scene.bgi.quad()
11
+
12
+ Canvas is the painted logical 384x448 (top-left origin, Y down) with an EXACT scale-1 map
13
+ (canvasX = rawProj.x + w/2, canvasY = h/2 - rawProj.y; see :mod:`ff9mapkit.scene.cam`). The old
14
+ per-pitch sx/sy fudge (0.926/0.889) is gone -- the projection is exact at every pitch.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from dataclasses import dataclass
21
+
22
+ from . import cam as _cam
23
+
24
+ CANVAS_W, CANVAS_H = 384, 448
25
+ # GRGR-derived defaults that work across the real FF9 pitch range (Sessions 6-10)
26
+ DEFAULT_DEPTH_OFFSET = 543
27
+ DEFAULT_VIEWPORT = (160, 224, 112, 336)
28
+
29
+
30
+ def proj_from_fov_x(fov_x_deg: float, range_w: int = CANVAS_W) -> int:
31
+ """Projection distance H for a horizontal FOV: H = (w/2) / tan(fov/2)."""
32
+ return int(round((range_w / 2.0) / math.tan(math.radians(fov_x_deg) / 2.0)))
33
+
34
+
35
+ def _canvas_wh(cam: _cam.Cam) -> tuple:
36
+ """The painted-canvas size (px) for this camera = its Range, else the 384x448 default.
37
+ A larger-than-screen (scrolling) field has Range > screen, so the paint guide/template must be
38
+ that full size, not the 384x448 single screen."""
39
+ w = int(cam.range[0]) if cam.range and cam.range[0] else CANVAS_W
40
+ h = int(cam.range[1]) if cam.range and cam.range[1] else CANVAS_H
41
+ return (w, h)
42
+
43
+
44
+ def _height_ticks(wall_h: float) -> list:
45
+ """Sensible labeled heights up to wall_h (quarters)."""
46
+ return [round(wall_h * k / 4) for k in range(1, 5)]
47
+
48
+
49
+ def _height_segments(cam: _cam.Cam, frame: "FloorFrame", S: int, wall_h: float) -> list:
50
+ """Vertical perspective guides (as colored line segments) so the artist can paint WALLS at the
51
+ right height. A flat floor grid can't show how "up" foreshortens. World-accurate projection:
52
+ * vertical POLES at the floor's 4 corners + back/front mid-edges (y=0 -> wall_h),
53
+ * back-wall horizontal RINGS at each quarter-height tick,
54
+ * the room-box TOP outline (the ceiling rectangle).
55
+ Returns [(p0_px, p1_px, rgba), ...]; heights share the floor's world units so the scales match.
56
+ (Height tick *labels* are omitted in the stdlib renderer -- the CLI prints the coordinates.)"""
57
+ def pc(x, y, z):
58
+ cx, cy = _cam.to_canvas((x, y, z), cam)
59
+ return (cx * S, cy * S)
60
+
61
+ (blx, _, blz), (brx, _, brz), (frx, _, frz), (flx, _, flz) = frame.corners_world
62
+ bl, br, fr, fl = (blx, blz), (brx, brz), (frx, frz), (flx, flz)
63
+ bm = ((blx + brx) / 2.0, blz)
64
+ fm = ((flx + frx) / 2.0, flz)
65
+ POLE, RING, BOX = (90, 220, 235, 255), (90, 215, 230, 200), (130, 240, 255, 255)
66
+ segs = [(pc(x, 0, z), pc(x, wall_h, z), POLE) for (x, z) in (bl, br, fr, fl, bm, fm)]
67
+ segs += [(pc(bl[0], h, bl[1]), pc(br[0], h, br[1]), RING) for h in _height_ticks(wall_h)]
68
+ tops = [pc(x, wall_h, z) for (x, z) in (bl, br, fr, fl)]
69
+ segs += [(tops[k], tops[(k + 1) % 4], BOX) for k in range(4)]
70
+ return segs
71
+
72
+
73
+ def make_camera(pitch_deg: float, distance: float, *, fov_x_deg: float | None = None,
74
+ proj: int | None = None, yaw_deg: float = 0.0,
75
+ range_wh: tuple = (CANVAS_W, CANVAS_H),
76
+ depth_offset: int = DEFAULT_DEPTH_OFFSET,
77
+ viewport: tuple = DEFAULT_VIEWPORT,
78
+ center_offset: tuple = (0, 0)) -> _cam.Cam:
79
+ """Synthesize a Cam looking down at *pitch_deg* from *distance*, optional *yaw_deg*.
80
+
81
+ Provide either ``fov_x_deg`` or ``proj`` (H). The camera ORBITS the scene centre: position at
82
+ rot_y(yaw)·(0, D·sinθ, −D·cosθ), view rotation R = rot_x(pitch)·rot_y(−yaw), then synth_r_t.
83
+ The post-multiply (−yaw) is required: because the projection applies R AFTER the y-flip F,
84
+ pre-multiplying rot_y(yaw) would NOT keep the origin centred (the floor flies off-screen). This
85
+ form keeps (0,0,0) projecting to the canvas centre at every yaw (verified).
86
+ """
87
+ if proj is None:
88
+ if fov_x_deg is None:
89
+ raise ValueError("provide either fov_x_deg or proj")
90
+ proj = proj_from_fov_x(fov_x_deg, range_wh[0])
91
+ th = math.radians(pitch_deg)
92
+ Cpos = (0.0, distance * math.sin(th), -distance * math.cos(th))
93
+ R = _cam.rot_x(pitch_deg)
94
+ if yaw_deg:
95
+ R = _cam.mm(R, _cam.rot_y(-yaw_deg))
96
+ # orbit the camera position by yaw about the origin too, so it keeps looking at center
97
+ cy, sy = math.cos(math.radians(yaw_deg)), math.sin(math.radians(yaw_deg))
98
+ x, y, z = Cpos
99
+ Cpos = (cy * x + sy * z, y, -sy * x + cy * z)
100
+ cam = _cam.Cam()
101
+ cam.proj = proj
102
+ cam.centerOffset = list(center_offset)
103
+ cam.range = list(range_wh)
104
+ cam.depthOffset = depth_offset
105
+ cam.viewport = list(viewport)
106
+ cam.r, cam.t = _cam.synth_r_t(Cpos, R, proj)
107
+ return cam
108
+
109
+
110
+ @dataclass
111
+ class FloorFrame:
112
+ """A flat floor quad, in both world and painted-canvas coordinates."""
113
+
114
+ zb: int # world z of the back edge
115
+ zf: int # world z of the front edge
116
+ half_width: int # world x half-extent
117
+ corners_world: list # [BL, BR, FR, FL] as (x, 0, z)
118
+ corners_canvas: list # parallel [(cx, cy), ...]
119
+
120
+
121
+ def frame_floor(cam: _cam.Cam, *, back_canvas_y: float = 130.0, front_canvas_y: float = 420.0,
122
+ half_width: int | None = None, back_span_px: float = 130.0) -> FloorFrame:
123
+ """Frame a flat floor between two painted-canvas rows; auto half-width if not given.
124
+
125
+ Raises ValueError if a requested row is above the camera's horizon (unreachable) — typically a
126
+ too-shallow pitch. The message reports the horizon row so you can steepen the pitch or move the
127
+ floor rows below it."""
128
+ zb_f = _cam.solve_z_for_canvasY(cam, back_canvas_y)
129
+ zf_f = _cam.solve_z_for_canvasY(cam, front_canvas_y)
130
+ if zb_f is None or zf_f is None:
131
+ hy = _cam.horizon_canvas_y(cam)
132
+ bad = "back" if zb_f is None else "front"
133
+ val = back_canvas_y if zb_f is None else front_canvas_y
134
+ raise ValueError(
135
+ f"floor {bad} edge (canvas Y={val:g}) is above the horizon for this camera "
136
+ f"(pitch {_cam.pitch_deg(cam):.1f} deg, horizon at canvas Y~{hy:.0f}): no floor projects "
137
+ f"there. Use a steeper pitch, or keep the floor rows below Y~{hy:.0f} (larger values).")
138
+ zb, zf = round(zb_f), round(zf_f)
139
+ if half_width is None:
140
+ nb = abs(_cam.project((0, 0, zb), cam)[2]) # depth at back center
141
+ # scale-1 map: canvas half-span = half_width * proj / depth -> invert for half_width
142
+ half_width = int(round(back_span_px * nb / cam.proj))
143
+ fx = half_width
144
+ world = [(-fx, 0, zb), (fx, 0, zb), (fx, 0, zf), (-fx, 0, zf)] # BL, BR, FR, FL
145
+ canvas = [tuple(round(v, 1) for v in _cam.to_canvas(P, cam)) for P in world]
146
+ return FloorFrame(zb, zf, fx, world, canvas)
147
+
148
+
149
+ def walkmesh_corners(frame: FloorFrame) -> list:
150
+ """The 4 (x, z) corners for scene.bgi.quad(), ordered front-edge-first for a forward exit."""
151
+ # bgi.quad order v0,v1,v2,v3 with diagonal v0-v2; use back-left, back-right, front-right, front-left
152
+ return [(frame.corners_world[0][0], frame.corners_world[0][2]),
153
+ (frame.corners_world[1][0], frame.corners_world[1][2]),
154
+ (frame.corners_world[2][0], frame.corners_world[2][2]),
155
+ (frame.corners_world[3][0], frame.corners_world[3][2])]
156
+
157
+
158
+ def render_paint_guide(cam: _cam.Cam, frame: FloorFrame, png_path, *, scale: int = 4,
159
+ nx: int = 6, nz: int = 6, wall_height: float | None = None) -> tuple:
160
+ """Render an opaque checkerboard floor + reference cross-markers as a paint underlay (pure
161
+ stdlib, no PIL). ``wall_height`` adds vertical height guides (poles/rings/ceiling); ``None`` =
162
+ auto (the floor's depth), ``0`` = floor only. Coordinate values are PRINTED by the CLI, not
163
+ drawn (stdlib has no font), so the markers are crosses without text. Returns (W, H) px."""
164
+ from . import placeholder as _ph
165
+
166
+ S = scale
167
+ cw, ch = _canvas_wh(cam)
168
+ W, H = cw * S, ch * S
169
+ buf = bytearray(bytes((20, 22, 28, 255))) * (W * H) # opaque dark
170
+
171
+ def px(P):
172
+ cx, cy = _cam.to_canvas(P, cam)
173
+ return (cx * S, cy * S)
174
+
175
+ fx, zb, zf = frame.half_width, frame.zb, frame.zf
176
+ xs = [-fx + 2 * fx * i / nx for i in range(nx + 1)]
177
+ zs = [zb + (zf - zb) * j / nz for j in range(nz + 1)]
178
+ for j in range(nz):
179
+ for i in range(nx):
180
+ q = [px((xs[i], 0, zs[j])), px((xs[i + 1], 0, zs[j])),
181
+ px((xs[i + 1], 0, zs[j + 1])), px((xs[i], 0, zs[j + 1]))]
182
+ _ph._fill_quad(buf, W, H, q, (90, 110, 140, 255) if (i + j) % 2 == 0 else (50, 60, 80, 255))
183
+ edge = [px(P) for P in frame.corners_world]
184
+ for k in range(4):
185
+ _ph.draw_line(buf, W, H, edge[k], edge[(k + 1) % 4], (255, 180, 70, 255), 2)
186
+ _ph.draw_line(buf, W, H, edge[0], edge[1], (255, 180, 70, 255), 3) # back edge highlighted
187
+
188
+ def mark(P, col):
189
+ x, y = px(P)
190
+ _ph.draw_line(buf, W, H, (x - 18, y), (x + 18, y), col, 2)
191
+ _ph.draw_line(buf, W, H, (x, y - 18), (x, y + 18), col, 2)
192
+
193
+ mark((0, 0, 0), (90, 255, 120, 255)) # origin
194
+ mark((1000, 0, 0), (120, 200, 255, 255))
195
+ mark((-1000, 0, 0), (120, 200, 255, 255))
196
+ mark((0, 0, zb), (255, 120, 120, 255)) # floor back
197
+ mark((0, 0, zf), (255, 120, 120, 255)) # floor front
198
+ wall_h = abs(zb - zf) if wall_height is None else wall_height
199
+ if wall_h > 0:
200
+ for p0, p1, col in _height_segments(cam, frame, S, wall_h):
201
+ _ph.draw_line(buf, W, H, p0, p1, col, max(1, S // 2))
202
+ with open(png_path, "wb") as fh:
203
+ fh.write(_ph._png_rgba(W, H, buf))
204
+ return (W, H)
205
+
206
+
207
+ # --- transparent trace-over paint template (single-PNG default + opt-in per-layer PNGs) ----
208
+ _GRID_RGBA = (210, 215, 230, 90) # faint perspective grid
209
+ _OUTLINE_RGBA = (255, 170, 60, 255) # bright floor outline
210
+ _BORDER_RGBA = (120, 200, 255, 200) # canvas safe-frame
211
+ # Ordered bottom -> top (the paint-app layer order), each with the manifest opacity + blurb:
212
+ PAINT_TEMPLATE_LAYERS = (
213
+ ("grid", 0.35, "Perspective floor grid (alignment only)"),
214
+ ("outline", 1.0, "Floor outline + canvas safe-frame (where the floor lands)"),
215
+ ("height", 0.7, "Vertical height guides (corner poles / back rings / ceiling box)"),
216
+ )
217
+
218
+
219
+ def _draw_template_layer(buf: bytearray, W: int, H: int, layer: str, cam: _cam.Cam,
220
+ frame: "FloorFrame", S: int, nx: int, nz: int, wall_h: float) -> None:
221
+ """Draw ONE named paint-template layer into ``buf`` ('grid' | 'outline' | 'height'). Shared by the
222
+ single-PNG ``render_paint_template`` and the per-layer ``render_paint_template_layers`` so the two
223
+ can never drift. 'outline' carries the canvas border; 'height' is the colored vertical guides."""
224
+ from . import placeholder as _ph
225
+
226
+ def px(P):
227
+ cx, cy = _cam.to_canvas(P, cam)
228
+ return (cx * S, cy * S)
229
+
230
+ fx, zb, zf = frame.half_width, frame.zb, frame.zf
231
+ if layer == "grid":
232
+ xs = [-fx + 2 * fx * i / nx for i in range(nx + 1)]
233
+ zs = [zb + (zf - zb) * j / nz for j in range(nz + 1)]
234
+ for x in xs:
235
+ _ph.draw_line(buf, W, H, px((x, 0, zb)), px((x, 0, zf)), _GRID_RGBA, 1)
236
+ for z in zs:
237
+ _ph.draw_line(buf, W, H, px((-fx, 0, z)), px((fx, 0, z)), _GRID_RGBA, 1)
238
+ elif layer == "outline":
239
+ edge = [px(P) for P in frame.corners_world] # bright floor outline (back thicker)
240
+ for k in range(4):
241
+ _ph.draw_line(buf, W, H, edge[k], edge[(k + 1) % 4], _OUTLINE_RGBA, 2 * S)
242
+ _ph.draw_line(buf, W, H, edge[0], edge[1], _OUTLINE_RGBA, 3 * S)
243
+ for a, b in (((1, 1), (W - 2, 1)), ((W - 2, 1), (W - 2, H - 2)), # canvas border
244
+ ((W - 2, H - 2), (1, H - 2)), ((1, H - 2), (1, 1))):
245
+ _ph.draw_line(buf, W, H, a, b, _BORDER_RGBA, 2)
246
+ elif layer == "height":
247
+ if wall_h > 0:
248
+ for p0, p1, col in _height_segments(cam, frame, S, wall_h):
249
+ _ph.draw_line(buf, W, H, p0, p1, col, max(1, S // 2))
250
+
251
+
252
+ def render_paint_template(cam: _cam.Cam, frame: FloorFrame, png_path, *, scale: int = 4,
253
+ nx: int = 8, nz: int = 8, wall_height: float | None = None) -> tuple:
254
+ """Render a TRANSPARENT trace-over paint template (canvas_w*scale x canvas_h*scale), pure stdlib.
255
+
256
+ A transparent overlay: open it in your paint app, paint your room on layers BELOW it, then
257
+ hide/delete this layer. Draws a faint perspective floor grid + a bright floor outline + the
258
+ canvas border + vertical height guides. Coordinate labels are PRINTED by the CLI (no font in
259
+ stdlib). ``render_paint_template_layers`` writes the same content as separate per-layer PNGs.
260
+ Returns the image (w, h) in pixels.
261
+ """
262
+ from . import placeholder as _ph
263
+
264
+ S = scale
265
+ cw, ch = _canvas_wh(cam)
266
+ W, H = cw * S, ch * S
267
+ buf = bytearray(W * H * 4) # transparent
268
+ wall_h = abs(frame.zb - frame.zf) if wall_height is None else wall_height
269
+ for layer, _opacity, _desc in PAINT_TEMPLATE_LAYERS:
270
+ _draw_template_layer(buf, W, H, layer, cam, frame, S, nx, nz, wall_h)
271
+ with open(png_path, "wb") as fh:
272
+ fh.write(_ph._png_rgba(W, H, buf))
273
+ return (W, H)
274
+
275
+
276
+ def render_paint_template_layers(cam: _cam.Cam, frame: FloorFrame, out_dir, *, scale: int = 4,
277
+ nx: int = 8, nz: int = 8, wall_height: float | None = None,
278
+ basename: str = "paint_template") -> list:
279
+ """Write the paint template as SEPARATE transparent PNGs -- one per layer (grid / outline /
280
+ height) -- plus a ``<basename>.manifest.json`` listing them in bottom-to-top paint order with a
281
+ suggested opacity. Lets the artist toggle each guide independently in a paint app. The single-PNG
282
+ ``render_paint_template`` stays the default; this is the opt-in per-layer form. Returns the list
283
+ of written paths (the manifest last)."""
284
+ import json
285
+ import os
286
+
287
+ from . import placeholder as _ph
288
+
289
+ S = scale
290
+ cw, ch = _canvas_wh(cam)
291
+ W, H = cw * S, ch * S
292
+ wall_h = abs(frame.zb - frame.zf) if wall_height is None else wall_height
293
+ os.makedirs(out_dir, exist_ok=True)
294
+ written, entries = [], []
295
+ for layer, opacity, desc in PAINT_TEMPLATE_LAYERS:
296
+ buf = bytearray(W * H * 4)
297
+ _draw_template_layer(buf, W, H, layer, cam, frame, S, nx, nz, wall_h)
298
+ fn = f"{basename}_{layer}.png"
299
+ path = os.path.join(out_dir, fn)
300
+ with open(path, "wb") as fh:
301
+ fh.write(_ph._png_rgba(W, H, buf))
302
+ written.append(path)
303
+ entries.append({"file": fn, "type": layer, "opacity": opacity, "blend": "normal",
304
+ "description": desc})
305
+ man = {"version": 1, "canvas_size": [W, H], "scale": S, "layers": entries}
306
+ man_path = os.path.join(out_dir, f"{basename}.manifest.json")
307
+ with open(man_path, "w", encoding="utf-8", newline="\n") as fh:
308
+ json.dump(man, fh, indent=2)
309
+ fh.write("\n")
310
+ written.append(man_path)
311
+ return written