diff --git a/core/event_handler.py b/core/event_handler.py index 9d4fdc64..a958daf3 100644 --- a/core/event_handler.py +++ b/core/event_handler.py @@ -450,7 +450,8 @@ class EventHandler: print(f"点击的是碰撞节点: {hitNode.getName()}") # 碰撞节点的父节点应该是模型 parent = hitNode.getParent() - if parent in self.world.models: + model_list = self.world.models if hasattr(self.world, 'models') else [] + if parent in model_list or parent.hasTag('is_model_root'): selectedModel = parent print(f"找到对应的模型: {selectedModel.getName()}") else: @@ -460,13 +461,15 @@ class EventHandler: current = hitNode while current != self.world.render: # 检查是否是模型 - if current in self.world.models: + model_list = self.world.models if hasattr(self.world, 'models') else [] + if current in model_list or current.hasTag('is_model_root'): selectedModel = current print(f"找到模型节点: {selectedModel.getName()}") break # 检查是否是模型的子节点 - for model in self.world.models: + model_list = self.world.models if hasattr(self.world, 'models') else [] + for model in model_list: if current.getParent() == model or current.isAncestorOf(model): selectedModel = model print(f"找到父模型: {selectedModel.getName()}") diff --git a/core/imgui_webview.py b/core/imgui_webview.py new file mode 100644 index 00000000..41e82547 --- /dev/null +++ b/core/imgui_webview.py @@ -0,0 +1,225 @@ +""" +imgui_webview.py +后台 playwright 无头浏览器 + 截图 → Panda3D 纹理,供 ImGui 面板显示。 +""" +from __future__ import annotations +import threading +import io +import time + + +class ImGuiWebView: + """ + 后台线程运行 playwright Chromium,定期截图并转换为 Panda3D 纹理。 + ImGui 直接用 imgui.image() 显示纹理,鼠标/滚轮事件转发给浏览器。 + """ + + def __init__(self, width: int = 1280, height: int = 720): + self.view_width = width + self.view_height = height + + # 截图数据(bytes) + self._screenshot: bytes | None = None + self._screenshot_lock = threading.Lock() + self.tex_dirty = False # 有新截图待上传 GPU + + # 状态 + self.current_url = "" + self.title = "" + self.is_loading = False + self.error: str | None = None + + # 待处理指令(由 ImGui 线程写,浏览器线程读) + self._cmd_navigate: str | None = None + self._cmd_click: tuple | None = None # (x_ratio, y_ratio) + self._cmd_scroll: float | None = None # pixels + self._cmd_back = False + self._cmd_forward = False + self._cmd_reload = False + self._lock = threading.Lock() + + self._running = False + self._thread: threading.Thread | None = None + + # ------------------------------------------------------------------ # + # 公开控制 API(由 ImGui 线程调用,线程安全) + # ------------------------------------------------------------------ # + + def start(self, url: str): + if self._running: + return + self._running = True + self._cmd_navigate = url + self._thread = threading.Thread(target=self._run, daemon=True, + name="imgui-webview") + self._thread.start() + + def stop(self): + self._running = False + + def navigate(self, url: str): + if not url.startswith(('http://', 'https://', 'file://')): + url = 'https://' + url + with self._lock: + self._cmd_navigate = url + self.is_loading = True + + def click(self, x_ratio: float, y_ratio: float): + with self._lock: + self._cmd_click = (x_ratio, y_ratio) + + def scroll(self, delta_px: float): + with self._lock: + self._cmd_scroll = delta_px + + def go_back(self): + with self._lock: + self._cmd_back = True + + def go_forward(self): + with self._lock: + self._cmd_forward = True + + def reload(self): + with self._lock: + self._cmd_reload = True + + def get_screenshot_bytes(self) -> bytes | None: + with self._screenshot_lock: + return self._screenshot + + # ------------------------------------------------------------------ # + # 内部线程 + # ------------------------------------------------------------------ # + + def _run(self): + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._async_run()) + except Exception as exc: + self.error = f"WebView 线程异常: {exc}" + import traceback; traceback.print_exc() + finally: + loop.close() + + async def _async_run(self): + try: + from playwright.async_api import async_playwright + except ImportError: + self.error = ( + "playwright 未安装。\n" + "请运行: pip install playwright\n" + "然后运行: playwright install chromium" + ) + self._running = False + return + + try: + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + ctx = await browser.new_context( + viewport={"width": self.view_width, + "height": self.view_height}, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/121.0.0.0 Safari/537.36" + ), + ) + page = await ctx.new_page() + + # 初次导航 + start_url = self._cmd_navigate or "about:blank" + self._cmd_navigate = None + await self._goto(page, start_url) + await self._snap(page) + + # 主循环 + import asyncio + while self._running: + await asyncio.sleep(0.05) + + with self._lock: + nav_url = self._cmd_navigate; self._cmd_navigate = None + clk = self._cmd_click; self._cmd_click = None + scr = self._cmd_scroll; self._cmd_scroll = None + do_back = self._cmd_back; self._cmd_back = False + do_fwd = self._cmd_forward; self._cmd_forward = False + do_reload = self._cmd_reload; self._cmd_reload = False + + changed = False + + if nav_url: + self.is_loading = True + await self._goto(page, nav_url) + changed = True + self.is_loading = False + + if do_back: + await page.go_back() + await asyncio.sleep(0.5) + changed = True + + if do_fwd: + await page.go_forward() + await asyncio.sleep(0.5) + changed = True + + if do_reload: + await page.reload(wait_until="domcontentloaded") + changed = True + + if clk is not None: + xr, yr = clk + x = int(xr * self.view_width) + y = int(yr * self.view_height) + await page.mouse.click(x, y) + await asyncio.sleep(0.4) + changed = True + + if scr is not None: + await page.evaluate( + f"window.scrollBy(0, {int(scr)})" + ) + await asyncio.sleep(0.15) + changed = True + + if changed: + self.current_url = page.url + try: + self.title = await page.title() + except Exception: + pass + await self._snap(page) + + await browser.close() + + except Exception as exc: + self.error = str(exc) + import traceback; traceback.print_exc() + finally: + self._running = False + + async def _goto(self, page, url: str): + import asyncio + try: + await page.goto(url, wait_until="domcontentloaded", timeout=20_000) + self.current_url = page.url + try: + self.title = await page.title() + except Exception: + pass + except Exception as exc: + print(f"[WebView] 导航失败 {url}: {exc}") + + async def _snap(self, page): + """截图并更新 self._screenshot""" + try: + data = await page.screenshot(type="png", full_page=False) + with self._screenshot_lock: + self._screenshot = data + self.tex_dirty = True + except Exception as exc: + print(f"[WebView] 截图失败: {exc}") diff --git a/core/selection.py b/core/selection.py index 0f6e993d..06ca7f1d 100644 --- a/core/selection.py +++ b/core/selection.py @@ -1963,10 +1963,30 @@ class SelectionSystem: command = RotateNodeCommand(self.gizmoTarget, self.gizmoTargetStartHpr, current_hpr) self.world.command_manager.execute_command(command) print(f"创建旋转命令: {self.gizmoTargetStartHpr} -> {current_hpr}") + print(f"创建旋转命令: {self.gizmoTargetStartHpr} -> {current_hpr}") except Exception as e: print(f"创建撤销命令时出错: {e}") + # 同步碰撞体 + try: + target = self.gizmoTarget + if target: + # 寻找它的所属模型根节点 + root_model = target + while root_model and root_model != self.world.render: + model_list = self.world.models if hasattr(self.world, 'models') else [] + if root_model in model_list or root_model.hasTag('is_model_root'): + break + root_model = root_model.getParent() + + # 如果这个节点属于某个模型,或者是模型自己,更新该模型的碰撞边界 + if root_model and hasattr(self.world, 'models') and root_model in self.world.models: + if hasattr(self.world, 'scene_manager') and hasattr(self.world.scene_manager, 'refreshCollisionBounds'): + self.world.scene_manager.refreshCollisionBounds(root_model) + except Exception as e: + print(f"同步模型碰撞体失败: {e}") + # 恢复所有轴的颜色 for axis_name in ["x", "y", "z"]: self.setGizmoAxisColor(axis_name, self.gizmo_colors[axis_name]) diff --git a/imgui.ini b/imgui.ini index ada48696..842d92b0 100644 --- a/imgui.ini +++ b/imgui.ini @@ -24,26 +24,26 @@ Size=832,45 Collapsed=0 [Window][工具栏] -Pos=241,20 -Size=908,74 +Pos=276,20 +Size=1413,74 Collapsed=0 DockId=0x0000000D,0 [Window][场景树] Pos=0,20 -Size=239,468 +Size=274,634 Collapsed=0 DockId=0x00000007,0 [Window][属性面板] -Pos=1151,20 -Size=229,730 +Pos=1691,20 +Size=229,989 Collapsed=0 DockId=0x00000003,0 [Window][控制台] -Pos=0,490 -Size=239,260 +Pos=0,656 +Size=274,353 Collapsed=0 DockId=0x00000008,0 @@ -60,7 +60,7 @@ Collapsed=0 [Window][WindowOverViewport_11111111] Pos=0,20 -Size=1380,730 +Size=1920,989 Collapsed=0 [Window][测试窗口1] @@ -99,8 +99,8 @@ Size=600,500 Collapsed=0 [Window][资源管理器] -Pos=241,464 -Size=908,286 +Pos=276,723 +Size=1413,286 Collapsed=0 DockId=0x00000006,0 @@ -201,17 +201,17 @@ Size=600,400 Collapsed=0 [Window][Web面板] -Pos=373,98 +Pos=474,122 Size=942,580 Collapsed=0 [Docking][Data] -DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1380,730 Split=X +DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,20 Size=1920,989 Split=X DockNode ID=0x00000001 Parent=0x08BD597D SizeRef=1689,989 Split=X - DockNode ID=0x00000009 Parent=0x00000001 SizeRef=239,989 Split=Y Selected=0xE0015051 + DockNode ID=0x00000009 Parent=0x00000001 SizeRef=274,989 Split=Y Selected=0xE0015051 DockNode ID=0x00000007 Parent=0x00000009 SizeRef=271,634 Selected=0xE0015051 DockNode ID=0x00000008 Parent=0x00000009 SizeRef=271,353 Selected=0x5428E753 - DockNode ID=0x0000000A Parent=0x00000001 SizeRef=1448,989 Split=Y + DockNode ID=0x0000000A Parent=0x00000001 SizeRef=1413,989 Split=Y DockNode ID=0x0000000D Parent=0x0000000A SizeRef=1318,74 HiddenTabBar=1 Selected=0x43A39006 DockNode ID=0x0000000E Parent=0x0000000A SizeRef=1318,913 Split=Y DockNode ID=0x00000005 Parent=0x0000000E SizeRef=1341,625 CentralNode=1 diff --git a/scene/scene_manager.py b/scene/scene_manager.py index c070b3f5..f110c8f1 100644 --- a/scene/scene_manager.py +++ b/scene/scene_manager.py @@ -787,29 +787,14 @@ class SceneManager: minPoint = Point3() maxPoint = Point3() - # 使用与选择框相同的calcTightBounds方法获取边界 - if model.calcTightBounds(minPoint, maxPoint, self.world.render): + # 使用与选择框相同的calcTightBounds方法获取边界,但是在局部坐标系中进行计算 + # 这样计算出的包围盒直接贴合几何体,并且无论模型自身受到什么平移/缩放/旋转都不会发生两次形变! + if model.calcTightBounds(minPoint, maxPoint, model): # 检查边界框的有效性 if (abs(minPoint.x) < 1e10 and abs(minPoint.y) < 1e10 and abs(minPoint.z) < 1e10 and abs(maxPoint.x) < 1e10 and abs(maxPoint.y) < 1e10 and abs(maxPoint.z) < 1e10): - # 特殊处理FBX模型的碰撞体 - if model.hasTag("model_path") and model.getTag("model_path").lower().endswith('.fbx'): - print("检测到FBX模型,调整碰撞体...") - # 反向应用FBX的变换以匹配视觉表现 - # 缩放调整: 乘以100(因为模型被缩小了0.01倍) - minPoint *= 100 - maxPoint *= 100 - - # 旋转调整: 绕P轴旋转-90度 - # 创建旋转矩阵 - from panda3d.core import Mat4, LRotation - rotation = LRotation(0, -90, 0) # 绕P轴旋转-90度 - rot_matrix = Mat4() - rotation.extractToMatrix(rot_matrix) - - # 应用旋转变换到边界点 - minPoint = rot_matrix.xformPoint(minPoint) - maxPoint = rot_matrix.xformPoint(maxPoint) + + # 我们现在获取的是纯局部几何数据,因此不再需要手动乘以100或应用旋转来抵消FBX形变 # 创建与选择框完全一致的碰撞体 cBox = CollisionBox(minPoint, maxPoint) @@ -849,6 +834,31 @@ class SceneManager: traceback.print_exc() return None + def refreshCollisionBounds(self, model): + """重新计算并更新模型的碰撞框""" + try: + if not model or model.isEmpty(): + return + + # 使用列表以便在遍历后安全删除 + children_to_remove = [] + for child in model.getChildren(): + name = child.getName() if hasattr(child, 'getName') else "" + if name.startswith("modelCollision_"): + children_to_remove.append(child) + + # 如果存在旧碰撞节点,删除它并重新创建 + if children_to_remove: + for child in children_to_remove: + child.removeNode() + + # 由于 calcTightBounds 被修改为使用局部坐标计算 (第三个参数传 model) + # 无需再像之前一样为了解决偏移而将其重置回0点。 + self.setupCollision(model) + + except Exception as e: + print(f"刷新碰撞框失败: {e}") + # ==================== 场景树管理 ==================== def updateSceneTree(self): diff --git a/ssbo_component/ssbo_controller.py b/ssbo_component/ssbo_controller.py index 35844e51..487f0c28 100644 --- a/ssbo_component/ssbo_controller.py +++ b/ssbo_component/ssbo_controller.py @@ -38,6 +38,7 @@ class ObjectController: self.local_to_global_id = {} self.local_transform_state = {} self.local_transform_base_positions = {} + self.pick_vertex_index = {} self.virtual_tree = None self.virtual_tree_meta = None @@ -47,6 +48,105 @@ class ObjectController: self._source_model_name = "" self._source_model_stem = "" + def _get_model_world_mat(self): + """Return current model net transform matrix (to top/root).""" + if not self.model: + return LMatrix4f.ident_mat() + try: + if self.model.isEmpty(): + return LMatrix4f.ident_mat() + except Exception: + try: + if self.model.is_empty(): + return LMatrix4f.ident_mat() + except Exception: + pass + + try: + return LMatrix4f(self.model.getNetTransform().getMat()) + except Exception: + try: + # snake_case fallback in newer Panda3D bindings + return LMatrix4f(self.model.get_net_transform().get_mat()) + except Exception: + pass + + try: + top = self.model.getTop() + if top and not top.isEmpty(): + return LMatrix4f(self.model.getMat(top)) + except Exception: + pass + + try: + return LMatrix4f(self.model.getMat()) + except Exception: + return LMatrix4f.ident_mat() + + def get_model_world_mat(self): + """Public accessor for current model net transform matrix.""" + return self._get_model_world_mat() + + def _local_point_to_world(self, local_pos): + """Convert a local-space point to world-space based on model net transform.""" + mat = self._get_model_world_mat() + p = Point3(float(local_pos.x), float(local_pos.y), float(local_pos.z)) + wp = mat.xformPoint(p) + return Vec3(wp.x, wp.y, wp.z) + + def _world_vec_to_local(self, world_vec): + """Convert a world-space vector to model-local space.""" + mat = self._get_model_world_mat() + inv = LMatrix4f(mat) + try: + inv.invertInPlace() + except Exception: + try: + inv.invert_in_place() + except Exception: + return Vec3(world_vec) + v = Vec3(world_vec) + lv = inv.xformVec(v) + return Vec3(lv.x, lv.y, lv.z) + + def world_vector_to_model_local(self, world_vec): + """Public converter from world delta vector to model-local delta vector.""" + return self._world_vec_to_local(world_vec) + + def get_model_world_quat(self): + """Return current model world quaternion.""" + if not self.model: + return Quat.identQuat() + try: + if self.model.isEmpty(): + return Quat.identQuat() + except Exception: + pass + try: + top = self.model.getTop() + if top and not top.isEmpty(): + return Quat(self.model.getQuat(top)) + except Exception: + pass + try: + return Quat(self.model.getQuat()) + except Exception: + return Quat.identQuat() + + def world_quat_delta_to_model_local(self, delta_quat_world): + """ + Convert world-space delta quaternion to model-local delta quaternion. + local = inv(model_world_rot) * world_delta * model_world_rot + """ + if delta_quat_world is None: + return Quat.identQuat() + model_q = self.get_model_world_quat() + inv_model_q = Quat(model_q) + inv_model_q.invertInPlace() + local_q = inv_model_q * Quat(delta_quat_world) * model_q + local_q.normalize() + return local_q + def _build_original_hierarchy_key(self, np, model_root): """Capture hierarchy path before flatten/reparent.""" parts = [] @@ -99,6 +199,7 @@ class ObjectController: self.local_to_global_id = {} self.local_transform_state = {} self.local_transform_base_positions = {} + self.pick_vertex_index = {} self.virtual_tree = None self.virtual_tree_meta = None self.pick_model = None @@ -192,6 +293,7 @@ class ObjectController: # Keep ID colors only in picking clone to avoid affecting visible shading. self.pick_model = model.copy_to(NodePath("ssbo_pick_root")) + self._build_pick_vertex_index(self.pick_model) self._set_uniform_vertex_color(model, 1.0, 1.0, 1.0, 1.0) t2 = time.time() @@ -403,6 +505,89 @@ class ObjectController: self.vertex_index[uid].append((gn_np, gi, rows)) self.original_positions[uid].append(pos.copy()) + def _build_pick_vertex_index(self, pick_root): + """ + Build local_id -> [(geom_node_np, geom_idx, row_indices_array)] for pick model. + This keeps GPU-picking geometry writable in sync with visible geometry edits. + """ + import numpy as np + + self.pick_vertex_index = {} + if not pick_root: + return + + for gn_np in pick_root.find_all_matches("**/+GeomNode"): + gnode = gn_np.node() + for gi in range(gnode.get_num_geoms()): + geom = gnode.get_geom(gi) + vdata = geom.get_vertex_data() + num_rows = vdata.get_num_rows() + if num_rows == 0: + continue + + fmt = vdata.get_format() + color_col = fmt.get_column(InternalName.make("color")) + if color_col is None: + continue + + color_array_idx = fmt.get_array_with(InternalName.make("color")) + color_start = color_col.get_start() + color_array_format = fmt.get_array(color_array_idx) + color_stride = color_array_format.get_stride() + + color_handle = vdata.get_array(color_array_idx).get_handle() + color_raw = bytes(color_handle.get_data()) + color_buf = np.frombuffer(color_raw, dtype=np.uint8).reshape(num_rows, color_stride) + + num_components = color_col.get_num_components() + component_bytes = color_col.get_component_bytes() + + if component_bytes == 4: + color_data = np.ndarray( + (num_rows, num_components), + dtype=np.float32, + buffer=color_buf[:, color_start:color_start + num_components * 4].tobytes() + ) + r_vals = (color_data[:, 0] * 255.0 + 0.5).astype(np.int32) + g_vals = (color_data[:, 1] * 255.0 + 0.5).astype(np.int32) + elif component_bytes == 1: + color_bytes = color_buf[:, color_start:color_start + num_components].copy() + r_vals = color_bytes[:, 0].astype(np.int32) + g_vals = color_bytes[:, 1].astype(np.int32) + else: + continue + + local_ids = r_vals + (g_vals << 8) + sort_idx = np.argsort(local_ids) + sorted_ids = local_ids[sort_idx] + boundaries = np.where(np.diff(sorted_ids) != 0)[0] + 1 + id_groups = np.split(sort_idx, boundaries) + group_ids = sorted_ids[np.concatenate([[0], boundaries])] + + for k in range(len(group_ids)): + uid = int(group_ids[k]) + rows = id_groups[k] + if uid not in self.pick_vertex_index: + self.pick_vertex_index[uid] = [] + self.pick_vertex_index[uid].append((gn_np, gi, rows)) + + def _apply_vertices_to_pick(self, local_idx, entry_idx, new_pos): + """Mirror one transformed vertex group to pick-model geometry.""" + pick_entries = self.pick_vertex_index.get(local_idx) + if not pick_entries or entry_idx >= len(pick_entries): + return + + pick_gn_np, pick_gi, pick_rows = pick_entries[entry_idx] + gnode = pick_gn_np.node() + geom = gnode.modify_geom(pick_gi) + vdata = geom.modify_vertex_data() + writer = GeomVertexWriter(vdata, "vertex") + + max_rows = min(len(pick_rows), len(new_pos)) + for j in range(max_rows): + writer.set_row(int(pick_rows[j])) + writer.set_data3f(float(new_pos[j, 0]), float(new_pos[j, 1]), float(new_pos[j, 2])) + def _init_local_transform_state(self): """Initialize transform state for each local_idx after vertex index is ready.""" self.local_transform_state = {} @@ -437,7 +622,7 @@ class ObjectController: return local_indices def get_local_pivot(self, local_idx): - """Get pivot for one local object (world-space center).""" + """Get pivot for one local object (model-local center).""" global_id = self.local_to_global_id.get(local_idx) if global_id is None: return Vec3(0, 0, 0) @@ -457,7 +642,8 @@ class ObjectController: valid += 1 if valid == 0: return Vec3(0, 0, 0) - return acc / float(valid) + center_local = acc / float(valid) + return self._local_point_to_world(center_local) def begin_transform_session(self, local_indices): """Create immutable baseline snapshot for one gizmo drag session.""" @@ -543,6 +729,7 @@ class ObjectController: for j in range(len(rows)): writer.set_row(int(rows[j])) writer.set_data3f(float(new_pos[j, 0]), float(new_pos[j, 1]), float(new_pos[j, 2])) + self._apply_vertices_to_pick(local_idx, i, new_pos) def _quat_to_np_mat3(self, quat): """Convert Panda3D Quat to 3x3 numpy rotation matrix.""" @@ -611,6 +798,7 @@ class ObjectController: for j in range(len(rows)): writer.set_row(int(rows[j])) writer.set_data3f(float(new_pos[j, 0]), float(new_pos[j, 1]), float(new_pos[j, 2])) + self._apply_vertices_to_pick(local_idx, i, new_pos) def get_world_pos(self, global_id): """Get current world position of an object.""" @@ -621,8 +809,9 @@ class ObjectController: original_mat = self.global_transforms[global_id] original_pos = original_mat.get_row3(3) offset = self.position_offsets.get(local_idx, Vec3(0)) - - return Vec3(original_pos) + offset + + local_pos = Vec3(original_pos) + offset + return self._local_point_to_world(local_pos) def get_object_center(self, global_id): """Get the original center position of an object (for rotation pivot).""" diff --git a/ssbo_component/ssbo_editor.py b/ssbo_component/ssbo_editor.py index a19d6875..ac9e6636 100644 --- a/ssbo_component/ssbo_editor.py +++ b/ssbo_component/ssbo_editor.py @@ -147,6 +147,8 @@ class SSBOEditor: # Setup GPU Picking (uses simple vertex-color shader) self.setup_gpu_picking() + # Keep pick clone aligned with source model transform. + self._sync_pick_model_transform() print(f"[SSBOEditor] Model loaded. Total objects: {count}") @@ -248,10 +250,58 @@ class SSBOEditor: self.pick_buffer.set_clear_color(Vec4(0, 0, 0, 0)) self.pick_buffer.set_clear_color_active(True) + def _sync_pick_model_transform(self): + """Sync pick-scene clone to current source model world transform.""" + if not self.controller or not self.model: + return + pick_model = getattr(self.controller, "pick_model", None) + if pick_model is None: + return + try: + if pick_model.isEmpty(): + return + except Exception: + try: + if pick_model.is_empty(): + return + except Exception: + pass + try: + if hasattr(self.controller, "get_model_world_mat"): + world_mat = self.controller.get_model_world_mat() + else: + world_mat = LMatrix4f(self.model.getNetTransform().getMat()) + try: + pick_model.set_mat(world_mat) + except Exception: + pick_model.setMat(world_mat) + except Exception: + pass + + def _refresh_ssbo_proxy_center(self): + """Update proxy center when source model transform changes.""" + if self._ssbo_transform_active: + return + if not self.controller or not self._ssbo_selected_local_indices: + return + if self._ssbo_gizmo_proxy is None: + return + try: + if self._ssbo_gizmo_proxy.isEmpty(): + return + except Exception: + return + try: + center = self.controller.get_selection_center(self._ssbo_selected_local_indices) + self._ssbo_gizmo_proxy.set_pos(center) + except Exception: + pass + def pick_object(self, mx, my): if (not self.pick_buffer or not self.pick_texture or not self.pick_lens or not self.controller or not self.model): return False + self._sync_pick_model_transform() self.pick_lens.set_fov(0.1) self.pick_lens.set_film_offset(0, 0) @@ -294,6 +344,8 @@ class SSBOEditor: if io.want_capture_mouse: return if self.base.mouseWatcherNode.has_mouse(): + self._sync_pick_model_transform() + self._refresh_ssbo_proxy_center() mpos = self.base.mouseWatcherNode.get_mouse() # If clicking gizmo, skip SSBO pick. if self._try_start_gizmo_drag(mpos.x, mpos.y): @@ -457,16 +509,25 @@ class SSBOEditor: curr_quat = Quat(target.getQuat(self.base.render)) curr_scale = Vec3(target.getScale()) - delta_pos = curr_pos - start_pos + delta_pos_world = curr_pos - start_pos inv_start_quat = Quat(start_quat) inv_start_quat.invertInPlace() - delta_quat = curr_quat * inv_start_quat + delta_quat_world = curr_quat * inv_start_quat delta_scale = Vec3( curr_scale.x / start_scale.x if abs(start_scale.x) > 1e-8 else 1.0, curr_scale.y / start_scale.y if abs(start_scale.y) > 1e-8 else 1.0, curr_scale.z / start_scale.z if abs(start_scale.z) > 1e-8 else 1.0, ) + # TransformGizmo drag deltas are in world space. + # SSBO vertex transforms are applied in model-local space. + delta_pos = delta_pos_world + delta_quat = delta_quat_world + if hasattr(self.controller, "world_vector_to_model_local"): + delta_pos = self.controller.world_vector_to_model_local(delta_pos_world) + if hasattr(self.controller, "world_quat_delta_to_model_local"): + delta_quat = self.controller.world_quat_delta_to_model_local(delta_quat_world) + self.controller.apply_transform_session( self._ssbo_transform_snapshot, delta_pos, @@ -600,6 +661,8 @@ class SSBOEditor: def update_task(self, task): dt = globalClock.getDt() io = imgui.get_io() + self._sync_pick_model_transform() + self._refresh_ssbo_proxy_center() if io.want_capture_keyboard: return task.cont diff --git a/ui/panels/editor_panels.py b/ui/panels/editor_panels.py index 44e4300a..d6e80049 100644 --- a/ui/panels/editor_panels.py +++ b/ui/panels/editor_panels.py @@ -40,7 +40,7 @@ class EditorPanels: def _ensure_web_panel_state(self): if not hasattr(self.app, "web_panel_url_input") or not self.app.web_panel_url_input: - self.app.web_panel_url_input = "https://www.example.com" + self.app.web_panel_url_input = "https://www.baidu.com" if not hasattr(self.app, "_imgui_webview"): self.app._imgui_webview = None if not hasattr(self.app, "_imgui_webview_tex_id"):