diff --git a/starfab/app.py b/starfab/app.py index cddbe78983ae1a05c6ad092894b0d05a06642423..37a57eb84172f3824b843b677fd612a42a96f6d4 100644 --- a/starfab/app.py +++ b/starfab/app.py @@ -14,8 +14,10 @@ from qtpy.QtWidgets import QMainWindow import starfab.gui.widgets.dock_widgets.datacore_widget import starfab.gui.widgets.dock_widgets.file_view + from scdatatools.sc import StarCitizen from starfab.gui.widgets.pages.content import ContentView +from starfab.planets import HAS_COMPUSHADY from . import __version__, updates from .blender import BlenderManager from .gui import qtg, qtw, qtc @@ -121,6 +123,7 @@ class StarFab(QMainWindow): self.data_page_btn = None self.content_page_btn = None self.nav_page_btn = None + self.planets_page_btn = None # ------------- actions ----------------- # keyboard shortcuts, for QKeySequence see https://doc.qt.io/qtforpython-5/PySide2/QtGui/QKeySequence.html @@ -142,6 +145,10 @@ class StarFab(QMainWindow): self.actionContentView.triggered.connect(self._handle_workspace_action) self.actionNavView.setIcon(qta.icon("mdi6.map-marker-path")) self.actionNavView.triggered.connect(self._handle_workspace_action) + self.actionPlanetView.setIcon(qta.icon("ph.planet")) + if HAS_COMPUSHADY: + self.actionPlanetView.triggered.connect(self._handle_workspace_action) + self._open_settings = self.actionSettings self.actionSettings.setIcon(qta.icon("msc.settings-gear")) self._show_console = self.actionConsole @@ -184,6 +191,10 @@ class StarFab(QMainWindow): self.page_NavView = NavView(self) self.stackedWidgetWorkspace.addWidget(self.page_NavView) + if HAS_COMPUSHADY: + self.page_PlanetView = PlanetView(self) + self.stackedWidgetWorkspace.addWidget(self.page_PlanetView) + self.dock_widgets = {} self.setup_dock_widgets() self._progress_tasks = {} @@ -243,6 +254,14 @@ class StarFab(QMainWindow): self.nav_page_btn.released.connect(self.handle_workspace) self.workspace_panel.add_ribbon_widget(self.nav_page_btn) + self.planets_page_btn = RibbonButton(self, self.actionPlanetView, True) + self.planets_page_btn.setAutoExclusive(True) + self.planets_page_btn.released.connect(self.handle_workspace) + self.workspace_panel.add_ribbon_widget(self.planets_page_btn) + if not HAS_COMPUSHADY: + self.planets_page_btn.setEnabled(False) + self.planets_page_btn.setToolTip("You must install the 'compushady' package to use the planet viewer.") + self.options_panel = self.home_tab.add_ribbon_pane("Options") self.options_panel.add_ribbon_widget( RibbonButton(self, self._open_settings, True) @@ -378,7 +397,8 @@ Contributors: self.hide() self.close.emit() finally: - sys.exit(0) + print("closing!") + #sys.exit(0) def _refresh_recent(self, recent=None): if recent is None: @@ -751,6 +771,9 @@ Contributors: self.content_page_btn, self.nav_page_btn] + if HAS_COMPUSHADY: + self.workspace_btns.append(self.planets_page_btn) + def _clear_checked(self): for btn in self.workspace_btns: btn.setChecked(False) @@ -763,6 +786,8 @@ Contributors: else: self.stackedWidgetWorkspace.setCurrentWidget(self.page_OpenView) + logger.info(f"Switching to workspace: {view}") + if self.sc is None: _clear_checked(self) elif view == "data": @@ -773,4 +798,7 @@ Contributors: self.stackedWidgetWorkspace.setCurrentWidget(self.page_ContentView) elif view == "nav": self.nav_page_btn.setChecked(True) - self.stackedWidgetWorkspace.setCurrentWidget(self.page_NavView) \ No newline at end of file + self.stackedWidgetWorkspace.setCurrentWidget(self.page_NavView) + elif view == "planets": + self.planets_page_btn.setChecked(True) + self.stackedWidgetWorkspace.setCurrentWidget(self.page_PlanetView) \ No newline at end of file diff --git a/starfab/contrib/qtvscodestyle/stylesheet/build.py b/starfab/contrib/qtvscodestyle/stylesheet/build.py index e3ea40d62ff4120d68e30d3b8a92bcfd2b3ba6af..ae40a3277395580710463acb98c32e6f28ca3090 100644 --- a/starfab/contrib/qtvscodestyle/stylesheet/build.py +++ b/starfab/contrib/qtvscodestyle/stylesheet/build.py @@ -4,7 +4,7 @@ import json import operator import re from dataclasses import dataclass -from distutils.version import StrictVersion +from packaging.version import Version from importlib import resources from pathlib import Path from typing import Optional @@ -53,7 +53,7 @@ def _parse_env_patch(stylesheet: str) -> dict[str, str]: else: raise SyntaxError(f"invalid character in qualifier. Available qualifiers {list(operators.keys())}") - is_true = operators[qualifier](StrictVersion(QT_VERSION), StrictVersion(version)) + is_true = operators[qualifier](Version(QT_VERSION), Version(version)) replacements[match_text] = property["value"] if is_true else "" return replacements diff --git a/starfab/gui/widgets/image_viewer.py b/starfab/gui/widgets/image_viewer.py index 4f8d7fee80be730780ac213b1c3c122edc9176bf..cdb4fa17eac0116471ee193584e968df53b53b19 100644 --- a/starfab/gui/widgets/image_viewer.py +++ b/starfab/gui/widgets/image_viewer.py @@ -17,7 +17,6 @@ SUPPORTED_IMG_FORMATS = set(Image.EXTENSION.keys()) SUPPORTED_IMG_FORMATS.update(['.' + bytes(_).decode('utf-8') for _ in qtg.QImageReader.supportedImageFormats()]) DDS_CONV_FORMAT = "png" - class QImageViewer(qtw.QGraphicsView): """PyQt image viewer widget for a QPixmap in a QGraphicsView scene with mouse zooming and panning. Displays a QImage or QPixmap (QImage is internally converted to a QPixmap). @@ -59,7 +58,7 @@ class QImageViewer(qtw.QGraphicsView): self.act_save_as.triggered.connect(self._handle_save_as) # Store a local handle to the scene's current image pixmap. - self.image = qtw.QGraphicsPixmapItem() + self.image: qtw.QGraphicsPixmapItem = qtw.QGraphicsPixmapItem() self.scene.addItem(self.image) # Scroll bar behaviour. @@ -113,10 +112,11 @@ class QImageViewer(qtw.QGraphicsView): self.scale(factor, factor) self._zoom = 0 - def setImage(self, image): + def setImage(self, image, fit: bool = True): """Set the scene's current image pixmap to the input QImage or QPixmap. Raises a RuntimeError if the input image has type other than QImage or QPixmap. :type image: QImage | QPixmap + :type fit: bool """ self._zoom = 0 if isinstance(image, qtg.QPixmap): @@ -135,7 +135,8 @@ class QImageViewer(qtw.QGraphicsView): self._empty = True self.setDragMode(qtw.QGraphicsView.NoDrag) self.image.setPixmap(qtg.QPixmap()) - self.fitInView() + if fit: + self.fitInView() def wheelEvent(self, event): if self.hasImage(): diff --git a/starfab/gui/widgets/pages/__init__.py b/starfab/gui/widgets/pages/__init__.py index 3e20f04a57a8198686b09b7f2f476d6f86a64c35..8e2ba8a233a07bc5817aa0dd8c3d84d49cba04be 100644 --- a/starfab/gui/widgets/pages/__init__.py +++ b/starfab/gui/widgets/pages/__init__.py @@ -1,3 +1,7 @@ +from starfab.planets import HAS_COMPUSHADY + from .page_DataView import DataView from .page_NavView import NavView -from .content import ContentView \ No newline at end of file +if HAS_COMPUSHADY: + from .page_PlanetView import PlanetView +from .content import ContentView diff --git a/starfab/gui/widgets/pages/page_PlanetView.py b/starfab/gui/widgets/pages/page_PlanetView.py new file mode 100644 index 0000000000000000000000000000000000000000..42fee4f353e9054551d3f2db226f1a4d1bc5467e --- /dev/null +++ b/starfab/gui/widgets/pages/page_PlanetView.py @@ -0,0 +1,457 @@ +import io +from operator import itemgetter +from typing import Union, cast, NamedTuple + +from PIL import Image +from PySide6.QtCore import Qt, QPointF, QRectF, QItemSelectionModel, QItemSelection +from PySide6.QtGui import QStandardItem, QStandardItemModel +from PySide6.QtWidgets import QComboBox, QPushButton, QLabel, QCheckBox, QListView +from qtpy import uic +from scdatatools import StarCitizen +from scdatatools.sc.object_container import ObjectContainer, ObjectContainerInstance + +from starfab.gui import qtw, qtc +from starfab.gui.widgets.planets.planet_viewer import QPlanetViewer +from starfab.gui.widgets.planets.waypoint_overlay import WaypointOverlay +from starfab.log import getLogger +from starfab.planets import * +from starfab.planets.planet import Planet +from starfab.planets.data import RenderSettings +from starfab.planets.ecosystem import EcoSystem +from starfab.planets.planet_renderer import PlanetRenderer, RenderResult +from starfab.resources import RES_PATH +from starfab.settings import settings +from pathlib import Path + + +logger = getLogger(__name__) + + +class SolarSystem(NamedTuple): + id: str + name: str + model: QStandardItemModel + + +class PlanetView(qtw.QWidget): + def __init__(self, sc): + super().__init__(parent=None) + + self.renderButton: QPushButton = None + self.exportButton: QPushButton = None + self.systemComboBox: QComboBox = None + self.planetComboBox: QComboBox = None + self.renderResolutionComboBox: QComboBox = None + self.coordinateSystemComboBox: QComboBox = None + self.sampleModeComboBox: QComboBox = None + self.outputResolutionComboBox: QComboBox = None + self.heightmapBitDepthComboBox: QComboBox = None + self.displayModeComboBox: QComboBox = None + self.displayLayerComboBox: QComboBox = None + self.renderOutput: QPlanetViewer = None + self.enableGridCheckBox: QCheckBox = None + self.enableCrosshairCheckBox: QCheckBox = None + self.enableWaypointsCheckBox: QCheckBox = None + self.enableEcosystemBlendingCheckBox: QCheckBox = None + self.enableHillshadeCheckBox: QCheckBox = None + self.hillshadeLevelComboBox: QComboBox = None + self.enableBinaryOceanMaskCheckBox: QCheckBox = None + self.debugGridCheckBox: QCheckBox = None + self.debugModeComboBox: QComboBox = None + self.listWaypoints: QListView = None + self.lbl_planetDetails: QLabel = None + self.lbl_currentStatus: QLabel = None + uic.loadUi(str(RES_PATH / "ui" / "PlanetView.ui"), self) # Load the ui into self + + self.starmap = None + + self.solar_systems: dict[str, SolarSystem] = {} # mapping of solar system GUID to SolarSystem namedtuple + self.planets: dict[str, Planet] = {} # mapping of planet entity_name to Planet object + + self.renderer = PlanetRenderer((2048, 1024)) + self.last_render: Union[None, RenderResult] = None + + self.renderResolutionComboBox.setModel(self.create_model([ + ("1px per tile", 1), + ("2px per tile", 2), + ("4px per tile", 4), + ("8px per tile", 8), + ("16px per tile", 16), + ("32px per tile", 32), + ("64px per tile", 64), + ("128px per tile", 128) + ])) + self.renderResolutionComboBox.currentIndexChanged.connect(self._render_scale_changed) + + self.coordinateSystemComboBox.setModel(self.create_model([ + ("NASA Format (0/360deg) - Community Standard", "NASA"), + ("Earth Format (-180/180deg) Shifted", "EarthShifted"), + ("Earth Format (-180/180deg) Unshifted", "EarthUnShifted") + ])) + self.coordinateSystemComboBox.currentIndexChanged.connect(self._display_coordinate_system_changed) + + self.sampleModeComboBox.setModel(self.create_model([ + ("Nearest Neighbor", 0), + ("Bi-Linear", 1), + ("Bi-Cubic", 2) + ])) + self.sampleModeComboBox.setCurrentIndex(1) + + self.outputResolutionComboBox.setModel(self.create_model([ + ("2MP - 2,048 x 1,024", (2048, 1024)), + ("8MP - 4,096 x 2,048", (4096, 2048)), + ("32MP - 8,192 x 4,096", (8192, 4096)), + ("128MP - 16,384 x 8,192", (16384, 8192)) + ])) + self.outputResolutionComboBox.currentIndexChanged.connect(self._display_resolution_changed) + + self.heightmapBitDepthComboBox.setModel(self.create_model([ + ("8-Bit Greyscale (PNG)", 8), + ("16-Bit Greyscale (PNG)", 16), + ("32-Bit Greyscale (TIFF)", 32) + ])) + + self.displayModeComboBox.setModel(self.create_model([ + ("Pixel-Perfect", qtc.Qt.FastTransformation), + ("Smooth", qtc.Qt.SmoothTransformation) + ])) + self.displayModeComboBox.currentIndexChanged.connect(self._display_mode_changed) + + self.displayLayerComboBox.setModel(self.create_model([ + ("Surface", "surface"), + ("Heightmap", "heightmap"), + ("Ocean Mask", "ocean_mask") + ])) + self.displayLayerComboBox.currentIndexChanged.connect(self._display_layer_changed) + + self.hillshadeLevelComboBox.setModel(self.create_model([ + ("Weak", 0.5), + ("Normal", 1), + ("Strong", 2) + ])) + + self.debugModeComboBox.setModel(self.create_model([ + ("None", 0), + ("Mask", 1 << 1), + ("Terrain UV (no mask)", 1 << 2), + ("Patch UV (no mask)", 1 << 3), + ("Terrain Ecosystem (no mask)", 1 << 4), + ("Terrain UV", 1 << 5), + ("Patch UV", 1 << 6), + ("Terrain Ecosystem", 1 << 7) + ])) + + if isinstance(sc, StarCitizen): + self.sc = sc + self._handle_datacore_loaded() + else: + self.sc = sc.sc_manager + self.sc.datacore_model.loaded.connect(self._hack_before_load) + self.sc.datacore_model.unloading.connect(self._handle_datacore_unloading) + + self.systemComboBox.currentIndexChanged.connect(self._system_changed) + self.planetComboBox.currentIndexChanged.connect(self._planet_changed) + self.renderButton.clicked.connect(self._do_render) + self.exportButton.clicked.connect(self._do_export) + self.exportButton.setEnabled(False) + self.renderOutput.crosshair_moved.connect(self._do_crosshair_moved) + self.renderOutput.render_window_moved.connect(self._do_render_window_changed) + self.enableGridCheckBox.stateChanged.connect(self.renderOutput.lyr_grid.set_enabled) + self.enableCrosshairCheckBox.stateChanged.connect(self.renderOutput.lyr_crosshair.set_enabled) + self.enableWaypointsCheckBox.stateChanged.connect(self.renderOutput.lyr_waypoints.set_enabled) + + self.renderer.set_settings(self.get_settings()) + self._planet_changed() + + def _get_selected_planet(self): + planet_id: str = self.planetComboBox.currentData(role=Qt.UserRole) + try: + return self.planets[planet_id] + except KeyError: + return None + + def _system_changed(self): + system_id: str = self.systemComboBox.currentData(role=Qt.UserRole) + self.planetComboBox.setModel(self.solar_systems[system_id].model) + + def _planet_changed(self): + # TODO: Pre-load ecosystem data here w/ progressbar + self._update_waypoints() + + def _render_scale_changed(self): + new_scale = self.renderResolutionComboBox.currentData(role=Qt.UserRole) + self.renderer.settings.resolution = new_scale + self._update_planet_viewer() + + def _display_resolution_changed(self): + new_resolution = self.outputResolutionComboBox.currentData(role=Qt.UserRole) + self.renderer.set_resolution(new_resolution) + self._update_planet_viewer() + + def _display_mode_changed(self): + new_transform = self.displayModeComboBox.currentData(role=Qt.UserRole) + self.renderOutput.image.setTransformationMode(new_transform) + + def _display_coordinate_system_changed(self): + new_coordinate_mode = self.coordinateSystemComboBox.currentData(role=Qt.UserRole) + self.renderer.settings.coordinate_mode = new_coordinate_mode + self._update_planet_viewer() + + def _update_planet_viewer(self): + if not self.renderer.planet: + return + + planet_bounds = self.renderer.get_outer_bounds() + render_bounds = self.renderOutput.get_render_coords() + self.renderOutput.update_bounds(planet_bounds, + self.renderer.get_bounds_for_render(render_bounds.topLeft())) + + def _update_waypoints(self): + planet = self._get_selected_planet() + if not planet: + return + planet.load_waypoints() + + waypoint_records = [(wp.container.display_name, wp) for wp in planet.waypoints] + waypoint_model = self.create_model(waypoint_records) + waypoint_selection = QItemSelectionModel(waypoint_model) + waypoint_selection.selectionChanged.connect(self._waypoint_changed) + self.listWaypoints.setModel(waypoint_model) + self.listWaypoints.setSelectionModel(waypoint_selection) + self.renderOutput.set_waypoints(planet.waypoints) + + def _waypoint_changed(self, selected: QItemSelection, removed: QItemSelection): + if selected.size() == 0: + return + waypoint = selected.indexes()[0].data(role=Qt.UserRole) + self.renderOutput.set_selected_waypoint(waypoint) + + def _display_layer_changed(self): + layer = self.displayLayerComboBox.currentData(role=Qt.UserRole) + self.renderOutput.update_visible_layer(layer) + + def _update_image(self, image: Image): + # self.renderOutput.setImage(ImageQt.ImageQt(image), fit=False) + pass + + def _hack_before_load(self): + # Hacky method to support faster dev testing and launching directly in-app + self.sc = self.sc.sc + EcoSystem.read_eco_headers(self.sc) + self._handle_datacore_loaded() + + @staticmethod + def create_model(records): + # Create a model + model = QStandardItemModel() + + # Add items to the model with visible name and hidden ID + for item_text, item_id in records: + item = QStandardItem(item_text) + # Set the hidden ID in the user role of the item + item.setData(item_id, role=Qt.UserRole) + model.appendRow(item) + + return model + + @staticmethod + def shader_path(name) -> Path: + return Path(__file__) / f'../../../../planets/hlsl/{name}' + + @staticmethod + def _get_shader(name): + with io.open(PlanetView.shader_path(name), "r") as shader: + return hlsl.compile(shader.read()) + + def get_settings(self): + scale = self.renderResolutionComboBox.currentData(role=Qt.UserRole) + coordinates = self.coordinateSystemComboBox.currentData(role=Qt.UserRole) + interpolation = self.sampleModeComboBox.currentData(role=Qt.UserRole) + resolution = self.outputResolutionComboBox.currentData(role=Qt.UserRole) + hm_bitdepth = self.heightmapBitDepthComboBox.currentData(role=Qt.UserRole) + main_shader = self._get_shader("shader.hlsl") + hillshade_shader = self._get_shader("hillshade.hlsl") + blending_enabled = self.enableEcosystemBlendingCheckBox.isChecked() + hillshade_enabled = self.enableHillshadeCheckBox.isChecked() + hillshade_level = self.hillshadeLevelComboBox.currentData(role=Qt.UserRole) + ocean_mask_binary = self.enableBinaryOceanMaskCheckBox.isChecked() + + debug_mode = 0 + debug_mode += 1 if self.debugGridCheckBox.isChecked() else 0 + debug_mode += self.debugModeComboBox.currentData(role=Qt.UserRole) + + return RenderSettings(True, scale, coordinates, + main_shader, hillshade_shader, + interpolation, resolution, + blending_enabled, + hillshade_enabled, hillshade_level, ocean_mask_binary, + hm_bitdepth, debug_mode) + + def _do_render(self): + selected_obj = self._get_selected_planet() + if not selected_obj: + return + selected_obj.load_data() + + # TODO: Deal with buffer directly + try: + self.renderer.set_planet(selected_obj) + self.renderer.set_settings(self.get_settings()) + + layer = self.displayLayerComboBox.currentData(role=Qt.UserRole) + render_bounds = self.renderOutput.get_render_coords() + self.last_render = self.renderer.render(render_bounds.topLeft()) + self._display_layer_changed() + self.renderOutput.update_render(self.last_render, layer) + self.exportButton.setEnabled(True) + except Exception as ex: + logger.exception(ex) + + def _do_export(self): + prev_dir = settings.value("exportDirectory") + layer = self.displayLayerComboBox.currentData(role=Qt.UserRole) + + title = "Save Render to..." + format = "png" + formatfilter = "PNG Image (*.png)" + if self.renderer.settings.heightmap_bit_depth == 32 and layer == 'heightmap': + format = "tiff" + formatfilter = "TIFF Image (*.tiff)" + + edir = qtw.QFileDialog.getSaveFileName(self, title, + dir=f"{self.renderer.planet.oc.entity_name}.{format}", + filter=formatfilter) + + filename, filter = edir + if filename: + if layer == 'surface': + self.last_render.tex_color.save(filename, format="png") + elif layer == 'heightmap': + if self.renderer.settings.heightmap_bit_depth == 32: + self.last_render.tex_heightmap.save(filename, format="tiff") + else: + self.last_render.tex_heightmap.save(filename, format="png") + elif layer == 'ocean_mask': + self.last_render.tex_oceanmask.save(filename, format="png") + else: + raise ValueError() + + def _do_crosshair_moved(self, new_position: QPointF): + self._update_status() + + def _do_render_window_changed(self, new_window: QRectF): + self._update_status() + + def _update_status(self): + cross: QPointF = self.renderOutput.get_crosshair_coords() + render_window: QRectF = self.renderOutput.get_render_coords() + self.lbl_currentStatus.setText(f"Crosshair:\n" + f"\tLat:\t\t{self.coord_to_dms(cross.x())}\n" + f"\tLon:\t\t{self.coord_to_dms(cross.y())}\n" + f"\n" + f"Render Window:\n" + f"\tLeft Lat: \t{self.coord_to_dms(render_window.left())}\n" + f"\tRight Lat: \t{self.coord_to_dms(render_window.right())}\n" + f"\tTop Lat: \t{self.coord_to_dms(render_window.top())}\n" + f"\tBottom Lat:\t{self.coord_to_dms(render_window.bottom())}") + + @staticmethod + def coord_to_dms(coord): + degrees = int(coord) + minutes_float = (coord - degrees) * 60 + minutes = int(minutes_float) + seconds = (minutes_float - minutes) * 60 + return f"{degrees}° {minutes}' {seconds:.2f}" + + def _handle_datacore_unloading(self): + if self.starmap is not None: + del self.starmap + self.starmap = None + + def _handle_datacore_loaded(self): + logger.info("DataCore loaded") + + # TODO: A more graceful selection and/or detection of solar systems may be needed eventually. + # Normal Stanton-only builds use megamap.pu.xml and have only Stanton in their 'SolarSystems' property. + # The Pyro Playground Tech-Preview from late 2023 added a pyro.xml containing only Pyro, but the + # Stanton-only megamap.xml was also still preset (but not functional due to other files missing). + # The server meshing Tech-Preview from March 2024 introduced a pu_all.xml containing both Stanton and Pyro, + # but the other megamaps were also still present. + # For now, check all three megamaps in decreasing order of interest and use the first one found. + for filename in [ + 'libs/foundry/records/megamap/pu_all.xml', # Server meshing Tech-Preview builds containing both Stanton and Pyro + 'libs/foundry/records/megamap/pyro.xml', # Pyro playground Tech-Preview + 'libs/foundry/records/megamap/megamap.pu.xml', # default megamap record for Stanton-only builds + ]: + res = self.sc.datacore.search_filename(filename) + if res: + megamap_pu = res[0] + break + else: + logger.error("No megamap record found") + return + + # megamap_pu = self.sc.datacore.search_filename(f'libs/foundry/records/megamap/megamap.pu.xml')[0] + # pu_socpak = megamap_pu.properties['SolarSystems'][0].properties['ObjectContainers'][0].value + + for solar_system in megamap_pu.properties['SolarSystems']: + solar_system_guid = str(solar_system.properties['Record']) + solar_system_record = self.sc.datacore.records_by_guid[solar_system_guid] + + pu_socpak = solar_system.properties['ObjectContainers'][0].value + planet_records: list[tuple[str, str]] = [] # records for the model, tuples of label and id/entity_name + + try: + pu_oc = self.sc.oc_manager.load_socpak(pu_socpak) + + for body in self._search_for_bodies(pu_oc): + label = body.oc.display_name + if label != body.oc.entity_name: + # display_name falls back to entity_name if missing, + # only append the entity_name if it's different + label += f" - {body.oc.entity_name}" + planet_records.append((label, body.oc.entity_name)) + + if body.oc.entity_name in self.planets: + raise KeyError("Duplcate entity_name: %s", body.oc.entity_name) + self.planets[body.oc.entity_name] = body + except Exception as ex: + logger.exception(ex) + return + + if solar_system_guid in self.solar_systems: + raise KeyError("Duplicate solar system GUID: %s", solar_system_guid) + self.solar_systems[solar_system_guid] = SolarSystem( + solar_system_guid, + solar_system_record.name, + self.create_model(sorted(planet_records, key=itemgetter(1))) + ) + + self.systemComboBox.setModel( + self.create_model([(solar_system.name, solar_system.id) for guid, solar_system in self.solar_systems.items()]) + ) + + @staticmethod + def _search_for_bodies(socpak: ObjectContainer, search_depth_after_first_body: int = 1): + results: list[Planet] = [] + + def _inner_search(entry: ObjectContainerInstance, planet_depth: int, max_depth: int): + if planet_depth > max_depth: + return + for subchild in entry.children.values(): + planet = Planet.try_create(subchild) + if planet: + results.append(planet) + _inner_search(subchild, planet_depth + 1, max_depth) + else: + # Only increment the next depth when we've hit a planet already + new_depth = planet_depth if planet_depth == 0 else planet_depth + 1 + _inner_search(subchild, new_depth, max_depth) + + child: ObjectContainerInstance + for child in socpak.children.values(): + _inner_search(child, 0, search_depth_after_first_body) + + return results + + diff --git a/starfab/gui/widgets/planets/__init__.py b/starfab/gui/widgets/planets/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/starfab/gui/widgets/planets/crosshair_overlay.py b/starfab/gui/widgets/planets/crosshair_overlay.py new file mode 100644 index 0000000000000000000000000000000000000000..92f0ea509c8cb68c7421218c6d7beda0b0f8ee10 --- /dev/null +++ b/starfab/gui/widgets/planets/crosshair_overlay.py @@ -0,0 +1,29 @@ +from typing import Union + +from PySide6.QtCore import QRectF, QPointF +from PySide6.QtGui import QPen, QPainter +from PySide6.QtWidgets import QGraphicsEffect + + +class CrosshairOverlay(QGraphicsEffect): + def __init__(self, pen: QPen, region: QRectF, *args, **kwargs): + super().__init__(*args, **kwargs) + self.mouse_position: Union[QPointF, None] = None + self.pen = pen + self.planet_bounds = region + + def update_mouse_position(self, pos: QPointF): + self.mouse_position = pos + + def remove_mouse(self): + self.mouse_position = None + + def draw(self, painter: QPainter) -> None: + if not self.mouse_position: + return + + vp = self.planet_bounds + painter.setPen(self.pen) + painter.drawLine(vp.left(), self.mouse_position.y(), vp.right(), self.mouse_position.y()) + painter.drawLine(self.mouse_position.x(), vp.top(), self.mouse_position.x(), vp.bottom()) + painter.drawRect(vp) diff --git a/starfab/gui/widgets/planets/effect_overlay.py b/starfab/gui/widgets/planets/effect_overlay.py new file mode 100644 index 0000000000000000000000000000000000000000..6014888c2faa8b26dde291bdbe8dfadde7bce5f8 --- /dev/null +++ b/starfab/gui/widgets/planets/effect_overlay.py @@ -0,0 +1,40 @@ +from typing import Type, Callable, Any, Union + +from PySide6.QtCore import QRectF +from PySide6.QtGui import QPainterPath +from PySide6.QtWidgets import QGraphicsPathItem, QGraphicsEffect + + +class EffectOverlay(QGraphicsPathItem): + def __init__(self, fn_make_effect: Callable[[], Union[None | QGraphicsEffect]]): + self._bounds: QRectF = QRectF() + self._bounds_path = QPainterPath() + self._overlay_effect: Union[None, QGraphicsEffect] = None + self._enabled: bool = False + self._fn_make_effect: Callable = fn_make_effect + super().__init__(self._bounds_path) + # self.set_enabled(True) + + def update_bounds(self, new_bounds: QRectF): + self._bounds = new_bounds + self._bounds_path.clear() + self._bounds_path.addRect(self._bounds) + self.setPath(self._bounds_path) + self.invalidate() + + def invalidate(self): + self.update(self._bounds) + + def set_enabled(self, enabled: bool): + self._enabled = enabled + if self._enabled: + # Need to rebuild each time as it gets disposed of + self._overlay_effect = self._fn_make_effect() + self.setGraphicsEffect(self._overlay_effect) + else: + self.setGraphicsEffect(None) + self._overlay_effect = None + self.invalidate() + + def get_effect_instance(self) -> Union[None, QGraphicsEffect]: + return self._overlay_effect diff --git a/starfab/gui/widgets/planets/grid_overlay.py b/starfab/gui/widgets/planets/grid_overlay.py new file mode 100644 index 0000000000000000000000000000000000000000..4d8d14deca6d739ffbd28e273124d40965286fec --- /dev/null +++ b/starfab/gui/widgets/planets/grid_overlay.py @@ -0,0 +1,36 @@ +from PySide6.QtCore import QRectF +from PySide6.QtGui import QPen, QPainter +from PySide6.QtWidgets import QGraphicsEffect + + +class GridOverlay(QGraphicsEffect): + def __init__(self, + primary_pen: QPen, secondary_pen: QPen, + scale_factor: int, planet_bounds: QRectF, + *args, **kwargs): + super().__init__(*args, **kwargs) + self.primary_pen = primary_pen + self.secondary_pen = secondary_pen + self.scale_factor = scale_factor + self.planet_bounds = planet_bounds + + def draw(self, painter: QPainter) -> None: + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Difference) + self._draw_lines(painter, self.primary_pen, True) + self._draw_lines(painter, self.secondary_pen, False) + + def _draw_lines(self, painter: QPainter, pen: QPen, primary: bool): + painter.setPen(pen) + xoff = self.planet_bounds.x() + yoff = self.planet_bounds.y() + for lon in range(15, 360, 15): + is_primary = lon % 45 == 0 + if (is_primary and primary) or (not is_primary and not primary): + painter.drawLine(lon * self.scale_factor + xoff, self.planet_bounds.top(), + lon * self.scale_factor + xoff, self.planet_bounds.bottom()) + + for lat in range(15, 180, 15): + is_primary = lat % 45 == 0 + if (is_primary and primary) or (not is_primary and not primary): + painter.drawLine(self.planet_bounds.left(), lat * self.scale_factor + yoff, + self.planet_bounds.right(), lat * self.scale_factor + yoff) diff --git a/starfab/gui/widgets/planets/planet_viewer.py b/starfab/gui/widgets/planets/planet_viewer.py new file mode 100644 index 0000000000000000000000000000000000000000..4936e23ac28d5c5b330cef4a2a54306db2100af3 --- /dev/null +++ b/starfab/gui/widgets/planets/planet_viewer.py @@ -0,0 +1,240 @@ +from typing import cast + +import PySide6 +from PIL.ImageQt import ImageQt +from PySide6.QtCore import QPointF, QRectF, Signal, QItemSelection +from PySide6.QtGui import QPainterPath, QColor, QPen, Qt, QMouseEvent, QPixmap +from PySide6.QtWidgets import QGraphicsPathItem + +from starfab.gui import qtw +from starfab.gui.widgets.planets.crosshair_overlay import CrosshairOverlay +from starfab.gui.widgets.planets.grid_overlay import GridOverlay + +from starfab.gui.widgets.planets.effect_overlay import EffectOverlay +from starfab.gui.widgets.planets.waypoint_overlay import WaypointOverlay +from starfab.planets.planet import WaypointData +from starfab.planets.planet_renderer import RenderResult + + +class QPlanetViewer(qtw.QGraphicsView): + crosshair_moved: Signal = Signal(QPointF) + render_window_moved: Signal = Signal(QRectF) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + try: + # Image-space coordinates are always 0-360,000 (* 1000 to map closer to pixel sizes generally) + self._scale_factor: int = 100 + self._outer_perimeter: QRectF = QRectF() + self._render_perimeter: QRectF = QRectF() + self._crosshair_position: QPointF = QPointF() + self._render_result: None | RenderResult = None + self._waypoints: list[WaypointData] = [] + self._selected_waypoint: None | WaypointData = None + + self._empty = True + self._zoom = 0 + self._zoom_factor = 1.25 + # we start at a 0 zoom level when the image is changed and we fill the view with it + self._min_zoom = -2 + self._max_zoom = 50 + + self._major_grid_pen = QPen(QColor(255, 255, 255, 255), 50) + self._minor_grid_pen = QPen(QColor(200, 200, 200, 255), 20) + self._crosshair_pen = QPen(QColor(255, 0, 0, 255), 20) + + self.setCursor(Qt.CrossCursor) + + self.scene = qtw.QGraphicsScene() + self.scene.setBackgroundBrush(QColor(0, 0, 0, 255)) + self.setScene(self.scene) + + self.setTransformationAnchor(qtw.QGraphicsView.AnchorUnderMouse) + self.setResizeAnchor(qtw.QGraphicsView.AnchorUnderMouse) + self.setFrameShape(qtw.QFrame.NoFrame) + self.setDragMode(qtw.QGraphicsView.ScrollHandDrag) + + self.image: qtw.QGraphicsPixmapItem = qtw.QGraphicsPixmapItem() + self.scene.addItem(self.image) + + # Layer 1: Coordinate grid + self.lyr_grid: EffectOverlay = EffectOverlay( + lambda: GridOverlay(self._major_grid_pen, self._minor_grid_pen, + self._scale_factor, self._outer_perimeter)) + self.lyr_grid.setZValue(1000) + self.scene.addItem(self.lyr_grid) + + # Layer 2: Crosshair overlay + self.lyr_crosshair: EffectOverlay = EffectOverlay( + lambda: CrosshairOverlay(self._crosshair_pen, self._outer_perimeter)) + self.lyr_crosshair.setZValue(2000) + self.scene.addItem(self.lyr_crosshair) + + # Layer 3: Waypoint overlay + self.lyr_waypoints: EffectOverlay = EffectOverlay( + lambda: WaypointOverlay(self._waypoints, self._selected_waypoint)) + self.lyr_waypoints.setZValue(3000) + self.scene.addItem(self.lyr_waypoints) + + # Layer 4: Draggable active render window + self.lyr_render: EffectOverlay = EffectOverlay(lambda: None) + self.lyr_render.setPen(QPen(QColor(0, 255, 0, 255), 20)) + self.lyr_render.setZValue(4000) + self.scene.addItem(self.lyr_render) + + self.update_bounds(QRectF(0, -90, 360, 180), QRectF(0, -90, 360, 180)) + + self.render_window_dragging: bool = False + self.render_window_drag_pos: None | QPointF = None + + self.lyr_grid.set_enabled(True) + self.lyr_crosshair.set_enabled(True) + self.lyr_waypoints.set_enabled(True) + self.lyr_render.set_enabled(True) + + self.setMouseTracking(True) + self.image.setCursor(Qt.CrossCursor) + self.lyr_render.setCursor(Qt.SizeAllCursor) + self.fitInView(self._render_perimeter, Qt.KeepAspectRatio) + self.ensureVisible(QRectF(0, 0, 100, 100)) + self.setDragMode(qtw.QGraphicsView.ScrollHandDrag) + except Exception as ex: + print(ex) + raise ex + + def update_bounds(self, bounding_rect: QRectF, render_rect: QRectF): + self._outer_perimeter: QRectF = QRectF(bounding_rect.topLeft() * self._scale_factor, + bounding_rect.size() * self._scale_factor) + self._render_perimeter: QRectF = QRectF(render_rect.topLeft() * self._scale_factor, + render_rect.size() * self._scale_factor) + + # Add some extra padding around the planet bounds. + image_padding = 10 * self._scale_factor + scene_area = QRectF(self._outer_perimeter.x() - image_padding, + self._outer_perimeter.y() - image_padding, + self._outer_perimeter.width() + 2 * image_padding, + self._outer_perimeter.height() + 2 * image_padding) + self.scene.setSceneRect(scene_area) + + self.lyr_grid.update_bounds(self._outer_perimeter) + self.lyr_crosshair.update_bounds(self._outer_perimeter) + self.lyr_waypoints.update_bounds(self._outer_perimeter) + self.lyr_render.update_bounds(self._render_perimeter) + self.scene.update() + self.update() + + def get_render_coords(self): + return QRectF(self._render_perimeter.topLeft() / self._scale_factor, + self._render_perimeter.size() / self._scale_factor) + + def get_crosshair_coords(self) -> QPointF: + return self._crosshair_position + + def set_waypoints(self, new_wapoints: list[WaypointData]): + self._waypoints = new_wapoints + effect: WaypointOverlay = cast(WaypointOverlay, self.lyr_waypoints.get_effect_instance()) + if effect: + effect.update_waypoints(self._waypoints) + + def set_selected_waypoint(self, waypoint: None | WaypointData): + self._selected_waypoint = waypoint + print(f"New WP: {waypoint}") + effect: WaypointOverlay = cast(WaypointOverlay, self.lyr_waypoints.get_effect_instance()) + if effect: + effect.select_waypoint(self._selected_waypoint) + + def mousePressEvent(self, event: PySide6.QtGui.QMouseEvent) -> None: + image_space_pos = self.mapToScene(event.pos()) + global_coordinates = self.image_to_coordinates(image_space_pos) + + if event.button() == Qt.RightButton and \ + self._render_perimeter.contains(image_space_pos): + self.render_window_dragging = True + self.render_window_drag_pos = image_space_pos + else: + super().mousePressEvent(event) + + def mouseMoveEvent(self, event: QMouseEvent): + image_space_pos = self.mapToScene(event.pos()) + global_coordinates = self.image_to_coordinates(image_space_pos) + + if self.render_window_dragging: + delta = image_space_pos - self.render_window_drag_pos + drag_bounds = QRectF(self._outer_perimeter.topLeft(), + self._outer_perimeter.size() - self._render_perimeter.size()) + + self.render_window_drag_pos = image_space_pos + self._render_perimeter.translate(delta) + + final_position_x = max(drag_bounds.left(), min(drag_bounds.right(), self._render_perimeter.x())) + final_position_y = max(drag_bounds.top(), min(drag_bounds.bottom(), self._render_perimeter.y())) + self._render_perimeter.setRect(final_position_x, final_position_y, + self._render_perimeter.width(), self._render_perimeter.height()) + + self.lyr_render.update_bounds(self._render_perimeter) + self.render_window_moved.emit(self._render_perimeter) + else: + super().mouseMoveEvent(event) + + self._crosshair_position = global_coordinates + self.crosshair_moved.emit(self._crosshair_position) + + crosshair_overlay: CrosshairOverlay = cast(CrosshairOverlay, self.lyr_crosshair.get_effect_instance()) + if crosshair_overlay: + crosshair_overlay.update_mouse_position(image_space_pos) + self.lyr_crosshair.invalidate() + + def mouseReleaseEvent(self, event: PySide6.QtGui.QMouseEvent) -> None: + image_space_pos = self.mapToScene(event.pos()) + global_coordinates = self.image_to_coordinates(image_space_pos) + + self.render_window_dragging = False + + super().mouseReleaseEvent(event) + + def image_to_coordinates(self, image_position: QPointF) -> QPointF: + return QPointF(image_position.x() / self._scale_factor, image_position.y() / self._scale_factor) + + def update_render(self, new_render: RenderResult, layer: str): + self._render_result = new_render + self.update_visible_layer(layer) + + def update_visible_layer(self, layer: str): + if not self._render_result: + return + + s = self._scale_factor + img = None + if layer == "surface": + img = self._render_result.tex_color + elif layer == "heightmap": + img = self._render_result.tex_heightmap + elif layer == "ocean_mask": + img = self._render_result.tex_oceanmask + else: + raise Exception(f"Unknown layer: {layer}") + + # Convert the image to a mode supported by QPixmap + if img.mode not in ["RGB", "RGBA"]: + img = img.convert("RGBA") + + qt_image: QPixmap = QPixmap.fromImage(ImageQt(img)) + width_scale = self._render_result.coordinate_bounds.width() / qt_image.width() + self.image.setPixmap(qt_image) + self.image.setPos(self._render_result.coordinate_bounds.left() * s, + self._render_result.coordinate_bounds.top() * s) + # TODO: Better support non-standard render sizes + self.image.setScale(width_scale * s) + + def wheelEvent(self, event): + factor = 0 + if event.angleDelta().y() > 0: + if self._zoom < self._max_zoom: + factor = self._zoom_factor + self._zoom += 1 + else: + if self._zoom > self._min_zoom: + factor = 1 / self._zoom_factor + self._zoom -= 1 + if factor: + self.scale(factor, factor) diff --git a/starfab/gui/widgets/planets/waypoint_overlay.py b/starfab/gui/widgets/planets/waypoint_overlay.py new file mode 100644 index 0000000000000000000000000000000000000000..126cb6be90979292fc67e23280d980caccaf09f6 --- /dev/null +++ b/starfab/gui/widgets/planets/waypoint_overlay.py @@ -0,0 +1,31 @@ +from PySide6.QtCore import QRectF +from PySide6.QtGui import QPen, QPainter, QColor +from PySide6.QtWidgets import QGraphicsEffect + +from starfab.planets.planet import WaypointData + + +class WaypointOverlay(QGraphicsEffect): + def __init__(self, waypoints: list[WaypointData], selected_waypoint: None | WaypointData, *args, **kwargs): + super().__init__(*args, **kwargs) + self._waypoints: list[WaypointData] = waypoints + self._selected_waypoint: None | WaypointData = selected_waypoint + + def draw(self, painter: QPainter) -> None: + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) + for waypoint in self._waypoints: + if self._selected_waypoint and waypoint == self._selected_waypoint: + painter.setPen(QPen(QColor(0, 255, 0, 255), 10)) + painter.drawEllipse(waypoint.point * 100, 75, 75) + else: + painter.setPen(QPen(QColor(255, 0, 0, 255), 10)) + painter.drawEllipse(waypoint.point * 100, 50, 50) + + + def update_waypoints(self, waypoints: list[WaypointData]): + self._waypoints = waypoints + self.update() + + def select_waypoint(self, waypoint: None | WaypointData): + self._selected_waypoint = waypoint + self.update() diff --git a/starfab/log.py b/starfab/log.py index 925d70500dd3d3e01dc87d2012476fb288531b19..d9c02163191dda46299c9abb1aa2327fe105a960 100644 --- a/starfab/log.py +++ b/starfab/log.py @@ -41,15 +41,16 @@ class ThreadLogger: def __init__(self, name): self.logger = logging.getLogger(name) - def log(self, level, msg, extra=None, *args, **kwargs): - if extra is None: - extra = {} - extra["qThreadName"] = QThread.currentThread().objectName() - if not extra["qThreadName"]: - extra["qThreadName"] = threading.get_ident() + def log(self, level, msg, *args, **kwargs): + kwargs.setdefault('extra', {}) + + kwargs['extra']["qThreadName"] = QThread.currentThread().objectName() + if not kwargs['extra']["qThreadName"]: + kwargs['extra']["qThreadName"] = threading.get_ident() else: - extra["qThreadName"] += f"-{threading.get_ident()}" - self.logger.log(level, msg, extra=extra, *args, **kwargs) + kwargs['extra']["qThreadName"] += f"-{threading.get_ident()}" + + self.logger.log(level, msg, *args, **kwargs) def debug(self, msg, *args, **kwargs): self.log(logging.DEBUG, msg, *args, **kwargs) diff --git a/starfab/planets/__init__.py b/starfab/planets/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..23a015e937d86a52b7f2ddc10fed034e481dd4dc --- /dev/null +++ b/starfab/planets/__init__.py @@ -0,0 +1,8 @@ + +try: + from compushady import Texture2D, Compute, Resource, HEAP_UPLOAD, Buffer, Texture3D, HEAP_READBACK + from compushady.formats import R8G8B8A8_UINT, R8_UINT, R16_UINT, R32_SINT + from compushady.shaders import hlsl + HAS_COMPUSHADY = True +except ImportError as e: + HAS_COMPUSHADY = False diff --git a/starfab/planets/data.py b/starfab/planets/data.py new file mode 100644 index 0000000000000000000000000000000000000000..1481a5441850680cbc692d3cc72f2d67bf4611ef --- /dev/null +++ b/starfab/planets/data.py @@ -0,0 +1,159 @@ +import struct +from typing import Tuple + +from . import * + + +class LUTData: + def __init__(self): + self.bedrockGloss: list = [] + self.surfaceGloss: list = [] + self.bedrockColor: list = [] + self.surfaceColor: list = [] + self.brush_id: int = 0 + self.ground_texture_id: int = 0 + self.object_preset_id: int = 0 + self.bd_gradient_val_bedrock: int = 0 + self.bd_gradient_val_surface: int = 0 + self.bd_value_offset_bedrock: float = 0 + self.bd_value_offset_surface: float = 0 + self.bd_saturation_offset_bedrock: float = 0 + self.bd_saturation_offset_surface: float = 0 + self.bd_orp_blend_index: int = 0 + self.bd_texture_layer_index: int = 0 + self.brush_obj: Brush = None + + +class RenderJobSettings: + # NOTE: Bools are GPU-register aligned, so need to be 4 bytes, not 1 + # so we pack them as i instead of ? + PACK_STRING: str = "5f3i4f3i1f6i3f2i" + PACK_LENGTH: int = struct.calcsize(PACK_STRING) + + def __init__(self): + self.offset_x: float = 0 + self.offset_y: float = 0 + + self.size_x: float = 1 + self.size_y: float = 1 + + self.planet_radius: float = 0 + self.interpolation: int = 0 + self.render_scale_x: int = 0 + self.render_scale_y: int = 0 + + self.local_humidity_influence: float = 0 + self.local_temperature_influence: float = 0 + self.global_terrain_height_influence: float = 4000 + self.ecosystem_terrain_height_influence: float = 1000 + + self.ocean_enabled: bool = True + self.ocean_mask_binary: bool = False + self.ocean_heightmap_flat: bool = True + self.ocean_depth: float = -2000 + self.ocean_color: list[int] = [0, 0, 0, 255] + + self.blending_enabled: bool = True + + self.hillshade_enabled: bool = True + self.hillshade_level : float = 1 + + self.hillshade_zenith: float = 45 + self.hillshade_azimuth: float = 135 + + self.heightmap_bit_depth: int = 16 + + self.debug_mode: int = 0 + + def pack(self) -> bytes: + return struct.pack(RenderJobSettings.PACK_STRING, + self.offset_x, self.offset_y, self.size_x, self.size_y, + self.planet_radius, self.interpolation, + self.render_scale_x, self.render_scale_y, + self.local_humidity_influence, self.local_temperature_influence, + self.global_terrain_height_influence, self.ecosystem_terrain_height_influence, + self.ocean_enabled, self.ocean_mask_binary, self.ocean_heightmap_flat, + self.ocean_depth, *self.ocean_color, + self.blending_enabled, + self.hillshade_enabled, self.hillshade_level, self.hillshade_zenith, self.hillshade_azimuth, + self.heightmap_bit_depth, self.debug_mode) + + def update_buffer(self, buffer_gpu: Buffer): + data = self.pack() + buffer = Buffer(RenderJobSettings.PACK_LENGTH, HEAP_UPLOAD) + buffer.upload(data) + buffer.copy_to(buffer_gpu) + + +class Brush: + def __init__(self, record): + self.record = record + self.bedrockGradientColorA = self.record["bedrockBrush"]["colorGradient"]["gradientColorA"] + self.bedrockGradientColorB = self.record["bedrockBrush"]["colorGradient"]["gradientColorB"] + self.surfaceGradientColorA = self.record["surfaceBrush"]["colorGradient"]["gradientColorA"] + self.surfaceGradientColorB = self.record["surfaceBrush"]["colorGradient"]["gradientColorB"] + self.tMin = self.record["tMin"] + self.tMax = self.record["tMax"] + self.hMin = self.record["hMin"] + self.hMax = self.record["hMax"] + + +class LocalClimateData: + def __init__(self, x: float, y: float): + self.x: float = x + self.y: float = y + + self.temperature: int = 0 + self.humidity: int = 0 + self.eco_id: int = 0 + + self.elevation: float = 0 + self.random_offset: float = 0 + + self.normal_x: float = 0 + self.normal_y: float = 0 + self.normal_z: float = 0 + + self.surfaceTextureMap: int = 0 + self.oprBlendIndex: int = 0 + + @staticmethod + def create_packed_bytes(climate_records: list[list]): + pack_string = "2f2I" + pack_size = struct.calcsize(pack_string) + pack_index = 0 + pack_data = bytearray(pack_size * len(climate_records) * len(climate_records[0])) + # TODO: There has to be a better way to do this :^) + print("Packing") + for y in range(len(climate_records[0])): + print(f"{y}/{len(climate_records[0])}") + for x in range(len(climate_records)): + clim: LocalClimateData = climate_records[x][y] + struct.pack_into(pack_string, pack_data, pack_index, + clim.elevation, clim.random_offset, + clim.surfaceTextureMap, clim.oprBlendIndex) + pack_index += pack_size + print("Done Packing") + return pack_data + + +class RenderSettings: + def __init__(self, gpu: bool, resolution: int, coordinate_mode: str, + shader_main: str, shader_hillshade: str, + interpolation: int, output_resolution: Tuple[int, int], + blending_enabled: bool, + hillshade_enabled: bool, hillshade_level: float, ocean_mask_binary: bool, + heightmap_bit_depth: int, debug_mode: int): + self.gpu = gpu + self.resolution = resolution + self.coordinate_mode = coordinate_mode + self.shader_main = shader_main + self.shader_hillshade = shader_hillshade + self.interpolation = interpolation + self.output_resolution = output_resolution + self.blending_enabled = blending_enabled + self.hillshade_enabled = hillshade_enabled + self.hillshade_level = hillshade_level + self.ocean_mask_binary = ocean_mask_binary + self.heightmap_bit_depth = heightmap_bit_depth + self.debug_mode = debug_mode diff --git a/starfab/planets/ecosystem.py b/starfab/planets/ecosystem.py new file mode 100644 index 0000000000000000000000000000000000000000..e835779a1ead73371ac561b62294cb0baa5d97dd --- /dev/null +++ b/starfab/planets/ecosystem.py @@ -0,0 +1,103 @@ +import io +import json +import math +import os +from pathlib import Path +from typing import Union + +from PIL import Image +from scdatatools import StarCitizen +from scdatatools.engine.textures import unsplit_dds +from scdatatools.p4k import P4KInfo + +from starfab.log import getLogger +from starfab.planets.data import LocalClimateData +from starfab.utils import image_converter + + +logger = getLogger(__name__) + +CACHE_DIR = Path('.cache') + + +class EcoSystem: + _cache = {} + _tex_root = Path("Data/Textures/planets/terrain") + _sc: Union[None, StarCitizen] = None + + @staticmethod + def find_in_cache_(guid: str): + if guid in EcoSystem._cache: + return EcoSystem._cache[guid] + else: + logger.error("Could not find EcoSystem with guid %r", guid) + return None + + @staticmethod + def read_eco_headers(sc: StarCitizen): + if len(EcoSystem._cache) != 0: + return EcoSystem._cache + EcoSystem._sc = sc + p4k_results = EcoSystem._sc.p4k.search(".eco", mode="endswith") + for result in p4k_results: + eco = EcoSystem(json.loads(result.open().read())) + EcoSystem._cache[eco.id] = eco + return EcoSystem._cache + + def __init__(self, eco_data: dict): + self.id = eco_data["GUID"] + self.name = eco_data["name"] + self.offset = eco_data["fOffsetH"] + self.tex_path: str = eco_data["Textures"]["ecoSystemAlbedoTexture"].replace(".tif", ".dds") + self.norm_path: str = eco_data["Textures"]["ecoSystemNormalTexture"].replace(".tif", ".dds") + self.elevation_path: str = eco_data["Textures"]["elevationTexture"] + + self.climate_data: list[list[LocalClimateData]] = None + self.climate_image: Image = None + self.normal_texture: Image = None + self.elevation_bytes: bytearray = None + self.elevation_size: int = 0 + + def read_full_data(self): + if self.climate_data: + return + + def _read_texture(subpath: str) -> bytes: + texture_path = (EcoSystem._tex_root / subpath).as_posix().lower() + + p4k_dds_files = EcoSystem._sc.p4k.search(texture_path, mode="startswith") + dds_files = {record.orig_filename: record + for record + in p4k_dds_files + if not record.orig_filename.endswith("a")} + res: bytes = unsplit_dds(dds_files) + + test = Image.open(io.BytesIO(res), formats=["DDS"]) + test2 = test.convert("RGBA") + out_buffer = io.BytesIO() + test2.save(out_buffer, format="PNG") + out_buffer.seek(0) + return out_buffer.read() + + # TODO: Use settings to define a cache directory to store these in + def _read_with_cache(subpath: str) -> Image: + check_path = (CACHE_DIR / subpath).with_suffix(".png") + if not os.path.exists(check_path.parent): + os.makedirs(check_path.parent) + if check_path.exists(): + return Image.open(check_path) + else: + img = Image.open(io.BytesIO(_read_texture(subpath))) + img.save(check_path, "png") + return img + + self.climate_image = _read_with_cache(self.tex_path) + self.normal_texture = _read_with_cache(self.norm_path) + + elevation_path = (EcoSystem._tex_root / self.elevation_path).as_posix().lower() + elevation_info: P4KInfo = EcoSystem._sc.p4k.NameToInfoLower[elevation_path] + with elevation_info.open() as o: + self.elevation_bytes = bytearray(o.read()) + self.elevation_size = int(math.sqrt(len(self.elevation_bytes) / 2)) + + logger.info(f"Textures loaded for {self.name}") diff --git a/starfab/planets/hlsl/hillshade.hlsl b/starfab/planets/hlsl/hillshade.hlsl new file mode 100644 index 0000000000000000000000000000000000000000..cfc19d16d9e6ab9ae8ea550d428f23a9a5b77107 --- /dev/null +++ b/starfab/planets/hlsl/hillshade.hlsl @@ -0,0 +1,319 @@ +#define PI radians(180) +#define MODE_NN 0 +#define MODE_BI_LINEAR 1 +#define MODE_BI_CUBIC 2 + +struct RenderJobSettings +{ + float2 offset; + float2 size; + float planet_radius; + int interpolation; + int2 render_scale; + + float local_humidity_influence; + float local_temperature_influence; + float global_terrain_height_influence; + float ecosystem_terrain_height_influence; + + bool ocean_enabled; + bool ocean_mask_binary; + bool ocean_heightmap_flat; + float ocean_depth; + uint4 ocean_color; + + bool blending_enabled; + + bool hillshade_enabled; + float hillshade_level; + float hillshade_zenith; + float hillshade_azimuth; + + int heightmap_bit_depth; + + int debug_mode; +}; + +Texture2D input_color : register(t0); +Texture2D input_heightmap: register(t1); +Texture2D input_ocean_mask: register(t2); + +ConstantBuffer jobSettings : register(b0); + +RWTexture2D output_color : register(u0); + +uint4 lerp2d(uint4 ul, uint4 ur, uint4 bl, uint4 br, float2 value) +{ + float4 topRow = lerp(ul, ur, value.x); + float4 bottomRow = lerp(bl, br, value.x); + + return lerp(topRow, bottomRow, value.y); +} + +uint lerp2d(uint ul, uint ur, uint bl, uint br, float2 value) +{ + float topRow = lerp(ul, ur, value.x); + float bottomRow = lerp(bl, br, value.x); + + return lerp(topRow, bottomRow, value.y); +} + +uint4 interpolate_cubic(float4 v0, float4 v1, float4 v2, float4 v3, float fraction) +{ + float4 p = (v3 - v2) - (v0 - v1); + float4 q = (v0 - v1) - p; + float4 r = v2 - v0; + + return (fraction * ((fraction * ((fraction * p) + q)) + r)) + v1; +} + +uint interpolate_cubic_uint(float v0, float v1, float v2, float v3, float fraction) +{ + float p = (v3 - v2) - (v0 - v1); + float q = (v0 - v1) - p; + float r = v2 - v0; + + return (fraction * ((fraction * ((fraction * p) + q)) + r)) + v1; +} + +/* Texture2D implementations */ + + +int take_sample_nn_int(Texture2D texture, float2 position, int2 dimensions) +{ + return texture[position % dimensions]; +} + +/* Texture2D implementations */ + +uint4 take_sample_nn(Texture2D texture, float2 position, int2 dimensions) +{ + return texture[position % dimensions]; +} + +uint4 take_sample_bilinear(Texture2D texture, float2 position, int2 dimensions) +{ + float2 offset = position - floor(position); + uint4 tl = take_sample_nn(texture, position, dimensions); + uint4 tr = take_sample_nn(texture, position + int2(1, 0), dimensions); + uint4 bl = take_sample_nn(texture, position + int2(0, 1), dimensions); + uint4 br = take_sample_nn(texture, position + int2(1, 1), dimensions); + return lerp2d(tl, tr, bl, br, offset); +} + +uint4 take_sample_bicubic(Texture2D texture, float2 position, int2 dimensions) +{ + float2 offset = position - floor(position); + uint4 samples[4]; + + for (int i = 0; i < 4; ++i) + { + float4 ll = take_sample_nn(texture, position + int2(-1, i - 1), dimensions); + float4 ml = take_sample_nn(texture, position + int2( 0, i - 1), dimensions); + float4 mr = take_sample_nn(texture, position + int2( 1, i - 1), dimensions); + float4 rr = take_sample_nn(texture, position + int2( 2, i - 1), dimensions); + samples[i] = interpolate_cubic(ll, ml, mr, rr, offset.x); + } + + return interpolate_cubic(samples[0], samples[1], samples[2], samples[3], offset.y); +} + +uint4 take_sample(Texture2D texture, float2 position, int2 dimensions, int mode) +{ + if(mode == MODE_NN) { + return take_sample_nn(texture, position, dimensions); + } else if (mode == MODE_BI_LINEAR) { + return take_sample_bilinear(texture, position, dimensions); + } else if (mode == MODE_BI_CUBIC) { + return take_sample_bicubic(texture, position, dimensions); + } else { + return uint4(0, 0, 0, 0); + } +} + + +float UnpackIntToFloat(int packedValue, int bit_depth) +{ + // Ensure the bit depth is valid + if (bit_depth != 8 && bit_depth != 16 && bit_depth != 32) + return 0.0; + + float normalizedValue = 0.0; + + if (bit_depth == 8) + { + // Convert unsigned 8-bit to signed range [-1, 1] + normalizedValue = (packedValue / 255.0f) * 2.0f - 1.0f; + } + else if (bit_depth == 16) + { + // Convert unsigned 16-bit to signed range [-1, 1] + normalizedValue = (packedValue / 65535.0f) * 2.0f - 1.0f; + } + else if (bit_depth == 32) + { + // Convert signed 32-bit to signed range [-1, 1] + normalizedValue = packedValue / 2147483647.0f; // 2^31 - 1 (maximum positive value for a 32-bit signed integer) + } + + return normalizedValue; +} + + + +float read_height(uint2 coordinate, int2 relative, int2 dimensions) +{ + float max_deform = jobSettings.global_terrain_height_influence + jobSettings.ecosystem_terrain_height_influence; + + int packedValue = take_sample_nn_int(input_heightmap, coordinate + relative, dimensions); //input_heightmap is uint for 8 and 16 bit! + float height = UnpackIntToFloat(packedValue, jobSettings.heightmap_bit_depth); //UnpackIntToFloat should deal with 8 and 16 bit cases (hopefully) + + return height * max_deform; +} + + + + +// Function to read and smooth the height map +float read_and_smooth_height(uint2 tid, int2 offset, uint2 hm_sz, Texture2D input_heightmap) +{ + + int smoothing = 1; + if (jobSettings.render_scale[1] == 2) { + smoothing = 2; + } else if (jobSettings.render_scale[1] == 4) { + smoothing = 3; + } else if (jobSettings.render_scale[1] == 8) { + smoothing = 4; + } else if (jobSettings.render_scale[1] == 16) { + smoothing = 5; + } + + float sum = 0.0; + int count = 0; + for (int y = -1 * smoothing; y <= smoothing; y++) + { + for (int x = -1 * smoothing; x <= smoothing; x++) + { + int2 sample_pos = tid + offset + int2(x, y); + if (sample_pos.x >= 0 && sample_pos.x < hm_sz.x && sample_pos.y >= 0 && sample_pos.y < hm_sz.y) + { + float sample_height = input_heightmap.Load(int3(sample_pos, 0)); + sum += sample_height; + count++; + } + } + } + return sum / count; +} + +[numthreads(8, 8, 1)] +void main(uint3 tid : SV_DispatchThreadID) +{ + uint2 hm_sz; + input_heightmap.GetDimensions(hm_sz.x, hm_sz.y); + + if (input_ocean_mask[tid.xy] != 0) + return; + + float pi = 3.14159265359f; + float ZFactor = 100.0f; + + // Calculating the cell size considering equirectangular projection + float planet_circ_m = pi * 2 * jobSettings.planet_radius; + float lat = (tid.y / (float)hm_sz.y) * pi - pi / 2; // Convert y to latitude in radians + float cosLat = cos(lat); + float cellsize_x = planet_circ_m * cosLat / (hm_sz.x * jobSettings.render_scale); + float cellsize_y = planet_circ_m / (hm_sz.y * jobSettings.render_scale); + + // Slope calculations with larger feature capture + float heights[9]; + int2 offsets[9] = { int2(-2, -2), int2(0, -2), int2(2, -2), int2(-2, 0), int2(2, 0), int2(-2, 2), int2(0, 2), int2(2, 2), int2(0, 0) }; + for (int i = 0; i < 9; i++) + { + heights[i] = read_and_smooth_height(tid.xy, offsets[i], hm_sz, input_heightmap); + } + + float dzdx = ((heights[2] + 2 * heights[4] + heights[5]) - (heights[0] + 2 * heights[3] + heights[6])) / (8 * cellsize_x); + float dzdy = ((heights[6] + 2 * heights[7] + heights[5]) - (heights[0] + 2 * heights[1] + heights[2])) / (8 * cellsize_y); + float slope = atan(ZFactor * sqrt(dzdx * dzdx + dzdy * dzdy)); + + float aspect = 0.0; + if (dzdx != 0) + { + aspect = atan2(dzdy, -dzdx); + if (aspect < 0) + aspect += pi * 2; + } + else + { + if (dzdy > 0) + aspect = pi / 2; + else if (dzdy < 0) + aspect = 3 * pi / 2; + } + + // Normalize slope to [0, 1] + slope = min(pi, max(0, slope)) / pi; + + // Calculate hillshade + float cos_zenith = cos(jobSettings.hillshade_zenith); + float sin_zenith = sin(jobSettings.hillshade_zenith); + float cos_slope = cos(slope); + float sin_slope = sin(slope); + float cos_azimuth_aspect = cos(jobSettings.hillshade_azimuth - aspect); + + int hillshade_amount = (int)(255 * (cos_zenith * cos_slope + sin_zenith * sin_slope * cos_azimuth_aspect)); + hillshade_amount = (hillshade_amount - 127) / 4; + + // Ray marching for longer shadows + float dx = cos(jobSettings.hillshade_azimuth) * cos(jobSettings.hillshade_zenith); + float dy = sin(jobSettings.hillshade_azimuth) * cos(jobSettings.hillshade_zenith); + float dz = sin(jobSettings.hillshade_zenith); + + float max_distance = 1000.0; // Maximum distance to march the ray + float step_size = 1.0; // Step size for ray marching + float shadow_intensity = 0.0; // Shadow intensity + float current_distance = 0.0; + + float3 position = float3(tid.xy, read_and_smooth_height(tid.xy, int2(0, 0), hm_sz, input_heightmap) * ZFactor); + + while (current_distance < max_distance) + { + position += float3(dx * step_size, dy * step_size, dz * step_size); + current_distance += step_size; + + int2 sample_pos = int2(position.xy); + if (sample_pos.x < 0 || sample_pos.x >= hm_sz.x || sample_pos.y < 0 || sample_pos.y >= hm_sz.y) + break; + + float sample_height = read_and_smooth_height(sample_pos, int2(0, 0), hm_sz, input_heightmap) * ZFactor; + + if (position.z < sample_height) + { + shadow_intensity = 1.0; + break; + } + } + + int ray_march_shadow_amount = (int)(255 * (1.0 - shadow_intensity)); + ray_march_shadow_amount = (ray_march_shadow_amount - 127) / 16; + + // Combine hillshade and ray marching shadows + int combined_shadow_amount = (hillshade_amount + ray_march_shadow_amount) * jobSettings.hillshade_level; + + // Apply combined shadows to color + int3 final_color = output_color[tid.xy].xyz; + + final_color.x = max(0, min(255, final_color.x + combined_shadow_amount)); + final_color.y = max(0, min(255, final_color.y + combined_shadow_amount)); + final_color.z = max(0, min(255, final_color.z + combined_shadow_amount)); + + uint3 final_color_uint; + final_color_uint.x = (uint)final_color.x; + final_color_uint.y = (uint)final_color.y; + final_color_uint.z = (uint)final_color.z; + + output_color[tid.xy].xyz = final_color_uint; +} + diff --git a/starfab/planets/hlsl/shader.hlsl b/starfab/planets/hlsl/shader.hlsl new file mode 100644 index 0000000000000000000000000000000000000000..718e346ab18750b6c9f852db0d329f9f170ef09c --- /dev/null +++ b/starfab/planets/hlsl/shader.hlsl @@ -0,0 +1,617 @@ +#define PI radians(180) +#define MODE_NN 0 +#define MODE_BI_LINEAR 1 +#define MODE_BI_CUBIC 2 + +#define DEBUG_GRID 1 << 0 + +#define DEBUG_MASK 1 << 1 +#define DEBUG_UV_TERRAIN_NO_MASK 1 << 2 +#define DEBUG_UV_PATCH_NO_MASK 1 << 3 +#define DEBUG_ECOSYSTEM_NO_MASK 1 << 4 +#define DEBUG_UV_TERRAIN 1 << 5 +#define DEBUG_UV_PATCH 1 << 6 +#define DEBUG_ECOSYSTEM 1 << 7 + +#define DEBUG_ANY (DEBUG_MASK | \ + DEBUG_UV_TERRAIN_NO_MASK | DEBUG_UV_TERRAIN | \ + DEBUG_UV_PATCH_NO_MASK | DEBUG_UV_PATCH | \ + DEBUG_ECOSYSTEM_NO_MASK | DEBUG_ECOSYSTEM) + +#define HORIZONTAL_BLEED 0.50f +#define VERTICAL_BLEED 0.50f + +struct RenderJobSettings +{ + float2 offset; + float2 size; + float planet_radius; + int interpolation; + int2 render_scale; + + float local_humidity_influence; + float local_temperature_influence; + float global_terrain_height_influence; + float ecosystem_terrain_height_influence; + + bool ocean_enabled; + bool ocean_mask_binary; + bool ocean_heightmap_flat; + float ocean_depth; + uint4 ocean_color; + + bool blending_enabled; + + bool hillshade_enabled; + float hillshade_level; + float hillshade_zenith; + float hillshade_azimuth; + + int heightmap_bit_depth; + + int debug_mode; +}; + +struct LocalizedWarping +{ + float2 center; + float vertical_delta; + float upper_width; + float lower_width; +}; + +struct ProjectedTerrainInfluence +{ + float2 temp_humidity; + float elevation; + float mask_total; + int num_influences; + + bool is_override; + uint3 override; +}; + +Texture2D bedrock : register(t0); +Texture2D surface : register(t1); +Texture2D planet_climate : register(t2); +Texture2D planet_offsets : register(t3); +Texture2D planet_heightmap : register(t4); +Texture3D ecosystem_climates: register(t5); +Texture3D ecosystem_heightmaps: register(t6); + +ConstantBuffer jobSettings : register(b0); + +RWTexture2D output_color : register(u0); +RWTexture2D output_heightmap: register(u1); +RWTexture2D output_ocean_mask: register(u2); + +uint4 lerp2d(uint4 ul, uint4 ur, uint4 bl, uint4 br, float2 value) +{ + float4 topRow = lerp(ul, ur, value.x); + float4 bottomRow = lerp(bl, br, value.x); + + return lerp(topRow, bottomRow, value.y); +} + +uint lerp2d(uint ul, uint ur, uint bl, uint br, float2 value) +{ + float topRow = lerp(ul, ur, value.x); + float bottomRow = lerp(bl, br, value.x); + + return lerp(topRow, bottomRow, value.y); +} + +float lanczos(float x, float a) +{ + if (x == 0.0) return 1.0; + if (x < -a || x > a) return 0.0; + x *= PI; + return a * sin(x) * sin(x / a) / (x * x); +} + +uint4 interpolate_cubic(float4 v0, float4 v1, float4 v2, float4 v3, float fraction) +{ + float4 p = (v3 - v2) - (v0 - v1); + float4 q = (v0 - v1) - p; + float4 r = v2 - v0; + + return (fraction * ((fraction * ((fraction * p) + q)) + r)) + v1; +} + +uint interpolate_cubic_uint(float v0, float v1, float v2, float v3, float fraction) +{ + float p = (v3 - v2) - (v0 - v1); + float q = (v0 - v1) - p; + float r = v2 - v0; + + return (fraction * ((fraction * ((fraction * p) + q)) + r)) + v1; +} + +/* Texture2D implementations */ + +uint4 take_sample_nn(Texture2D texture, float2 position, int2 dimensions) +{ + return texture[position % dimensions]; +} + +uint4 take_sample_bilinear(Texture2D texture, float2 position, int2 dimensions) +{ + float2 offset = position - floor(position); + uint4 tl = take_sample_nn(texture, position, dimensions); + uint4 tr = take_sample_nn(texture, position + int2(1, 0), dimensions); + uint4 bl = take_sample_nn(texture, position + int2(0, 1), dimensions); + uint4 br = take_sample_nn(texture, position + int2(1, 1), dimensions); + return lerp2d(tl, tr, bl, br, offset); +} + +uint4 take_sample_bicubic(Texture2D texture, float2 position, int2 dimensions) +{ + float2 offset = position - floor(position); + uint4 samples[4]; + + for (int i = 0; i < 4; ++i) + { + float4 ll = take_sample_nn(texture, position + int2(-1, i - 1), dimensions); + float4 ml = take_sample_nn(texture, position + int2( 0, i - 1), dimensions); + float4 mr = take_sample_nn(texture, position + int2( 1, i - 1), dimensions); + float4 rr = take_sample_nn(texture, position + int2( 2, i - 1), dimensions); + samples[i] = interpolate_cubic(ll, ml, mr, rr, offset.x); + } + + return interpolate_cubic(samples[0], samples[1], samples[2], samples[3], offset.y); +} + +uint4 take_sample(Texture2D texture, float2 position, int2 dimensions, int mode) +{ + if(mode == MODE_NN) { + return take_sample_nn(texture, position, dimensions); + } else if (mode == MODE_BI_LINEAR) { + return take_sample_bilinear(texture, position, dimensions); + } else if (mode == MODE_BI_CUBIC) { + return take_sample_bicubic(texture, position, dimensions); + } else { + return uint4(0, 0, 0, 0); + } +} + +/* Texture2D implementations */ + +uint take_sample_nn(Texture2D texture, float2 position, int2 dimensions) +{ + return texture[position % dimensions]; +} + +uint take_sample_bilinear(Texture2D texture, float2 position, int2 dimensions) +{ + float2 offset = position - floor(position); + uint tl = take_sample_nn(texture, position, dimensions); + uint tr = take_sample_nn(texture, position + int2(1, 0), dimensions); + uint bl = take_sample_nn(texture, position + int2(0, 1), dimensions); + uint br = take_sample_nn(texture, position + int2(1, 1), dimensions); + return lerp2d(tl, tr, bl, br, offset); +} + +uint take_sample_bicubic(Texture2D texture, float2 position, int2 dimensions) +{ + float2 offset = position - floor(position); + uint samples[4]; + + for (int i = 0; i < 4; ++i) + { + float ll = take_sample_nn(texture, position + int2(-1, i - 1), dimensions); + float ml = take_sample_nn(texture, position + int2( 0, i - 1), dimensions); + float mr = take_sample_nn(texture, position + int2( 1, i - 1), dimensions); + float rr = take_sample_nn(texture, position + int2( 2, i - 1), dimensions); + samples[i] = interpolate_cubic_uint(ll, ml, mr, rr, offset.x); + } + + return interpolate_cubic_uint(samples[0], samples[1], samples[2], samples[3], offset.y); +} + +uint take_sample(Texture2D texture, float2 position, int2 dimensions, int mode) +{ + if(mode == 0) { + return take_sample_nn(texture, position, dimensions); + } else if (mode == 1) { + return take_sample_bilinear(texture, position, dimensions); + } else if (mode == 2) { + return take_sample_bicubic(texture, position, dimensions); + } else { + return uint(0); + } +} + +/* Texture3D implementations */ + +uint take_sample_nn_3d(Texture3D texture, float2 position, int2 dimensions, int layer) +{ + uint3 read_pos; + read_pos.xy = uint2(position % dimensions); + read_pos.z = layer; + return texture[read_pos]; +} + +/* Texture3D implementations */ + +uint4 take_sample_nn_3d(Texture3D texture, float2 position, int2 dimensions, int layer) +{ + uint3 read_pos; + read_pos.xy = uint2(position % dimensions); + read_pos.z = layer; + return texture[read_pos]; +} + +int4 take_sample_bicubic_3d(Texture3D texture, float2 position, int2 dimensions, int layer) +{ + float2 offset = position - floor(position); + int4 samples[4]; + + for (int i = 0; i < 4; ++i) + { + float4 ll = take_sample_nn_3d(texture, position + int2(-1, i - 1), dimensions, layer); + float4 ml = take_sample_nn_3d(texture, position + int2( 0, i - 1), dimensions, layer); + float4 mr = take_sample_nn_3d(texture, position + int2( 1, i - 1), dimensions, layer); + float4 rr = take_sample_nn_3d(texture, position + int2( 2, i - 1), dimensions, layer); + samples[i] = interpolate_cubic(ll, ml, mr, rr, offset.x); + } + + return interpolate_cubic(samples[0], samples[1], samples[2], samples[3], offset.y); +} + +float circumference_at_distance_from_equator(float vertical_distance_meters) +{ + float half_circumference_km = PI * jobSettings.planet_radius; + //Normalize to +/-0.5, then * pi to get +/- half_pi + float angle = (vertical_distance_meters / half_circumference_km) * PI; + return (float)(cos(angle) * jobSettings.planet_radius * PI * 2); +} + +float2 get_normalized_location(float2 position_meters) +{ + float half_circumference_m = (float)(PI * jobSettings.planet_radius); + float vert_normalized = ((position_meters.y / half_circumference_m) + 0.5f); + float vert_circ = circumference_at_distance_from_equator(position_meters.y); + float horiz_normalized = ((position_meters.x / vert_circ) + 0.5f); + + return float2(min(1.0f, max(0.0f, horiz_normalized)), + min(1.0f, max(0.0f, vert_normalized))); // 0.0 - 1.0 for x,y +} + +float2 pixels_to_meters(float2 position, int2 dimensions) +{ + if (position.x < 0) position.x += dimensions.x; + if (position.y < 0) position.y += dimensions.y; + if (position.x >= dimensions.x) position.x -= dimensions.x; + if (position.y >= dimensions.y) position.y -= dimensions.y; + + float half_circumference_km = (float)(PI * jobSettings.planet_radius); + + float vert_distance = ((position.y / dimensions.y) - 0.5f) * half_circumference_km; // +/- 1/4 circumference + float vert_circ = circumference_at_distance_from_equator(vert_distance); + float horiz_distance = ((position.x / dimensions.x) - 0.5f) * vert_circ; // +/- 1/2 circumference + + return float2(horiz_distance, vert_distance); +} + +LocalizedWarping get_local_image_warping(float2 position_meters, float2 patch_size_meters) +{ + LocalizedWarping result; + + float upper_circ = circumference_at_distance_from_equator(position_meters.y + (patch_size_meters.y / 2)); + float lower_circ = circumference_at_distance_from_equator(position_meters.y - (patch_size_meters.y / 2)); + + result.center = get_normalized_location(position_meters); + result.vertical_delta = (float)(patch_size_meters.y / 2.0f / (PI * jobSettings.planet_radius)); + result.upper_width = (patch_size_meters.x / 2.0f) / upper_circ; + result.lower_width = (patch_size_meters.x / 2.0f) / lower_circ; + + return result; +} + +ProjectedTerrainInfluence calculate_projected_tiles(float2 normal_position, float2 projected_size, float2 terrain_size) +{ + uint2 out_sz; output_color.GetDimensions(out_sz.x, out_sz.y); + uint2 clim_sz; planet_climate.GetDimensions(clim_sz.x, clim_sz.y); + uint2 off_sz; planet_offsets.GetDimensions(off_sz.x, off_sz.y); + uint2 hm_sz; planet_heightmap.GetDimensions(hm_sz.x, hm_sz.y); + uint3 eco_sz; ecosystem_climates.GetDimensions(eco_sz.x, eco_sz.y, eco_sz.z); + + ProjectedTerrainInfluence result = { + float2(0, 0), //float2 temp_humidity + 0.0f, //float elevation; + 0.0f, //float mask_total; + 0, //int num_influences; + false, //bool is_override; + uint3(0,0,0) //uint3 override; + }; + + // NOTE: These pixel dimensions are relative to the climate image + float2 position_px = normal_position * clim_sz; + float2 position_m = pixels_to_meters(position_px, clim_sz); + + LocalizedWarping projection_warping = get_local_image_warping(position_m, projected_size); + LocalizedWarping physical_warping = get_local_image_warping(position_m, terrain_size); + + //upper_bound will be the lower number here because image is 0,0 top-left + float upper_bound = position_px.y - (projection_warping.vertical_delta * clim_sz.y); + float lower_bound = position_px.y + (projection_warping.vertical_delta * clim_sz.y); + + //No wrapping for Y-axis + float search_y_start = clamp(floor(upper_bound), 0, clim_sz.y - 1); + float search_y_end = clamp(ceil(lower_bound), 0, clim_sz.y - 1); + + int terrain_step = 1; + int pole_distance = clim_sz.y / 16; + + //TODO Vary terrain step from 1 at pole_distance to TileCount at the pole + if (position_px.y < pole_distance / 2 || position_px.y >= clim_sz.y - pole_distance / 2) { + //terrain_step = 3; + }else if(position_px.y < pole_distance || position_px.y >= clim_sz.y - pole_distance) { + //terrain_step = 2; + } + + //Search vertically all cells that our projection overlaps with + for(float search_y_px = search_y_start; search_y_px <= search_y_end; search_y_px += 1.0f) + { + //Turn this cells position back into meters, and calculate local distortion size for this row specifically + float2 search_meters = pixels_to_meters(float2(0, search_y_px), clim_sz); + float search_circumference = circumference_at_distance_from_equator(search_meters.y); + float half_projected_width_px = (projected_size.x / 2 / search_circumference) * clim_sz.y; + + //Break if the circumference at this pixel is less than a single projection, ie: directly at poles + if(search_circumference < projected_size.x) + continue; + + float row_left_bound = position_px.x - half_projected_width_px; + float row_right_bound = position_px.x + half_projected_width_px; + float search_x_start = floor(row_left_bound); + float search_x_end = ceil(row_right_bound); + + //Now search horizontally all cells that out projection (at this vertical position) overlaps with + for (float search_x_px = search_x_start; search_x_px <= search_x_end; search_x_px += 1.0f) + { + if ((int)search_x_px % terrain_step != 0) continue; + + //We can use NN here since we are just looking for the ecosystem data + uint2 search_pos = uint2(search_x_px, search_y_px); + float2 search_pos_normal = search_pos / float2(clim_sz); + + // Only needed to extract the ecosystem ID at the location we are testing + // This lets us determine which texture we blend with the ground, projecting from this grid position + uint4 local_climate_data = take_sample(planet_climate, search_pos, clim_sz, 0); + uint ecosystem_id = uint(local_climate_data.z / 16); + + // TODO: Use global random offset data + float offset = take_sample_nn(planet_offsets, search_pos_normal * off_sz, off_sz) / 256.0f; + float2 terrain_center = float2(search_x_px, search_y_px) + float2(0.5f, 0.5f);// + offset; + + //Now finally calculate the local distortion at the center of the terrain + float2 terrain_center_m = pixels_to_meters(terrain_center, clim_sz.y); + float terrain_circumference = circumference_at_distance_from_equator(terrain_center_m.y); + float half_terrain_width_projected_px = (projected_size.x / 2 / terrain_circumference) * clim_sz.y; + float half_terrain_width_physical_px = (terrain_size.x / 2 / terrain_circumference) * clim_sz.y; + + // Both of these coordinates represent 0-1 on the scale of a single pixel in the climate output_heightmap + // So we can simply subtract them to get a UV-like coordinate for our position in this grid cell + float2 grid_uv = (position_px - search_pos) + offset; + + float terrain_left_edge = terrain_center.x - half_terrain_width_projected_px; + float terrain_right_edge = terrain_center.x + half_terrain_width_projected_px; + float terrain_top_edge = terrain_center.y - (projection_warping.vertical_delta * clim_sz.y); + float terrain_bottom_edge = terrain_center.y + (projection_warping.vertical_delta * clim_sz.y); + + //Reject pixels outside of the grid square + if( grid_uv.x < -HORIZONTAL_BLEED || grid_uv.x >= 1 + HORIZONTAL_BLEED || + grid_uv.y < -VERTICAL_BLEED || grid_uv.y >= 1 + VERTICAL_BLEED) + { + continue; + } + + //Finally calculate UV coordinates and return result + float terrain_u = ((position_px.x - terrain_center.x) / half_terrain_width_physical_px / 2); + float terrain_v = ((position_px.y - terrain_center.y) / (physical_warping.vertical_delta * clim_sz.y * 2)); + float patch_u = ((position_px.x - terrain_left_edge) / (half_terrain_width_projected_px * 2)); + float patch_v = ((position_px.y - terrain_top_edge) / (projection_warping.vertical_delta * clim_sz.y * 2)); + + float2 terrain_uv = float2(terrain_u, terrain_v) + offset; + float2 patch_uv = float2(patch_u, patch_v); + + terrain_uv = (terrain_uv + 10) % 1; + patch_uv = (patch_uv + 10) % 1; + + float2 delta = grid_uv - float2(0.5f, 0.5f); + float center_distance = sqrt(delta.x * delta.x + delta.y * delta.y); + float local_mask_value = 1 - (center_distance / float2(1 + HORIZONTAL_BLEED, 1 + VERTICAL_BLEED)); + //(float)(center_distance > 0.5f ? 0 : cos(center_distance * PI)); + + local_mask_value = pow(clamp(local_mask_value, 0, 1), 3); + + int4 local_eco_data = take_sample_nn_3d(ecosystem_climates, terrain_uv * eco_sz.xy, eco_sz.xy, ecosystem_id); + float4 local_eco_normalized = (local_eco_data - 127) / 127.0f; + + float local_eco_height = take_sample_nn_3d(ecosystem_heightmaps, terrain_uv * eco_sz.xy, eco_sz.xy, ecosystem_id); + local_eco_height = (local_eco_height - 32767) / 32767.0f; + + if (jobSettings.debug_mode & DEBUG_ANY) + { + if ((round(search_x_px % 10) == 0 && round(search_y_px % 10) == 0)) + { + result.is_override = true; + if (jobSettings.debug_mode & DEBUG_MASK) { + result.override = uint3(local_mask_value * 256, local_mask_value * 256, local_mask_value * 256); + } else if (jobSettings.debug_mode & DEBUG_UV_TERRAIN_NO_MASK) { + result.override = uint3(255 * terrain_uv.x, 255 * terrain_uv.y, 0); + } else if (jobSettings.debug_mode & DEBUG_UV_PATCH_NO_MASK) { + result.override = uint3(255 * patch_uv.x, 255 * patch_uv.y, 0); + } else if (jobSettings.debug_mode & DEBUG_ECOSYSTEM_NO_MASK) { + result.override = uint3(local_eco_data.x, local_eco_data.y, 0); + } else if (jobSettings.debug_mode & DEBUG_UV_TERRAIN) { + result.override = uint3(255 * terrain_uv.x, 255 * terrain_uv.y, 0) * local_mask_value; + } else if (jobSettings.debug_mode & DEBUG_UV_PATCH) { + result.override = uint3(255 * patch_uv.x, 255 * patch_uv.y, 0) * local_mask_value; + } else if (jobSettings.debug_mode & DEBUG_ECOSYSTEM) { + result.override = uint3(local_eco_data.x, local_eco_data.y, 0) * local_mask_value; + } + return result; + } + } + + result.temp_humidity += local_eco_normalized.xy * local_mask_value; + result.elevation += local_eco_height * local_mask_value; + result.mask_total += local_mask_value; + result.num_influences += 1; + } + } + + // Ensure safe division + result.temp_humidity /= max(result.mask_total, 1.0f); + result.elevation /= max(result.mask_total, 1.0f); + + // Normalize and clamp + result.temp_humidity = min(float2(1, 1), max(float2(-1, -1), result.temp_humidity)); + result.elevation = min(1, max(-1, result.elevation)); + + return result; +} + +uint PackFloatToUInt(float value, int bit_depth) +{ + // Clamp the input value to the range [-1.0, 1.0] + value = clamp(value, -1.0f, 1.0f); + + // Map the range [-1.0, 1.0] to the range [0.0, 1.0] + value = value * 0.5f + 0.5f; + + uint intValue; + + if (bit_depth == 8) { + // Convert the float value to an 8-bit unsigned integer + intValue = uint(value * 255.0f); + } else if (bit_depth == 16) { + // Convert the float value to an 16-bit unsigned integer + intValue = uint(value * 65535.0f); + } else { + intValue = 0; + } + + return intValue; +} + +int PackFloatToInt(float value, int bit_depth) +{ + // Clamp the input value to the range [-1.0, 1.0] + value = clamp(value, -1.0f, 1.0f); + + // Map the range [-1.0, 1.0] to the range [0.0, 1.0] + value = value * 0.5f + 0.5f; + + // Convert the float value to an 32-bit signed integer + int intValue = int(value * 4294967295.0f - 2147483648.0f); + + return intValue; +} + + +[numthreads(8, 8, 4)] // 4 threads in the z dimension for sub-pixel sampling +void main(uint3 tid : SV_DispatchThreadID) +{ + uint2 out_sz; output_color.GetDimensions(out_sz.x, out_sz.y); + uint2 clim_sz; planet_climate.GetDimensions(clim_sz.x, clim_sz.y); + uint2 off_sz; planet_offsets.GetDimensions(off_sz.x, off_sz.y); + uint2 hm_sz; planet_heightmap.GetDimensions(hm_sz.x, hm_sz.y); + + float terrain_scaling = 1; + float2 projected_size = float2(6000, 6000) * terrain_scaling; + float2 physical_size = float2(4000, 4000) * terrain_scaling; + float2 local_influence = float2(jobSettings.local_temperature_influence, jobSettings.local_humidity_influence); + float max_deformation = jobSettings.global_terrain_height_influence + jobSettings.ecosystem_terrain_height_influence; + + // Calculate normalized position in the world (ie: 0,0 = top-left, 1,1 = bottom-right) + float2 normalized_position = tid.xy / float2(clim_sz) / jobSettings.render_scale; + normalized_position.xy += jobSettings.offset; + normalized_position = normalized_position % 1; + + // Offsets for sub-pixel sampling based on z-thread ID + float sub_pixel_offset = (tid.z / 4.0) - 0.375; // Example offset, adjust as needed + + // Adjust the normalized position with the sub-pixel offset + normalized_position += sub_pixel_offset / float2(out_sz); + + // Sample global data + uint4 global_climate = take_sample(planet_climate, normalized_position * clim_sz, clim_sz, jobSettings.interpolation); + float global_height = take_sample(planet_heightmap, normalized_position * hm_sz, hm_sz, jobSettings.interpolation); + uint global_offset = take_sample_nn(planet_offsets, normalized_position * off_sz, off_sz); + + global_height = (global_height - 32767) / 32767.0f; + global_climate = uint4(global_climate.xy / 2, 0, 0); + + bool out_override = false; + uint4 out_color = uint4(0, 0, 0, 255); + float out_height = global_height * jobSettings.global_terrain_height_influence; // Value + float out_ocean_mask = 0; + + if (jobSettings.blending_enabled) { + // Calculate influence of all neighboring terrain + ProjectedTerrainInfluence eco_influence = calculate_projected_tiles(normalized_position, projected_size, physical_size); + + if (eco_influence.is_override) { + out_override = true; + out_color.xyz = eco_influence.override; + } else { + if (eco_influence.mask_total > 0) { + global_climate.yx += eco_influence.temp_humidity * local_influence; + out_height += eco_influence.elevation * jobSettings.ecosystem_terrain_height_influence; + } + + uint4 surface_color = take_sample(surface, global_climate.yx, int2(128, 128), jobSettings.interpolation); + out_color.xyz = surface_color.xyz; + } + } else { + uint4 surface_color = take_sample(surface, global_climate.yx, int2(128, 128), jobSettings.interpolation); + out_color.xyz = surface_color.xyz; + } + + if (jobSettings.ocean_enabled && out_height < jobSettings.ocean_depth) { + if (!out_override) { + out_color.xyz = jobSettings.ocean_color.xyz; + } + + if (jobSettings.ocean_mask_binary) { + out_ocean_mask = 1.0; + } else { + float ocean_bottom = -max_deformation; + float relative_depth = jobSettings.ocean_depth - out_height; + float ocean_max_depth = jobSettings.ocean_depth - ocean_bottom; + + out_ocean_mask = relative_depth / ocean_max_depth; + } + + if (jobSettings.ocean_heightmap_flat) { + out_height = jobSettings.ocean_depth; + } + } else { + // Color already applied, no need to do anything + out_ocean_mask = 0; + } + + // DEBUG: Grid rendering + if (jobSettings.debug_mode & DEBUG_GRID) { + int2 cell_position = (normalized_position.xy * clim_sz * jobSettings.render_scale) % jobSettings.render_scale; + if (cell_position.x == 0 || cell_position.y == 0) { + out_color.xyz = uint3(255, 0, 0); + } + } + + // Squash out_height from meter range to normalized +/- 1.0 range + out_height /= max_deformation; + + output_color[tid.xy] = out_color; + + if (jobSettings.heightmap_bit_depth == 32) { + output_heightmap[tid.xy] = PackFloatToInt(out_height, jobSettings.heightmap_bit_depth); + } else { + output_heightmap[tid.xy] = PackFloatToUInt(out_height, jobSettings.heightmap_bit_depth); + } + + output_ocean_mask[tid.xy] = min(max(out_ocean_mask * 255, 0), 255); +} \ No newline at end of file diff --git a/starfab/planets/planet.py b/starfab/planets/planet.py new file mode 100644 index 0000000000000000000000000000000000000000..ac75c52e2d686ab4e66cd8ca9c97af077264a264 --- /dev/null +++ b/starfab/planets/planet.py @@ -0,0 +1,217 @@ +import math +import struct +from math import atan2, sqrt +from pathlib import Path +from typing import Tuple + +from PySide6.QtCore import QPointF +from scdatatools.engine.chunkfile import ChunkFile, Chunk, JSONChunk, CryXMLBChunk +from scdatatools.engine.cryxml import dict_from_cryxml_file +from scdatatools.p4k import P4KInfo +from scdatatools.sc.object_container import ObjectContainer, ObjectContainerInstance + +from starfab.log import getLogger +from starfab.planets.data import LUTData, Brush +from starfab.planets.ecosystem import EcoSystem + +from . import * + + +logger = getLogger(__name__) + + +class WaypointData: + def __init__(self, point: QPointF, container: ObjectContainerInstance): + self.point: QPointF = point + self.container: ObjectContainerInstance = container + + +class Planet: + def __init__(self, oc: ObjectContainerInstance, data: JSONChunk): + self.oc: ObjectContainerInstance = oc + self.data: JSONChunk = data + + self.planet_data = None + self.tile_count = None + self.radius_m = None + self.humidity_influence = None + self.temperature_influence = None + + self.climate_data: bytearray = None + self.offset_data: bytearray = None + self.heightmap_data: bytearray = None + + self.brushes: list[Brush] = None + self.ecosystems: list[EcoSystem] = None + + self.lut: list[list[LUTData]] = None + + self.waypoints: list[WaypointData] = [] + + self.gpu_resources = {} + self.gpu_computer: Compute = None + + @staticmethod + def position_to_coordinates(x: float, y: float, z: float) -> Tuple[QPointF, float]: + xy_len = sqrt(x * x + y * y) + lat = atan2(-z, xy_len) * (180 / math.pi) + lon = atan2(-x, y) * (180 / math.pi) + alt = sqrt(x * x + y * y + z * z) + # +90 if offsetting for NASA coords, gives us 0-360deg output range + return QPointF((lon + 90 + 360) % 360, lat), alt + + def load_waypoints(self): + # If we already loaded waypoints, don't do anything + if len(self.waypoints) != 0: + return + + # Need to preload *all* entities in the entdata folder to be able to map them + # We used to be able to look up based on the guid, but that's no longer valid + ent_paths = [p.filename for p in self.oc.socpak.filelist if "/entdata/" in p.filename] + ent_infos = [self.oc.socpak.p4k.getinfo(p) for p in ent_paths] + ent_data = [dict_from_cryxml_file(a.open())["Entity"] for a in ent_infos] + ent_map = {ent["@EntityCryGUID"]: ent for ent in ent_data} + + for child_name in self.oc.children: + child_soc: ObjectContainerInstance = self.oc.children[child_name] + coords = self.position_to_coordinates(child_soc.position.x, child_soc.position.y, child_soc.position.z) + self.waypoints.append(WaypointData(coords[0], child_soc)) + if child_soc.guid in ent_map: + child_soc.entdata = ent_map[child_soc.guid] + + def load_data(self) -> object: + if self.planet_data: + return self.planet_data + + self.planet_data = self.data.dict() + + self.tile_count = self.planet_data["data"]["globalSplatWidth"] + self.radius_m = self.planet_data["data"]["General"]["tSphere"]["fPlanetTerrainRadius"] + self.humidity_influence = self.planet_data["data"]["General"]["textureLayers"]["localHumidityInfluence"] + self.temperature_influence = self.planet_data["data"]["General"]["textureLayers"]["localTemperatureInfluence"] + + ocean_material = self.planet_data["data"]["oceanParams"]["Geometry"]["MaterialOceanPlanet"] + ocean_path_posix = ("data" / Path(ocean_material)).as_posix().lower() + try: + if ocean_path_posix in self.oc.container.socpak.p4k.NameToInfoLower: + ocean_mat_info = self.oc.container.socpak.p4k.NameToInfoLower[ocean_path_posix] + ocean_mat_chunks = ChunkFile(ocean_mat_info) + for chunk in ocean_mat_chunks.chunks: + if isinstance(chunk, CryXMLBChunk): + diffuse_path = chunk.dict()["Material"] + print(diffuse_path) + except Exception as ex: + print(ex) + + self.brushes = [Brush(b) for b in self.planet_data["data"]["General"]["brushes"]] + + ecosystem_ids = self.planet_data["data"]["General"]["uniqueEcoSystemsGUIDs"] + self.ecosystems = [EcoSystem.find_in_cache_(e) + for e in ecosystem_ids + if e != "00000000-0000-0000-0000-000000000000"] + + eco: EcoSystem + for eco in self.ecosystems: + eco.read_full_data() + + # R = Temp, G = Humidity, B = Biome ID, A = Unused + splat_raw = self.planet_data["data"]["splatMap"] + self.climate_data = bytearray(splat_raw) + + offsets_raw = self.planet_data["data"]["randomOffset"] + self.offset_data = bytearray(offsets_raw) + + hm_path = self.planet_data["data"]["General"]["tSphere"]["sHMWorld"] + hm_path_posix = ("data" / Path(hm_path)).as_posix().lower() + hm_data: ObjectContainer = self.oc.container + hm_info: P4KInfo = hm_data.socpak.p4k.NameToInfoLower[hm_path_posix] + hm_raw: bytearray = bytearray(hm_data.socpak.p4k.open(hm_info).read()) + # Flip the endian-ness of the heightmap in-place, for easier interpolation + for offset in range(0, len(hm_raw), 2): + struct.pack_into("h", hm_raw, offset)[0]) + + self.heightmap_data = hm_raw + + self._build_lut() + + def _build_lut(self): + # Addressed as [x][y] + self.lut = [[LUTData() + for y in range(self.tile_count)] + for x in range(self.tile_count)] + + def _clamp(val, _min, _max): + return _min if val < _min else (_max if val > _max else val) + + def _lerp_int(_min, _max, val): + return int(((_max - _min) * val) + _min) + + def _lerp_color(a, b, val): + return [ + _clamp(_lerp_int(a[0], b[0], val), 0, 255), + _clamp(_lerp_int(a[1], b[1], val), 0, 255), + _clamp(_lerp_int(a[2], b[2], val), 0, 255), + _clamp(_lerp_int(a[3], b[3], val), 0, 255) + ] + + brush_id_errors = [] + + for y in range(128): + for x in range(128): + lut = self.lut[x][y] + lut.ground_texture_id = self.planet_data["data"]["groundTexIDLUT"][y][x] + lut.object_preset_id = self.planet_data["data"]["objectPresetLUT"][y][x] + lut.brush_id = self.planet_data["data"]["brushIDLUT"][y][x] + try: + lut.brush_obj = self.brushes[lut.brush_id] + except IndexError as e: + lut.brush_obj = None + brush_id_errors.append((x, y, lut.brush_id)) + + brush_data = self.planet_data["data"]["brushDataLUT"][y][x] + + lut.bd_gradient_val_bedrock = brush_data["gradientPosBedRock"] + lut.bd_gradient_val_surface = brush_data["gradientPosSurface"] + lut.bd_value_offset_bedrock = brush_data["valOffsetBedRock"] + lut.bd_value_offset_surface = brush_data["valOffsetSurface"] + lut.bd_saturation_offset_bedrock = brush_data["satOffsetBedRock"] + lut.bd_saturation_offset_surface = brush_data["satOffsetSurface"] + lut.bd_orp_blend_index = brush_data["oprBlendIndex"] + lut.bd_texture_layer_index = brush_data["texturLayerIndex"] + + if lut.brush_obj: + lut.bedrockColor = _lerp_color(lut.brush_obj.bedrockGradientColorA, + lut.brush_obj.bedrockGradientColorB, + lut.bd_gradient_val_bedrock / 127) + + lut.surfaceColor = _lerp_color(lut.brush_obj.surfaceGradientColorA, + lut.brush_obj.surfaceGradientColorB, + lut.bd_gradient_val_surface / 127) + else: + lut.bedrockColor = [255, 0, 255, 255] # purple placeholder to stand out + lut.surfaceColor = [255, 0, 255, 255] + + if brush_id_errors: + logger.warning("One or more tiles with invalid brushIDLUT, used placeholder color.") + logger.debug("brush_id_errors: %r", brush_id_errors) + + @staticmethod + def try_create(oc: ObjectContainerInstance): + json_chunk = Planet.find_planet_data(oc) + if json_chunk: + return Planet(oc, json_chunk) + else: + return None + + @staticmethod + def find_planet_data(oc: ObjectContainerInstance) -> [None, JSONChunk]: + if (not oc.container) or (not oc.container.has_additional): + return None + if oc.container.additional_data: + chunkfile: ChunkFile + for chunkfile in oc.container.additional_data: + chunk: Chunk + for c_id, chunk in chunkfile.chunks.items(): + if isinstance(chunk, JSONChunk): + return chunk + return None diff --git a/starfab/planets/planet_renderer.py b/starfab/planets/planet_renderer.py new file mode 100644 index 0000000000000000000000000000000000000000..8ee98c62b72130059df5f91d959c0ab98234e294 --- /dev/null +++ b/starfab/planets/planet_renderer.py @@ -0,0 +1,342 @@ +import gc +import math +from typing import Union, Callable, Tuple, cast, Literal + +from PIL import Image +from PySide6.QtCore import QRectF, QPointF, QSizeF, QSize + +from . import * +from starfab.log import getLogger +from starfab.planets.planet import Planet +from starfab.planets.data import LUTData, RenderJobSettings, RenderSettings +from starfab.planets.ecosystem import EcoSystem + + +logger = getLogger(__name__) + + +class RenderResult: + def __init__(self, + settings: RenderJobSettings, + tex_color: Image.Image, + tex_heightmap: Image.Image, + tex_oceanmask: Image.Image, + splat_dimensions: Tuple[float, float], + coordinate_bounds_planet: QRectF, + coordinate_bounds: QRectF): + self.settings = settings + self.tex_color: Image.Image = tex_color + self.tex_heightmap: Image.Image = tex_heightmap + self.tex_oceanmask: Image.Image = tex_oceanmask + self.splat_resolution = splat_dimensions + self.coordinate_bounds = coordinate_bounds + self.coordinate_bounds_planet = coordinate_bounds_planet + start_norm_x = (coordinate_bounds.left() - coordinate_bounds_planet.left()) / coordinate_bounds_planet.width() + start_norm_y = (coordinate_bounds.top() - coordinate_bounds_planet.top()) / coordinate_bounds_planet.height() + size_norm = coordinate_bounds.height() / coordinate_bounds_planet.height() + self.coordinate_normalized = QRectF(QPointF(start_norm_x, start_norm_y), QSizeF(size_norm, size_norm)) + + +class PlanetRenderer: + + def __init__(self, buffer_resolution: Tuple[int, int]): + self.planet: None | Planet = None + self.settings: None | RenderSettings = None + self.gpu_resources: dict[str, Resource] = {} + self.render_resolution: QSize = QSize(*buffer_resolution) + + self._create_gpu_output_resources() + + def set_planet(self, planet: Planet): + if planet != self.planet: + # self._cleanup_planet_gpu_resources() + self._cleanup_planet_gpu_resources() + self.planet = planet + self._create_planet_gpu_resources() + self._write_planet_input_resources() + + def set_settings(self, settings: RenderSettings): + self._cleanup_gpu_output_resources() # Cleanup current GPU resources + self.settings = settings + self._create_gpu_output_resources() # Create new GPU resources with updated settings + + def set_resolution(self, new_dimensions: Tuple[int, int]): + self._cleanup_gpu_output_resources() + self.render_resolution = QSize(*new_dimensions) + self._create_gpu_output_resources() + + def get_outer_bounds(self) -> QRectF: + base_coordinate: QPointF = QPointF(0, -90) + if self.settings.coordinate_mode != "NASA": + base_coordinate.setX(-180) + + return QRectF(base_coordinate, QSize(360, 180)) + + def get_bounds_for_render(self, render_coords: QPointF) -> QRectF: + width_norm = self.render_resolution.width() / self.settings.resolution / self.planet.tile_count / 2 + height_norm = self.render_resolution.height() / self.settings.resolution / self.planet.tile_count + render_size_degrees: QSizeF = QSizeF(width_norm * 360, height_norm * 180) + + return QRectF(render_coords, render_size_degrees) + + def get_normalized_from_coordinates(self, coordinates: QPointF) -> QPointF: + bounds = self.get_outer_bounds() + return QPointF((coordinates.x() - bounds.x()) / bounds.width(), + (coordinates.y() - bounds.y()) / bounds.height()) + + def render(self, render_coords: QPointF) -> RenderResult: + job_s = RenderJobSettings() + + if not self.planet: + raise Exception("Planet not set yet!") + + # offset_x/y are be normalized between 0-1 + norm_coords = self.get_normalized_from_coordinates(render_coords) + job_s.offset_x = norm_coords.x() + job_s.offset_y = norm_coords.y() + + # In NASA Mode we are shifting only our sampling offset + # so that [0,0] render coordinates equals [0deg, 360deg] world coordinates + if self.settings.coordinate_mode == "NASA": + job_s.offset_x += 0.5 + # In EarthShifted Mode we are + elif self.settings.coordinate_mode == "EarthShifted": + job_s.offset_x += 0.5 + elif self.settings.coordinate_mode == "EarthUnShifted": + job_s.offset_x = 0 + + job_s.offset_x = job_s.offset_x % 1.0 + job_s.interpolation = self.settings.interpolation + job_s.render_scale_x = self.settings.resolution * 2 + job_s.render_scale_y = self.settings.resolution + job_s.blending_enabled = self.settings.blending_enabled + job_s.hillshade_enabled = self.settings.hillshade_enabled + job_s.hillshade_level = self.settings.hillshade_level + job_s.ocean_mask_binary = self.settings.ocean_mask_binary + job_s.heightmap_bit_depth = self.settings.heightmap_bit_depth + job_s.debug_mode = self.settings.debug_mode + + job_s.planet_radius = self.planet.radius_m + job_s.local_humidity_influence = self.planet.humidity_influence + job_s.local_temperature_influence = self.planet.temperature_influence + + job_s.update_buffer(cast(Buffer, self.gpu_resources['settings'])) + + # Main render pass + computer = self._get_computer() + + computer.dispatch(self.render_resolution.width() // 8, self.render_resolution.height() // 8, 1) + # TODO: Keep this around and render multiple tiles with the same Compute obj + + del computer + + if job_s.hillshade_enabled: + # Hill-shade pass + hillshade_compute = self._get_hillshade_computer() + + hillshade_compute.dispatch(self.render_resolution.width() // 8, self.render_resolution.height() // 8, 1) + + del hillshade_compute + + out_color: Image = self._read_frame("output_color", "RGBA") + + # Check if self.settings is defined; otherwise, revert to R8_UINT + if self.settings is None: + out_heightmap: Image = self._read_frame("output_heightmap", "L") + else: + # Determine the appropriate format for the heightmap texture based on the bit depth + if self.settings.heightmap_bit_depth == 8: + out_heightmap: Image = self._read_frame("output_heightmap", "L") + elif self.settings.heightmap_bit_depth == 16: + out_heightmap: Image = self._read_frame("output_heightmap", "I;16") + elif self.settings.heightmap_bit_depth == 32: + out_heightmap: Image = self._read_frame("output_heightmap", "I;32") + else: + raise ValueError(f"Unsupported heightmap bit depth: {self.settings.heightmap_bit_depth}") + + out_oceanmask: Image = self._read_frame("output_ocean_mask", "L") + + planet_bouds = self.get_outer_bounds() + render_bounds = self.get_bounds_for_render(render_coords) + + return RenderResult(job_s, out_color, out_heightmap, out_oceanmask, + self.render_resolution, planet_bouds, render_bounds) + + def _read_frame(self, resource_name: str, mode: Literal['L', 'RGBA', 'I;16', 'I;32']) -> Image: + resource: Resource = self.gpu_resources['readback'] + readback: Buffer = cast(Buffer, resource) + destination: Texture2D = cast(Texture2D, self.gpu_resources[resource_name]) + destination.copy_to(readback) + output_bytes = readback.readback() + del readback + + if mode == 'I;16': # 16-bit unsigned integer, single channel + return Image.frombuffer('I;16', (destination.width, destination.height), output_bytes, 'raw', 'I;16', 0, 1) + elif mode == 'I;32': # 32-bit signed integer, single channel + return Image.frombuffer('I', (destination.width, destination.height), output_bytes, 'raw', 'I', 0, 1) + else: + return Image.frombuffer(mode, + (destination.width, destination.height), + output_bytes) + + def _get_computer(self) -> Compute: + res = self.gpu_resources + samplers = [ + res['bedrock'], res['surface'], + res['planet_climate'], res['planet_offsets'], res['planet_heightmap'], + res['ecosystem_climates'], res['ecosystem_heightmaps'] + ] + + constant_buffers = [ + res['settings'] + ] + + output_buffers = [ + res['output_color'], res['output_heightmap'], res['output_ocean_mask'] + ] + + return Compute(self.settings.shader_main, + srv=samplers, + cbv=constant_buffers, + uav=output_buffers) + + def _get_hillshade_computer(self) -> Compute: + res = self.gpu_resources + + samplers = [ + res['output_color'], res['output_heightmap'], res['output_ocean_mask'] + ] + + constant_buffers = [ + res['settings'] + ] + + output_buffers = [ + res['output_color'] + ] + + return Compute(self.settings.shader_hillshade, + srv=samplers, + cbv=constant_buffers, + uav=output_buffers) + + def _do_cleanup(self, *resources): + for res_name in resources: + if res_name in self.gpu_resources: + del self.gpu_resources[res_name] + + gc.collect() # Force GC to clean up/dispose the buffers we just del'd + + def _cleanup_planet_gpu_resources(self): + self._do_cleanup('bedrock', 'surface', + 'planet_climate', 'planet_offsets', 'planet_heightmap', + 'ecosystem_climates', 'ecosystem_heightmaps', + 'settings') + + def _create_planet_gpu_resources(self): + climate_size = int(math.sqrt(len(self.planet.climate_data) / 4)) + offset_size = int(math.sqrt(len(self.planet.offset_data))) + heightmap_size = int(math.sqrt(len(self.planet.heightmap_data) / 2)) + ecosystem_climate_size = self.planet.ecosystems[0].climate_image.width + ecosystem_heightmap_size = self.planet.ecosystems[0].elevation_size + + self.gpu_resources['bedrock'] = Texture2D(128, 128, R8G8B8A8_UINT) + self.gpu_resources['surface'] = Texture2D(128, 128, R8G8B8A8_UINT) + self.gpu_resources['planet_climate'] = Texture2D(climate_size, climate_size, R8G8B8A8_UINT) + self.gpu_resources['planet_offsets'] = Texture2D(offset_size, offset_size, R8_UINT) + self.gpu_resources['planet_heightmap'] = Texture2D(heightmap_size, heightmap_size, R16_UINT) + logger.debug(f"{climate_size=} {offset_size=} {heightmap_size=}") + + self.gpu_resources['ecosystem_climates'] = Texture3D(ecosystem_climate_size, ecosystem_climate_size, + len(self.planet.ecosystems), R8G8B8A8_UINT) + self.gpu_resources['ecosystem_heightmaps'] = Texture3D(ecosystem_heightmap_size, ecosystem_heightmap_size, + len(self.planet.ecosystems), R16_UINT) + + self.gpu_resources['settings'] = Buffer(RenderJobSettings.PACK_LENGTH) + + def _write_planet_input_resources(self): + def _update_from_lut(gpu_resource: Resource, fn_color: Callable[[LUTData], list[int]]): + staging = Buffer(gpu_resource.size, HEAP_UPLOAD) + data = [0 for _ in range(128 * 128 * 4)] + for y in range(128): + for x in range(128): + color = fn_color(self.planet.lut[x][y]) + index = 4 * (y * 128 + x) + data[index + 0] = color[0] + data[index + 1] = color[1] + data[index + 2] = color[2] + data[index + 3] = color[3] + # TODO: Can we write directly into the buffer as we generate? + staging.upload(bytes(data)) + staging.copy_to(gpu_resource) + # del staging + + def _update_from_bytes(gpu_resource: Resource, data: bytearray): + logger.debug(f"_update_from_bytes({gpu_resource.size=} {len(data)=})") + if gpu_resource.size < len(data): + raise ValueError(f"Resource size ({gpu_resource.size}) does not match data size ({len(data)})") + elif gpu_resource.size > len(data): + logger.info(f"Resource size ({gpu_resource.size}) mismatch with data size ({len(data)})") + staging = Buffer(gpu_resource.size, HEAP_UPLOAD) + staging.upload(data) + staging.copy_to(gpu_resource) + # del staging + # return gpu_resource + + def _update_from_ecosystems(gpu_resource: Resource, fn_data: Callable[[EcoSystem], Union[bytes, Image.Image]]): + staging = Buffer(gpu_resource.size, HEAP_UPLOAD) + framesize = int(gpu_resource.size / len(self.planet.ecosystems)) + for i, eco in enumerate(self.planet.ecosystems): + eco_data = fn_data(eco) + if isinstance(eco_data, Image.Image): + eco_bytes = eco_data.tobytes('raw', 'RGBA') + else: + eco_bytes = eco_data + staging.upload(eco_bytes, i * framesize) + staging.copy_to(gpu_resource) + del staging + + _update_from_lut(self.gpu_resources['bedrock'], lambda x: x.bedrockColor) + _update_from_lut(self.gpu_resources['surface'], lambda x: x.surfaceColor) + _update_from_bytes(self.gpu_resources['planet_climate'], self.planet.climate_data) + _update_from_bytes(self.gpu_resources['planet_offsets'], self.planet.offset_data) + _update_from_bytes(self.gpu_resources['planet_heightmap'], self.planet.heightmap_data) + _update_from_ecosystems(self.gpu_resources['ecosystem_climates'], lambda x: x.climate_image) + _update_from_ecosystems(self.gpu_resources['ecosystem_heightmaps'], lambda x: x.elevation_bytes) + + def _cleanup_gpu_output_resources(self): + self._do_cleanup('output_color', 'output_heightmap', 'output_ocean_mask', 'readback') + + def _create_gpu_output_resources(self): + if 'output_color' in self.gpu_resources: + return + + # TODO: Support variable size output buffers. For now just render to fixed size and stitch + # Also wasting a bit of space having the heightmap 32BPP when we only need 16 + # but this makes things a lot easier to work with elsewhere :^) + out_w = self.render_resolution.width() + out_h = self.render_resolution.height() + output_color_texture = Texture2D(out_w, out_h, R8G8B8A8_UINT) + + # Check if self.settings is defined; otherwise, revert to R8_UINT + if self.settings is None: + output_heightmap_texture = Texture2D(out_w, out_h, R8_UINT) + else: + # Determine the appropriate format for the heightmap texture based on the bit depth + if self.settings.heightmap_bit_depth == 8: + output_heightmap_texture = Texture2D(out_w, out_h, R8_UINT) + elif self.settings.heightmap_bit_depth == 16: + output_heightmap_texture = Texture2D(out_w, out_h, R16_UINT) + elif self.settings.heightmap_bit_depth == 32: + output_heightmap_texture = Texture2D(out_w, out_h, R32_SINT) + else: + raise ValueError(f"Unsupported heightmap bit depth: {self.settings.heightmap_bit_depth}") + + output_ocean_mask_texture = Texture2D(out_w, out_h, R8_UINT) + + self.gpu_resources['output_color'] = output_color_texture + self.gpu_resources['output_heightmap'] = output_heightmap_texture + self.gpu_resources['output_ocean_mask'] = output_ocean_mask_texture + # NOTE: We will use the same readback buffer to read output_color and output_heightmap + # We take output_color's size because it will be 2x the size of the heightmap tex + self.gpu_resources['readback'] = Buffer(output_color_texture.size, HEAP_READBACK) diff --git a/starfab/planets/planet_server.py b/starfab/planets/planet_server.py new file mode 100644 index 0000000000000000000000000000000000000000..e5bea1fc5665d1b3ae53e8f00103b429053f1994 --- /dev/null +++ b/starfab/planets/planet_server.py @@ -0,0 +1,241 @@ +import asyncio +from threading import Lock + +from PySide6.QtCore import QPointF +from flask import Flask, send_file, request +from flask_cors import CORS +from io import BytesIO +from PIL import Image +from math import pi, atan, sinh, degrees, floor + +from scdatatools import StarCitizen + +from starfab.planets.data import RenderSettings +from starfab.planets.ecosystem import EcoSystem +from starfab.planets.planet_renderer import PlanetRenderer, RenderResult + +app = Flask(__name__) +CORS(app) + +from starfab.planets import planet_renderer +from starfab.planets.planet import Planet +from starfab.gui.widgets.pages.page_PlanetView import PlanetView + +def tile_to_lon_lat(z, x, y): + n = 2.0 ** z + lon_deg = x / n * 360.0 - 180.0 + lat_rad = atan(sinh(pi * (1 - 2 * y / n))) + lat_deg = degrees(lat_rad) + return lon_deg, lat_deg + + +def create_gradient_tile(z, x, y): + # Assuming each tile is 256x256 pixels + tile_size = 256 + map_size = 2 ** z * tile_size + + # Create a gradient image + image = Image.new('RGB', (tile_size, tile_size)) + pixels = image.load() + + for i in range(tile_size): + for j in range(tile_size): + u = (x * tile_size + i) / map_size # U coordinate + v = (y * tile_size + j) / map_size # V coordinate + + lon = u * 360.0 - 180.0 + lat_rad = atan(sinh(pi * (1 - 2 * v))) + lat = degrees(lat_rad) + + red = int((1 - v) * 255) # Red based on V (inverse of latitude) + green = int(u * 255) # Green based on U (longitude) + blue = 0 # No blue channel + pixels[i, j] = (red, green, blue) + + return image + + +def get_rendersize_from_zoom(z): + tile_size = 256 + map_size = 2 ** z * tile_size + planet_size = renderer.planet.tile_count + + if map_size <= planet_size: + return 1 + else: + return int(map_size / planet_size) + + +def get_xyz_normalized(z, x, y) -> QPointF: + tile_size = 256 + map_size = 2 ** z * tile_size + + n_x = (x * tile_size) / map_size # U coordinate + n_y = (y * tile_size) / map_size # V coordinate + + return QPointF(n_x, n_y) + + +def find_render(z, x, y) -> RenderResult: + for cached_result in result_stack: + if contains_tile(cached_result[1], z, x, y): + # print("Cache HIT!") + cached_result[0] += 1 + return cached_result[1] + + # print("Cache MISS!") + new_render = render_tile(z, x, y) + + if not contains_tile(new_render, z, x, y): + #breakpoint() + # TODO: Something still isn't quite right... + pass + + if len(result_stack) == 10: + # Remove the cache entry with the fewest hits + result_stack.remove(min(result_stack, key=lambda r: r[0])) + + result_stack.append([1, new_render]) + + return new_render + + +def render_tile(z, x, y) -> RenderResult: + renderscale = get_rendersize_from_zoom(z) + render_settings.resolution = renderscale + factor = 2 ** z + + tile_norm = get_xyz_normalized(z, x, y) + + # round x and y to the nearest 2^z interval + render_x = floor(tile_norm.x() * renderscale) / renderscale + render_y = floor(tile_norm.y() * renderscale) / renderscale + + # print(f"x={x},y={y},z={z} => {render_x},{render_y},{tile_norm}") + + if factor * 256 <= render_settings.output_resolution[1]: + return do_render(0, 0) + + return do_render(render_x, render_y) + + +def contains_tile(result: RenderResult, z, x, y): + tile_size = 256 + map_size = 2 ** z * tile_size + + # Even the smallest buffer fits 4 tiles tall, so is good to 3 zoom levels by itself + if result.tex_color.height >= map_size: + return True + + planet_scale = result.coordinate_bounds_planet.height() / result.coordinate_bounds.height() + planet_size = result.tex_color.height * planet_scale + + # If this is intended for a different zoom level + if map_size != planet_size: + return False + + n_r = result.coordinate_normalized + n_x = (x * tile_size) / map_size # U coordinate + n_y = (y * tile_size) / map_size # V coordinate + + if n_r.left() <= n_x < n_r.right() and \ + n_r.top() <= n_y < n_r.bottom(): + return True + else: + return False + + +def extract_tile(result: RenderResult, z, x, y, layer: str): + tile_size = 256 + map_size = 2 ** z * tile_size + planet_scale = result.coordinate_bounds_planet.height() / result.coordinate_bounds.height() + planet_size = result.tex_color.height * planet_scale + + n_r = result.coordinate_normalized + n_t = get_xyz_normalized(z, x, y) + + sample_offset_x = (n_t.x() - n_r.left()) / n_r.width() * result.tex_color.width + sample_offset_y = (n_t.y() - n_r.top()) / n_r.height() * result.tex_color.height + + # Outermost zoom levels + # source_box is left, upper, *right*, *bottom* + if map_size <= planet_size: + upscale = planet_size / map_size + source_box = (sample_offset_x, sample_offset_y, + sample_offset_x + (tile_size * 2 * upscale), sample_offset_y + (tile_size * upscale)) + else: + source_box = (sample_offset_x, sample_offset_y, + sample_offset_x + (tile_size * 2), sample_offset_y + tile_size) + target_size = (tile_size, tile_size) + + # print(f"x={x},y={y},z={z}") + # print(f"n_r={n_r}, n_t={n_t}") + # print(f"{result.coordinate_bounds}, {result.coordinate_bounds_planet} => {result.coordinate_normalized}") + # print(source_box) + source_image: Image + + if layer == "surface": + source_image = result.tex_color + elif layer == "heightmap": + source_image = result.tex_heightmap + else: + raise Exception(f"Unknown layer: {layer}") + + return source_image.resize(target_size, box=source_box) + + +def do_render(x, y): + with render_lock: + # TODO: Check the cache first + renderer.set_settings(render_settings) + result = renderer.render(QPointF(x * 360, -90 + y * 180)) + return result + + +@app.route('////.png') +def get_tile(layer, z, x, y): + result: RenderResult = find_render(z, x, y) + image = extract_tile(result, z, x, y, layer) + + # Convert the image to bytes + img_bytes = BytesIO() + image.save(img_bytes, format='PNG') + img_bytes.seek(0) + + # Return the image as a PNG file + return send_file(img_bytes, mimetype='image/png') + +print("SC Init") +sc = StarCitizen("E:\\SC\\StarCitizen\\PTU", p4k_load_monitor=None) +sc.load_all() + +EcoSystem.read_eco_headers(sc) + +print("Megamap Load") +megamap_pu = sc.datacore.search_filename(f'libs/foundry/records/megamap/megamap.pu.xml')[0] +pu_socpak_path = megamap_pu.properties['SolarSystems'][0].properties['ObjectContainers'][0].value +pu_oc = sc.oc_manager.load_socpak(pu_socpak_path) + +print("Loading Body") + +bodies = PlanetView._search_for_bodies(pu_oc) +body = bodies[0] +body.load_data() + + +main_shader = PlanetView._get_shader("shader.hlsl") +hillshade_shader = PlanetView._get_shader("hillshade.hlsl") + +renderer: PlanetRenderer = PlanetRenderer((2048, 1024)) +renderer.set_planet(body) +render_lock = Lock() +render_settings = RenderSettings(True, 1, "NASA", main_shader, hillshade_shader, 1, (2048, 1024), True, False, 16) + +base_render = do_render(0, 0) + +result_stack: list[list[int | RenderResult]] = [] + +if __name__ == '__main__': + app.run(debug=False, port=8082) + + diff --git a/starfab/resources/ui/PlanetView.ui b/starfab/resources/ui/PlanetView.ui new file mode 100644 index 0000000000000000000000000000000000000000..182ebfb7f1bbbb1b8aeb3f506bec2c8bc24665d3 --- /dev/null +++ b/starfab/resources/ui/PlanetView.ui @@ -0,0 +1,382 @@ + + + page_ContentView + + + + 0 + 0 + 1860 + 918 + + + + Form + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + 4 + 0 + + + + Qt::Horizontal + + + + true + + + + 0 + 0 + + + + + 400 + 0 + + + + + 800 + 16777215 + + + + + 3 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QLayout::SetDefaultConstraint + + + + + + + System + + + + + + + + + + Planet + + + + + + + + + + Render Resolution + + + + + + + + + + Coordinate System + + + + + + + + + + Sample Mode + + + + + + + + + + Output Resolution + + + + + + + + + + Heightmap Bit Depth + + + + + + + + + + Hillshade Level + + + + + + + + + + Display Mode + + + + + + + + + + Display Layer + + + + + + + + + + Enable Grid + + + + + + + true + + + + + + + Enable Crosshair + + + + + + + true + + + + + + + Enable Waypoints + + + + + + + true + + + + + + + Enable Ecosystem Blending + + + + + + + true + + + + + + + Enable Hillshade + + + + + + + + + + Enable Binary Ocean Mask + + + + + + + + + + Waypoints + + + + + + + + + + + + + 16777215 + 250 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + + Debug Settings + + + + + + + Grid + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Render + + + + + + + Export + + + + + + + + + + + 3 + 0 + + + + + 0 + + + 0 + + + 0 + + + 3 + + + + + + + + + + + + + QPlanetViewer + QGraphicsView +
starfab.gui.widgets.planets.planet_viewer
+
+
+ + +
diff --git a/starfab/resources/ui/mainwindow.ui b/starfab/resources/ui/mainwindow.ui index 13d36d4bd82609a75140cbd13e26ff7598c74e6b..bd183082d2505a5d86ee778c8fcd86539b15b970 100644 --- a/starfab/resources/ui/mainwindow.ui +++ b/starfab/resources/ui/mainwindow.ui @@ -405,6 +405,14 @@ QToolButton { Mobi + + + true + + + Planets + + diff --git a/starfab/utils.py b/starfab/utils.py index e4f4fef1da0269b34623874fae3e7f62774061d8..0d4acc5225ba9cd947478e2b5cf77097dfd64a78 100644 --- a/starfab/utils.py +++ b/starfab/utils.py @@ -1,7 +1,6 @@ import importlib import subprocess import sys -from distutils.util import strtobool from scdatatools.engine.textures.converter import ( convert_buffer, @@ -12,6 +11,16 @@ from starfab.gui import qtw from starfab.settings import get_texconv, get_compressonatorcli +def strtobool(val: str): + val = val.lower() + if val in ('y', 'yes', 't', 'true', 'on', '1'): + return True + elif val in ('n', 'no', 'f', 'false', 'off', '0'): + return False + else: + raise ValueError(f"Invalid boolean string value: {val!r}") + + def parsebool(val: any): if isinstance(val, bool): return val