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/bgi.py
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
"""FF9 walkmesh (``.bgi.bytes``) codec + a flat-floor builder.
|
|
2
|
+
|
|
3
|
+
The ``.bgi`` describes the invisible walkable geometry: a set of triangles (with vertex /
|
|
4
|
+
edge / neighbor links so the engine can path and detect borders), grouped into floors. This
|
|
5
|
+
module mirrors Memoria's ``BGI_DEF`` read/write exactly, so :meth:`BgiWalkmesh.to_bytes`
|
|
6
|
+
round-trips any real ``.bgi`` byte-for-byte, and adds:
|
|
7
|
+
|
|
8
|
+
* :func:`build_flat` — construct a complete ``.bgi`` from world-space vertices + triangle
|
|
9
|
+
faces (the human's Blender walkmesh, flat y=0 floor). Computes triangle centers, the
|
|
10
|
+
per-triangle edge entries, the neighbor/edgeClone links, and a single floor. This removes
|
|
11
|
+
the dependency on Memoria's in-editor ``ConvertToBGI``.
|
|
12
|
+
* :func:`load_obj` — read a Wavefront ``.obj`` (vertices in FF9 world coords, faces).
|
|
13
|
+
* :meth:`BgiWalkmesh.rebuild_neighbors` — recompute all neighbor/edgeClone links from
|
|
14
|
+
shared-vertex analysis (the ``bgi_fix_neighbors`` fix: ``ConvertToBGI`` links unreliably).
|
|
15
|
+
|
|
16
|
+
File layout (little-endian; offsets in the header are relative to byte 4):
|
|
17
|
+
magic u32 = 0xACDCDEAD ; dataSize u16 ;
|
|
18
|
+
orgPos curPos minPos maxPos charPos : BGI_VEC (3xi16 each) ;
|
|
19
|
+
activeFloor i16 ; activeTri i16 ;
|
|
20
|
+
{tri,edge,anm,floor,normal,vertex} x (count u16, offset u16) ;
|
|
21
|
+
sections in order: tris(40B) edges(4B) anms(16B) floors(32B) normals(16B) verts(6B)
|
|
22
|
+
then per-anm frames(8B), per-floor triNdx int32 list, per-frame triNdx.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import struct
|
|
28
|
+
from collections import deque
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
|
|
31
|
+
MAGIC = 0xACDCDEAD
|
|
32
|
+
# THE FRAME (confirmed from Memoria source -- WalkMesh.cs:53,141,227):
|
|
33
|
+
# world_vertex = vertexList[i] + floor.org + bgi.orgPos (collision uses *.cur, equal
|
|
34
|
+
# to *.org for a static field)
|
|
35
|
+
# So orgPos and each floor.org ARE a render/collision transform, NOT mere bookkeeping. A real .bgi
|
|
36
|
+
# stores each FLOOR's verts CORNER-ORIGIN in the floor's own frame and tiles them with floor.org,
|
|
37
|
+
# with bgi.orgPos placing the whole mesh in the world (== header.minPos by convention). The IMPORTER
|
|
38
|
+
# inverts this in BgiWalkmesh.world_verts(); this EXPORTER is its inverse: for authored/edited
|
|
39
|
+
# WORLD-coordinate geometry it emits orgPos=0 and every floor.org=0, so world_vertex = vertexList[i]
|
|
40
|
+
# verbatim -- what you author is exactly where the engine renders it. (header.minPos/maxPos and the
|
|
41
|
+
# per-floor min/max are loaded but UNUSED at runtime -- WavefrontObject.cs sets them, nothing reads
|
|
42
|
+
# them; charPos is the debug spawn. Verified in-game across GLGV/GRGR/BRMC/BMVL, 1..7 floors.)
|
|
43
|
+
HEADER_SIZE = 64 # magic+dataSize+5*vec(30)+activeFloor/Tri(4)+12*u16(24)
|
|
44
|
+
FIRST_SECTION_REL = 0x3C # triOffset (relative to byte 4)
|
|
45
|
+
TRI_SIZE, EDGE_SIZE, ANM_SIZE, FLOOR_SIZE, NORMAL_SIZE, VERT_SIZE = 40, 4, 16, 32, 16, 6
|
|
46
|
+
|
|
47
|
+
# slot -> the pair of triangle-local vertex indices forming that edge (BGI convention)
|
|
48
|
+
SLOT_PAIRS = [(0, 2), (0, 1), (1, 2)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _pt_in_tri_xz(px, pz, a, b, c) -> bool:
|
|
52
|
+
"""(px,pz) inside-or-on triangle (a,b,c) using only X and Z (top-down). Same-sign barycentric."""
|
|
53
|
+
def cross(p, q):
|
|
54
|
+
return (px - q[0]) * (p[2] - q[2]) - (p[0] - q[0]) * (pz - q[2])
|
|
55
|
+
d1, d2, d3 = cross(a, b), cross(b, c), cross(c, a)
|
|
56
|
+
return not ((d1 < 0 or d2 < 0 or d3 < 0) and (d1 > 0 or d2 > 0 or d3 > 0))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pt_seg_dist_xz(px, pz, a, b) -> float:
|
|
60
|
+
"""Distance from (px,pz) to segment a-b in the XZ plane (Y ignored)."""
|
|
61
|
+
ax, az, bx, bz = a[0], a[2], b[0], b[2]
|
|
62
|
+
dx, dz = bx - ax, bz - az
|
|
63
|
+
l2 = dx * dx + dz * dz
|
|
64
|
+
t = 0.0 if l2 == 0 else max(0.0, min(1.0, ((px - ax) * dx + (pz - az) * dz) / l2))
|
|
65
|
+
cx, cz = ax + t * dx, az + t * dz
|
|
66
|
+
return ((px - cx) ** 2 + (pz - cz) ** 2) ** 0.5
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _i16(b, o):
|
|
70
|
+
return struct.unpack_from("<h", b, o)[0]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _u16(b, o):
|
|
74
|
+
return struct.unpack_from("<H", b, o)[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _i32(b, o):
|
|
78
|
+
return struct.unpack_from("<i", b, o)[0]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Vec3:
|
|
83
|
+
x: int = 0
|
|
84
|
+
y: int = 0
|
|
85
|
+
z: int = 0
|
|
86
|
+
|
|
87
|
+
def pack(self) -> bytes:
|
|
88
|
+
return struct.pack("<hhh", self.x, self.y, self.z)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def read(cls, b, o):
|
|
92
|
+
return cls(_i16(b, o), _i16(b, o + 2), _i16(b, o + 4))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class FVec:
|
|
97
|
+
coord: tuple = (0, 0, 0)
|
|
98
|
+
one_over_y: int = 0
|
|
99
|
+
|
|
100
|
+
def pack(self) -> bytes:
|
|
101
|
+
return struct.pack("<iiii", self.coord[0], self.coord[1], self.coord[2], self.one_over_y)
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def read(cls, b, o):
|
|
105
|
+
return cls((_i32(b, o), _i32(b, o + 4), _i32(b, o + 8)), _i32(b, o + 12))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class Tri:
|
|
110
|
+
tri_flags: int = 1
|
|
111
|
+
tri_data: int = 0
|
|
112
|
+
floor_ndx: int = 0
|
|
113
|
+
normal_ndx: int = -1
|
|
114
|
+
theta_x: int = 0
|
|
115
|
+
theta_z: int = 0
|
|
116
|
+
vtx: list = field(default_factory=lambda: [0, 0, 0])
|
|
117
|
+
edge: list = field(default_factory=lambda: [0, 0, 0])
|
|
118
|
+
nbr: list = field(default_factory=lambda: [-1, -1, -1])
|
|
119
|
+
center: Vec3 = field(default_factory=Vec3)
|
|
120
|
+
d: int = 0
|
|
121
|
+
|
|
122
|
+
def pack(self) -> bytes:
|
|
123
|
+
return (struct.pack("<HHhhhh", self.tri_flags, self.tri_data, self.floor_ndx,
|
|
124
|
+
self.normal_ndx, self.theta_x, self.theta_z)
|
|
125
|
+
+ struct.pack("<hhh", *self.vtx)
|
|
126
|
+
+ struct.pack("<hhh", *self.edge)
|
|
127
|
+
+ struct.pack("<hhh", *self.nbr)
|
|
128
|
+
+ self.center.pack()
|
|
129
|
+
+ struct.pack("<i", self.d))
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def read(cls, b, o):
|
|
133
|
+
t = cls()
|
|
134
|
+
t.tri_flags, t.tri_data, t.floor_ndx, t.normal_ndx, t.theta_x, t.theta_z = \
|
|
135
|
+
struct.unpack_from("<HHhhhh", b, o)
|
|
136
|
+
t.vtx = list(struct.unpack_from("<hhh", b, o + 12))
|
|
137
|
+
t.edge = list(struct.unpack_from("<hhh", b, o + 18))
|
|
138
|
+
t.nbr = list(struct.unpack_from("<hhh", b, o + 24))
|
|
139
|
+
t.center = Vec3.read(b, o + 30)
|
|
140
|
+
t.d = _i32(b, o + 36)
|
|
141
|
+
return t
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class Edge:
|
|
146
|
+
flags: int = 0
|
|
147
|
+
clone: int = -1
|
|
148
|
+
|
|
149
|
+
def pack(self) -> bytes:
|
|
150
|
+
return struct.pack("<Hh", self.flags, self.clone)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def read(cls, b, o):
|
|
154
|
+
return cls(_u16(b, o), _i16(b, o + 2))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class Floor:
|
|
159
|
+
flags: int = 0
|
|
160
|
+
ndx: int = 0
|
|
161
|
+
org: Vec3 = field(default_factory=Vec3)
|
|
162
|
+
cur: Vec3 = field(default_factory=Vec3)
|
|
163
|
+
min: Vec3 = field(default_factory=Vec3)
|
|
164
|
+
max: Vec3 = field(default_factory=Vec3)
|
|
165
|
+
tri_ndx_list: list = field(default_factory=list)
|
|
166
|
+
|
|
167
|
+
def pack_struct(self, tri_ndx_offset: int) -> bytes:
|
|
168
|
+
return (struct.pack("<HH", self.flags, self.ndx)
|
|
169
|
+
+ self.org.pack() + self.cur.pack() + self.min.pack() + self.max.pack()
|
|
170
|
+
+ struct.pack("<HH", len(self.tri_ndx_list), tri_ndx_offset))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class Anm:
|
|
175
|
+
flags: int = 0
|
|
176
|
+
frame_rate: int = 0
|
|
177
|
+
counter: int = 0
|
|
178
|
+
cur_frame: int = 0
|
|
179
|
+
frames: list = field(default_factory=list) # list of (flags, value, tri_idx_list)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class BgiWalkmesh:
|
|
183
|
+
def __init__(self):
|
|
184
|
+
self.orgPos = Vec3(0, 0, 300)
|
|
185
|
+
self.curPos = Vec3(0, 0, 300)
|
|
186
|
+
self.minPos = Vec3(0, 0, 300)
|
|
187
|
+
self.maxPos = Vec3(0, 0, 300)
|
|
188
|
+
self.charPos = Vec3(0, 0, 300)
|
|
189
|
+
self.activeFloor = 0
|
|
190
|
+
self.activeTri = 0
|
|
191
|
+
self.tris: list[Tri] = []
|
|
192
|
+
self.edges: list[Edge] = []
|
|
193
|
+
self.anms: list[Anm] = []
|
|
194
|
+
self.floors: list[Floor] = []
|
|
195
|
+
self.normals: list[FVec] = []
|
|
196
|
+
self.verts: list[Vec3] = []
|
|
197
|
+
|
|
198
|
+
# ---------------- world transform (corner-origin per-floor -> world) ----------------
|
|
199
|
+
def vert_floor_map(self) -> dict:
|
|
200
|
+
"""vert index -> floor index. A real .bgi gives each floor a DISJOINT vertex set, so this
|
|
201
|
+
is well-defined; unowned verts map to floor 0."""
|
|
202
|
+
m = {}
|
|
203
|
+
for fi, fl in enumerate(self.floors):
|
|
204
|
+
for ti in fl.tri_ndx_list:
|
|
205
|
+
if 0 <= ti < len(self.tris):
|
|
206
|
+
for vi in self.tris[ti].vtx:
|
|
207
|
+
m[vi] = fi
|
|
208
|
+
return m
|
|
209
|
+
|
|
210
|
+
def world_verts(self):
|
|
211
|
+
"""World (camera/art/engine) position of each vertex = vert + header.orgPos + its floor.org.
|
|
212
|
+
|
|
213
|
+
Each FLOOR stores its verts CORNER-ORIGIN in the floor's own frame; `floor.org` tiles the
|
|
214
|
+
floors and the header `orgPos` places the whole walkmesh in the world. Verified: this lands
|
|
215
|
+
every GRGR vert exactly inside the header [minPos,maxPos] and tiles its 7 floors into a
|
|
216
|
+
coherent centred tunnel. Single-floor fields have floor.org=(0,0,0), so this reduces to
|
|
217
|
+
vert + orgPos (GLGV unchanged). This is the exact transform the EXPORTER inverts."""
|
|
218
|
+
vf = self.vert_floor_map()
|
|
219
|
+
op = self.orgPos
|
|
220
|
+
out = []
|
|
221
|
+
for i, v in enumerate(self.verts):
|
|
222
|
+
fi = vf.get(i)
|
|
223
|
+
fo = self.floors[fi].org if (fi is not None and fi < len(self.floors)) else Vec3(0, 0, 0)
|
|
224
|
+
out.append((v.x + op.x + fo.x, v.y + op.y + fo.y, v.z + op.z + fo.z))
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
# ---------------- connectivity (the navmesh adjacency graph) ----------------
|
|
228
|
+
def all_floors(self) -> set:
|
|
229
|
+
return {t.floor_ndx for t in self.tris}
|
|
230
|
+
|
|
231
|
+
def reachable_floors(self, start_tri: int | None = None) -> set:
|
|
232
|
+
"""Floor indices walk-reachable from start_tri (default activeTri) by following triangle
|
|
233
|
+
neighbor links -- the SAME links the engine pathfinds over. If this is a strict subset of
|
|
234
|
+
all_floors(), some floors are stranded (unreachable on foot).
|
|
235
|
+
|
|
236
|
+
This is the build-time guard for the obj->build connectivity loss: a multi-floor walkmesh
|
|
237
|
+
gives each floor a DISJOINT vertex set, so rebuild_neighbors (which links by shared vertex
|
|
238
|
+
index) can only connect within a floor -- cross-floor seams vanish and the player is trapped.
|
|
239
|
+
The .bgi codec itself preserves the original links; only the obj intermediate drops them."""
|
|
240
|
+
start = start_tri if start_tri is not None else self.activeTri
|
|
241
|
+
if not (0 <= start < len(self.tris)):
|
|
242
|
+
start = 0
|
|
243
|
+
seen, q = set(), deque([start])
|
|
244
|
+
while q:
|
|
245
|
+
t = q.popleft()
|
|
246
|
+
if t in seen or not (0 <= t < len(self.tris)):
|
|
247
|
+
continue
|
|
248
|
+
seen.add(t)
|
|
249
|
+
for n in self.tris[t].nbr:
|
|
250
|
+
if n >= 0:
|
|
251
|
+
q.append(n)
|
|
252
|
+
return {self.tris[t].floor_ndx for t in seen}
|
|
253
|
+
|
|
254
|
+
def tri_components(self) -> list:
|
|
255
|
+
"""Connected components of triangles by neighbor links -- each a list of triangle indices. A walkmesh
|
|
256
|
+
that splits into >1 component has walled-off regions (e.g. a shop counter separating the customer area
|
|
257
|
+
from the behind-counter pocket); the largest on-screen component is the player's free-roam area, so a
|
|
258
|
+
fork should spawn there (not stranded in a pocket). Single-region walkmesh -> one component."""
|
|
259
|
+
seen, comps = set(), []
|
|
260
|
+
for s in range(len(self.tris)):
|
|
261
|
+
if s in seen:
|
|
262
|
+
continue
|
|
263
|
+
comp, q = [], deque([s])
|
|
264
|
+
while q:
|
|
265
|
+
t = q.popleft()
|
|
266
|
+
if t in seen or not (0 <= t < len(self.tris)):
|
|
267
|
+
continue
|
|
268
|
+
seen.add(t)
|
|
269
|
+
comp.append(t)
|
|
270
|
+
for n in self.tris[t].nbr:
|
|
271
|
+
if n >= 0:
|
|
272
|
+
q.append(n)
|
|
273
|
+
comps.append(comp)
|
|
274
|
+
return comps
|
|
275
|
+
|
|
276
|
+
# ---------------- seam extract / reconcile (the editable-multi-floor sidecar; WALKMESH_EDITING.md) ----------------
|
|
277
|
+
def _tri_floor(self) -> dict:
|
|
278
|
+
return {ti: fi for fi, fl in enumerate(self.floors) for ti in fl.tri_ndx_list}
|
|
279
|
+
|
|
280
|
+
def _edge_world_pos(self, wv, ti, slot):
|
|
281
|
+
"""The slot's edge as a sorted pair of world positions (a stable, renumber-proof key)."""
|
|
282
|
+
i, j = SLOT_PAIRS[slot]
|
|
283
|
+
return tuple(sorted((wv[self.tris[ti].vtx[i]], wv[self.tris[ti].vtx[j]])))
|
|
284
|
+
|
|
285
|
+
def extract_seams(self):
|
|
286
|
+
"""Cross-floor seams as (a_floor, a_edge, b_floor, b_edge); each edge a sorted world-position
|
|
287
|
+
pair. This is the adjacency a geometry-only `.obj` can't carry (rebuild_neighbors links only
|
|
288
|
+
within a floor, and FF9 floors use disjoint vertex sets). Pair with `apply_seams` to reconcile
|
|
289
|
+
an edited obj against an imported field. Validated game-wide (tools/sweep_seams.py)."""
|
|
290
|
+
wv = self.world_verts()
|
|
291
|
+
fo = self._tri_floor()
|
|
292
|
+
seams, seen = [], set()
|
|
293
|
+
for ti, t in enumerate(self.tris):
|
|
294
|
+
fa = fo.get(ti)
|
|
295
|
+
for k in range(3):
|
|
296
|
+
nb = t.nbr[k]
|
|
297
|
+
if nb < 0 or nb >= len(self.tris) or fo.get(nb) == fa:
|
|
298
|
+
continue
|
|
299
|
+
key = (min(ti, nb), max(ti, nb))
|
|
300
|
+
if key in seen:
|
|
301
|
+
continue
|
|
302
|
+
seen.add(key)
|
|
303
|
+
ec = self.edges[t.edge[k]].clone if 0 <= t.edge[k] < len(self.edges) else -1
|
|
304
|
+
a = self._edge_world_pos(wv, ti, k)
|
|
305
|
+
b = self._edge_world_pos(wv, nb, ec) if 0 <= ec < 3 else None
|
|
306
|
+
seams.append((fa, a, fo.get(nb), b))
|
|
307
|
+
return seams
|
|
308
|
+
|
|
309
|
+
def apply_seams(self, seams):
|
|
310
|
+
"""Link cross-floor neighbors by matching each seam's edge endpoints by WORLD POSITION (this
|
|
311
|
+
mesh already has intra-floor links from `bgi.build`). Sets `nbr` + `edgeClone` on both sides,
|
|
312
|
+
the same convention as `rebuild_neighbors`. Returns (linked, missing, misses) -- a miss means
|
|
313
|
+
a seam's connecting edge was moved/deleted in the edit. The v2 reconcile."""
|
|
314
|
+
wv = self.world_verts()
|
|
315
|
+
fo = self._tri_floor()
|
|
316
|
+
lut = {}
|
|
317
|
+
for ti in range(len(self.tris)):
|
|
318
|
+
for k in range(3):
|
|
319
|
+
lut[(fo[ti], self._edge_world_pos(wv, ti, k))] = (ti, k)
|
|
320
|
+
linked = missing = 0
|
|
321
|
+
misses = []
|
|
322
|
+
for (fa, a_edge, fb, b_edge) in seams:
|
|
323
|
+
ta = lut.get((fa, a_edge))
|
|
324
|
+
tb = lut.get((fb, b_edge)) if b_edge else None
|
|
325
|
+
if ta and tb:
|
|
326
|
+
(ia, sa), (ib, sb) = ta, tb
|
|
327
|
+
self.tris[ia].nbr[sa] = ib
|
|
328
|
+
self.tris[ib].nbr[sb] = ia
|
|
329
|
+
self.edges[self.tris[ia].edge[sa]].clone = sb
|
|
330
|
+
self.edges[self.tris[ib].edge[sb]].clone = sa
|
|
331
|
+
linked += 1
|
|
332
|
+
else:
|
|
333
|
+
missing += 1
|
|
334
|
+
misses.append((fa, a_edge, fb, b_edge))
|
|
335
|
+
return linked, missing, misses
|
|
336
|
+
|
|
337
|
+
# ---------------- placement / geometry validation (catch authoring mistakes pre-build) ----------------
|
|
338
|
+
def point_on_walkmesh(self, x, z):
|
|
339
|
+
"""Floor index of the triangle whose XZ-projection contains (x, z), else None. Validates that
|
|
340
|
+
authored content (NPC / player spawn / gateway zone) sits on the walkable area before build --
|
|
341
|
+
a top-down point-in-triangle test (multi-floor fields can overlap in XZ; returns first match)."""
|
|
342
|
+
wv = self.world_verts()
|
|
343
|
+
fo = self._tri_floor()
|
|
344
|
+
for ti, t in enumerate(self.tris):
|
|
345
|
+
if _pt_in_tri_xz(x, z, wv[t.vtx[0]], wv[t.vtx[1]], wv[t.vtx[2]]):
|
|
346
|
+
return fo.get(ti, t.floor_ndx)
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
def height_at(self, x, z):
|
|
350
|
+
"""The floor Y (world height) at (x, z): the triangle whose XZ-projection contains (x, z),
|
|
351
|
+
barycentric-interpolated from its 3 verts. None if off-mesh. A navigable ladder's dismount must
|
|
352
|
+
land at the floor's REAL height -- otherwise the jump arcs to Y=0 and SetPathing snaps you up
|
|
353
|
+
(the fall+slingshot). Lets the builder auto-fill an omitted floor_landing/top_landing Y."""
|
|
354
|
+
wv = self.world_verts()
|
|
355
|
+
for t in self.tris:
|
|
356
|
+
a, b, c = wv[t.vtx[0]], wv[t.vtx[1]], wv[t.vtx[2]] # each vert = (x, y, z)
|
|
357
|
+
if _pt_in_tri_xz(x, z, a, b, c):
|
|
358
|
+
den = (b[2] - c[2]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[2] - c[2])
|
|
359
|
+
if den == 0:
|
|
360
|
+
return int(round(a[1]))
|
|
361
|
+
wa = ((b[2] - c[2]) * (x - c[0]) + (c[0] - b[0]) * (z - c[2])) / den
|
|
362
|
+
wb = ((c[2] - a[2]) * (x - c[0]) + (a[0] - c[0]) * (z - c[2])) / den
|
|
363
|
+
return int(round(wa * a[1] + wb * b[1] + (1 - wa - wb) * c[1]))
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
def distance_to_boundary(self, x, z):
|
|
367
|
+
"""Min XZ distance from (x,z) to the nearest collision WALL of the floor it sits on -- a
|
|
368
|
+
walkmesh-boundary edge (one with no neighbor across it). A cross-floor SEAM is NOT a wall
|
|
369
|
+
(the player crosses it to the next floor), so a seamed edge is skipped. Returns None if the
|
|
370
|
+
point is off the walkmesh. The player's CENTRE can't get within ~COLLISION_RADIUS_W of a
|
|
371
|
+
wall, so content closer than that to an edge may be unreachable / shoved inward in-game."""
|
|
372
|
+
floor = self.point_on_walkmesh(x, z)
|
|
373
|
+
if floor is None:
|
|
374
|
+
return None
|
|
375
|
+
wv = self.world_verts()
|
|
376
|
+
fo = self._tri_floor()
|
|
377
|
+
best = None
|
|
378
|
+
for ti, t in enumerate(self.tris):
|
|
379
|
+
if fo.get(ti, t.floor_ndx) != floor:
|
|
380
|
+
continue
|
|
381
|
+
for k in range(3):
|
|
382
|
+
if t.nbr[k] >= 0: # neighbor across this edge -> not a wall
|
|
383
|
+
continue
|
|
384
|
+
i, j = SLOT_PAIRS[k]
|
|
385
|
+
d = _pt_seg_dist_xz(x, z, wv[t.vtx[i]], wv[t.vtx[j]])
|
|
386
|
+
if best is None or d < best:
|
|
387
|
+
best = d
|
|
388
|
+
return best
|
|
389
|
+
|
|
390
|
+
def degenerate_tris(self):
|
|
391
|
+
"""Triangle indices with ~zero XZ area -- collinear/vertical verts make a DEAD ZONE in the
|
|
392
|
+
engine's IsInQuad fan test (the player can't stand there). Almost always an editing mistake."""
|
|
393
|
+
wv = self.world_verts()
|
|
394
|
+
out = []
|
|
395
|
+
for ti, t in enumerate(self.tris):
|
|
396
|
+
a, b, c = wv[t.vtx[0]], wv[t.vtx[1]], wv[t.vtx[2]]
|
|
397
|
+
if (b[0] - a[0]) * (c[2] - a[2]) - (c[0] - a[0]) * (b[2] - a[2]) == 0:
|
|
398
|
+
out.append(ti)
|
|
399
|
+
return out
|
|
400
|
+
|
|
401
|
+
# ---------------- parse ----------------
|
|
402
|
+
@classmethod
|
|
403
|
+
def from_bytes(cls, data: bytes) -> "BgiWalkmesh":
|
|
404
|
+
b = bytes(data)
|
|
405
|
+
if struct.unpack_from("<I", b, 0)[0] != MAGIC:
|
|
406
|
+
raise ValueError("not a .bgi (bad magic)")
|
|
407
|
+
self = cls()
|
|
408
|
+
self.orgPos = Vec3.read(b, 6)
|
|
409
|
+
self.curPos = Vec3.read(b, 12)
|
|
410
|
+
self.minPos = Vec3.read(b, 18)
|
|
411
|
+
self.maxPos = Vec3.read(b, 24)
|
|
412
|
+
self.charPos = Vec3.read(b, 30)
|
|
413
|
+
self.activeFloor = _i16(b, 36)
|
|
414
|
+
self.activeTri = _i16(b, 38)
|
|
415
|
+
triCount, triOff = _u16(b, 40), _u16(b, 42)
|
|
416
|
+
edgeCount, edgeOff = _u16(b, 44), _u16(b, 46)
|
|
417
|
+
anmCount, anmOff = _u16(b, 48), _u16(b, 50)
|
|
418
|
+
floorCount, floorOff = _u16(b, 52), _u16(b, 54)
|
|
419
|
+
normalCount, normalOff = _u16(b, 56), _u16(b, 58)
|
|
420
|
+
vertexCount, vertexOff = _u16(b, 60), _u16(b, 62)
|
|
421
|
+
for i in range(triCount):
|
|
422
|
+
self.tris.append(Tri.read(b, 4 + triOff + i * TRI_SIZE))
|
|
423
|
+
for i in range(edgeCount):
|
|
424
|
+
self.edges.append(Edge.read(b, 4 + edgeOff + i * EDGE_SIZE))
|
|
425
|
+
raw_anms = []
|
|
426
|
+
for i in range(anmCount):
|
|
427
|
+
o = 4 + anmOff + i * ANM_SIZE
|
|
428
|
+
flags, fcount, frate, counter = struct.unpack_from("<HHhH", b, o)
|
|
429
|
+
curframe = _i32(b, o + 8)
|
|
430
|
+
foff = struct.unpack_from("<I", b, o + 12)[0]
|
|
431
|
+
raw_anms.append((flags, fcount, frate, counter, curframe, foff))
|
|
432
|
+
for i in range(floorCount):
|
|
433
|
+
o = 4 + floorOff + i * FLOOR_SIZE
|
|
434
|
+
fl = Floor()
|
|
435
|
+
fl.flags, fl.ndx = struct.unpack_from("<HH", b, o)
|
|
436
|
+
fl.org = Vec3.read(b, o + 4)
|
|
437
|
+
fl.cur = Vec3.read(b, o + 10)
|
|
438
|
+
fl.min = Vec3.read(b, o + 16)
|
|
439
|
+
fl.max = Vec3.read(b, o + 22)
|
|
440
|
+
tcount, toff = struct.unpack_from("<HH", b, o + 28)
|
|
441
|
+
fl.tri_ndx_list = [_i32(b, 4 + toff + k * 4) for k in range(tcount)]
|
|
442
|
+
self.floors.append(fl)
|
|
443
|
+
for i in range(normalCount):
|
|
444
|
+
self.normals.append(FVec.read(b, 4 + normalOff + i * NORMAL_SIZE))
|
|
445
|
+
for i in range(vertexCount):
|
|
446
|
+
self.verts.append(Vec3.read(b, 4 + vertexOff + i * VERT_SIZE))
|
|
447
|
+
# anm frames (after vertices); preserved for round-trip fidelity
|
|
448
|
+
for (flags, fcount, frate, counter, curframe, foff) in raw_anms:
|
|
449
|
+
a = Anm(flags, frate, counter, curframe)
|
|
450
|
+
for j in range(fcount):
|
|
451
|
+
fo = 4 + foff + j * 8
|
|
452
|
+
fflags, value, tcount, toff = struct.unpack_from("<HhHH", b, fo)
|
|
453
|
+
idxs = [_i32(b, 4 + toff + k * 4) for k in range(tcount)]
|
|
454
|
+
a.frames.append((fflags, value, idxs))
|
|
455
|
+
self.anms.append(a)
|
|
456
|
+
return self
|
|
457
|
+
|
|
458
|
+
@classmethod
|
|
459
|
+
def from_file(cls, path) -> "BgiWalkmesh":
|
|
460
|
+
with open(path, "rb") as fh:
|
|
461
|
+
return cls.from_bytes(fh.read())
|
|
462
|
+
|
|
463
|
+
# ---------------- serialize (mirrors BGI_DEF.WriteData + UpdateOffsets) ----------------
|
|
464
|
+
def to_bytes(self) -> bytes:
|
|
465
|
+
triCount, edgeCount = len(self.tris), len(self.edges)
|
|
466
|
+
anmCount, floorCount = len(self.anms), len(self.floors)
|
|
467
|
+
normalCount, vertexCount = len(self.normals), len(self.verts)
|
|
468
|
+
|
|
469
|
+
off = FIRST_SECTION_REL
|
|
470
|
+
triOff = off; off += TRI_SIZE * triCount
|
|
471
|
+
edgeOff = off; off += EDGE_SIZE * edgeCount
|
|
472
|
+
anmOff = off; off += ANM_SIZE * anmCount
|
|
473
|
+
floorOff = off; off += FLOOR_SIZE * floorCount
|
|
474
|
+
normalOff = off; off += NORMAL_SIZE * normalCount
|
|
475
|
+
vertexOff = off; off += VERT_SIZE * vertexCount
|
|
476
|
+
# per-anm frame tables
|
|
477
|
+
frame_offsets = []
|
|
478
|
+
for a in self.anms:
|
|
479
|
+
frame_offsets.append(off); off += 8 * len(a.frames)
|
|
480
|
+
# per-floor tri-index lists
|
|
481
|
+
floor_list_offsets = []
|
|
482
|
+
for fl in self.floors:
|
|
483
|
+
floor_list_offsets.append(off); off += 4 * len(fl.tri_ndx_list)
|
|
484
|
+
# per-frame tri-index lists
|
|
485
|
+
frame_list_offsets = []
|
|
486
|
+
for ai, a in enumerate(self.anms):
|
|
487
|
+
row = []
|
|
488
|
+
for (_f, _v, idxs) in a.frames:
|
|
489
|
+
row.append(off); off += 4 * len(idxs)
|
|
490
|
+
frame_list_offsets.append(row)
|
|
491
|
+
data_size = off
|
|
492
|
+
|
|
493
|
+
out = bytearray()
|
|
494
|
+
out += struct.pack("<IH", MAGIC, data_size & 0xFFFF)
|
|
495
|
+
for v in (self.orgPos, self.curPos, self.minPos, self.maxPos, self.charPos):
|
|
496
|
+
out += v.pack()
|
|
497
|
+
out += struct.pack("<hh", self.activeFloor, self.activeTri)
|
|
498
|
+
out += struct.pack("<HHHHHHHHHHHH",
|
|
499
|
+
triCount, triOff, edgeCount, edgeOff, anmCount, anmOff,
|
|
500
|
+
floorCount, floorOff, normalCount, normalOff, vertexCount, vertexOff)
|
|
501
|
+
assert len(out) == HEADER_SIZE, len(out)
|
|
502
|
+
# the writer seeks to absolute offsets; our sections are contiguous in the same order,
|
|
503
|
+
# so we can simply append. Pad to data_size+4 at the end.
|
|
504
|
+
for t in self.tris:
|
|
505
|
+
out += t.pack()
|
|
506
|
+
for e in self.edges:
|
|
507
|
+
out += e.pack()
|
|
508
|
+
for ai, a in enumerate(self.anms):
|
|
509
|
+
out += struct.pack("<HHhHiI", a.flags, len(a.frames), a.frame_rate, a.counter,
|
|
510
|
+
a.cur_frame, frame_offsets[ai])
|
|
511
|
+
for fi, fl in enumerate(self.floors):
|
|
512
|
+
out += fl.pack_struct(floor_list_offsets[fi])
|
|
513
|
+
for nrm in self.normals:
|
|
514
|
+
out += nrm.pack()
|
|
515
|
+
for v in self.verts:
|
|
516
|
+
out += v.pack()
|
|
517
|
+
# trailing variable-length tables, in UpdateOffsets order: anm frames, floor lists, frame lists
|
|
518
|
+
for ai, a in enumerate(self.anms):
|
|
519
|
+
for fj, (fflags, value, idxs) in enumerate(a.frames):
|
|
520
|
+
out += struct.pack("<HhHH", fflags, value, len(idxs), frame_list_offsets[ai][fj])
|
|
521
|
+
for fi, fl in enumerate(self.floors):
|
|
522
|
+
for idx in fl.tri_ndx_list:
|
|
523
|
+
out += struct.pack("<i", idx)
|
|
524
|
+
for ai, a in enumerate(self.anms):
|
|
525
|
+
for (fflags, value, idxs) in a.frames:
|
|
526
|
+
for idx in idxs:
|
|
527
|
+
out += struct.pack("<i", idx)
|
|
528
|
+
return bytes(out)
|
|
529
|
+
|
|
530
|
+
# ---------------- neighbor rebuild (bgi_fix_neighbors) ----------------
|
|
531
|
+
def rebuild_neighbors(self) -> None:
|
|
532
|
+
"""Recompute all neighbor + edgeClone links from shared-vertex analysis."""
|
|
533
|
+
for t in self.tris:
|
|
534
|
+
t.nbr = [-1, -1, -1]
|
|
535
|
+
for e in self.edges:
|
|
536
|
+
e.clone = -1
|
|
537
|
+
|
|
538
|
+
def slot_of(tri: Tri, a: int, c: int):
|
|
539
|
+
s = {a, c}
|
|
540
|
+
for k, (i, j) in enumerate(SLOT_PAIRS):
|
|
541
|
+
if {tri.vtx[i], tri.vtx[j]} == s:
|
|
542
|
+
return k
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
n = len(self.tris)
|
|
546
|
+
for ia in range(n):
|
|
547
|
+
for ib in range(ia + 1, n):
|
|
548
|
+
shared = set(self.tris[ia].vtx) & set(self.tris[ib].vtx)
|
|
549
|
+
if len(shared) != 2:
|
|
550
|
+
continue
|
|
551
|
+
a, c = tuple(shared)
|
|
552
|
+
sa, sb = slot_of(self.tris[ia], a, c), slot_of(self.tris[ib], a, c)
|
|
553
|
+
if sa is None or sb is None:
|
|
554
|
+
continue
|
|
555
|
+
self.tris[ia].nbr[sa] = ib
|
|
556
|
+
self.tris[ib].nbr[sb] = ia
|
|
557
|
+
self.edges[self.tris[ia].edge[sa]].clone = sb
|
|
558
|
+
self.edges[self.tris[ib].edge[sb]].clone = sa
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ----------------------------------------------------------------------------- builders
|
|
562
|
+
|
|
563
|
+
def _bbox(verts):
|
|
564
|
+
xs = [v[0] for v in verts]
|
|
565
|
+
ys = [v[1] for v in verts]
|
|
566
|
+
zs = [v[2] for v in verts]
|
|
567
|
+
return (min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs))
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _check_int16(verts):
|
|
571
|
+
for (x, y, z) in verts: # .bgi stores verts as Int16
|
|
572
|
+
for v in (x, y, z):
|
|
573
|
+
if not (-32768 <= round(v) <= 32767):
|
|
574
|
+
raise ValueError(
|
|
575
|
+
f"walkmesh vertex coordinate {v:.0f} exceeds the .bgi Int16 range +/-32767 "
|
|
576
|
+
f"-- the room/floor is too large in world units; scale it down (FF9 rooms are "
|
|
577
|
+
f"typically a few thousand units across).")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def build(verts, faces, *, floor_ids=None, tri_flags: int = 1, floor_flags: int = 0,
|
|
581
|
+
org=(0, 0, 0), header_min=None, header_max=None, char=None) -> BgiWalkmesh:
|
|
582
|
+
"""Build a complete walkmesh from WORLD-space verts + triangle faces (the EXPORTER).
|
|
583
|
+
|
|
584
|
+
This is the inverse of :meth:`BgiWalkmesh.world_verts`: with ``org=(0,0,0)`` and every floor at
|
|
585
|
+
``org=(0,0,0)`` the engine renders ``world = vert + 0 + 0``, so the verts you pass ARE the
|
|
586
|
+
in-game positions (see the frame note at the top of this module).
|
|
587
|
+
|
|
588
|
+
verts : iterable of (x, y, z) — FF9 WORLD coords (flat floor => y = 0)
|
|
589
|
+
faces : iterable of (i, j, k) — 0-based vertex indices (one triangle each)
|
|
590
|
+
floor_ids : optional per-FACE floor id (len == len(faces)). ``None`` => one floor with every
|
|
591
|
+
triangle (the flat case). Distinct ids => one BGI floor each — a multi-level room or
|
|
592
|
+
a faithful re-export of an imported real field (e.g. GRGR's 7 floors). Tris are
|
|
593
|
+
grouped by id; each floor sits at org=cur=(0,0,0) (verts already carry world height).
|
|
594
|
+
|
|
595
|
+
Computes triangle centers, the 3-edges-per-triangle table, and neighbor/edgeClone links via
|
|
596
|
+
shared-vertex analysis. header.minPos/maxPos default to the true world bounding box (informative;
|
|
597
|
+
the engine ignores them — see the frame note); charPos (debug spawn) defaults to the bbox centre.
|
|
598
|
+
"""
|
|
599
|
+
verts = [(round(x), round(y), round(z)) for (x, y, z) in verts]
|
|
600
|
+
faces = [tuple(f) for f in faces]
|
|
601
|
+
_check_int16(verts)
|
|
602
|
+
if not verts or not faces:
|
|
603
|
+
raise ValueError("walkmesh has no geometry (need at least one vertex and one triangle) -- "
|
|
604
|
+
"check the .obj has both 'v' and 'f' lines.")
|
|
605
|
+
nv = len(verts)
|
|
606
|
+
for ti, f in enumerate(faces):
|
|
607
|
+
if len(f) != 3:
|
|
608
|
+
raise ValueError(f"walkmesh face {ti} has {len(f)} vertices, expected 3 (a triangle).")
|
|
609
|
+
for vi in f:
|
|
610
|
+
if not (0 <= vi < nv):
|
|
611
|
+
raise ValueError(f"walkmesh face {ti} references vertex index {vi}, out of range "
|
|
612
|
+
f"0..{nv - 1} -- a malformed or mis-edited .obj.")
|
|
613
|
+
if floor_ids is None:
|
|
614
|
+
floor_ids = [0] * len(faces)
|
|
615
|
+
if len(floor_ids) != len(faces):
|
|
616
|
+
raise ValueError(f"floor_ids has {len(floor_ids)} entries for {len(faces)} faces")
|
|
617
|
+
|
|
618
|
+
m = BgiWalkmesh()
|
|
619
|
+
bmin, bmax = _bbox(verts) if verts else ((0, 0, 0), (0, 0, 0))
|
|
620
|
+
hmin = tuple(header_min) if header_min is not None else bmin
|
|
621
|
+
hmax = tuple(header_max) if header_max is not None else bmax
|
|
622
|
+
hchar = tuple(char) if char is not None else ((bmin[0] + bmax[0]) // 2,
|
|
623
|
+
(bmin[1] + bmax[1]) // 2,
|
|
624
|
+
(bmin[2] + bmax[2]) // 2)
|
|
625
|
+
m.orgPos = Vec3(*org)
|
|
626
|
+
m.curPos = Vec3(*org)
|
|
627
|
+
m.minPos = Vec3(*hmin)
|
|
628
|
+
m.maxPos = Vec3(*hmax)
|
|
629
|
+
m.charPos = Vec3(*hchar)
|
|
630
|
+
m.verts = [Vec3(x, y, z) for (x, y, z) in verts]
|
|
631
|
+
|
|
632
|
+
# distinct floor ids in first-seen order -> contiguous BGI floor indices 0..N-1
|
|
633
|
+
order = []
|
|
634
|
+
for fid in floor_ids:
|
|
635
|
+
if fid not in order:
|
|
636
|
+
order.append(fid)
|
|
637
|
+
remap = {fid: i for i, fid in enumerate(order)}
|
|
638
|
+
|
|
639
|
+
for ti, (i, j, k) in enumerate(faces):
|
|
640
|
+
t = Tri(tri_flags=tri_flags, floor_ndx=remap[floor_ids[ti]], normal_ndx=-1)
|
|
641
|
+
t.vtx = [i, j, k]
|
|
642
|
+
t.edge = [ti * 3 + 0, ti * 3 + 1, ti * 3 + 2]
|
|
643
|
+
cx = (m.verts[i].x + m.verts[j].x + m.verts[k].x) / 3.0
|
|
644
|
+
cy = (m.verts[i].y + m.verts[j].y + m.verts[k].y) / 3.0
|
|
645
|
+
cz = (m.verts[i].z + m.verts[j].z + m.verts[k].z) / 3.0
|
|
646
|
+
t.center = Vec3(int(round(cx)), int(round(cy)), int(round(cz)))
|
|
647
|
+
m.tris.append(t)
|
|
648
|
+
m.edges += [Edge(0, -1), Edge(0, -1), Edge(0, -1)]
|
|
649
|
+
|
|
650
|
+
for fi, fid in enumerate(order): # floor.org=cur=min=max=(0,0,0): verts carry world
|
|
651
|
+
tris = [ti for ti, f in enumerate(floor_ids) if f == fid]
|
|
652
|
+
m.floors.append(Floor(flags=floor_flags, ndx=fi, tri_ndx_list=tris))
|
|
653
|
+
m.rebuild_neighbors()
|
|
654
|
+
return m
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def build_flat(verts, faces, *, tri_flags: int = 1, floor_flags: int = 0,
|
|
658
|
+
header_vec=(0, 0, 300)) -> BgiWalkmesh:
|
|
659
|
+
"""Legacy single-floor builder with a UNIFORM header vector (a thin wrapper over :func:`build`).
|
|
660
|
+
|
|
661
|
+
Equivalent to ``build(..., org=header_vec, header_min/max=header_vec, char=header_vec)`` with one
|
|
662
|
+
floor — kept so the proven HUT / auto-frame pipeline (calibrated against ``header_vec=(0,0,300)``)
|
|
663
|
+
stays byte-for-byte unchanged. NEW multi-floor / world-frame authoring calls :func:`build`
|
|
664
|
+
(``org`` defaults to 0, so what you author is exactly where the engine renders it).
|
|
665
|
+
"""
|
|
666
|
+
return build(verts, faces, floor_ids=None, tri_flags=tri_flags, floor_flags=floor_flags,
|
|
667
|
+
org=header_vec, header_min=header_vec, header_max=header_vec, char=header_vec)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def quad(corners) -> BgiWalkmesh:
|
|
671
|
+
"""Build a 2-triangle quad floor from 4 corners (each (x, z) or (x, y, z)).
|
|
672
|
+
|
|
673
|
+
Vertex order around the quad: v0, v1, v2, v3 (tri0 = v0v1v2, tri1 = v0v2v3, diagonal v0-v2)
|
|
674
|
+
— the convention the proven HUT walkmesh uses.
|
|
675
|
+
"""
|
|
676
|
+
verts = []
|
|
677
|
+
for c in corners:
|
|
678
|
+
if len(c) == 2:
|
|
679
|
+
verts.append((c[0], 0, c[1]))
|
|
680
|
+
else:
|
|
681
|
+
verts.append(tuple(c))
|
|
682
|
+
if len(verts) != 4:
|
|
683
|
+
raise ValueError("quad() needs exactly 4 corners")
|
|
684
|
+
return build_flat(verts, [(0, 1, 2), (0, 2, 3)])
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def load_obj_floors(path):
|
|
688
|
+
"""Parse a Wavefront .obj into (verts, faces, floor_ids), one floor per ``o``/``g`` object.
|
|
689
|
+
|
|
690
|
+
Each ``o <name>`` (or ``g <name>``) starts a new floor; a repeated name reuses its floor; faces
|
|
691
|
+
before any object go to floor 0. Vertices are FF9 world coords (shared across floors — OBJ vertex
|
|
692
|
+
indices are file-global). Faces with >3 verts are fan-triangulated; refs may be ``a/b/c``.
|
|
693
|
+
"""
|
|
694
|
+
verts, faces, floor_ids = [], [], []
|
|
695
|
+
names, cur, next_id = {}, 0, 0
|
|
696
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
697
|
+
for line in fh:
|
|
698
|
+
s = line.split()
|
|
699
|
+
if not s:
|
|
700
|
+
continue
|
|
701
|
+
if s[0] == "v":
|
|
702
|
+
verts.append((float(s[1]), float(s[2]), float(s[3])))
|
|
703
|
+
elif s[0] in ("o", "g"):
|
|
704
|
+
name = s[1] if len(s) > 1 else ""
|
|
705
|
+
if name not in names:
|
|
706
|
+
names[name] = next_id
|
|
707
|
+
next_id += 1
|
|
708
|
+
cur = names[name]
|
|
709
|
+
elif s[0] == "f":
|
|
710
|
+
idx = [int(tok.split("/")[0]) - 1 for tok in s[1:]] # 1-based -> 0-based
|
|
711
|
+
for k in range(1, len(idx) - 1):
|
|
712
|
+
faces.append((idx[0], idx[k], idx[k + 1]))
|
|
713
|
+
floor_ids.append(cur)
|
|
714
|
+
return verts, faces, floor_ids
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def load_obj(path):
|
|
718
|
+
"""Parse a Wavefront .obj into (verts, faces) — floors merged. See :func:`load_obj_floors`."""
|
|
719
|
+
verts, faces, _ = load_obj_floors(path)
|
|
720
|
+
return verts, faces
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def obj_to_bgi(path, **kwargs) -> bytes:
|
|
724
|
+
"""Convert a Wavefront .obj walkmesh (FF9 world coords) to .bgi bytes.
|
|
725
|
+
|
|
726
|
+
Multiple ``o``/``g`` objects => a multi-floor world-frame walkmesh (:func:`build`, org=0); a
|
|
727
|
+
single object => the legacy flat builder (:func:`build_flat`), so existing output is unchanged.
|
|
728
|
+
"""
|
|
729
|
+
verts, faces, floor_ids = load_obj_floors(path)
|
|
730
|
+
if len(set(floor_ids)) > 1:
|
|
731
|
+
return build(verts, faces, floor_ids=floor_ids).to_bytes()
|
|
732
|
+
return build_flat(verts, faces, **kwargs).to_bytes()
|