diff --git a/Makefile b/Makefile index 184efe9..cd678bc 100644 --- a/Makefile +++ b/Makefile @@ -24,13 +24,63 @@ _STUDIO := carla-studio-$(_PLATFORM)-$(_ARCH)$(_APP_EXT) _CLI := carla-studio-test-suite-$(_PLATFORM)-$(_ARCH)$(_EXT) ifdef CARLA_DIR -_CARLA := -DCARLA_DIR="$(CARLA_DIR)" +_CARLA_ABS := $(abspath "$(CARLA_DIR)") +_CARLA := -DCARLA_DIR="$(_CARLA_ABS)" +_CARLA_BUILD := $(_CARLA_ABS)/Build +_CARLA_LIB := $(_CARLA_ABS)/Build/LibCarla/libcarla-client.a + +ifdef UNREAL_DIR +# Full build: delegates to CarlaSetup.sh (same as `carla-studio build --engine`). +# Builds libcarla + UE5 simulator + shipping package in one shot. +_UNREAL_ABS := $(abspath $(UNREAL_DIR)) +_CARLA_SIM_STAMP := $(_CARLA_BUILD)/.carla-studio-setup-done + +$(_CARLA_SIM_STAMP): + @rm -rf "$(_CARLA_BUILD)" + CARLA_UNREAL_ENGINE_PATH="$(_UNREAL_ABS)" \ + cmake -S "$(CARLA_DIR)" -B "$(_CARLA_BUILD)" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE="$(_CARLA_ABS)/CMake/Toolchain.cmake" \ + -DBUILD_CARLA_CLIENT=ON \ + -DBUILD_CARLA_SERVER=ON \ + -DBUILD_CARLA_UNREAL=ON \ + -DBUILD_PYTHON_API=ON \ + -DBUILD_OSM2ODR=OFF \ + -DENABLE_ROS2=OFF + cmake --build "$(_CARLA_BUILD)" --target carla-client -j$(JOBS) + cmake --build "$(_CARLA_BUILD)" --target carla-unreal -j$(JOBS) + cmake --build "$(_CARLA_BUILD)" --target carla-unreal-editor -j$(JOBS) + @touch "$(_CARLA_SIM_STAMP)" + +$(_CARLA_LIB): $(_CARLA_SIM_STAMP) + +$(BUILD)/CMakeCache.txt: $(_CARLA_SIM_STAMP) + cmake -S src -B "$(BUILD)" -DCMAKE_BUILD_TYPE=Release $(_CARLA) + else -_CARLA := +# Client-only build: libcarla-client.a only, no UE5. +$(_CARLA_LIB): + cmake -S "$(CARLA_DIR)" -B "$(_CARLA_BUILD)" \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_CARLA_CLIENT=ON \ + -DBUILD_CARLA_SERVER=OFF \ + -DBUILD_PYTHON_API=ON \ + -DBUILD_OSM2ODR=OFF \ + -DENABLE_ROS2=OFF + cmake --build "$(_CARLA_BUILD)" --target carla-client -j$(JOBS) + +$(BUILD)/CMakeCache.txt: $(_CARLA_LIB) + cmake -S src -B "$(BUILD)" -DCMAKE_BUILD_TYPE=Release $(_CARLA) + endif +else +_CARLA := + $(BUILD)/CMakeCache.txt: - cmake -S src -B "$(BUILD)" -DCMAKE_BUILD_TYPE=Release $(_CARLA) + cmake -S src -B "$(BUILD)" -DCMAKE_BUILD_TYPE=Release + +endif _build: $(BUILD)/CMakeCache.txt cmake --build "$(BUILD)" --target carla-studio -j$(JOBS) @@ -101,10 +151,21 @@ all: $(BUILD)/CMakeCache.txt app: $(BUILD)/CMakeCache.txt cmake --build "$(BUILD)" --target carla-studio -j$(JOBS) +ifdef UNREAL_DIR +_CARLA_SIM_ROOT := $(shell find "$(_CARLA_BUILD)/Package" -maxdepth 2 \ + \( -name "CarlaUnreal-Linux-Shipping" -o -name "CarlaUnreal.sh" \ + -o -name "CarlaUE5.sh" -o -name "CarlaUE4.sh" \) \ + -printf '%h\n' 2>/dev/null | head -1) +endif + test: $(BUILD)/CMakeCache.txt cmake --build "$(BUILD)" --target carla-studio -j$(JOBS) cmake --build "$(BUILD)" --target test-suite -j$(JOBS) +ifdef _CARLA_SIM_ROOT + CARLA_SIM_ROOT="$(_CARLA_SIM_ROOT)" "$(BUILD)/carla-studio-test-suite$(_EXT)" --update-documentation +else "$(BUILD)/carla-studio-test-suite$(_EXT)" --update-documentation +endif clean: rm -rf "$(BUILD)" @@ -116,7 +177,7 @@ ifeq ($(_PLATFORM), linux) endif help: - @echo "Usage: make [target] [CARLA_DIR=/path/to/carla/source]" + @echo "Usage: make [target] [CARLA_DIR=] [UNREAL_DIR=] [JOBS=N]" @echo "" @echo "Targets:" @echo " make build + keep only carla-studio binary (default)" @@ -129,5 +190,9 @@ help: @echo " make test build + run full test suite" @echo " make clean remove app/ and uninstall .desktop entry (Linux)" @echo "" - @echo " CARLA_DIR= link against CARLA source tree" - @echo " JOBS=N parallel build jobs (default: all cores via nproc)" + @echo " CARLA_DIR= link against CARLA source tree (builds libcarla-client.a)" + @echo " UNREAL_DIR= also build full UE5 simulator + shipping package" + @echo " requires CARLA_DIR; builds carla-unreal-package-shipping" + @echo " simulator lands at CARLA_DIR/Build/Package/Carla-*-Linux-Shipping/" + @echo " test target sets CARLA_SIM_ROOT automatically" + @echo " JOBS=N parallel build jobs (default: all cores via nproc)" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d79d02e..087c533 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -69,6 +69,56 @@ option (CARLA_STUDIO_FETCH_ASSIMP "Fetch + build Assimp at configure time for multi-format mesh ingest in CarlaStudio." OFF) +if (DEFINED CARLA_DIR AND NOT DEFINED CARLA_BUILD_DIR) + set (CARLA_BUILD_DIR "${CARLA_DIR}/Build") +endif () + +if (DEFINED CARLA_BUILD_DIR AND NOT TARGET carla-client) + set (_cbd "${CARLA_BUILD_DIR}") + set (_libcarla_a "${_cbd}/LibCarla/libcarla-client.a") + if (NOT EXISTS "${_libcarla_a}") + carla_warning ("CARLA_BUILD_DIR=${_cbd}: libcarla-client.a not found; LibCarla disabled.") + else () + get_filename_component (_carla_root "${_cbd}/.." ABSOLUTE) + set (_boost_src "${_cbd}/_deps/boost-src") + set (_boost_bld "${_cbd}/_deps/boost-build/libs") + set (_rpclib_src "${_cbd}/_deps/rpclib-src") + set (_rpclib_a "${_cbd}/_deps/rpclib-build/librpc.a") + set (_png_a "${_cbd}/_deps/libpng-build/libpng16.a") + set (_zlib_a "${_cbd}/_deps/zlib-build/libz.a") + + file (GLOB _boost_lib_incs "${_boost_src}/libs/*/include") + file (GLOB _boost_numeric_incs "${_boost_src}/libs/numeric/*/include") + file (GLOB _boost_compiled_libs "${_boost_bld}/*/libboost_*.a") + + add_library (carla-client STATIC IMPORTED GLOBAL) + set_target_properties (carla-client PROPERTIES + IMPORTED_LOCATION "${_libcarla_a}" + INTERFACE_INCLUDE_DIRECTORIES + "${_carla_root}/LibCarla/source;${_boost_lib_incs};${_boost_numeric_incs};${_rpclib_src}/include;${_rpclib_src}/dependencies/include" + INTERFACE_COMPILE_DEFINITIONS + "BOOST_ATOMIC_NO_LIB;BOOST_ATOMIC_STATIC_LINK;\ +BOOST_CHRONO_NO_LIB;BOOST_CHRONO_STATIC_LINK;\ +BOOST_CONTAINER_NO_LIB;BOOST_CONTAINER_STATIC_LINK;\ +BOOST_COROUTINE_NO_LIB;BOOST_COROUTINE_STATIC_LINK;\ +BOOST_DATE_TIME_NO_LIB;BOOST_DATE_TIME_STATIC_LINK;\ +BOOST_FILESYSTEM_NO_LIB;BOOST_FILESYSTEM_STATIC_LINK=1;\ +BOOST_RANDOM_NO_LIB;BOOST_RANDOM_STATIC_LINK;\ +BOOST_SERIALIZATION_NO_LIB;BOOST_SERIALIZATION_STATIC_LINK;\ +BOOST_THREAD_NO_LIB;BOOST_THREAD_STATIC_LINK;BOOST_THREAD_USE_LIB;\ +LIBCARLA_IMAGE_SUPPORT_PNG=1;\ +RPCLIB_LINUX;RPCLIB_MSGPACK=clmdep_msgpack" + INTERFACE_LINK_LIBRARIES + "${_rpclib_a};${_boost_compiled_libs};\ +${_cbd}/_deps/recastnavigation-build/DetourCrowd/libDetourCrowd.a;\ +${_cbd}/_deps/recastnavigation-build/Detour/libDetour.a;\ +${_cbd}/_deps/recastnavigation-build/Recast/libRecast.a;\ +${_png_a};${_zlib_a}" + ) + carla_message ("LibCarla integration enabled (CARLA_BUILD_DIR=${_cbd})") + endif () +endif () + if (CARLA_STUDIO_FETCH_ASSIMP) include (FetchContent) set (ASSIMP_BUILD_TESTS OFF CACHE BOOL "" FORCE) diff --git a/src/carla_studio.cpp b/src/carla_studio.cpp index 408f05c..4a3c624 100644 --- a/src/carla_studio.cpp +++ b/src/carla_studio.cpp @@ -3332,8 +3332,8 @@ int main(int argc, char *argv[]) { const float cameraDistance = 6.0f; cg::Location cameraLocation = vehicleTransform.location; - cameraLocation.x -= static_cast(std::cos(yawRad) * cameraDistance); - cameraLocation.y -= static_cast(std::sin(yawRad) * cameraDistance); + cameraLocation.x -= static_cast(std::cos(yawRad) * static_cast(cameraDistance)); + cameraLocation.y -= static_cast(std::sin(yawRad) * static_cast(cameraDistance)); cameraLocation.z += 2.6f; cg::Rotation cameraRotation(-12.0f, vehicleTransform.rotation.yaw, 0.0f); @@ -3877,8 +3877,8 @@ int main(int argc, char *argv[]) { for (int xi = 0; xi < 4; ++xi) { for (int yi = 0; yi < 11; ++yi) { if ((xi + yi) % 2 != 0) continue; - const float x0 = 88.0f + xi * 1.0f; - const float y0 = -5.5f + yi * 1.0f; + const float x0 = 88.0f + static_cast(xi); + const float y0 = -5.5f + static_cast(yi); for (float dy = 0.0f; dy <= 1.0f; dy += 0.12f) { dbg.DrawLine(cg::Location(x0, y0 + dy, 0.12f), cg::Location(x0 + 1.0f, y0 + dy, 0.12f), @@ -10029,7 +10029,7 @@ int main(int argc, char *argv[]) { double maxMag = 1e-6; for (size_t i = 0; i < flow->size(); ++i) { const auto &p = (*flow)[i]; - const double m = std::sqrt(double(p.x) * p.x + double(p.y) * p.y); + const double m = std::sqrt(double(p.x) * double(p.x) + double(p.y) * double(p.y)); if (m > maxMag) maxMag = m; } @@ -10038,12 +10038,12 @@ int main(int argc, char *argv[]) { uchar *L = frame.scanLine(y); for (int x = 0; x < w; ++x) { const auto &p = (*flow)[size_t(y * w + x)]; - const double mag = std::sqrt(double(p.x) * p.x + double(p.y) * p.y); + const double mag = std::sqrt(double(p.x) * double(p.x) + double(p.y) * double(p.y)); const double ang = std::atan2(double(p.y), double(p.x)); const double hue = (ang + M_PI) / (2.0 * M_PI); const double sat = std::min(1.0, mag / maxMag); - const QColor c = QColor::fromHsvF(hue, sat, 1.0); - L[x*3+0] = c.red(); L[x*3+1] = c.green(); L[x*3+2] = c.blue(); + const QColor c = QColor::fromHsvF(static_cast(hue), static_cast(sat), 1.0f); + L[x*3+0] = static_cast(c.red()); L[x*3+1] = static_cast(c.green()); L[x*3+2] = static_cast(c.blue()); } } postImageToSink(tab, frame); @@ -10107,7 +10107,7 @@ int main(int argc, char *argv[]) { QString("accel=(%1,%2,%3) gyro=(%4,%5,%6) compass=%7°") .arg(a.x, 0, 'f', 2).arg(a.y, 0, 'f', 2).arg(a.z, 0, 'f', 2) .arg(g.x, 0, 'f', 2).arg(g.y, 0, 'f', 2).arg(g.z, 0, 'f', 2) - .arg(m->GetCompass() * 180.0 / M_PI, 0, 'f', 1), + .arg(static_cast(m->GetCompass()) * 180.0 / M_PI, 0, 'f', 1), true); break; } @@ -10123,7 +10123,7 @@ int main(int argc, char *argv[]) { QString("[%1] ↯ %2 impulse=%3") .arg(QTime::currentTime().toString("HH:mm:ss")) .arg(otherName) - .arg(std::sqrt(double(imp.x)*imp.x + double(imp.y)*imp.y + double(imp.z)*imp.z), 0, 'f', 1), + .arg(std::sqrt(double(imp.x)*double(imp.x) + double(imp.y)*double(imp.y) + double(imp.z)*double(imp.z)), 0, 'f', 1), false); break; } @@ -10376,8 +10376,8 @@ int main(int argc, char *argv[]) { if (!bp) continue; const SensorMountConfig mount = getOrCreateSensorMount(name); - cg::Location loc(mount.tx, mount.ty, mount.tz); - cg::Rotation rot(mount.ry, mount.rz, mount.rx); + cg::Location loc(static_cast(mount.tx), static_cast(mount.ty), static_cast(mount.tz)); + cg::Rotation rot(static_cast(mount.ry), static_cast(mount.rz), static_cast(mount.rx)); cg::Transform tr(loc, rot); for (int k = 0; k < counts[i]; ++k) { @@ -11356,7 +11356,7 @@ int main(int argc, char *argv[]) { if (bp.ContainsAttribute("role_name")) { bp.SetAttribute("role_name", "scenario_traffic"); } - auto actor = world.TrySpawnActor(bp, spawns[idx[i]]); + auto actor = world.TrySpawnActor(bp, spawns[idx[static_cast(i)]]); if (!actor) continue; try { auto v = std::static_pointer_cast(actor); @@ -11449,10 +11449,10 @@ int main(int argc, char *argv[]) { }; for (const auto &pair : topology) { if (!pair.first || !pair.second) continue; - const auto l1 = pair.first->GetTransform().location; - const auto l2 = pair.second->GetTransform().location; - grow(l1.x, l1.y); - grow(l2.x, l2.y); + const auto loc1 = pair.first->GetTransform().location; + const auto loc2 = pair.second->GetTransform().location; + grow(loc1.x, loc1.y); + grow(loc2.x, loc2.y); } for (const auto &tr : spawns) { grow(tr.location.x, tr.location.y); @@ -11466,9 +11466,9 @@ int main(int argc, char *argv[]) { topoPen.setWidthF(0); for (const auto &pair : topology) { if (!pair.first || !pair.second) continue; - const auto l1 = pair.first->GetTransform().location; - const auto l2 = pair.second->GetTransform().location; - previewScene->addLine(l1.x, -l1.y, l2.x, -l2.y, topoPen); + const auto loc1 = pair.first->GetTransform().location; + const auto loc2 = pair.second->GetTransform().location; + previewScene->addLine(loc1.x, -loc1.y, loc2.x, -loc2.y, topoPen); } const double r = std::max(1.5, (std::max(maxX - minX, maxY - minY)) / 300.0); @@ -11476,7 +11476,7 @@ int main(int argc, char *argv[]) { spawnPen.setWidthF(0); QBrush spawnBrush(QColor(47, 166, 107, 200)); for (const auto &tr : spawns) { - previewScene->addEllipse(tr.location.x - r, -tr.location.y - r, + previewScene->addEllipse(static_cast(tr.location.x) - r, -static_cast(tr.location.y) - r, 2 * r, 2 * r, spawnPen, spawnBrush); } diff --git a/src/vehicle/import/blender_calibration.cpp b/src/vehicle/import/blender_calibration.cpp new file mode 100644 index 0000000..8ee966c --- /dev/null +++ b/src/vehicle/import/blender_calibration.cpp @@ -0,0 +1,343 @@ +// Copyright (C) 2026 Abdul, Hashim. +// +// This file is part of CARLA Studio. +// Licensed under the GNU Affero General Public License v3 or later - +// see at the project root or +// for the full text. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "vehicle/import/blender_calibration.h" + +#include +#include +#include +#include +#include +#include + +namespace carla_studio::vehicle_import { + +// --------------------------------------------------------------------------- +// Embedded Blender Python script +// +// Receives arguments after "--": +// mesh_path ext scale_cm wheel_fl_x,y,z wheel_fr_x,y,z wheel_rl_x,y,z wheel_rr_x,y,z out_dir +// --------------------------------------------------------------------------- +static const char kBlenderScript[] = R"PY( +import bpy, math, os, sys + +def parse_vec3(s): + return [float(v) for v in s.split(',')] + +argv = sys.argv[sys.argv.index('--') + 1:] +mesh_path = argv[0] +ext = argv[1].lower() +scale_cm = float(argv[2]) # cm per model unit → multiply vertex by scale_cm to get cm +has_wheels = argv[3] == '1' +if has_wheels: + wfl = parse_vec3(argv[4]) + wfr = parse_vec3(argv[5]) + wrl = parse_vec3(argv[6]) + wrr = parse_vec3(argv[7]) + out_dir = argv[8] +else: + out_dir = argv[4] + +os.makedirs(out_dir, exist_ok=True) + +# ── Clear scene ────────────────────────────────────────────────────────────── +bpy.ops.wm.read_factory_settings(use_empty=True) + +# ── Import mesh ────────────────────────────────────────────────────────────── +try: + if ext == 'fbx': + bpy.ops.import_scene.fbx(filepath=mesh_path, use_manual_orientation=False) + elif ext in ('glb', 'gltf'): + bpy.ops.import_scene.gltf(filepath=mesh_path) + elif ext == 'dae': + bpy.ops.wm.collada_import(filepath=mesh_path) + else: # obj - try new API first (Blender 4+), fall back to old + try: + bpy.ops.wm.obj_import(filepath=mesh_path) + except AttributeError: + bpy.ops.import_scene.obj(filepath=mesh_path) +except Exception as e: + print(f'[blender-calib] import failed: {e}', flush=True) + sys.exit(1) + +# ── Scale all objects to metric cm then normalise to meters ───────────────── +# Vertices are already in model units. scale_cm converts 1 model unit → cm. +# Blender uses meters, so we scale by scale_cm / 100. +scale_m = scale_cm / 100.0 +bpy.ops.object.select_all(action='SELECT') +bpy.ops.transform.resize(value=(scale_m, scale_m, scale_m)) +bpy.ops.object.transform_apply(scale=True) + +# ── Ground plane grid ──────────────────────────────────────────────────────── +bpy.ops.mesh.primitive_grid_add(x_subdivisions=20, y_subdivisions=20, + size=20.0, location=(0, 0, 0)) +grid = bpy.context.active_object +grid.name = 'CalibGrid' +mat_grid = bpy.data.materials.new('GridMat') +mat_grid.use_nodes = True +nodes = mat_grid.node_tree.nodes +nodes.clear() +emit = nodes.new('ShaderNodeEmission') +emit.inputs['Color'].default_value = (0.15, 0.15, 0.15, 1.0) +emit.inputs['Strength'].default_value = 0.3 +out = nodes.new('ShaderNodeOutputMaterial') +mat_grid.node_tree.links.new(emit.outputs[0], out.inputs[0]) +grid.data.materials.append(mat_grid) +grid.display_type = 'WIRE' + +# ── Wheel markers ──────────────────────────────────────────────────────────── +WHEEL_COLORS = [(1,0,0,1),(0,1,0,1),(0,0,1,1),(1,1,0,1)] # FL FR RL RR +WHEEL_LABELS = ['FL','FR','RL','RR'] +if has_wheels: + for idx, (xyz, color, label) in enumerate( + zip([wfl, wfr, wrl, wrr], WHEEL_COLORS, WHEEL_LABELS)): + x_m, y_m, z_m = xyz[0]/100.0, xyz[1]/100.0, xyz[2]/100.0 + bpy.ops.mesh.primitive_uv_sphere_add(radius=0.08, + location=(x_m, y_m, z_m)) + sphere = bpy.context.active_object + sphere.name = f'Wheel_{label}' + mat = bpy.data.materials.new(f'WheelMat_{label}') + mat.use_nodes = True + nodes = mat.node_tree.nodes + nodes.clear() + em = nodes.new('ShaderNodeEmission') + em.inputs['Color'].default_value = color + em.inputs['Strength'].default_value = 2.0 + outn = nodes.new('ShaderNodeOutputMaterial') + mat.node_tree.links.new(em.outputs[0], outn.inputs[0]) + sphere.data.materials.append(mat) + +# ── Lighting ───────────────────────────────────────────────────────────────── +bpy.ops.object.light_add(type='SUN', location=(5, 5, 10)) +sun = bpy.context.active_object +sun.data.energy = 2.0 +bpy.ops.object.light_add(type='SUN', location=(-5, -5, 8)) +sun2 = bpy.context.active_object +sun2.data.energy = 0.8 +sun2.data.angle = math.radians(60) + +# ── World background ───────────────────────────────────────────────────────── +world = bpy.data.worlds.new('World') +bpy.context.scene.world = world +world.use_nodes = True +bg = world.node_tree.nodes['Background'] +bg.inputs['Color'].default_value = (0.05, 0.05, 0.05, 1.0) +bg.inputs['Strength'].default_value = 0.5 + +# ── Render settings ────────────────────────────────────────────────────────── +scene = bpy.context.scene +scene.render.engine = 'BLENDER_EEVEE_NEXT' if hasattr(bpy.types, 'EEVEE_NEXT_RenderSettings') else 'BLENDER_EEVEE' +scene.render.resolution_x = 720 +scene.render.resolution_y = 720 +scene.render.resolution_percentage = 100 +scene.render.image_settings.file_format = 'PNG' +scene.render.film_transparent = False + +# EEVEE settings for speed +if hasattr(scene, 'eevee'): + scene.eevee.taa_render_samples = 8 + scene.eevee.use_gtao = True + scene.eevee.gtao_distance = 0.2 + +# ── Camera helper ──────────────────────────────────────────────────────────── +cam_data = bpy.data.cameras.new('CalibCam') +cam_obj = bpy.data.objects.new('CalibCam', cam_data) +bpy.context.collection.objects.link(cam_obj) +scene.camera = cam_obj + +def render_view(name, cam_type, location, rotation_deg, ortho_scale=None): + cam_data.type = cam_type + cam_obj.location = location + cam_obj.rotation_euler = [math.radians(r) for r in rotation_deg] + if ortho_scale is not None: + cam_data.ortho_scale = ortho_scale + scene.render.filepath = os.path.join(out_dir, name + '.png') + bpy.ops.render.render(write_still=True) + print(f'[blender-calib] rendered {name}.png', flush=True) + +# Compute bounding box to auto-fit camera +all_verts = [] +for obj in bpy.context.scene.objects: + if obj.type == 'MESH' and obj.name not in ('CalibGrid',) \ + and not obj.name.startswith('Wheel_'): + mat = obj.matrix_world + for v in obj.data.vertices: + co = mat @ v.co + all_verts.append((co.x, co.y, co.z)) + +if all_verts: + xs = [v[0] for v in all_verts] + ys = [v[1] for v in all_verts] + zs = [v[2] for v in all_verts] + cx = (min(xs)+max(xs))/2; cy = (min(ys)+max(ys))/2 + sz = max(max(xs)-min(xs), max(ys)-min(ys), max(zs)-min(zs)) + dist = sz * 1.4 + scale_o = sz * 1.2 + top_z = max(zs) + dist +else: + cx = cy = 0.0; dist = 8.0; scale_o = 8.0; top_z = dist + +# Top (orthographic, looking down) +render_view('top', 'ORTHO', (cx, cy, top_z), (0, 0, 0), scale_o) +# Side (orthographic, looking in -X) +render_view('side', 'ORTHO', (cx + dist, cy, 1.0), (90, 0, 90), scale_o) +# Front (orthographic, looking in +Y) +render_view('front', 'ORTHO', (cx, cy - dist, 1.0), (90, 0, 0), scale_o) +# 3D perspective +render_view('3d', 'PERSP', (cx + dist*0.7, cy - dist*0.7, dist*0.6), + (60, 0, 45)) + +print('[blender-calib] all views rendered', flush=True) +)PY"; + +// --------------------------------------------------------------------------- + +static QString findBlender() { + // Check env override first, then PATH search. + const QString envBlender = qEnvironmentVariable("CARLA_STUDIO_BLENDER"); + if (!envBlender.isEmpty() && QFileInfo(envBlender).isExecutable()) return envBlender; + return QStandardPaths::findExecutable("blender"); +} + +BlenderCalibrationResult render_calibration_with_blender( + const QString &mesh_path, + const std::array &wheel_xyz_cm, + const QString &out_dir) +{ + BlenderCalibrationResult res; + + const QString blender = findBlender(); + if (blender.isEmpty()) { + res.log = "blender not found in PATH (set CARLA_STUDIO_BLENDER to override)"; + return res; + } + if (!QFileInfo::exists(mesh_path)) { + res.log = "mesh not found: " + mesh_path; + return res; + } + + QDir().mkpath(out_dir); + + // Write the embedded Python script to a temp file. + const QString scriptPath = QDir(out_dir).filePath("__calib_render.py"); + { + QFile f(scriptPath); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { + res.log = "cannot write script to " + scriptPath; + return res; + } + f.write(kBlenderScript); + } + + // Resolve symlinks so Blender can find sibling MTL/texture files. + const QString resolved = QFileInfo(mesh_path).canonicalFilePath(); + const QString actual = resolved.isEmpty() ? mesh_path : resolved; + const QString ext = QFileInfo(actual).suffix(); + + // Wheel args: scale_cm is 1 (mesh already in cm for carla-studio) + QStringList args = { + "--background", + "--python", scriptPath, + "--", + actual, + ext, + "1", // scale_cm: carla-studio verts are already in cm + "1", // has_wheels = true + QString("%1,%2,%3").arg(wheel_xyz_cm[0]).arg(wheel_xyz_cm[1]).arg(wheel_xyz_cm[2]), + QString("%1,%2,%3").arg(wheel_xyz_cm[3]).arg(wheel_xyz_cm[4]).arg(wheel_xyz_cm[5]), + QString("%1,%2,%3").arg(wheel_xyz_cm[6]).arg(wheel_xyz_cm[7]).arg(wheel_xyz_cm[8]), + QString("%1,%2,%3").arg(wheel_xyz_cm[9]).arg(wheel_xyz_cm[10]).arg(wheel_xyz_cm[11]), + out_dir, + }; + + QProcess p; + p.setProcessChannelMode(QProcess::MergedChannels); + p.start(blender, args); + if (!p.waitForStarted(10000)) { + res.log = "blender failed to start"; + return res; + } + p.waitForFinished(300000); // 5 min max + res.log = QString::fromLocal8Bit(p.readAll()).right(2000); + + res.top_png = QDir(out_dir).filePath("top.png"); + res.side_png = QDir(out_dir).filePath("side.png"); + res.front_png = QDir(out_dir).filePath("front.png"); + res.perspective_png = QDir(out_dir).filePath("3d.png"); + + res.ok = QFileInfo(res.top_png).isFile() + && QFileInfo(res.side_png).isFile() + && QFileInfo(res.front_png).isFile(); + return res; +} + +BlenderCalibrationResult render_calibration_with_blender( + const QString &mesh_path, + const QString &out_dir) +{ + BlenderCalibrationResult res; + + const QString blender = findBlender(); + if (blender.isEmpty()) { + res.log = "blender not found in PATH"; + return res; + } + if (!QFileInfo::exists(mesh_path)) { + res.log = "mesh not found: " + mesh_path; + return res; + } + + QDir().mkpath(out_dir); + + const QString scriptPath = QDir(out_dir).filePath("__calib_render.py"); + { + QFile f(scriptPath); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { + res.log = "cannot write script"; + return res; + } + f.write(kBlenderScript); + } + + const QString resolved2 = QFileInfo(mesh_path).canonicalFilePath(); + const QString actual2 = resolved2.isEmpty() ? mesh_path : resolved2; + const QString ext = QFileInfo(actual2).suffix(); + + QStringList args = { + "--background", + "--python", scriptPath, + "--", + actual2, + ext, + "1", // scale_cm + "0", // has_wheels = false + out_dir, + }; + + QProcess p; + p.setProcessChannelMode(QProcess::MergedChannels); + p.start(blender, args); + if (!p.waitForStarted(10000)) { + res.log = "blender failed to start"; + return res; + } + p.waitForFinished(300000); + res.log = QString::fromLocal8Bit(p.readAll()).right(2000); + + res.top_png = QDir(out_dir).filePath("top.png"); + res.side_png = QDir(out_dir).filePath("side.png"); + res.front_png = QDir(out_dir).filePath("front.png"); + res.perspective_png = QDir(out_dir).filePath("3d.png"); + + res.ok = QFileInfo(res.top_png).isFile() + && QFileInfo(res.side_png).isFile() + && QFileInfo(res.front_png).isFile(); + return res; +} + +} diff --git a/src/vehicle/import/blender_calibration.h b/src/vehicle/import/blender_calibration.h new file mode 100644 index 0000000..f9ad0dd --- /dev/null +++ b/src/vehicle/import/blender_calibration.h @@ -0,0 +1,39 @@ +// Copyright (C) 2026 Abdul, Hashim. +// +// This file is part of CARLA Studio. +// Licensed under the GNU Affero General Public License v3 or later - +// see at the project root or +// for the full text. +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include +#include + +namespace carla_studio::vehicle_import { + +// Result of a Blender-based calibration render. +struct BlenderCalibrationResult { + bool ok = false; + QString top_png, side_png, front_png, perspective_png; + QString log; // blender stdout/stderr (truncated) +}; + +// Render calibration views (top / side / front / perspective) for a vehicle +// mesh using Blender in headless/batch mode. Wheel positions are in CARLA cm +// (X=lateral, Y=forward, Z=up). Returns false if blender is not found or +// rendering fails. +// +// Order of wheel_xyz_cm: FL x,y,z FR x,y,z RL x,y,z RR x,y,z +BlenderCalibrationResult render_calibration_with_blender( + const QString &mesh_path, + const std::array &wheel_xyz_cm, + const QString &out_dir); + +// Convenience overload: no wheel positions (shows mesh only). +BlenderCalibrationResult render_calibration_with_blender( + const QString &mesh_path, + const QString &out_dir); + +} diff --git a/src/vehicle/import/carla_spawn.cpp b/src/vehicle/import/carla_spawn.cpp index cd1e5b4..b49672d 100644 --- a/src/vehicle/import/carla_spawn.cpp +++ b/src/vehicle/import/carla_spawn.cpp @@ -52,7 +52,7 @@ QString contentSubpathForBp(const QString &bp_asset_path) { QString p = bp_asset_path; if (p.startsWith("/Game/")) p.remove(0, QString("/Game/").size()); const qsizetype lastSlash = p.lastIndexOf('/'); - return lastSlash > 0 ? p.left(lastSlash) : p; + return lastSlash > 0 ? p.left(static_cast(lastSlash)) : p; } QString shippingContentRoot(const QString &shipping_carla_root) { @@ -322,7 +322,8 @@ DeployResult deploy_vehicle_to_shipping_carla(const VehicleRegistration ®) { const QString needle = QStringLiteral("/Content/"); const qsizetype idx = cookedContentRoot.lastIndexOf(needle); if (idx >= 0) - cookedContentRoot = cookedContentRoot.left(idx + needle.size() - 1); + cookedContentRoot = cookedContentRoot.left( + static_cast(idx + needle.size() - 1)); } const QString vehicleRel = sub; QDirIterator it(cookedContentRoot, @@ -381,7 +382,7 @@ SpawnResult spawn_in_running_carla(const QString &make, const QString &model, return sr; #else try { - auto client = carla::client::Client(host.toStdString(), port); + auto client = carla::client::Client(host.toStdString(), static_cast(port)); client.SetTimeout(std::chrono::seconds(60)); client.GetServerVersion(); auto world = client.GetWorld(); @@ -521,7 +522,7 @@ DriveTestResult drive_test_vehicle(const QString &make, const QString &model, return r; #else try { - auto client = carla::client::Client(host.toStdString(), port); + auto client = carla::client::Client(host.toStdString(), static_cast(port)); client.SetTimeout(std::chrono::seconds(60)); client.GetServerVersion(); auto world = client.GetWorld(); diff --git a/src/vehicle/import/cleanup_cli.cpp b/src/vehicle/import/cleanup_cli.cpp index a2889d5..e0d32a0 100644 --- a/src/vehicle/import/cleanup_cli.cpp +++ b/src/vehicle/import/cleanup_cli.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include diff --git a/src/vehicle/import/editor_process.cpp b/src/vehicle/import/editor_process.cpp index abfe19a..74b9766 100644 --- a/src/vehicle/import/editor_process.cpp +++ b/src/vehicle/import/editor_process.cpp @@ -9,6 +9,7 @@ #include "vehicle/import/editor_process.h" #include +#include #include #include #include @@ -43,7 +44,7 @@ qint64 procStartUnix(qint64 pid) { if (data.isEmpty()) return 0; const qsizetype rparen = data.lastIndexOf(')'); if (rparen < 0 || rparen + 2 >= data.size()) return 0; - const QList rest = data.mid(rparen + 2).split(' '); + const QList rest = data.mid(static_cast(rparen + 2)).split(' '); if (rest.size() <= 19) return 0; bool ok = false; const qulonglong ticks = rest[19].toULongLong(&ok); diff --git a/src/vehicle/import/import_pipeline.cpp b/src/vehicle/import/import_pipeline.cpp index 58fcf66..a5253ec 100644 --- a/src/vehicle/import/import_pipeline.cpp +++ b/src/vehicle/import/import_pipeline.cpp @@ -11,6 +11,7 @@ #include "vehicle/import/carla_spawn.h" #include "vehicle/import/importer_client.h" #include "vehicle/import/merged_spec_builder.h" +#include "vehicle/import/mesh_analysis.h" #include "vehicle/import/mesh_geometry.h" #include "vehicle/import/vehicle_preview_window.h" @@ -34,13 +35,50 @@ void emitLog(const ImportPipelineCallbacks &cb, const QString &msg) { if (cb.log) cb.log(msg); } -VehicleSpec buildSpecFromInput(const ImportPipelineInput &in) { +VehicleSpec buildSpecFromInput(const ImportPipelineInput &in, + const ImportPipelineCallbacks &cb) { VehicleSpec s; MeshGeometry g = load_mesh_geometry(in.mesh_path); - if (g.valid) { - const MeshAnalysisResult ar = analyze_mesh(g, 1.0f); + if (!g.valid) { + emitLog(cb, QString("[mesh-analysis] could not load geometry from %1 " + "(unsupported format or empty file); using fallback wheel positions.") + .arg(QFileInfo(in.mesh_path).fileName())); + } else { + const float scale_to_cm = detect_scale_to_cm(g); + emitLog(cb, QString("[mesh-analysis] loaded %1 verts, %2 tris from %3 " + "unit→cm scale=×%4") + .arg(g.vertex_count()).arg(g.face_count()) + .arg(QFileInfo(in.mesh_path).fileName()) + .arg(scale_to_cm, 0, 'f', 2)); + const MeshAnalysisResult ar = analyze_mesh(g, scale_to_cm); if (ar.ok) { - s = build_spec_from_analysis(ar, in.vehicle_name, in.mesh_path, 1.0f, 2, 0); + s = build_spec_from_analysis(ar, in.vehicle_name, in.mesh_path, scale_to_cm, 2, 0); + emitLog(cb, QString("[mesh-analysis] size=%1 chassis=%2×%3×%4 cm " + "wheels_detected=%5") + .arg(size_class_name(ar.size_class)) + .arg(static_cast(ar.chassis_x_max - ar.chassis_x_min)) + .arg(static_cast(ar.chassis_y_max - ar.chassis_y_min)) + .arg(static_cast(ar.chassis_z_max - ar.chassis_z_min)) + .arg(ar.has_four_wheels ? "yes" : "no")); + if (ar.has_four_wheels) { + const char *wlabels[4] = { "FL", "FR", "RL", "RR" }; + for (size_t i = 0; i < 4 && i < ar.wheels.size(); ++i) { + const WheelCandidate &w = ar.wheels[i]; + emitLog(cb, QString("[mesh-analysis] wheel[%1] cx=%.1f cy=%.1f cz=%.1f " + "r=%.1f w=%.1f") + .arg(wlabels[i]) + .arg(w.cx).arg(w.cy).arg(w.cz) + .arg(w.radius).arg(w.width)); + } + } else { + emitLog(cb, "[mesh-analysis] 4-wheel detection failed — " + "wheels may be topologically merged with body or " + "aspect-ratio filter excluded them; " + "derived wheel anchors from chassis AABB instead."); + } + } else { + emitLog(cb, "[mesh-analysis] analysis returned no result " + "(zero faces after loading?); using fallback wheel positions."); } } if (s.name.isEmpty()) { @@ -54,12 +92,14 @@ VehicleSpec buildSpecFromInput(const ImportPipelineInput &in) { s.wheels[1].x = 140; s.wheels[1].y = 80; s.wheels[1].z = 35; s.wheels[2].x = -140; s.wheels[2].y = -80; s.wheels[2].z = 35; s.wheels[3].x = -140; s.wheels[3].y = 80; s.wheels[3].z = 35; + emitLog(cb, "[mesh-analysis] fallback: using default sedan wheel positions " + "(140/80/35 cm); review calibration window and adjust if needed."); } - s.content_path = "/Game/Carla/Static/Vehicles/4Wheeled"; + s.content_path = "/Game/Carla/Static/Vehicles/4Wheeled"; s.base_vehicle_bp = "/Game/Carla/Blueprints/Vehicles/Mustang/BP_Mustang"; - s.mass = in.knobs.mass; - s.susp_damping = in.knobs.susp_damping; - s.size_class = in.knobs.size_class; + s.mass = in.knobs.mass; + s.susp_damping = in.knobs.susp_damping; + s.size_class = in.knobs.size_class; for (size_t i = 0; i < 4; ++i) { s.wheels[i].max_steer_angle = (i < 2) ? in.knobs.max_steer_angle : 0.f; s.wheels[i].max_brake_torque = in.knobs.max_brake_torque; @@ -107,7 +147,7 @@ ImportPipelineResult run_import_pipeline(const ImportPipelineInput &inIn, return res; } - VehicleSpec spec = buildSpecFromInput(in); + VehicleSpec spec = buildSpecFromInput(in, cb); if (!in.tires_path.isEmpty() && QFileInfo(in.mesh_path).suffix().toLower() == "obj" && QFileInfo(in.tires_path).suffix().toLower() == "obj" diff --git a/src/vehicle/import/import_test_cli.cpp b/src/vehicle/import/import_test_cli.cpp index 2ef449d..28e8f56 100644 --- a/src/vehicle/import/import_test_cli.cpp +++ b/src/vehicle/import/import_test_cli.cpp @@ -1123,7 +1123,7 @@ int runVehicleImportCli(int argc, char **argv) #ifdef CARLA_STUDIO_WITH_LIBCARLA if (r.spawned) { try { - carla::client::Client client("localhost", carla_port); + carla::client::Client client("localhost", static_cast(carla_port)); client.SetTimeout(std::chrono::seconds(20)); auto world = client.GetWorld(); std::shared_ptr ours; diff --git a/src/vehicle/import/importer_client.cpp b/src/vehicle/import/importer_client.cpp index 7150a65..440a502 100644 --- a/src/vehicle/import/importer_client.cpp +++ b/src/vehicle/import/importer_client.cpp @@ -13,6 +13,8 @@ #include #include +#include + namespace carla_studio::vehicle_import { namespace { @@ -77,13 +79,15 @@ QString send_json(const QJsonObject &spec) { | (static_cast(rhdr[2]) << 16) | (static_cast(rhdr[3]) << 24); if (rlen == 0 || rlen > kMaxBody) return QString(); + if (rlen > static_cast(std::numeric_limits::max())) return QString(); - QByteArray body(rlen, 0); + QByteArray body(static_cast(rlen), 0); qint64 got = 0; while (got < static_cast(rlen)) { if (sock.bytesAvailable() <= 0 && !sock.waitForReadyRead(kIoTimeoutMs)) return QString(); - const qint64 r = sock.read(body.data() + got, rlen - got); + const qint64 r = sock.read(body.data() + got, + static_cast(rlen) - got); if (r <= 0) return QString(); got += r; } diff --git a/src/vehicle/import/mesh_aabb.cpp b/src/vehicle/import/mesh_aabb.cpp index c6c4618..fc9a0bc 100644 --- a/src/vehicle/import/mesh_aabb.cpp +++ b/src/vehicle/import/mesh_aabb.cpp @@ -110,7 +110,9 @@ MeshAABB parse_gltf(const QString &path) { | (static_cast((quint8)raw[13]) << 8) | (static_cast((quint8)raw[14]) << 16) | (static_cast((quint8)raw[15]) << 24); - raw = raw.mid(20, chunkLen); + const int available = static_cast(std::max(qsizetype{0}, raw.size() - 20)); + const int boundedLen = std::min(static_cast(chunkLen), available); + raw = raw.mid(20, boundedLen); } QJsonParseError err; diff --git a/src/vehicle/import/mesh_analysis.cpp b/src/vehicle/import/mesh_analysis.cpp index c483cea..43e9e1a 100644 --- a/src/vehicle/import/mesh_analysis.cpp +++ b/src/vehicle/import/mesh_analysis.cpp @@ -109,6 +109,30 @@ QString size_class_name(SizeClass s) { } } +float detect_scale_to_cm_from_extent(float maxExt) { + if (maxExt <= 0.f) return 1.0f; + if (maxExt >= 2.0f && maxExt <= 8.0f) return 100.0f; + if (maxExt >= 200.0f && maxExt <= 800.0f) return 1.0f; + if (maxExt >= 2000.0f && maxExt <= 8000.0f) return 0.1f; + if (maxExt >= 0.3f && maxExt < 2.0f) return 100.0f; + return 450.0f / maxExt; +} + +float detect_scale_to_cm(const MeshGeometry &g) { + if (!g.valid || g.vertex_count() == 0) return 1.0f; + float xn = 1e30f, xx = -1e30f; + float yn = 1e30f, yx = -1e30f; + float zn = 1e30f, zx = -1e30f; + const int vc = g.vertex_count(); + for (int i = 0; i < vc; ++i) { + float x, y, z; g.vertex(i, x, y, z); + xn = std::min(xn, x); xx = std::max(xx, x); + yn = std::min(yn, y); yx = std::max(yx, y); + zn = std::min(zn, z); zx = std::max(zx, z); + } + return detect_scale_to_cm_from_extent(std::max({xx - xn, yx - yn, zx - zn})); +} + MeshAnalysisResult analyze_mesh(const MeshGeometry &g, float scale_to_cm) { MeshAnalysisResult r; if (!g.valid || g.face_count() == 0) return r; diff --git a/src/vehicle/import/mesh_analysis.h b/src/vehicle/import/mesh_analysis.h index ec73abd..a5d1cf4 100644 --- a/src/vehicle/import/mesh_analysis.h +++ b/src/vehicle/import/mesh_analysis.h @@ -56,6 +56,9 @@ struct MeshAnalysisResult { MeshAnalysisResult analyze_mesh(const MeshGeometry &g, float scale_to_cm); +float detect_scale_to_cm(const MeshGeometry &g); +float detect_scale_to_cm_from_extent(float maxExt); + QString size_class_name(SizeClass s); SizeClass classify_by_name(const QString &filename_stem); diff --git a/src/vehicle/import/mesh_geometry.cpp b/src/vehicle/import/mesh_geometry.cpp index e0aa597..5c98714 100644 --- a/src/vehicle/import/mesh_geometry.cpp +++ b/src/vehicle/import/mesh_geometry.cpp @@ -10,15 +10,22 @@ #include #include +#include #include +#include +#include + namespace carla_studio::vehicle_import { namespace { +static const QRegularExpression kWs(QStringLiteral("\\s+")); + int parseFaceIndex(const QString &token, int vertex_count) { const qsizetype slash = token.indexOf(QLatin1Char('/')); const QString head = slash < 0 ? token : token.left(slash); + if (head.isEmpty()) return -1; bool ok = false; int idx = head.toInt(&ok); if (!ok) return -1; @@ -27,6 +34,65 @@ int parseFaceIndex(const QString &token, int vertex_count) { return idx - 1; } +static QString stripBom(const QString &s) { + if (!s.isEmpty() && s.at(0) == QChar(0xFEFF)) return s.mid(1); + return s; +} + +} + +void compute_smooth_normals(MeshGeometry &g) { + const int nV = g.vertex_count(); + const int nF = g.face_count(); + if (nV == 0 || nF == 0) return; + g.normals.assign(static_cast(nV) * 3, 0.0f); + + for (int fi = 0; fi < nF; ++fi) { + const size_t fi3 = static_cast(fi) * 3; + const int idx[3] = { g.faces[fi3], g.faces[fi3+1], g.faces[fi3+2] }; + float p[3][3]; + for (int k = 0; k < 3; ++k) g.vertex(idx[k], p[k][0], p[k][1], p[k][2]); + + const float e01[3] = {p[1][0]-p[0][0], p[1][1]-p[0][1], p[1][2]-p[0][2]}; + const float e02[3] = {p[2][0]-p[0][0], p[2][1]-p[0][1], p[2][2]-p[0][2]}; + const float fn[3] = { + e01[1]*e02[2] - e01[2]*e02[1], + e01[2]*e02[0] - e01[0]*e02[2], + e01[0]*e02[1] - e01[1]*e02[0] + }; + const float fnLen = std::sqrt(fn[0]*fn[0] + fn[1]*fn[1] + fn[2]*fn[2]); + if (fnLen < 1e-12f) continue; + const float fn_n[3] = {fn[0]/fnLen, fn[1]/fnLen, fn[2]/fnLen}; + + for (int k = 0; k < 3; ++k) { + const int prev = (k + 2) % 3, next = (k + 1) % 3; + float ep[3], en[3], lpSq = 0.f, lnSq = 0.f; + for (int d = 0; d < 3; ++d) { + ep[d] = p[prev][d] - p[k][d]; + en[d] = p[next][d] - p[k][d]; + lpSq += ep[d]*ep[d]; + lnSq += en[d]*en[d]; + } + const float lp = std::sqrt(lpSq), ln = std::sqrt(lnSq); + if (lp < 1e-12f || ln < 1e-12f) continue; + const float cosA = std::clamp( + (ep[0]*en[0] + ep[1]*en[1] + ep[2]*en[2]) / (lp * ln), -1.f, 1.f); + const float angle = std::acos(cosA); + + const size_t o = static_cast(idx[k]) * 3; + g.normals[o] += fn_n[0] * angle; + g.normals[o+1] += fn_n[1] * angle; + g.normals[o+2] += fn_n[2] * angle; + } + } + + for (int i = 0; i < nV; ++i) { + const size_t o = static_cast(i) * 3; + const float L = std::sqrt( + g.normals[o]*g.normals[o] + g.normals[o+1]*g.normals[o+1] + g.normals[o+2]*g.normals[o+2]); + if (L > 1e-12f) { g.normals[o] /= L; g.normals[o+1] /= L; g.normals[o+2] /= L; } + else { g.normals[o+2] = 1.f; } + } } MeshGeometry load_mesh_geometry_obj(const QString &path) { @@ -34,11 +100,28 @@ MeshGeometry load_mesh_geometry_obj(const QString &path) { QFile f(path); if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return g; QTextStream in(&f); + + bool firstLine = true; while (!in.atEnd()) { - const QString line = in.readLine().trimmed(); + QString line = in.readLine(); + + if (firstLine) { line = stripBom(line); firstLine = false; } + + line = line.trimmed(); + + while (line.endsWith(QLatin1Char('\\')) && !in.atEnd()) { + line.chop(1); + line += QLatin1Char(' '); + line += in.readLine().trimmed(); + } + if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) continue; - if (line.startsWith(QLatin1String("v "))) { - const QStringList tokens = line.split(QLatin1Char(' '), Qt::SkipEmptyParts); + + const QStringList tokens = line.split(kWs, Qt::SkipEmptyParts); + if (tokens.isEmpty()) continue; + const QString &cmd = tokens[0]; + + if (cmd == QLatin1String("v")) { if (tokens.size() < 4) continue; bool ok1, ok2, ok3; const float x = tokens[1].toFloat(&ok1); @@ -46,8 +129,8 @@ MeshGeometry load_mesh_geometry_obj(const QString &path) { const float z = tokens[3].toFloat(&ok3); if (!(ok1 && ok2 && ok3)) continue; g.verts.push_back(x); g.verts.push_back(y); g.verts.push_back(z); - } else if (line.startsWith(QLatin1String("f "))) { - const QStringList tokens = line.split(QLatin1Char(' '), Qt::SkipEmptyParts); + + } else if (cmd == QLatin1String("f")) { if (tokens.size() < 4) { ++g.malformed_faces; continue; } const int vc = g.vertex_count(); std::vector ring; @@ -66,7 +149,9 @@ MeshGeometry load_mesh_geometry_obj(const QString &path) { } } } + g.valid = !g.verts.empty() && !g.faces.empty(); + if (g.valid) compute_smooth_normals(g); return g; } diff --git a/src/vehicle/import/mesh_geometry.h b/src/vehicle/import/mesh_geometry.h index 8af8e8c..7eb69f5 100644 --- a/src/vehicle/import/mesh_geometry.h +++ b/src/vehicle/import/mesh_geometry.h @@ -16,11 +16,13 @@ namespace carla_studio::vehicle_import { struct MeshGeometry { bool valid = false; std::vector verts; + std::vector normals; std::vector faces; int malformed_faces = 0; int vertex_count() const { return static_cast(verts.size() / 3); } int face_count() const { return static_cast(faces.size() / 3); } + bool has_normals() const { return !normals.empty() && normals.size() == verts.size(); } void vertex(int i, float &x, float &y, float &z) const { const size_t o = static_cast(i) * 3; x = verts[o]; y = verts[o + 1]; z = verts[o + 2]; @@ -30,4 +32,6 @@ struct MeshGeometry { MeshGeometry load_mesh_geometry(const QString &path); MeshGeometry load_mesh_geometry_obj(const QString &path); +void compute_smooth_normals(MeshGeometry &g); + } diff --git a/src/vehicle/import/mesh_preview_renderer.cpp b/src/vehicle/import/mesh_preview_renderer.cpp index 765b7ff..be98062 100644 --- a/src/vehicle/import/mesh_preview_renderer.cpp +++ b/src/vehicle/import/mesh_preview_renderer.cpp @@ -200,7 +200,9 @@ QImage render_preview(const MeshGeometry &g, QPointF h1 = tip - dir * 16 + perp * 8; QPointF h2 = tip - dir * 16 - perp * 8; p.setBrush(col); - p.drawPolygon(QPolygonF{ tip, h1, h2 }); + QPolygonF tri; + tri << tip << h1 << h2; + p.drawPolygon(tri); } p.setPen(col); QFont f = p.font(); f.setBold(true); p.setFont(f); diff --git a/src/vehicle/import/run_test_suite_cli.cpp b/src/vehicle/import/run_test_suite_cli.cpp index b146de8..555d840 100644 --- a/src/vehicle/import/run_test_suite_cli.cpp +++ b/src/vehicle/import/run_test_suite_cli.cpp @@ -1,4 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +#include +#include #include #include #include @@ -93,6 +95,17 @@ int main(int argc, char *argv[]) { const QStringList args = app.arguments(); const bool updateDocs = args.contains("--update-documentation"); + // Strip --update-documentation so QTest::qExec never sees it. + std::vector filteredStorage; + std::vector filteredArgv; + for (int i = 0; i < argc; ++i) { + if (QLatin1String(argv[i]) != QLatin1String("--update-documentation")) { + filteredStorage.push_back(argv[i]); + filteredArgv.push_back(filteredStorage.back().data()); + } + } + int filteredArgc = static_cast(filteredArgv.size()); + const QString display = qEnvironmentVariable("DISPLAY", ":0"); QTextStream(stdout) << "[test-suite] DISPLAY=" << display << "\n"; @@ -113,11 +126,11 @@ int main(int argc, char *argv[]) { int exit_code = 0; QTextStream(stdout) << "\n=== CLI pipeline tests ===\n"; - runCliPipelineTests(argc, argv, exit_code); + runCliPipelineTests(filteredArgc, filteredArgv.data(), exit_code); int gui_code = 0; QTextStream(stdout) << "\n=== GUI matrix tests ===\n"; - runGuiMatrixTests(argc, argv, gui_code); + runGuiMatrixTests(filteredArgc, filteredArgv.data(), gui_code); exit_code |= gui_code; if (updateDocs) { diff --git a/src/vehicle/import/vehicle_import_page.cpp b/src/vehicle/import/vehicle_import_page.cpp index 36bf2c9..5c8ae6f 100644 --- a/src/vehicle/import/vehicle_import_page.cpp +++ b/src/vehicle/import/vehicle_import_page.cpp @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -1047,7 +1048,7 @@ void VehicleImportPage::on_import() { capturedRR = QVector3D(m_detected_spec->wheels[3].x, m_detected_spec->wheels[3].y, m_detected_spec->wheels[3].z); } - auto sendNow = [=]() { + auto sendNow = [=, this]() { if (!log_ptr) return; log_ptr->appendPlainText(QString("[%1] Sending spec to UE Editor…").arg(stamp())); if (statusPtr) statusPtr->setText("UE Editor: sending …"); @@ -1055,9 +1056,9 @@ void VehicleImportPage::on_import() { progressPtr->setRange(0, 0); progressPtr->setFormat("importing in UE Editor…"); } - Q_UNUSED(QtConcurrent::run([=]() { + Q_UNUSED(QtConcurrent::run([=, this]() { const QString response = send_json(spec); - QMetaObject::invokeMethod(qApp, [=]() { + QMetaObject::invokeMethod(qApp, [=, this]() { if (!log_ptr) return; if (btn_ptr) btn_ptr->setEnabled(true); if (progressPtr) progressPtr->setRange(0, 100); @@ -1242,7 +1243,7 @@ void VehicleImportPage::on_import() { const qint64 launchedPid = pid; auto *poll = new QTimer(this); poll->setInterval(1000); - connect(poll, &QTimer::timeout, this, [=]() { + connect(poll, &QTimer::timeout, this, [=, this]() { ++*attempts; if (probe_importer_port()) { poll->stop(); poll->deleteLater(); @@ -1317,7 +1318,7 @@ void VehicleImportPage::on_drop() { const QString model = reg.model; QPointer progPtr = m_progress; - Q_UNUSED(QtConcurrent::run([=]() { + Q_UNUSED(QtConcurrent::run([=, this]() { RegisterResult rr; DeployResult dr; SpawnResult sr; @@ -1369,7 +1370,7 @@ void VehicleImportPage::on_drop() { unhandled = "unknown C++ exception"; } - QMetaObject::invokeMethod(qApp, [=]() { + QMetaObject::invokeMethod(qApp, [=, this]() { if (!unhandled.isEmpty()) { if (log_ptr) log_ptr->appendPlainText(QString( "[%1] Drive: caught %2 - likely API mismatch between this Studio's " @@ -1464,18 +1465,18 @@ void VehicleImportPage::on_drop() { ::close(s); return up; }; - connect(poll, &QTimer::timeout, this, [=]() { + connect(poll, &QTimer::timeout, this, [=, this]() { ++*attempts; const bool up = probePort2000(); if (up) { poll->stop(); poll->deleteLater(); if (logL) logL->appendPlainText(QString("[%1] CARLA simulator is up after %2 s - retrying spawn.") .arg(stamp()).arg(*attempts * 2)); - Q_UNUSED(QtConcurrent::run([=]() { + Q_UNUSED(QtConcurrent::run([=, this]() { SpawnResult sr2; try { sr2 = spawn_in_running_carla(mk, md); } catch (...) { sr2.detail = "second-pass spawn threw"; } - QMetaObject::invokeMethod(qApp, [=]() { + QMetaObject::invokeMethod(qApp, [=, this]() { if (logL) logL->appendPlainText(QString("[%1] Retry spawn: %2 - %3") .arg(stamp()) .arg(sr2.kind == SpawnResult::Kind::Spawned ? "OK" : "FAIL") @@ -1566,7 +1567,7 @@ void VehicleImportPage::on_export() { QPointer log_ptr = m_log; const QString engineRoot = qEnvironmentVariable("CARLA_UNREAL_ENGINE_PATH"); - Q_UNUSED(QtConcurrent::run([=]() { + Q_UNUSED(QtConcurrent::run([=, this]() { DeployResult dr; QString unhandled; try { @@ -1612,7 +1613,7 @@ void VehicleImportPage::on_export() { } } - QMetaObject::invokeMethod(qApp, [=]() { + QMetaObject::invokeMethod(qApp, [=, this]() { if (btn_ptr) btn_ptr->setEnabled(true); if (!log_ptr) return; if (!unhandled.isEmpty()) { diff --git a/src/vehicle/import/vehicle_preview_page.cpp b/src/vehicle/import/vehicle_preview_page.cpp index e43b37d..1944d82 100644 --- a/src/vehicle/import/vehicle_preview_page.cpp +++ b/src/vehicle/import/vehicle_preview_page.cpp @@ -7,7 +7,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later #include "vehicle_preview_page.h" -#include "view_gizmo.h" #ifndef CARLA_STUDIO_WITH_QT3D namespace carla_studio::vehicle_import { @@ -42,10 +41,14 @@ void VehiclePreviewPage::mirror_y() {} void VehiclePreviewPage::recenter_mesh() {} void VehiclePreviewPage::reset_mesh_transform() {} void VehiclePreviewPage::apply_adjustment(const QString&) {} +void VehiclePreviewPage::apply_to_spec() {} +void VehiclePreviewPage::capture_window() {} +bool VehiclePreviewPage::eventFilter(QObject *, QEvent *) { return false; } } #else #include "mesh_geometry.h" +#include "view_gizmo.h" #include #include @@ -889,6 +892,23 @@ static bool installMeshGeometry(Qt3DCore::QEntity *meshEntity, posAttr->setCount(static_cast(nV)); geom->addAttribute(posAttr); + if (g.has_normals()) { + QByteArray nBuf; + nBuf.resize(nV * 3 * static_cast(sizeof(float))); + std::memcpy(nBuf.data(), g.normals.data(), static_cast(nBuf.size())); + auto *nBuffer = new QBuffer(geom); + nBuffer->setData(nBuf); + auto *normAttr = new QAttribute(geom); + normAttr->setName(QAttribute::defaultNormalAttributeName()); + normAttr->setVertexBaseType(QAttribute::Float); + normAttr->setVertexSize(3); + normAttr->setAttributeType(QAttribute::VertexAttribute); + normAttr->setBuffer(nBuffer); + normAttr->setByteStride(3 * sizeof(float)); + normAttr->setCount(static_cast(nV)); + geom->addAttribute(normAttr); + } + const int nF = g.face_count(); QByteArray iBuf; iBuf.resize(nF * 3 * static_cast(sizeof(quint32)));