q3dviewer 1.2.1__tar.gz → 1.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/PKG-INFO +1 -1
- q3dviewer-1.2.3/q3dviewer/custom_items/mesh_item.py +442 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/glwidget.py +26 -1
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/tools/cloud_viewer.py +70 -54
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/utils/cloud_io.py +46 -19
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer.egg-info/PKG-INFO +1 -1
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer.egg-info/requires.txt +0 -1
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/setup.py +1 -2
- q3dviewer-1.2.1/q3dviewer/custom_items/mesh_item.py +0 -342
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/README.md +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/Qt/__init__.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/__init__.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/base_glwidget.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/base_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/__init__.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/axis_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/cloud_io_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/cloud_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/frame_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/gaussian_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/grid_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/image_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/line_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/text3d_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/custom_items/text_item.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/tools/__init__.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/tools/example_viewer.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/tools/film_maker.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/tools/gaussian_viewer.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/tools/lidar_calib.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/tools/lidar_cam_calib.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/tools/ros_viewer.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/utils/__init__.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/utils/convert_ros_msg.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/utils/gl_helper.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/utils/helpers.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/utils/maths.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/utils/range_slider.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer/viewer.py +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer.egg-info/SOURCES.txt +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer.egg-info/dependency_links.txt +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer.egg-info/entry_points.txt +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/q3dviewer.egg-info/top_level.txt +0 -0
- {q3dviewer-1.2.1 → q3dviewer-1.2.3}/setup.cfg +0 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Copyright 2024 Panasonic Advanced Technology Development Co.,Ltd. (Liu Yang)
|
|
5
|
+
Distributed under MIT license. See LICENSE for more information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from q3dviewer.base_item import BaseItem
|
|
11
|
+
from OpenGL.GL import *
|
|
12
|
+
from OpenGL.GL import shaders
|
|
13
|
+
from q3dviewer.Qt.QtWidgets import QLabel, QCheckBox, QDoubleSpinBox, QSlider, QHBoxLayout, QLineEdit
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from q3dviewer.utils import set_uniform, text_to_rgba
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MeshItem(BaseItem):
|
|
22
|
+
"""
|
|
23
|
+
A OpenGL mesh item for rendering 3D triangular meshes.
|
|
24
|
+
Attributes:
|
|
25
|
+
color (str or tuple): Accepts any valid matplotlib color (e.g., 'red', '#FF4500', (1.0, 0.5, 0.0)).
|
|
26
|
+
wireframe (bool): If True, renders the mesh in wireframe mode.
|
|
27
|
+
"""
|
|
28
|
+
def __init__(self, color='lightblue', wireframe=False):
|
|
29
|
+
super(MeshItem, self).__init__()
|
|
30
|
+
self.wireframe = wireframe
|
|
31
|
+
self.color = color
|
|
32
|
+
self.flat_rgb = text_to_rgba(color, flat=True)
|
|
33
|
+
|
|
34
|
+
# Incremental buffer management
|
|
35
|
+
self.FACE_CAPACITY = 1000000 # Initial capacity for faces
|
|
36
|
+
|
|
37
|
+
# Faces buffer: N x 13 numpy array
|
|
38
|
+
# Each row: [v0.x, v0.y, v0.z, v1.x, v1.y, v1.z, v2.x, v2.y, v2.z, v3.x, v3.y, v3.z, good]
|
|
39
|
+
self.faces = np.zeros((self.FACE_CAPACITY, 13), dtype=np.float32)
|
|
40
|
+
|
|
41
|
+
# valid_f_top: pointer to end of valid faces
|
|
42
|
+
self.valid_f_top = 0
|
|
43
|
+
|
|
44
|
+
# key2index: mapping from face_key to face buffer index
|
|
45
|
+
self.key2index = {} # {face_key: face_index}
|
|
46
|
+
|
|
47
|
+
# OpenGL objects
|
|
48
|
+
self.vao = None
|
|
49
|
+
self.vbo = None
|
|
50
|
+
self.program = None
|
|
51
|
+
self._gpu_face_capacity = 0 # Track GPU buffer capacity
|
|
52
|
+
|
|
53
|
+
# Fixed rendering parameters (not adjustable via UI)
|
|
54
|
+
self.enable_lighting = True
|
|
55
|
+
self.line_width = 1.0
|
|
56
|
+
self.light_pos = [1.0, 1.0, 1.0]
|
|
57
|
+
self.light_color = [1.0, 1.0, 1.0]
|
|
58
|
+
self.ambient_strength = 0.1
|
|
59
|
+
self.diffuse_strength = 1.2
|
|
60
|
+
self.specular_strength = 0.1
|
|
61
|
+
self.shininess = 32.0
|
|
62
|
+
self.alpha = 1.0
|
|
63
|
+
|
|
64
|
+
# Settings flag
|
|
65
|
+
self.need_update_setting = True
|
|
66
|
+
self.path = os.path.dirname(__file__)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def add_setting(self, layout):
|
|
70
|
+
"""Add UI controls for mesh visualization"""
|
|
71
|
+
# Only keep wireframe toggle - all other parameters are fixed
|
|
72
|
+
self.wireframe_box = QCheckBox("Wireframe Mode")
|
|
73
|
+
self.wireframe_box.setChecked(self.wireframe)
|
|
74
|
+
self.wireframe_box.toggled.connect(self.update_wireframe)
|
|
75
|
+
layout.addWidget(self.wireframe_box)
|
|
76
|
+
|
|
77
|
+
# Enable lighting toggle
|
|
78
|
+
self.lighting_box = QCheckBox("Enable Lighting")
|
|
79
|
+
self.lighting_box.setChecked(self.enable_lighting)
|
|
80
|
+
self.lighting_box.toggled.connect(self.update_enable_lighting)
|
|
81
|
+
layout.addWidget(self.lighting_box)
|
|
82
|
+
|
|
83
|
+
label_rgb = QLabel("Color:")
|
|
84
|
+
label_rgb.setToolTip("Use hex color, i.e. #FF4500, or named color, i.e. 'red'")
|
|
85
|
+
layout.addWidget(label_rgb)
|
|
86
|
+
self.edit_rgb = QLineEdit()
|
|
87
|
+
self.edit_rgb.setToolTip("Use hex color, i.e. #FF4500, or named color, i.e. 'red'")
|
|
88
|
+
self.edit_rgb.setText(self.color)
|
|
89
|
+
self.edit_rgb.textChanged.connect(self._on_color)
|
|
90
|
+
layout.addWidget(self.edit_rgb)
|
|
91
|
+
|
|
92
|
+
# Material property controls for Phong lighting
|
|
93
|
+
if self.enable_lighting:
|
|
94
|
+
# Ambient strength control (slider 0-100 mapped to 0.0-1.0)
|
|
95
|
+
ambient_layout = QHBoxLayout()
|
|
96
|
+
ambient_label = QLabel("Ambient Strength:")
|
|
97
|
+
ambient_layout.addWidget(ambient_label)
|
|
98
|
+
self.ambient_slider = QSlider()
|
|
99
|
+
self.ambient_slider.setOrientation(1) # Qt.Horizontal
|
|
100
|
+
self.ambient_slider.setRange(0, 100)
|
|
101
|
+
self.ambient_slider.setValue(int(self.ambient_strength * 100))
|
|
102
|
+
self.ambient_slider.valueChanged.connect(lambda v: self.update_ambient_strength(v / 100.0))
|
|
103
|
+
ambient_layout.addWidget(self.ambient_slider)
|
|
104
|
+
layout.addLayout(ambient_layout)
|
|
105
|
+
|
|
106
|
+
# Diffuse strength control (slider 0-200 mapped to 0.0-2.0)
|
|
107
|
+
diffuse_layout = QHBoxLayout()
|
|
108
|
+
diffuse_label = QLabel("Diffuse Strength:")
|
|
109
|
+
diffuse_layout.addWidget(diffuse_label)
|
|
110
|
+
self.diffuse_slider = QSlider()
|
|
111
|
+
self.diffuse_slider.setOrientation(1)
|
|
112
|
+
self.diffuse_slider.setRange(0, 200)
|
|
113
|
+
self.diffuse_slider.setValue(int(self.diffuse_strength * 100))
|
|
114
|
+
self.diffuse_slider.valueChanged.connect(lambda v: self.update_diffuse_strength(v / 100.0))
|
|
115
|
+
diffuse_layout.addWidget(self.diffuse_slider)
|
|
116
|
+
layout.addLayout(diffuse_layout)
|
|
117
|
+
|
|
118
|
+
# Specular strength control (slider 0-200 mapped to 0.0-2.0)
|
|
119
|
+
specular_layout = QHBoxLayout()
|
|
120
|
+
specular_label = QLabel("Specular Strength:")
|
|
121
|
+
specular_layout.addWidget(specular_label)
|
|
122
|
+
self.specular_slider = QSlider()
|
|
123
|
+
self.specular_slider.setOrientation(1)
|
|
124
|
+
self.specular_slider.setRange(0, 200)
|
|
125
|
+
self.specular_slider.setValue(int(self.specular_strength * 100))
|
|
126
|
+
self.specular_slider.valueChanged.connect(lambda v: self.update_specular_strength(v / 100.0))
|
|
127
|
+
specular_layout.addWidget(self.specular_slider)
|
|
128
|
+
layout.addLayout(specular_layout)
|
|
129
|
+
|
|
130
|
+
# Shininess control (slider 1-256 mapped to 1-256)
|
|
131
|
+
shininess_layout = QHBoxLayout()
|
|
132
|
+
shininess_label = QLabel("Shininess:")
|
|
133
|
+
shininess_layout.addWidget(shininess_label)
|
|
134
|
+
self.shininess_slider = QSlider()
|
|
135
|
+
self.shininess_slider.setOrientation(1)
|
|
136
|
+
self.shininess_slider.setRange(1, 256)
|
|
137
|
+
self.shininess_slider.setValue(int(self.shininess))
|
|
138
|
+
self.shininess_slider.valueChanged.connect(lambda v: self.update_shininess(float(v)))
|
|
139
|
+
shininess_layout.addWidget(self.shininess_slider)
|
|
140
|
+
layout.addLayout(shininess_layout)
|
|
141
|
+
|
|
142
|
+
def _on_color(self, color):
|
|
143
|
+
try:
|
|
144
|
+
self.color = color
|
|
145
|
+
self.flat_rgb = text_to_rgba(color, flat=True)
|
|
146
|
+
self.need_update_setting = True
|
|
147
|
+
except ValueError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def update_wireframe(self, value):
|
|
151
|
+
self.wireframe = value
|
|
152
|
+
|
|
153
|
+
def update_enable_lighting(self, value):
|
|
154
|
+
self.enable_lighting = value
|
|
155
|
+
self.need_update_setting = True
|
|
156
|
+
|
|
157
|
+
def update_line_width(self, value):
|
|
158
|
+
self.line_width = value
|
|
159
|
+
self.need_update_setting = True
|
|
160
|
+
|
|
161
|
+
def update_ambient_strength(self, value):
|
|
162
|
+
self.ambient_strength = value
|
|
163
|
+
self.need_update_setting = True
|
|
164
|
+
|
|
165
|
+
def update_diffuse_strength(self, value):
|
|
166
|
+
self.diffuse_strength = value
|
|
167
|
+
self.need_update_setting = True
|
|
168
|
+
|
|
169
|
+
def update_specular_strength(self, value):
|
|
170
|
+
self.specular_strength = value
|
|
171
|
+
self.need_update_setting = True
|
|
172
|
+
|
|
173
|
+
def update_shininess(self, value):
|
|
174
|
+
self.shininess = value
|
|
175
|
+
self.need_update_setting = True
|
|
176
|
+
|
|
177
|
+
def update_alpha(self, value):
|
|
178
|
+
"""Update mesh alpha (opacity)"""
|
|
179
|
+
self.alpha = float(value)
|
|
180
|
+
self.need_update_setting = True
|
|
181
|
+
|
|
182
|
+
def set_data(self, data):
|
|
183
|
+
"""
|
|
184
|
+
Set complete mesh data at once.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
data: is Nx3 numpy array (N must be divisible by 3) or dict
|
|
188
|
+
if is dict, uses the dict format:
|
|
189
|
+
[face_key: (v0.x, v0.y, v0.z, ..., v3.z, good)]
|
|
190
|
+
"""
|
|
191
|
+
self.clear_mesh()
|
|
192
|
+
|
|
193
|
+
if isinstance(data, dict):
|
|
194
|
+
# Use dict format directly (from get_mesh_data/get_incremental_mesh_data)
|
|
195
|
+
self.set_incremental_data(data)
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
# Check if Nx3 array
|
|
199
|
+
|
|
200
|
+
if not isinstance(data, np.ndarray):
|
|
201
|
+
raise ValueError("Invalid data type")
|
|
202
|
+
|
|
203
|
+
good_format = False
|
|
204
|
+
if data.ndim == 2 and \
|
|
205
|
+
data.shape[1] == 3 and \
|
|
206
|
+
data.shape[0] % 3 == 0:
|
|
207
|
+
good_format = True
|
|
208
|
+
|
|
209
|
+
if not good_format:
|
|
210
|
+
raise ValueError("Invalid data shape")
|
|
211
|
+
|
|
212
|
+
# Convert to Nx13 numpy array
|
|
213
|
+
N = data.shape[0] // 3
|
|
214
|
+
faces = np.zeros((N, 13), dtype=np.float32)
|
|
215
|
+
tmp = data.reshape(N, 9)
|
|
216
|
+
faces[:, 0:3] = tmp[:, 0:3] # copy v0
|
|
217
|
+
faces[:, 3:6] = tmp[:, 3:6] # copy v1
|
|
218
|
+
faces[:, 6:9] = tmp[:, 6:9] # copy v2
|
|
219
|
+
faces[:, 9:12] = tmp[:, 6:9] # copy v3 from v2 (degenerate quad)
|
|
220
|
+
faces[:, 12] = 1.0 # set good=1.0
|
|
221
|
+
self.faces = faces
|
|
222
|
+
self.valid_f_top = N
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def set_incremental_data(self, fs):
|
|
226
|
+
"""
|
|
227
|
+
Incrementally update mesh with new face data.
|
|
228
|
+
Args:
|
|
229
|
+
fs: Dict {face_key: (v0.x, v0.y, v0.z, v1.x, v1.y, v1.z,
|
|
230
|
+
v2.x, v2.y, v2.z, v3.x, v3.y, v3.z, good), ...}
|
|
231
|
+
13-tuple with vertex positions and good flag (0.0 or 1.0)
|
|
232
|
+
If good==1:
|
|
233
|
+
Triangle 1: (v0, v1, v3)
|
|
234
|
+
Triangle 2: (v0, v3, v2)
|
|
235
|
+
Updates:
|
|
236
|
+
- faces: updates existing faces or appends new ones
|
|
237
|
+
- key2index: tracks face_key -> face_index mapping
|
|
238
|
+
"""
|
|
239
|
+
if not fs:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
# Ensure enough capacity in faces buffer
|
|
243
|
+
# wasted cases are better than frequent expansions
|
|
244
|
+
while self.valid_f_top + len(fs) > len(self.faces):
|
|
245
|
+
self._expand_face_buffer()
|
|
246
|
+
|
|
247
|
+
# Optimization: Separate updates from new insertions to avoid
|
|
248
|
+
# dictionary lookup performance degradation during growth
|
|
249
|
+
update_idxs = [] # [idx, ...]
|
|
250
|
+
update_data = [] # [face_data, ...]
|
|
251
|
+
new_keys = [] # [key, ...]
|
|
252
|
+
new_data = [] # [face_data, ...]
|
|
253
|
+
|
|
254
|
+
for face_key, face_data in fs.items():
|
|
255
|
+
face_idx = self.key2index.get(face_key)
|
|
256
|
+
if face_idx is not None:
|
|
257
|
+
update_idxs.append(face_idx)
|
|
258
|
+
update_data.append(face_data)
|
|
259
|
+
else:
|
|
260
|
+
new_keys.append(face_key)
|
|
261
|
+
new_data.append(face_data)
|
|
262
|
+
|
|
263
|
+
# Batch update existing faces
|
|
264
|
+
if update_data:
|
|
265
|
+
indices = np.array(update_idxs, dtype=np.int32)
|
|
266
|
+
data = np.array(update_data, dtype=np.float32)
|
|
267
|
+
self.faces[indices] = data
|
|
268
|
+
|
|
269
|
+
# Batch insert new faces
|
|
270
|
+
if new_data:
|
|
271
|
+
n_new = len(new_data)
|
|
272
|
+
data = np.array(new_data, dtype=np.float32)
|
|
273
|
+
self.faces[self.valid_f_top: self.valid_f_top + n_new] = data
|
|
274
|
+
|
|
275
|
+
# Update key2index mapping for new faces
|
|
276
|
+
for i, face_key in enumerate(new_keys):
|
|
277
|
+
self.key2index[face_key] = self.valid_f_top + i
|
|
278
|
+
|
|
279
|
+
self.valid_f_top += n_new
|
|
280
|
+
|
|
281
|
+
def _expand_face_buffer(self):
|
|
282
|
+
"""Expand the faces buffer when capacity is reached"""
|
|
283
|
+
new_capacity = len(self.faces) + self.FACE_CAPACITY
|
|
284
|
+
new_buffer = np.zeros((new_capacity, 13), dtype=np.float32)
|
|
285
|
+
new_buffer[:len(self.faces)] = self.faces
|
|
286
|
+
self.faces = new_buffer
|
|
287
|
+
|
|
288
|
+
def clear_mesh(self):
|
|
289
|
+
"""Clear all mesh data and reset buffers"""
|
|
290
|
+
self.valid_f_top = 0
|
|
291
|
+
self.key2index.clear()
|
|
292
|
+
if hasattr(self, 'indices_array'):
|
|
293
|
+
self.indices_array = np.array([], dtype=np.uint32)
|
|
294
|
+
|
|
295
|
+
def initialize_gl(self):
|
|
296
|
+
"""OpenGL initialization"""
|
|
297
|
+
# Use instanced mesh shaders with geometry shader for GPU-side triangle generation
|
|
298
|
+
vert_shader = open(self.path + '/../shaders/mesh_vert.glsl', 'r').read()
|
|
299
|
+
geom_shader = open(self.path + '/../shaders/mesh_geom.glsl', 'r').read()
|
|
300
|
+
frag_shader = open(self.path + '/../shaders/mesh_frag.glsl', 'r').read()
|
|
301
|
+
try:
|
|
302
|
+
program = shaders.compileProgram(
|
|
303
|
+
shaders.compileShader(vert_shader, GL_VERTEX_SHADER),
|
|
304
|
+
shaders.compileShader(geom_shader, GL_GEOMETRY_SHADER),
|
|
305
|
+
shaders.compileShader(frag_shader, GL_FRAGMENT_SHADER),
|
|
306
|
+
)
|
|
307
|
+
self.program = program
|
|
308
|
+
except Exception as e:
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
def update_render_buffer(self):
|
|
312
|
+
"""
|
|
313
|
+
Update GPU buffer with face data (no separate vertex buffer).
|
|
314
|
+
Each face contains embedded vertex positions (13 floats).
|
|
315
|
+
Geometry shader generates triangles on GPU from face vertices.
|
|
316
|
+
Dynamically resizes GPU buffer when Python buffer expands.
|
|
317
|
+
"""
|
|
318
|
+
if self.valid_f_top == 0:
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
# Initialize buffers on first call
|
|
322
|
+
if self.vao is None:
|
|
323
|
+
self.vao = glGenVertexArrays(1)
|
|
324
|
+
self.vbo = glGenBuffers(1)
|
|
325
|
+
self._gpu_face_capacity = 0
|
|
326
|
+
|
|
327
|
+
# Check if we need to reallocate VBO for faces
|
|
328
|
+
if self._gpu_face_capacity < len(self.faces):
|
|
329
|
+
glBindVertexArray(self.vao)
|
|
330
|
+
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
|
|
331
|
+
glBufferData(GL_ARRAY_BUFFER,
|
|
332
|
+
self.faces.nbytes,
|
|
333
|
+
None,
|
|
334
|
+
GL_DYNAMIC_DRAW)
|
|
335
|
+
|
|
336
|
+
# Setup face attributes (per-instance)
|
|
337
|
+
# Face data: [v0.x, v0.y, v0.z, v1.x, v1.y, v1.z, v2.x, v2.y, v2.z, v3.x, v3.y, v3.z, good]
|
|
338
|
+
# 13 floats = 52 bytes stride
|
|
339
|
+
|
|
340
|
+
# v0 (location 1) - vec3
|
|
341
|
+
glEnableVertexAttribArray(1)
|
|
342
|
+
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 52, ctypes.c_void_p(0))
|
|
343
|
+
glVertexAttribDivisor(1, 1)
|
|
344
|
+
|
|
345
|
+
# v1 (location 2) - vec3
|
|
346
|
+
glEnableVertexAttribArray(2)
|
|
347
|
+
glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, 52, ctypes.c_void_p(12))
|
|
348
|
+
glVertexAttribDivisor(2, 1)
|
|
349
|
+
|
|
350
|
+
# v2 (location 3) - vec3
|
|
351
|
+
glEnableVertexAttribArray(3)
|
|
352
|
+
glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, 52, ctypes.c_void_p(24))
|
|
353
|
+
glVertexAttribDivisor(3, 1)
|
|
354
|
+
|
|
355
|
+
# v3 (location 4) - vec3
|
|
356
|
+
glEnableVertexAttribArray(4)
|
|
357
|
+
glVertexAttribPointer(4, 3, GL_FLOAT, GL_FALSE, 52, ctypes.c_void_p(36))
|
|
358
|
+
glVertexAttribDivisor(4, 1)
|
|
359
|
+
|
|
360
|
+
# good flag (location 5) - float
|
|
361
|
+
glEnableVertexAttribArray(5)
|
|
362
|
+
glVertexAttribPointer(5, 1, GL_FLOAT, GL_FALSE, 52, ctypes.c_void_p(48))
|
|
363
|
+
glVertexAttribDivisor(5, 1)
|
|
364
|
+
|
|
365
|
+
glBindVertexArray(0)
|
|
366
|
+
glBindBuffer(GL_ARRAY_BUFFER, 0)
|
|
367
|
+
self._gpu_face_capacity = len(self.faces)
|
|
368
|
+
|
|
369
|
+
# Upload faces to VBO
|
|
370
|
+
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
|
|
371
|
+
glBufferSubData(GL_ARRAY_BUFFER,
|
|
372
|
+
0,
|
|
373
|
+
self.valid_f_top * 13 * 4, # 13 floats * 4 bytes per face
|
|
374
|
+
self.faces[:self.valid_f_top])
|
|
375
|
+
glBindBuffer(GL_ARRAY_BUFFER, 0)
|
|
376
|
+
|
|
377
|
+
def update_setting(self):
|
|
378
|
+
"""Set fixed rendering parameters (called once during initialization)"""
|
|
379
|
+
if not self.need_update_setting:
|
|
380
|
+
return
|
|
381
|
+
# Set fixed uniforms for instanced shaders
|
|
382
|
+
set_uniform(self.program, int(self.enable_lighting), 'if_light')
|
|
383
|
+
set_uniform(self.program, 1, 'two_sided')
|
|
384
|
+
|
|
385
|
+
set_uniform(self.program, np.array(self.light_color, dtype=np.float32), 'light_color')
|
|
386
|
+
set_uniform(self.program, float(self.ambient_strength), 'ambient_strength')
|
|
387
|
+
set_uniform(self.program, float(self.diffuse_strength), 'diffuse_strength')
|
|
388
|
+
set_uniform(self.program, float(self.specular_strength), 'specular_strength')
|
|
389
|
+
set_uniform(self.program, float(self.shininess), 'shininess')
|
|
390
|
+
set_uniform(self.program, float(self.alpha), 'alpha')
|
|
391
|
+
set_uniform(self.program, int(self.flat_rgb), 'flat_rgb')
|
|
392
|
+
self.need_update_setting = False
|
|
393
|
+
|
|
394
|
+
def paint(self):
|
|
395
|
+
"""
|
|
396
|
+
Render the mesh using instanced rendering with geometry shader.
|
|
397
|
+
Each face instance is rendered as a point, geometry shader generates 2 triangles.
|
|
398
|
+
GPU filters faces based on good flag.
|
|
399
|
+
"""
|
|
400
|
+
if self.valid_f_top == 0:
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
glUseProgram(self.program)
|
|
404
|
+
|
|
405
|
+
self.update_render_buffer()
|
|
406
|
+
self.update_setting()
|
|
407
|
+
|
|
408
|
+
view_matrix = self.glwidget().view_matrix
|
|
409
|
+
set_uniform(self.program, view_matrix, 'view')
|
|
410
|
+
project_matrix = self.glwidget().projection_matrix
|
|
411
|
+
set_uniform(self.program, project_matrix, 'projection')
|
|
412
|
+
view_pos = self.glwidget().center
|
|
413
|
+
set_uniform(self.program, np.array(view_pos), 'view_pos')
|
|
414
|
+
|
|
415
|
+
# Enable blending and depth testing
|
|
416
|
+
glEnable(GL_BLEND)
|
|
417
|
+
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
|
418
|
+
glEnable(GL_DEPTH_TEST)
|
|
419
|
+
glDisable(GL_CULL_FACE) # two-sided rendering
|
|
420
|
+
|
|
421
|
+
# Set line width
|
|
422
|
+
glLineWidth(self.line_width)
|
|
423
|
+
|
|
424
|
+
# Bind VAO (vertex positions are now in VBO attributes)
|
|
425
|
+
glBindVertexArray(self.vao)
|
|
426
|
+
|
|
427
|
+
if self.wireframe:
|
|
428
|
+
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)
|
|
429
|
+
else:
|
|
430
|
+
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
|
|
431
|
+
|
|
432
|
+
# Draw using instanced rendering
|
|
433
|
+
# Input: POINTS (one per face instance)
|
|
434
|
+
# Geometry shader generates 2 triangles (6 vertices) per point
|
|
435
|
+
glDrawArraysInstanced(GL_POINTS, 0, 1, self.valid_f_top)
|
|
436
|
+
|
|
437
|
+
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
|
|
438
|
+
glBindVertexArray(0)
|
|
439
|
+
glDisable(GL_DEPTH_TEST)
|
|
440
|
+
glDisable(GL_BLEND)
|
|
441
|
+
glUseProgram(0)
|
|
442
|
+
|
|
@@ -55,6 +55,7 @@ class GLWidget(BaseGLWidget):
|
|
|
55
55
|
self.followable_item_name = None
|
|
56
56
|
self.setting_window = SettingWindow()
|
|
57
57
|
self.enable_show_center = True
|
|
58
|
+
self.old_center = None
|
|
58
59
|
super(GLWidget, self).__init__()
|
|
59
60
|
|
|
60
61
|
def keyPressEvent(self, ev: QKeyEvent):
|
|
@@ -63,14 +64,38 @@ class GLWidget(BaseGLWidget):
|
|
|
63
64
|
self.open_setting_window()
|
|
64
65
|
else:
|
|
65
66
|
super().keyPressEvent(ev)
|
|
67
|
+
if ev.key() == QtCore.Qt.Key_F: # reset follow
|
|
68
|
+
if self.followable_item_name is None:
|
|
69
|
+
self.initial_followable()
|
|
70
|
+
|
|
71
|
+
if self.followed_name != 'none':
|
|
72
|
+
self.followed_name = 'none'
|
|
73
|
+
print("Reset follow.")
|
|
74
|
+
elif len(self.followable_item_name) > 1:
|
|
75
|
+
self.followed_name = self.followable_item_name[1]
|
|
76
|
+
print("Set follow to ", self.followed_name)
|
|
77
|
+
else:
|
|
78
|
+
pass # do nothing
|
|
66
79
|
|
|
67
80
|
def on_followable_selection(self, index):
|
|
68
81
|
self.followed_name = self.followable_item_name[index]
|
|
69
82
|
|
|
83
|
+
def mouseDoubleClickEvent(self, event):
|
|
84
|
+
"""Double click to set center."""
|
|
85
|
+
p = self.get_point(event.x(), event.y())
|
|
86
|
+
if p is not None:
|
|
87
|
+
self.set_center(p)
|
|
88
|
+
super().mouseDoubleClickEvent(event)
|
|
89
|
+
|
|
70
90
|
def update(self):
|
|
71
91
|
if self.followed_name != 'none':
|
|
72
92
|
new_center = self.named_items[self.followed_name].T[:3, 3]
|
|
73
|
-
self.
|
|
93
|
+
if self.old_center is None:
|
|
94
|
+
self.old_center = self.center
|
|
95
|
+
return
|
|
96
|
+
delta = new_center - self.old_center
|
|
97
|
+
self.set_center(self.center + delta)
|
|
98
|
+
self.old_center = new_center
|
|
74
99
|
super().update()
|
|
75
100
|
|
|
76
101
|
def add_setting(self, layout):
|
|
@@ -7,39 +7,29 @@ Distributed under MIT license. See LICENSE for more information.
|
|
|
7
7
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
import q3dviewer as q3d
|
|
10
|
-
from q3dviewer.Qt.QtWidgets import QVBoxLayout,
|
|
10
|
+
from q3dviewer.Qt.QtWidgets import QVBoxLayout, QDialog, QLabel
|
|
11
11
|
from q3dviewer.Qt.QtCore import QThread, Signal, Qt
|
|
12
|
-
from q3dviewer.Qt.QtGui import QKeyEvent
|
|
13
12
|
from q3dviewer import GLWidget
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
class ProgressDialog(QDialog):
|
|
14
|
+
class ProgressWindow(QDialog):
|
|
17
15
|
def __init__(self, parent=None):
|
|
18
16
|
super().__init__(parent)
|
|
19
|
-
self.setWindowTitle("Loading
|
|
17
|
+
self.setWindowTitle("Loading")
|
|
20
18
|
self.setModal(True)
|
|
21
|
-
self.
|
|
22
|
-
self.
|
|
19
|
+
self.setMinimumWidth(400)
|
|
20
|
+
self.label = QLabel(self)
|
|
21
|
+
self.label.setAlignment(Qt.AlignCenter)
|
|
23
22
|
layout = QVBoxLayout()
|
|
24
|
-
layout.addWidget(self.
|
|
25
|
-
layout.addWidget(self.progress_bar)
|
|
23
|
+
layout.addWidget(self.label)
|
|
26
24
|
self.setLayout(layout)
|
|
27
25
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def set_file_name(self, file_name):
|
|
32
|
-
self.file_label.setText(f"Loading: {file_name}")
|
|
33
|
-
|
|
34
|
-
def closeEvent(self, event):
|
|
35
|
-
if self.parent().progress_thread and self.parent().progress_thread.isRunning():
|
|
36
|
-
event.ignore()
|
|
37
|
-
else:
|
|
38
|
-
event.accept()
|
|
26
|
+
def update_progress(self, current, total, file_name):
|
|
27
|
+
text = f"[{current}/{total}] loading file: {file_name}"
|
|
28
|
+
self.label.setText(text)
|
|
39
29
|
|
|
40
30
|
|
|
41
31
|
class FileLoaderThread(QThread):
|
|
42
|
-
progress = Signal(int)
|
|
32
|
+
progress = Signal(int, int, str) # current, total, filename
|
|
43
33
|
finished = Signal()
|
|
44
34
|
|
|
45
35
|
def __init__(self, viewer, files):
|
|
@@ -50,20 +40,22 @@ class FileLoaderThread(QThread):
|
|
|
50
40
|
def run(self):
|
|
51
41
|
cloud_item = self.viewer['cloud']
|
|
52
42
|
mesh_item = self.viewer['mesh']
|
|
43
|
+
total = len(self.files)
|
|
53
44
|
for i, url in enumerate(self.files):
|
|
54
45
|
# if the file is a mesh file, use mesh_item to load
|
|
55
46
|
file_path = url.toLocalFile()
|
|
56
|
-
|
|
57
|
-
|
|
47
|
+
import os
|
|
48
|
+
file_name = os.path.basename(file_path)
|
|
49
|
+
self.progress.emit(i + 1, total, file_name)
|
|
50
|
+
|
|
58
51
|
if url.toLocalFile().lower().endswith(('.stl')):
|
|
59
52
|
from q3dviewer.utils.cloud_io import load_stl
|
|
60
|
-
|
|
61
|
-
mesh_item.set_data(
|
|
53
|
+
mesh = load_stl(file_path)
|
|
54
|
+
mesh_item.set_data(mesh)
|
|
62
55
|
else:
|
|
63
56
|
cloud = cloud_item.load(file_path, append=(i > 0))
|
|
64
57
|
center = np.nanmean(cloud['xyz'].astype(np.float64), axis=0)
|
|
65
58
|
self.viewer.glwidget.set_cam_position(center=center)
|
|
66
|
-
self.progress.emit(int((i + 1) / len(self.files) * 100))
|
|
67
59
|
self.finished.emit()
|
|
68
60
|
|
|
69
61
|
|
|
@@ -130,20 +122,19 @@ class CloudViewer(q3d.Viewer):
|
|
|
130
122
|
"""
|
|
131
123
|
Overwrite the drop event to open the cloud file.
|
|
132
124
|
"""
|
|
133
|
-
self.
|
|
134
|
-
self.
|
|
125
|
+
self.progress_window = ProgressWindow(self)
|
|
126
|
+
self.progress_window.show()
|
|
135
127
|
files = event.mimeData().urls()
|
|
136
128
|
self.progress_thread = FileLoaderThread(self, files)
|
|
137
|
-
self['cloud'].load(files[0].toLocalFile(), append=False)
|
|
138
129
|
self.progress_thread.progress.connect(self.file_loading_progress)
|
|
139
130
|
self.progress_thread.finished.connect(self.file_loading_finished)
|
|
140
131
|
self.progress_thread.start()
|
|
141
132
|
|
|
142
|
-
def file_loading_progress(self,
|
|
143
|
-
self.
|
|
133
|
+
def file_loading_progress(self, current, total, file_name):
|
|
134
|
+
self.progress_window.update_progress(current, total, file_name)
|
|
144
135
|
|
|
145
136
|
def file_loading_finished(self):
|
|
146
|
-
self.
|
|
137
|
+
self.progress_window.close()
|
|
147
138
|
|
|
148
139
|
def open_cloud_file(self, file, append=False):
|
|
149
140
|
cloud_item = self['cloud']
|
|
@@ -154,27 +145,50 @@ class CloudViewer(q3d.Viewer):
|
|
|
154
145
|
center = np.nanmean(cloud['xyz'].astype(np.float64), axis=0)
|
|
155
146
|
self.glwidget.set_cam_position(center=center)
|
|
156
147
|
|
|
157
|
-
# print a quick help message
|
|
148
|
+
# print a quick help message using rich
|
|
158
149
|
def print_help():
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
150
|
+
from rich.console import Console
|
|
151
|
+
from rich.panel import Panel
|
|
152
|
+
from rich.table import Table
|
|
153
|
+
from rich.text import Text
|
|
154
|
+
|
|
155
|
+
console = Console()
|
|
156
|
+
|
|
157
|
+
# Create a table for better organization
|
|
158
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
159
|
+
table.add_column(style="bold cyan", width=30)
|
|
160
|
+
table.add_column(style="white")
|
|
161
|
+
|
|
162
|
+
# File loading section
|
|
163
|
+
table.add_row("📁 Load Files","Drag and drop files into the viewer")
|
|
164
|
+
table.add_row("","[dim]• Point clouds: .pcd, .ply, .las, .e57[/dim]")
|
|
165
|
+
table.add_row("","[dim]• Mesh files: .stl[/dim]")
|
|
166
|
+
table.add_row("", "")
|
|
167
|
+
|
|
168
|
+
# Measurement section
|
|
169
|
+
table.add_row("📏 Measure Distance", "Interactive point measurement")
|
|
170
|
+
table.add_row("","[dim]• Ctrl + Left Click: Add measurement point[/dim]")
|
|
171
|
+
table.add_row("","[dim]• Ctrl + Right Click: Remove last point[/dim]")
|
|
172
|
+
table.add_row("","[dim]• Total distance displayed automatically[/dim]")
|
|
173
|
+
table.add_row("", "")
|
|
174
|
+
|
|
175
|
+
# Camera controls
|
|
176
|
+
table.add_row("🎥 Camera Controls","Navigate the 3D scene")
|
|
177
|
+
table.add_row("","[dim]• Double Click: Set camera center to point[/dim]")
|
|
178
|
+
table.add_row("","[dim]• Right Drag: Rotate view[/dim]")
|
|
179
|
+
table.add_row("","[dim]• Left Drag: Pan view[/dim]")
|
|
180
|
+
table.add_row("","[dim]• Mouse Wheel: Zoom in/out[/dim]")
|
|
181
|
+
table.add_row("", "")
|
|
182
|
+
|
|
183
|
+
# Settings section
|
|
184
|
+
table.add_row("⚙️ Settings","Press [bold green]'M'[/bold green] to open settings window")
|
|
185
|
+
table.add_row("","[dim]Adjust visualization properties[/dim]")
|
|
164
186
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
{GREEN}• Measure distance between points:{END}
|
|
171
|
-
{BLUE}- Hold Ctrl and left-click to select points on the cloud.{END}
|
|
172
|
-
{BLUE}- Hold Ctrl and right-click to remove the last selected point.{END}
|
|
173
|
-
{BLUE}- The total distance between selected points will be displayed.{END}
|
|
174
|
-
{GREEN}• Press 'M' to open the settings window.{END}
|
|
175
|
-
{BLUE}- Use the settings window to adjust item properties.{END}
|
|
176
|
-
"""
|
|
177
|
-
print(help_msg)
|
|
187
|
+
# Print title and table without border
|
|
188
|
+
console.print()
|
|
189
|
+
console.print("[bold magenta]☁️ Cloud Viewer Help[/bold magenta]\n")
|
|
190
|
+
console.print(table)
|
|
191
|
+
console.print()
|
|
178
192
|
|
|
179
193
|
def main():
|
|
180
194
|
print_help()
|
|
@@ -186,18 +200,20 @@ def main():
|
|
|
186
200
|
viewer = CloudViewer(name='Cloud Viewer')
|
|
187
201
|
cloud_item = q3d.CloudIOItem(size=1, alpha=0.1)
|
|
188
202
|
axis_item = q3d.AxisItem(size=0.5, width=5)
|
|
203
|
+
axis_item.disable_setting()
|
|
189
204
|
grid_item = q3d.GridItem(size=1000, spacing=20)
|
|
190
205
|
marker_item = q3d.Text3DItem() # Changed from CloudItem to Text3DItem
|
|
191
206
|
text_item = q3d.Text2DItem(pos=(20, 40), text="", color='lime', size=16)
|
|
207
|
+
text_item.disable_setting()
|
|
192
208
|
mesh_item = q3d.MeshItem() # Added MeshIOItem for mesh support
|
|
193
209
|
|
|
194
210
|
viewer.add_items(
|
|
195
211
|
{'marker': marker_item,
|
|
196
|
-
'cloud': cloud_item,
|
|
212
|
+
'cloud': cloud_item,
|
|
213
|
+
'mesh': mesh_item,
|
|
197
214
|
'grid': grid_item,
|
|
198
215
|
'axis': axis_item,
|
|
199
|
-
'text': text_item,
|
|
200
|
-
'mesh': mesh_item})
|
|
216
|
+
'text': text_item,})
|
|
201
217
|
|
|
202
218
|
if args.path:
|
|
203
219
|
pcd_fn = args.path
|
|
@@ -7,25 +7,52 @@ import numpy as np
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def load_stl(file_path):
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
10
|
+
import meshio
|
|
11
|
+
mesh = meshio.read(file_path)
|
|
12
|
+
# meshio returns cells as a list of (cell_type, cell_data) tuples
|
|
13
|
+
# For STL, we expect 'triangle' cells
|
|
14
|
+
vertices = mesh.points.astype(np.float32)
|
|
15
|
+
|
|
16
|
+
# Find triangle cells
|
|
17
|
+
triangles = None
|
|
18
|
+
for cell_block in mesh.cells:
|
|
19
|
+
if cell_block.type == 'triangle':
|
|
20
|
+
triangles = cell_block.data
|
|
21
|
+
break
|
|
22
|
+
|
|
23
|
+
if triangles is None:
|
|
24
|
+
raise ValueError(f"No triangle cells found in STL file: {file_path}")
|
|
25
|
+
|
|
26
|
+
# Convert indexed triangles to flat vertex array (N*3, 3)
|
|
27
|
+
faces = vertices[triangles.flatten()].astype(np.float32)
|
|
28
|
+
return faces
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def save_stl(faces, save_path, binary=True):
|
|
32
|
+
import meshio
|
|
33
|
+
faces = np.asarray(faces, dtype=np.float32)
|
|
34
|
+
if faces.shape[0] % 3 != 0:
|
|
35
|
+
raise ValueError(f"Invalid faces shape: {faces.shape}, must be (N*3, 3)")
|
|
36
|
+
|
|
37
|
+
# Reshape to (num_triangles, 3, 3)
|
|
38
|
+
num_triangles = faces.shape[0] // 3
|
|
39
|
+
triangles = faces.reshape(num_triangles, 3, 3)
|
|
40
|
+
|
|
41
|
+
# Get unique vertices and create index array
|
|
42
|
+
vertices, indices = np.unique(triangles.reshape(-1, 3), axis=0, return_inverse=True)
|
|
43
|
+
triangle_indices = indices.reshape(num_triangles, 3)
|
|
44
|
+
|
|
45
|
+
# Create meshio mesh object
|
|
46
|
+
mesh = meshio.Mesh(
|
|
47
|
+
points=vertices,
|
|
48
|
+
cells=[("triangle", triangle_indices)]
|
|
49
|
+
)
|
|
50
|
+
# meshio automatically saves STL as binary by default
|
|
51
|
+
# Use file_format="stl-ascii" for ASCII format
|
|
52
|
+
if binary:
|
|
53
|
+
mesh.write(save_path, binary=True)
|
|
54
|
+
else:
|
|
55
|
+
mesh.write(save_path, binary=False)
|
|
29
56
|
|
|
30
57
|
def save_ply(cloud, save_path):
|
|
31
58
|
import meshio
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name='q3dviewer',
|
|
5
|
-
version='1.2.
|
|
5
|
+
version='1.2.3',
|
|
6
6
|
author="Liu Yang",
|
|
7
7
|
description="A library designed for quickly deploying a 3D viewer.",
|
|
8
8
|
long_description=open("README.md").read(),
|
|
@@ -29,7 +29,6 @@ setup(
|
|
|
29
29
|
'imageio',
|
|
30
30
|
'imageio[ffmpeg]',
|
|
31
31
|
'matplotlib',
|
|
32
|
-
'numpy-stl',
|
|
33
32
|
],
|
|
34
33
|
entry_points={
|
|
35
34
|
'console_scripts': [
|
|
@@ -1,342 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
"""
|
|
4
|
-
Copyright 2024 Panasonic Advanced Technology Development Co.,Ltd. (Liu Yang)
|
|
5
|
-
Distributed under MIT license. See LICENSE for more information.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import numpy as np
|
|
10
|
-
from q3dviewer.base_item import BaseItem
|
|
11
|
-
from OpenGL.GL import *
|
|
12
|
-
from OpenGL.GL import shaders
|
|
13
|
-
from q3dviewer.Qt.QtWidgets import QLabel, QCheckBox, QDoubleSpinBox, QSlider, QHBoxLayout, QLineEdit
|
|
14
|
-
import matplotlib.colors as mcolors
|
|
15
|
-
import os
|
|
16
|
-
from q3dviewer.utils import set_uniform, text_to_rgba
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class MeshItem(BaseItem):
|
|
21
|
-
def __init__(self, color='lightblue', wireframe=False, enable_lighting=True):
|
|
22
|
-
super(MeshItem, self).__init__()
|
|
23
|
-
self.color = color
|
|
24
|
-
self.flat_rgb = text_to_rgba(color, flat=True)
|
|
25
|
-
self.wireframe = wireframe
|
|
26
|
-
self.enable_lighting = enable_lighting
|
|
27
|
-
|
|
28
|
-
# Mesh data
|
|
29
|
-
self.triangles = None
|
|
30
|
-
self.normals = None
|
|
31
|
-
|
|
32
|
-
# OpenGL objects
|
|
33
|
-
self.vao = None
|
|
34
|
-
self.vbo_vertices = None
|
|
35
|
-
self.vbo_normals = None
|
|
36
|
-
# self.vbo_colors = None
|
|
37
|
-
self.program = None
|
|
38
|
-
|
|
39
|
-
# Rendering parameters
|
|
40
|
-
self.line_width = 1.0
|
|
41
|
-
self.light_pos = [1.0, 1.0, 1.0]
|
|
42
|
-
self.light_color = [1.0, 1.0, 1.0]
|
|
43
|
-
|
|
44
|
-
# Phong lighting material properties
|
|
45
|
-
self.ambient_strength = 0.1
|
|
46
|
-
self.diffuse_strength = 1.2
|
|
47
|
-
self.specular_strength = 0.1
|
|
48
|
-
self.shininess = 32.0
|
|
49
|
-
# Alpha (opacity)
|
|
50
|
-
self.alpha = 1.0
|
|
51
|
-
|
|
52
|
-
# Buffer initialization flag
|
|
53
|
-
self.need_update_buffer = True
|
|
54
|
-
self.need_update_setting = True
|
|
55
|
-
self.path = os.path.dirname(__file__)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def add_setting(self, layout):
|
|
59
|
-
"""Add UI controls for mesh visualization"""
|
|
60
|
-
# Wireframe toggle
|
|
61
|
-
self.wireframe_box = QCheckBox("Wireframe Mode")
|
|
62
|
-
self.wireframe_box.setChecked(self.wireframe)
|
|
63
|
-
self.wireframe_box.toggled.connect(self.update_wireframe)
|
|
64
|
-
layout.addWidget(self.wireframe_box)
|
|
65
|
-
|
|
66
|
-
# Enable lighting toggle
|
|
67
|
-
self.lighting_box = QCheckBox("Enable Lighting")
|
|
68
|
-
self.lighting_box.setChecked(self.enable_lighting)
|
|
69
|
-
self.lighting_box.toggled.connect(self.update_enable_lighting)
|
|
70
|
-
layout.addWidget(self.lighting_box)
|
|
71
|
-
|
|
72
|
-
# Line width control
|
|
73
|
-
line_width_label = QLabel("Line Width:")
|
|
74
|
-
layout.addWidget(line_width_label)
|
|
75
|
-
self.line_width_box = QDoubleSpinBox()
|
|
76
|
-
self.line_width_box.setRange(0.5, 5.0)
|
|
77
|
-
self.line_width_box.setSingleStep(0.5)
|
|
78
|
-
self.line_width_box.setValue(self.line_width)
|
|
79
|
-
self.line_width_box.valueChanged.connect(self.update_line_width)
|
|
80
|
-
layout.addWidget(self.line_width_box)
|
|
81
|
-
|
|
82
|
-
# Alpha control
|
|
83
|
-
alpha_label = QLabel("Alpha:")
|
|
84
|
-
layout.addWidget(alpha_label)
|
|
85
|
-
alpha_box = QDoubleSpinBox()
|
|
86
|
-
alpha_box.setRange(0.0, 1.0)
|
|
87
|
-
alpha_box.setSingleStep(0.05)
|
|
88
|
-
alpha_box.setValue(self.alpha)
|
|
89
|
-
alpha_box.valueChanged.connect(self.update_alpha)
|
|
90
|
-
layout.addWidget(alpha_box)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
label_rgb = QLabel("Color:")
|
|
94
|
-
label_rgb.setToolTip("Use hex color, i.e. #FF4500, or named color, i.e. 'red'")
|
|
95
|
-
layout.addWidget(label_rgb)
|
|
96
|
-
self.edit_rgb = QLineEdit()
|
|
97
|
-
self.edit_rgb.setToolTip("Use hex color, i.e. #FF4500, or named color, i.e. 'red'")
|
|
98
|
-
self.edit_rgb.setText(self.color)
|
|
99
|
-
self.edit_rgb.textChanged.connect(self._on_color)
|
|
100
|
-
layout.addWidget(self.edit_rgb)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# Material property controls for Phong lighting
|
|
104
|
-
if self.enable_lighting:
|
|
105
|
-
# Ambient strength control (slider 0-100 mapped to 0.0-1.0)
|
|
106
|
-
ambient_layout = QHBoxLayout()
|
|
107
|
-
ambient_label = QLabel("Ambient Strength:")
|
|
108
|
-
ambient_layout.addWidget(ambient_label)
|
|
109
|
-
self.ambient_slider = QSlider()
|
|
110
|
-
self.ambient_slider.setOrientation(1) # Qt.Horizontal
|
|
111
|
-
self.ambient_slider.setRange(0, 100)
|
|
112
|
-
self.ambient_slider.setValue(int(self.ambient_strength * 100))
|
|
113
|
-
self.ambient_slider.valueChanged.connect(lambda v: self.update_ambient_strength(v / 100.0))
|
|
114
|
-
ambient_layout.addWidget(self.ambient_slider)
|
|
115
|
-
layout.addLayout(ambient_layout)
|
|
116
|
-
|
|
117
|
-
# Diffuse strength control (slider 0-200 mapped to 0.0-2.0)
|
|
118
|
-
diffuse_layout = QHBoxLayout()
|
|
119
|
-
diffuse_label = QLabel("Diffuse Strength:")
|
|
120
|
-
diffuse_layout.addWidget(diffuse_label)
|
|
121
|
-
self.diffuse_slider = QSlider()
|
|
122
|
-
self.diffuse_slider.setOrientation(1)
|
|
123
|
-
self.diffuse_slider.setRange(0, 200)
|
|
124
|
-
self.diffuse_slider.setValue(int(self.diffuse_strength * 100))
|
|
125
|
-
self.diffuse_slider.valueChanged.connect(lambda v: self.update_diffuse_strength(v / 100.0))
|
|
126
|
-
diffuse_layout.addWidget(self.diffuse_slider)
|
|
127
|
-
layout.addLayout(diffuse_layout)
|
|
128
|
-
|
|
129
|
-
# Specular strength control (slider 0-200 mapped to 0.0-2.0)
|
|
130
|
-
specular_layout = QHBoxLayout()
|
|
131
|
-
specular_label = QLabel("Specular Strength:")
|
|
132
|
-
specular_layout.addWidget(specular_label)
|
|
133
|
-
self.specular_slider = QSlider()
|
|
134
|
-
self.specular_slider.setOrientation(1)
|
|
135
|
-
self.specular_slider.setRange(0, 200)
|
|
136
|
-
self.specular_slider.setValue(int(self.specular_strength * 100))
|
|
137
|
-
self.specular_slider.valueChanged.connect(lambda v: self.update_specular_strength(v / 100.0))
|
|
138
|
-
specular_layout.addWidget(self.specular_slider)
|
|
139
|
-
layout.addLayout(specular_layout)
|
|
140
|
-
|
|
141
|
-
# Shininess control (slider 1-256 mapped to 1-256)
|
|
142
|
-
shininess_layout = QHBoxLayout()
|
|
143
|
-
shininess_label = QLabel("Shininess:")
|
|
144
|
-
shininess_layout.addWidget(shininess_label)
|
|
145
|
-
self.shininess_slider = QSlider()
|
|
146
|
-
self.shininess_slider.setOrientation(1)
|
|
147
|
-
self.shininess_slider.setRange(1, 256)
|
|
148
|
-
self.shininess_slider.setValue(int(self.shininess))
|
|
149
|
-
self.shininess_slider.valueChanged.connect(lambda v: self.update_shininess(float(v)))
|
|
150
|
-
shininess_layout.addWidget(self.shininess_slider)
|
|
151
|
-
layout.addLayout(shininess_layout)
|
|
152
|
-
|
|
153
|
-
def _on_color(self, color):
|
|
154
|
-
try:
|
|
155
|
-
self.color = color
|
|
156
|
-
self.flat_rgb = text_to_rgba(color, flat=True)
|
|
157
|
-
self.need_update_setting = True
|
|
158
|
-
except ValueError:
|
|
159
|
-
pass
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def update_wireframe(self, value):
|
|
163
|
-
self.wireframe = value
|
|
164
|
-
|
|
165
|
-
def update_enable_lighting(self, value):
|
|
166
|
-
self.enable_lighting = value
|
|
167
|
-
self.need_update_setting = True
|
|
168
|
-
|
|
169
|
-
def update_line_width(self, value):
|
|
170
|
-
self.line_width = value
|
|
171
|
-
self.need_update_setting = True
|
|
172
|
-
|
|
173
|
-
def update_ambient_strength(self, value):
|
|
174
|
-
self.ambient_strength = value
|
|
175
|
-
self.need_update_setting = True
|
|
176
|
-
|
|
177
|
-
def update_diffuse_strength(self, value):
|
|
178
|
-
self.diffuse_strength = value
|
|
179
|
-
self.need_update_setting = True
|
|
180
|
-
|
|
181
|
-
def update_specular_strength(self, value):
|
|
182
|
-
self.specular_strength = value
|
|
183
|
-
self.need_update_setting = True
|
|
184
|
-
|
|
185
|
-
def update_shininess(self, value):
|
|
186
|
-
self.shininess = value
|
|
187
|
-
self.need_update_setting = True
|
|
188
|
-
|
|
189
|
-
def update_alpha(self, value):
|
|
190
|
-
"""Update mesh alpha (opacity)"""
|
|
191
|
-
self.alpha = float(value)
|
|
192
|
-
self.need_update_setting = True
|
|
193
|
-
|
|
194
|
-
def set_data(self, verts, faces):
|
|
195
|
-
"""
|
|
196
|
-
verts: np.ndarray of shape (N, 3)
|
|
197
|
-
faces: np.ndarray of shape (M, 3) with uint32 indices
|
|
198
|
-
"""
|
|
199
|
-
verts = np.asarray(verts, dtype=np.float32)
|
|
200
|
-
faces = np.asarray(faces, dtype=np.uint32)
|
|
201
|
-
triangles = verts[faces.flatten()]
|
|
202
|
-
|
|
203
|
-
self.triangles = np.asarray(triangles, dtype=np.float32)
|
|
204
|
-
self.normals = self.calculate_normals()
|
|
205
|
-
self.need_update_buffer = True
|
|
206
|
-
|
|
207
|
-
def calculate_normals(self):
|
|
208
|
-
if self.triangles is None or len(self.triangles) == 0:
|
|
209
|
-
return None
|
|
210
|
-
|
|
211
|
-
# Ensure we have complete triangles
|
|
212
|
-
num_vertices = len(self.triangles)
|
|
213
|
-
num_triangles = num_vertices // 3
|
|
214
|
-
if num_triangles == 0:
|
|
215
|
-
return None
|
|
216
|
-
|
|
217
|
-
# Reshape vertices into triangles (N, 3, 3) where N is number of triangles
|
|
218
|
-
vertices_reshaped = self.triangles[:num_triangles * 3].reshape(-1, 3, 3)
|
|
219
|
-
|
|
220
|
-
v0 = vertices_reshaped[:, 0, :]
|
|
221
|
-
v1 = vertices_reshaped[:, 1, :]
|
|
222
|
-
v2 = vertices_reshaped[:, 2, :]
|
|
223
|
-
|
|
224
|
-
# Calculate edges for all triangles at once
|
|
225
|
-
edge1 = v1 - v0
|
|
226
|
-
edge2 = v2 - v0
|
|
227
|
-
|
|
228
|
-
face_normals = np.cross(edge1, edge2)
|
|
229
|
-
|
|
230
|
-
norms = np.linalg.norm(face_normals, axis=1, keepdims=True)
|
|
231
|
-
norms[norms < 1e-6] = 1.0
|
|
232
|
-
face_normals = face_normals / norms
|
|
233
|
-
|
|
234
|
-
normals_per_vertex = np.repeat(face_normals[:, np.newaxis, :], 3, axis=1)
|
|
235
|
-
normals = normals_per_vertex.reshape(-1, 3)
|
|
236
|
-
return normals.astype(np.float32)
|
|
237
|
-
|
|
238
|
-
def initialize_gl(self):
|
|
239
|
-
"""OpenGL initialization"""
|
|
240
|
-
vertex_shader = open(self.path + '/../shaders/mesh_vert.glsl', 'r').read()
|
|
241
|
-
fragment_shader = open(self.path + '/../shaders/mesh_frag.glsl', 'r').read()
|
|
242
|
-
|
|
243
|
-
program = shaders.compileProgram(
|
|
244
|
-
shaders.compileShader(vertex_shader, GL_VERTEX_SHADER),
|
|
245
|
-
shaders.compileShader(fragment_shader, GL_FRAGMENT_SHADER),
|
|
246
|
-
)
|
|
247
|
-
self.program = program
|
|
248
|
-
|
|
249
|
-
def update_render_buffer(self):
|
|
250
|
-
"""Initialize OpenGL buffers"""
|
|
251
|
-
if not self.need_update_buffer:
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
# Generate VAO and VBOs
|
|
255
|
-
if self.vao is None:
|
|
256
|
-
self.vao = glGenVertexArrays(1)
|
|
257
|
-
self.vbo_vertices = glGenBuffers(1)
|
|
258
|
-
self.vbo_normals = glGenBuffers(1)
|
|
259
|
-
# self.vbo_colors = glGenBuffers(1)
|
|
260
|
-
|
|
261
|
-
glBindVertexArray(self.vao)
|
|
262
|
-
|
|
263
|
-
# Vertex buffer
|
|
264
|
-
glBindBuffer(GL_ARRAY_BUFFER, self.vbo_vertices)
|
|
265
|
-
glBufferData(GL_ARRAY_BUFFER, self.triangles.nbytes, self.triangles, GL_STATIC_DRAW)
|
|
266
|
-
glEnableVertexAttribArray(0)
|
|
267
|
-
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, None)
|
|
268
|
-
|
|
269
|
-
# Normal buffer
|
|
270
|
-
if self.normals is not None:
|
|
271
|
-
glBindBuffer(GL_ARRAY_BUFFER, self.vbo_normals)
|
|
272
|
-
glBufferData(GL_ARRAY_BUFFER, self.normals.nbytes, self.normals, GL_STATIC_DRAW)
|
|
273
|
-
glEnableVertexAttribArray(1)
|
|
274
|
-
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, None)
|
|
275
|
-
|
|
276
|
-
# Color buffer
|
|
277
|
-
# if self.flat_rgbs is not None:
|
|
278
|
-
# glBindBuffer(GL_ARRAY_BUFFER, self.vbo_colors)
|
|
279
|
-
# glBufferData(GL_ARRAY_BUFFER, self.flat_rgbs.nbytes, self.flat_rgbs, GL_STATIC_DRAW)
|
|
280
|
-
# glEnableVertexAttribArray(2)
|
|
281
|
-
# glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, 0, None)
|
|
282
|
-
#
|
|
283
|
-
glBindVertexArray(0)
|
|
284
|
-
self.need_update_buffer = False
|
|
285
|
-
|
|
286
|
-
def update_setting(self):
|
|
287
|
-
if (self.need_update_setting is False):
|
|
288
|
-
return
|
|
289
|
-
set_uniform(self.program, int(self.enable_lighting), 'if_light')
|
|
290
|
-
set_uniform(self.program, 1, 'two_sided')
|
|
291
|
-
set_uniform(self.program, np.array(self.light_color), 'light_color')
|
|
292
|
-
set_uniform(self.program, float(self.ambient_strength), 'ambient_strength')
|
|
293
|
-
set_uniform(self.program, float(self.diffuse_strength), 'diffuse_strength')
|
|
294
|
-
set_uniform(self.program, float(self.specular_strength), 'specular_strength')
|
|
295
|
-
set_uniform(self.program, float(self.shininess), 'shininess')
|
|
296
|
-
set_uniform(self.program, float(self.alpha), 'alpha')
|
|
297
|
-
set_uniform(self.program, int(self.flat_rgb), 'flat_rgb')
|
|
298
|
-
self.need_update_setting = False
|
|
299
|
-
|
|
300
|
-
def paint(self):
|
|
301
|
-
"""Render the mesh using modern OpenGL with shaders"""
|
|
302
|
-
if self.triangles is None or len(self.triangles) == 0:
|
|
303
|
-
return
|
|
304
|
-
glUseProgram(self.program)
|
|
305
|
-
self.update_render_buffer()
|
|
306
|
-
self.update_setting()
|
|
307
|
-
view_matrix = self.glwidget().view_matrix
|
|
308
|
-
set_uniform(self.program, view_matrix, 'view')
|
|
309
|
-
project_matrix = self.glwidget().projection_matrix
|
|
310
|
-
set_uniform(self.program, project_matrix, 'projection')
|
|
311
|
-
view_pos = self.glwidget().center
|
|
312
|
-
set_uniform(self.program, np.array(view_pos), 'view_pos')
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
# Enable blending and depth testing
|
|
316
|
-
glEnable(GL_BLEND)
|
|
317
|
-
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
|
318
|
-
glEnable(GL_DEPTH_TEST)
|
|
319
|
-
glDisable(GL_CULL_FACE) # two-sided rendering
|
|
320
|
-
|
|
321
|
-
# Set line width
|
|
322
|
-
glLineWidth(self.line_width)
|
|
323
|
-
|
|
324
|
-
# Bind VAO and render
|
|
325
|
-
glBindVertexArray(self.vao)
|
|
326
|
-
|
|
327
|
-
if len(self.triangles) > 0:
|
|
328
|
-
# Render faces
|
|
329
|
-
if self.wireframe:
|
|
330
|
-
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)
|
|
331
|
-
else:
|
|
332
|
-
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
|
|
333
|
-
|
|
334
|
-
# Draw triangles
|
|
335
|
-
glDrawArrays(GL_TRIANGLES, 0, len(self.triangles))
|
|
336
|
-
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL)
|
|
337
|
-
|
|
338
|
-
glBindVertexArray(0)
|
|
339
|
-
glDisable(GL_DEPTH_TEST)
|
|
340
|
-
glDisable(GL_BLEND)
|
|
341
|
-
glUseProgram(0)
|
|
342
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|