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/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.")
|
ff9mapkit/scene/guide.py
ADDED
|
@@ -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
|