-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy paththree_d_viewport.py
More file actions
1509 lines (1387 loc) · 63.5 KB
/
three_d_viewport.py
File metadata and controls
1509 lines (1387 loc) · 63.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from PyQt5.QtWidgets import QOpenGLWidget
from PyQt5.QtCore import Qt, QPoint, QTimer, QPointF, pyqtSignal as Signal
from PyQt5.QtGui import QCursor, QTabletEvent
from PyQt5.QtGui import QSurfaceFormat
from OpenGL.GL import *
from OpenGL.GL import shaders
from OpenGL.error import GLError
import numpy as np
import ctypes
from mesh_loader import MeshData
from brush_cursor import BrushCursorWidget
from tangent_space import TangentSpaceGenerator
SIMPLE_VERT = """
#version 150
in vec3 aPos;
in vec3 aNormal;
in vec2 aUV;
out vec2 vUV;
out vec3 vNormal;
uniform mat4 u_model;
uniform mat4 u_viewProj;
void main(){
vUV = aUV;
vNormal = mat3(u_model) * aNormal;
gl_Position = u_viewProj * (u_model * vec4(aPos, 1.0));
}
"""
SIMPLE_FRAG = """
#version 150
in vec2 vUV;
in vec3 vNormal;
out vec4 FragColor;
uniform sampler2D baseMap;
uniform sampler2D flowMap;
uniform bool u_hasBaseMap;
uniform float u_flowSpeed;
uniform float u_flowDistortion;
uniform float u_time;
uniform bool u_repeat;
uniform float u_useDirectX;
uniform float u_scale;
void main(){
vec2 uv = vUV;
if (u_hasBaseMap) {
vec2 flowDir = texture(flowMap, uv).rg * 2.0 - 1.0;
flowDir.x = -flowDir.x; // 默认反转R通道
if (u_useDirectX >= 1.0) flowDir.y *= -1.0;
float phaseTime = u_time * u_flowSpeed;
float phase0 = fract(phaseTime);
float phase1 = fract(phaseTime + 0.5);
vec2 offset0 = flowDir * phase0 * u_flowDistortion;
vec2 offset1 = flowDir * phase1 * u_flowDistortion;
vec4 color0;
vec4 color1;
// Apply base scale to texture coordinates
vec2 scaledUV = uv / u_scale;
if (u_repeat) {
color0 = texture(baseMap, fract(scaledUV + offset0));
color1 = texture(baseMap, fract(scaledUV + offset1));
} else {
vec2 s0 = clamp(scaledUV + offset0, 0.0, 1.0);
vec2 s1 = clamp(scaledUV + offset1, 0.0, 1.0);
color0 = texture(baseMap, s0);
color1 = texture(baseMap, s1);
}
float weight = abs((0.5 - phase0) / 0.5);
FragColor = mix(color0, color1, weight);
} else {
if (u_repeat) {
FragColor = vec4(texture(flowMap, fract(uv)).rg, 0.0, 1.0);
} else {
if (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0)
FragColor = vec4(texture(flowMap, uv).rg, 0.0, 1.0);
else
FragColor = vec4(0.1,0.1,0.1,1.0);
}
}
}
"""
class ThreeDViewport(QOpenGLWidget):
# 3D绘制开始/结束信号,用于复用2D撤销栈逻辑
paint_started = Signal()
paint_finished = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.setFocusPolicy(Qt.StrongFocus)
self.program = 0
self.vao = 0
self.vbo = 0
self.ebo = 0
self.index_count = 0
self.model_loaded = False
self._use_vao = True
# repaint timer (60 FPS)
self._repaint_timer = QTimer(self)
self._repaint_timer.setInterval(16)
self._repaint_timer.timeout.connect(self.update)
# painting state
self._is_painting = False
self._is_erasing = False
self._last_hit_uv = None
self._s_pressed = False
self._is_adjusting = False
self._adjust_origin = QPoint(0, 0)
self._initial_brush_radius = 40.0
self._initial_brush_strength = 0.5
# optional brush cursor overlay
self._brush_cursor = BrushCursorWidget(self)
try:
self._brush_cursor.resize(self.size())
except Exception:
pass
self._brush_cursor.hide()
# default attribute indices
self._attr_pos = 0
self._attr_nrm = 1
self._attr_uv = 2
# model fit matrix
self._model_matrix = np.identity(4, dtype=np.float32)
# camera state (orbit)
self._cam_yaw = 0.0
self._cam_pitch = 0.35
self._cam_distance = 3.0
self._cam_target = np.array([0.0, 0.0, 0.0], dtype=np.float32)
self._last_mouse = QPoint(0, 0)
self._is_rotating = False
self._is_panning = False
self._is_zooming = False
# placeholders
self._positions = np.zeros((0,3), dtype=np.float32)
self._uvs = np.zeros((0,2), dtype=np.float32) # primary UV set
self._normals = np.zeros((0,3), dtype=np.float32)
self._indices = np.zeros((0,), dtype=np.uint32)
# Multi-UV support
self._uv_sets = [] # List of UV sets
self._uv_set_names = [] # Names of UV sets
self._active_uv_set = 0 # Currently active UV set index
# Tangent space data (Mikk-based, computed from first UV set)
self._tangent_generator = TangentSpaceGenerator()
self._tangents = np.zeros((0,3), dtype=np.float32)
self._bitangents = np.zeros((0,3), dtype=np.float32)
# World-space hit cache for seamless strokes
self._last_hit_world_pos = None
self._last_hit_triangle_idx = None
self._last_hit_barycentric = None
self.setMouseTracking(True)
def showEvent(self, event):
try:
if self._repaint_timer is not None:
self._repaint_timer.start()
except Exception:
pass
return super().showEvent(event)
def hideEvent(self, event):
try:
if self._repaint_timer is not None:
self._repaint_timer.stop()
except Exception:
pass
return super().hideEvent(event)
def set_canvas(self, canvas_widget):
"""Provide a reference to the 2D canvas so we can sample its textures and params."""
self._canvas = canvas_widget
try:
# 同步笔刷半径
self._brush_cursor.radius = getattr(self._canvas, 'brush_radius', 40)
except Exception:
pass
def get_uv_wire_data(self, uv_set_index=None):
"""Return model UVs and triangle indices for 2D UV wire rendering.
Args:
uv_set_index: UV set index to use. If None, uses active UV set.
"""
try:
if uv_set_index is None:
uv_set_index = self._active_uv_set
# Get UV set data
if 0 <= uv_set_index < len(self._uv_sets):
uvs = self._uv_sets[uv_set_index].copy()
elif hasattr(self, '_uvs'):
uvs = self._uvs.copy()
else:
return (None, None)
indices = self._indices.copy() if hasattr(self, '_indices') else None
return (uvs, indices)
except Exception:
return (None, None)
def set_active_uv_set(self, uv_set_index):
"""Set the active UV set for 3D rendering and painting."""
try:
if 0 <= uv_set_index < len(self._uv_sets):
self._active_uv_set = uv_set_index
# Update current UV data for rendering and raycasting
self._uvs = self._uv_sets[uv_set_index].astype(np.float32, copy=False)
# Recreate vertex buffers with new UV data for 3D rendering
self.makeCurrent()
try:
self._create_buffers()
self.update() # Trigger a repaint
except Exception as e:
print(f"Buffer recreation error: {e}")
finally:
self.doneCurrent()
print(f"Switched to UV set {uv_set_index}: {self._uv_set_names[uv_set_index] if uv_set_index < len(self._uv_set_names) else 'Unknown'}")
except Exception as e:
print(f"set_active_uv_set error: {e}")
def get_uv_set_names(self):
"""Return list of UV set names."""
return self._uv_set_names.copy() if self._uv_set_names else []
def initializeGL(self):
try:
glEnable(GL_DEPTH_TEST)
self.program = shaders.compileProgram(
shaders.compileShader(SIMPLE_VERT, GL_VERTEX_SHADER),
shaders.compileShader(SIMPLE_FRAG, GL_FRAGMENT_SHADER)
)
# Cache attribute locations; fallback
self._attr_pos = glGetAttribLocation(self.program, b"aPos")
self._attr_nrm = glGetAttribLocation(self.program, b"aNormal")
self._attr_uv = glGetAttribLocation(self.program, b"aUV")
if self._attr_pos < 0: self._attr_pos = 0
if self._attr_nrm < 0: self._attr_nrm = 1
if self._attr_uv < 0: self._attr_uv = 2
# Context may be recreated by Qt; if we already have mesh data, (re)create buffers now
if self._indices.size > 0:
self._create_buffers()
except Exception as e:
print(f"3D viewport init error: {e}")
def _is_valid_vao(self, vao_id):
try:
return vao_id != 0 and glIsVertexArray(vao_id)
except Exception:
return vao_id != 0
def resizeGL(self, w, h):
glViewport(0, 0, w, h)
def paintGL(self):
glViewport(0, 0, self.width(), self.height())
glClearColor(0.08, 0.08, 0.1, 1.0)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
# Guard: program and mesh must exist
if self.program == 0 or self.index_count == 0:
return
# If VAO invalid (e.g., context recreated), try to rebuild lazily
if self._use_vao:
if not self._is_valid_vao(self.vao):
self._create_buffers()
if not self._is_valid_vao(self.vao):
# VAO path not viable; fallback to non-VAO
self._use_vao = False
else:
# Ensure buffers exist for non-VAO path
if self.vbo == 0 or self.ebo == 0:
self._create_buffers()
glUseProgram(self.program)
view = self._compute_view_matrix()
proj = self._compute_perspective_matrix(45.0, max(1.0, float(self.width()))/max(1.0, float(self.height())), 0.01, 100.0)
viewproj = proj @ view
# 缓存用于光线投射,防止高宽变化带来不一致
self._last_view = view
self._last_proj = proj
loc_viewproj = glGetUniformLocation(self.program, "u_viewProj")
glUniformMatrix4fv(loc_viewproj, 1, GL_TRUE, viewproj.astype(np.float32))
loc_model = glGetUniformLocation(self.program, "u_model")
glUniformMatrix4fv(loc_model, 1, GL_TRUE, self._model_matrix.astype(np.float32))
# Bind textures/uniforms from canvas
int_has_base = 0
if hasattr(self, '_canvas') and self._canvas is not None:
try:
glActiveTexture(GL_TEXTURE0)
base_id = int(getattr(self._canvas, 'base_texture_id', 0))
glBindTexture(GL_TEXTURE_2D, base_id)
loc_base = glGetUniformLocation(self.program, "baseMap")
if loc_base != -1:
glUniform1i(loc_base, 0)
int_has_base = 1 if (getattr(self._canvas, 'has_base_map', False) and base_id != 0) else 0
except Exception:
int_has_base = 0
try:
glActiveTexture(GL_TEXTURE1)
flow_id = int(getattr(self._canvas, 'flowmap_texture_id', 0))
glBindTexture(GL_TEXTURE_2D, flow_id)
loc_flow = glGetUniformLocation(self.program, "flowMap")
if loc_flow != -1:
glUniform1i(loc_flow, 1)
except Exception:
pass
t_loc = glGetUniformLocation(self.program, "u_time")
if t_loc != -1:
glUniform1f(t_loc, float(self._canvas.anim_time))
sp_loc = glGetUniformLocation(self.program, "u_flowSpeed")
if sp_loc != -1:
glUniform1f(sp_loc, float(self._canvas.flow_speed))
ds_loc = glGetUniformLocation(self.program, "u_flowDistortion")
if ds_loc != -1:
glUniform1f(ds_loc, float(self._canvas.flow_distortion))
rp_loc = glGetUniformLocation(self.program, "u_repeat")
if rp_loc != -1:
glUniform1i(rp_loc, 1 if getattr(self._canvas, 'preview_repeat', False) else 0)
udx_loc = glGetUniformLocation(self.program, "u_useDirectX")
if udx_loc != -1:
glUniform1f(udx_loc, 1.0 if getattr(self._canvas, 'graphics_api_mode', 'opengl') == 'directx' else 0.0)
# Pass base scale from 2D canvas
scale_loc = glGetUniformLocation(self.program, "u_scale")
if scale_loc != -1:
glUniform1f(scale_loc, float(getattr(self._canvas, 'base_scale', 1.0)))
has_loc = glGetUniformLocation(self.program, "u_hasBaseMap")
if has_loc != -1:
glUniform1i(has_loc, int_has_base)
if self._use_vao:
try:
glBindVertexArray(self.vao)
except GLError:
# Fallback if driver rejects VAO
self._use_vao = False
if self._use_vao:
glDrawElements(GL_TRIANGLES, self.index_count, GL_UNSIGNED_INT, None)
glBindVertexArray(0)
else:
# fall through to non-VAO draw
pass
if not self._use_vao:
stride = (3+3+2) * 4
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, self.ebo)
if self._attr_pos >= 0:
glEnableVertexAttribArray(self._attr_pos)
glVertexAttribPointer(self._attr_pos, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(0))
if self._attr_nrm >= 0:
glEnableVertexAttribArray(self._attr_nrm)
glVertexAttribPointer(self._attr_nrm, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(12))
if self._attr_uv >= 0:
glEnableVertexAttribArray(self._attr_uv)
glVertexAttribPointer(self._attr_uv, 2, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(24))
glDrawElements(GL_TRIANGLES, self.index_count, GL_UNSIGNED_INT, None)
glUseProgram(0)
def _create_buffers(self):
verts = np.hstack([self._positions, self._normals, self._uvs]).astype(np.float32) if self._positions.size else np.zeros((0,8), dtype=np.float32)
self.index_count = int(self._indices.size)
# create fresh objects
if self.vao != 0:
try:
glDeleteVertexArrays(1, [self.vao])
except Exception:
pass
self.vao = 0
if self.vbo != 0:
try: glDeleteBuffers(1, [self.vbo])
except Exception: pass
self.vbo = 0
if self.ebo != 0:
try: glDeleteBuffers(1, [self.ebo])
except Exception: pass
self.ebo = 0
if self._use_vao:
self.vao = glGenVertexArrays(1)
self.vbo = glGenBuffers(1)
self.ebo = glGenBuffers(1)
if (self._use_vao and self.vao == 0) or self.vbo == 0 or self.ebo == 0:
print(f"VAO/VBO/EBO creation failed: vao={self.vao}, vbo={self.vbo}, ebo={self.ebo}")
return
if self._use_vao:
glBindVertexArray(self.vao)
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
glBufferData(GL_ARRAY_BUFFER, verts.nbytes, verts, GL_STATIC_DRAW)
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, self.ebo)
glBufferData(GL_ELEMENT_ARRAY_BUFFER, self._indices.nbytes, self._indices, GL_STATIC_DRAW)
if self._use_vao:
stride = (3+3+2) * 4
if self._attr_pos >= 0:
glEnableVertexAttribArray(self._attr_pos)
glVertexAttribPointer(self._attr_pos, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(0))
if self._attr_nrm >= 0:
glEnableVertexAttribArray(self._attr_nrm)
glVertexAttribPointer(self._attr_nrm, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(12))
if self._attr_uv >= 0:
glEnableVertexAttribArray(self._attr_uv)
glVertexAttribPointer(self._attr_uv, 2, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(24))
glBindVertexArray(0)
def _fit_model_matrix(self):
if self._positions.size == 0:
self._model_matrix = np.identity(4, dtype=np.float32)
return
mins = self._positions.min(axis=0)
maxs = self._positions.max(axis=0)
center = (mins + maxs) * 0.5
extent = (maxs - mins)
max_dim = max(1e-6, float(np.max(extent)))
scale = 1.6 / max_dim
M = np.identity(4, dtype=np.float32)
M[0,0] = scale; M[1,1] = scale; M[2,2] = scale
M[0,3] = -center[0] * scale
M[1,3] = -center[1] * scale
M[2,3] = -center[2] * scale
self._model_matrix = M
self._cam_target = np.array([0.0, 0.0, 0.0], dtype=np.float32)
self._cam_distance = 3.0
# renderer-only API
def load_mesh(self, mesh: MeshData):
"""接受脱耦的MeshData进行上传,并进行居中/缩放适配"""
self._positions = mesh.positions.astype(np.float32, copy=False)
self._uvs = mesh.uvs.astype(np.float32, copy=False) # primary UV set
self._normals = mesh.normals.astype(np.float32, copy=False)
self._indices = mesh.indices.astype(np.uint32, copy=False)
# Load UV sets
if hasattr(mesh, 'uv_sets') and mesh.uv_sets:
self._uv_sets = [uv_set.astype(np.float32, copy=False) for uv_set in mesh.uv_sets]
else:
self._uv_sets = [self._uvs] if self._uvs.size > 0 else []
if hasattr(mesh, 'uv_set_names') and mesh.uv_set_names:
self._uv_set_names = mesh.uv_set_names.copy()
else:
self._uv_set_names = ["UV0"] if self._uvs.size > 0 else []
self._active_uv_set = 0 # Reset to first UV set
try:
self._tri_indices = self._indices.reshape(-1, 3)
except Exception:
self._tri_indices = np.zeros((0,3), dtype=np.uint32)
# fit
self._fit_model_matrix()
# Compute tangent space first (required for seamless painting)
self._compute_tangent_space()
# upload
self.makeCurrent()
try:
self._create_buffers()
# 预计算三角形数据与BVH
try:
self._tri_indices = self._indices.reshape(-1, 3).astype(np.int64, copy=False)
if self._tri_indices.size > 0:
P = self._positions.astype(np.float32, copy=False)
tris = self._tri_indices
v0 = P[tris[:, 0]]
v1 = P[tris[:, 1]]
v2 = P[tris[:, 2]]
self._tri_v0 = v0
self._tri_e1 = (v1 - v0).astype(np.float32, copy=False)
self._tri_e2 = (v2 - v0).astype(np.float32, copy=False)
tri_min = np.minimum(np.minimum(v0, v1), v2)
tri_max = np.maximum(np.maximum(v0, v1), v2)
tri_centroid = (v0 + v1 + v2) / 3.0
self._build_bvh(tri_min, tri_max, tri_centroid)
except Exception as e:
print(f"BVH precompute error: {e}")
self._fit_model_matrix()
self.model_loaded = True
self.update()
return True
except Exception as e:
print(f"load_mesh error: {e}")
return False
finally:
try: self.doneCurrent()
except Exception: pass
def _compute_tangent_space(self):
"""Compute tangent and bitangent vectors using Mikk tangent space algorithm.
Based on the first UV set (UV0) for consistency across seams.
"""
try:
if self._positions.size == 0 or self._normals.size == 0 or self._indices.size == 0:
self._tangents = np.zeros((0,3), dtype=np.float32)
self._bitangents = np.zeros((0,3), dtype=np.float32)
return
# Always use first UV set for tangent computation (Mikk requirement)
uv0 = self._uv_sets[0] if len(self._uv_sets) > 0 else self._uvs
if uv0 is None or uv0.size == 0:
# Fallback to default UV coordinates if no UV data
uv0 = np.zeros((self._positions.shape[0], 2), dtype=np.float32)
# Use the high-performance tangent space generator
self._tangents, self._bitangents = self._tangent_generator.compute_tangent_space(
self._positions, self._normals, uv0, self._indices
)
print(f"Computed tangent space for {self._positions.shape[0]} vertices using Mikk algorithm")
except Exception as e:
print(f"Tangent space computation error: {e}")
# Fallback: create identity tangent space
vcount = self._positions.shape[0] if self._positions.size > 0 else 0
self._tangents = np.tile([1.0, 0.0, 0.0], (vcount, 1)).astype(np.float32)
self._bitangents = np.tile([0.0, 1.0, 0.0], (vcount, 1)).astype(np.float32)
def _compute_view_matrix(self):
cy = np.cos(self._cam_yaw)
sy = np.sin(self._cam_yaw)
cp = np.cos(self._cam_pitch)
sp = np.sin(self._cam_pitch)
dir_vec = np.array([cy * cp, sp, sy * cp], dtype=np.float32)
eye = self._cam_target - dir_vec * self._cam_distance
up = np.array([0.0, 1.0, 0.0], dtype=np.float32)
return self._look_at(eye, self._cam_target, up)
def _look_at(self, eye, center, up):
f = center - eye
f = f / (np.linalg.norm(f) + 1e-8)
u = up / (np.linalg.norm(up) + 1e-8)
s = np.cross(f, u)
s = s / (np.linalg.norm(s) + 1e-8)
u = np.cross(s, f)
M = np.identity(4, dtype=np.float32)
M[0, :3] = s
M[1, :3] = u
M[2, :3] = -f
T = np.identity(4, dtype=np.float32)
T[0, 3] = -eye[0]
T[1, 3] = -eye[1]
T[2, 3] = -eye[2]
return M @ T
def _compute_perspective_matrix(self, fov_y_deg, aspect, near, far):
f = 1.0 / np.tan(np.deg2rad(fov_y_deg) * 0.5)
M = np.zeros((4, 4), dtype=np.float32)
M[0,0] = f / aspect
M[1,1] = f
M[2,2] = (far + near) / (near - far)
M[2,3] = (2 * far * near) / (near - far)
M[3,2] = -1.0
return M
def mousePressEvent(self, event):
self._last_mouse = event.pos()
# 鼠标事件时重置为最大压力,标记为鼠标输入
if hasattr(self, '_canvas') and self._canvas is not None:
self._canvas.is_tablet_input = False
self._canvas.current_pressure = 1.0
self._canvas.update_brush_from_pressure()
if event.button() == Qt.LeftButton:
if (event.modifiers() & Qt.AltModifier) or not self.model_loaded:
# Alt+左键:旋转
self._is_rotating = True
self._is_painting = False
self._hide_brush_cursor()
else:
# 左键绘制(无缝:基于TBN在切线空间编码方向)
hit_info = self._raycast_full_hit_info(event.pos())
if hit_info is not None:
self._is_painting = True
self._last_hit_uv = hit_info['uv']
self._last_hit_world_pos = hit_info['world_pos']
self._last_hit_triangle_idx = hit_info['triangle_idx']
self._last_hit_barycentric = hit_info['barycentric']
# 初始dab,使用微小默认方向
zero_dir = np.array([0.0, 0.0, 0.01], dtype=np.float32)
# 不立即绘制,等待第一次鼠标移动
# self._invoke_canvas_brush_tangent_dir(hit_info, zero_dir)
self.paint_started.emit()
self._show_brush_cursor(event.pos())
elif event.button() == Qt.MiddleButton:
# 中键仅用于平移
self._is_panning = True
elif event.button() == Qt.RightButton:
# 右键:擦除(与2D一致)
hit_info = self._raycast_full_hit_info(event.pos())
if hit_info is not None:
self._is_painting = True
self._is_erasing = True
self._last_hit_uv = hit_info['uv']
self._last_hit_world_pos = hit_info['world_pos']
self._last_hit_triangle_idx = hit_info['triangle_idx']
self._last_hit_barycentric = hit_info['barycentric']
# 初始擦除点
# self._invoke_canvas_erase(hit_info['uv'], hit_info['uv'])
try:
self.paint_started.emit()
except Exception:
pass
self._show_brush_cursor(event.pos())
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
if self._is_painting:
self._is_painting = False
self._last_hit_uv = None
self._last_hit_world_pos = None
self._last_hit_triangle_idx = None
self._last_hit_barycentric = None
self.paint_finished.emit()
self._hide_brush_cursor()
self._is_rotating = False
elif event.button() == Qt.RightButton:
# 右键释放:停止擦除
if self._is_painting and self._is_erasing:
self._is_painting = False
self._is_erasing = False
try:
self.paint_finished.emit()
except Exception:
pass
self._hide_brush_cursor()
elif event.button() == Qt.MiddleButton:
self._is_panning = False
self._is_rotating = False
# 中键不再用于S调整(仅平移)。S键分支已提前return。
elif event.button() == Qt.RightButton:
# 右键释放:停止擦除
if self._is_painting and self._is_erasing:
self._is_painting = False
self._is_erasing = False
try:
self.paint_finished.emit()
except Exception:
pass
self._hide_brush_cursor()
def mouseMoveEvent(self, event):
dx = event.x() - self._last_mouse.x()
dy = event.y() - self._last_mouse.y()
self._last_mouse = event.pos()
# S键 调整优先级最高(与2D一致,不需要按键鼠标,只要S按下并移动即可)
if self._s_pressed and self._is_adjusting:
dx = event.x() - self._adjust_origin.x()
dy = event.y() - self._adjust_origin.y()
if abs(dx) > abs(dy):
scale_factor = 0.1
new_radius = self._initial_brush_radius + dx * scale_factor
new_radius = max(5.0, min(200.0, float(new_radius)))
try:
if hasattr(self, '_canvas'):
# 同时更新当前值和基础值
self._canvas.brush_radius = new_radius
self._canvas.base_brush_radius = new_radius
self._brush_cursor.set_radius(int(new_radius))
self._canvas.brush_properties_changed.emit(new_radius, float(getattr(self._canvas, 'brush_strength', 0.5)))
except Exception:
pass
else:
scale_factor = 0.005
new_strength = self._initial_brush_strength - dy * scale_factor
new_strength = max(0.01, min(1.0, float(new_strength)))
try:
if hasattr(self, '_canvas'):
# 同时更新当前值和基础值
self._canvas.brush_strength = new_strength
self._canvas.base_brush_strength = new_strength
self._canvas.brush_properties_changed.emit(float(getattr(self._canvas, 'brush_radius', 40.0)), new_strength)
except Exception:
pass
# 光标显示在锚点位置
self._show_brush_cursor(self._adjust_origin)
self.update()
return
# Ctrl+左键优先:旋转,不绘制
# 移除Ctrl旋转逻辑(已使用Alt+左键)
# Alt+中键:调整笔刷(与2D一致)
# 中键不再用于S调整(仅平移)。S键分支已提前return。
if self._is_painting and (event.buttons() & Qt.LeftButton):
# CPU无缝:根据世界空间移动方向映射到切线空间
hit_info = self._raycast_full_hit_info(event.pos())
if hit_info is not None and self._last_hit_world_pos is not None:
world_direction = hit_info['world_pos'] - self._last_hit_world_pos
# 在3D绘制中禁用速度感应的强度调整,避免双重强度应用
# 保存当前状态
original_is_tablet_input = getattr(self._canvas, 'is_tablet_input', False)
original_pressure = getattr(self._canvas, 'current_pressure', 1.0)
# 临时设置为鼠标模式,避免速度感应影响强度
self._canvas.is_tablet_input = False
self._canvas.current_pressure = 1.0
self._invoke_canvas_brush_tangent_dir(hit_info, world_direction)
# 恢复原始状态
self._canvas.is_tablet_input = original_is_tablet_input
self._canvas.current_pressure = original_pressure
# 更新状态
self._last_hit_uv = hit_info['uv']
self._last_hit_world_pos = hit_info['world_pos']
self._last_hit_triangle_idx = hit_info['triangle_idx']
self._last_hit_barycentric = hit_info['barycentric']
self._show_brush_cursor(event.pos())
try:
if hasattr(self._canvas, 'update'):
self._canvas.update()
except Exception:
pass
self.update()
return
if self._is_painting and self._is_erasing and (event.buttons() & Qt.RightButton):
# 右键擦除拖动
hit_info = self._raycast_full_hit_info(event.pos())
if hit_info is not None:
last_uv = self._last_hit_uv if self._last_hit_uv is not None else hit_info['uv']
self._invoke_canvas_erase(last_uv, hit_info['uv'])
# 更新状态
self._last_hit_uv = hit_info['uv']
self._last_hit_world_pos = hit_info['world_pos']
self._last_hit_triangle_idx = hit_info['triangle_idx']
self._last_hit_barycentric = hit_info['barycentric']
self._show_brush_cursor(event.pos())
try:
if hasattr(self._canvas, 'update'):
self._canvas.update()
except Exception:
pass
self.update()
return
if self._is_rotating:
self._cam_yaw += dx * 0.005
self._cam_pitch += -dy * 0.005
self._cam_pitch = float(np.clip(self._cam_pitch, -1.2, 1.2))
self.update()
elif self._is_panning:
# Pan in view space: use right and up vectors
cy = np.cos(self._cam_yaw)
sy = np.sin(self._cam_yaw)
cp = np.cos(self._cam_pitch)
sp = np.sin(self._cam_pitch)
forward = np.array([cy * cp, sp, sy * cp], dtype=np.float32)
right = np.array([forward[2], 0.0, -forward[0]], dtype=np.float32)
right = right / (np.linalg.norm(right) + 1e-8)
up = np.array([0.0, 1.0, 0.0], dtype=np.float32)
pan_scale = self._cam_distance * 0.0015
# Direction: drag right -> move right; drag up -> move up
self._cam_target += right * dx * pan_scale
self._cam_target += up * dy * pan_scale
self.update()
elif self._is_zooming:
# Drag right -> zoom in; drag left -> zoom out
zoom_factor = 1.0 - dx * 0.005
if zoom_factor < 0.1:
zoom_factor = 0.1
self._cam_distance = float(np.clip(self._cam_distance * zoom_factor, 0.2, 100.0))
self.update()
else:
# 非绘制状态下也显示3D笔刷光标
self._show_brush_cursor(event.pos())
def tabletEvent(self, event: QTabletEvent):
"""处理3D视口的数位板事件"""
if not hasattr(self, '_canvas') or self._canvas is None:
return
# 获取笔压并更新画布,标记为数位板输入
pressure = event.pressure()
self._canvas.is_tablet_input = True
self._canvas.current_pressure = max(0.01, pressure)
self._canvas.update_brush_from_pressure()
# 根据事件类型处理绘制
if event.type() == QTabletEvent.TabletPress:
self._last_mouse = event.pos()
if event.button() == Qt.LeftButton:
if not self.model_loaded:
return
# 左键绘制
hit_info = self._raycast_full_hit_info(event.pos())
if hit_info is not None:
self._is_painting = True
self._is_erasing = False
self._last_hit_uv = hit_info['uv']
self._last_hit_world_pos = hit_info['world_pos']
self._last_hit_triangle_idx = hit_info['triangle_idx']
self._last_hit_barycentric = hit_info['barycentric']
# 初始dab
zero_dir = np.array([0.0, 0.0, 0.01], dtype=np.float32)
# 不立即绘制,等待第一次笔移动
# self._invoke_canvas_brush_tangent_dir(hit_info, zero_dir)
self.paint_started.emit()
self._show_brush_cursor(event.pos())
elif event.button() == Qt.RightButton:
# 右键擦除
hit_info = self._raycast_full_hit_info(event.pos())
if hit_info is not None:
self._is_painting = True
self._is_erasing = True
self._last_hit_uv = hit_info['uv']
self._last_hit_world_pos = hit_info['world_pos']
self._last_hit_triangle_idx = hit_info['triangle_idx']
self._last_hit_barycentric = hit_info['barycentric']
# 初始擦除点
# self._invoke_canvas_erase(hit_info['uv'], hit_info['uv'])
try:
self.paint_started.emit()
except Exception:
pass
self._show_brush_cursor(event.pos())
elif event.type() == QTabletEvent.TabletMove:
self._last_mouse = event.pos()
# 处理绘制移动
if self._is_painting and not self._is_erasing:
hit_info = self._raycast_full_hit_info(event.pos())
if hit_info is not None and self._last_hit_world_pos is not None:
world_direction = hit_info['world_pos'] - self._last_hit_world_pos
self._invoke_canvas_brush_tangent_dir(hit_info, world_direction)
# 更新状态
self._last_hit_uv = hit_info['uv']
self._last_hit_world_pos = hit_info['world_pos']
self._last_hit_triangle_idx = hit_info['triangle_idx']
self._last_hit_barycentric = hit_info['barycentric']
self._show_brush_cursor(event.pos())
try:
if hasattr(self._canvas, 'update'):
self._canvas.update()
except Exception:
pass
self.update()
elif self._is_painting and self._is_erasing:
# 擦除移动
hit_info = self._raycast_full_hit_info(event.pos())
if hit_info is not None:
last_uv = self._last_hit_uv if self._last_hit_uv is not None else hit_info['uv']
self._invoke_canvas_erase(last_uv, hit_info['uv'])
# 更新状态
self._last_hit_uv = hit_info['uv']
self._last_hit_world_pos = hit_info['world_pos']
self._last_hit_triangle_idx = hit_info['triangle_idx']
self._last_hit_barycentric = hit_info['barycentric']
self._show_brush_cursor(event.pos())
try:
if hasattr(self._canvas, 'update'):
self._canvas.update()
except Exception:
pass
self.update()
else:
# 非绘制状态下显示光标
self._show_brush_cursor(event.pos())
elif event.type() == QTabletEvent.TabletRelease:
if event.button() == Qt.LeftButton:
if self._is_painting:
self._is_painting = False
self._last_hit_uv = None
self._last_hit_world_pos = None
self._last_hit_triangle_idx = None
self._last_hit_barycentric = None
try:
self.paint_finished.emit()
except Exception:
pass
self._hide_brush_cursor()
elif event.button() == Qt.RightButton:
if self._is_painting and self._is_erasing:
self._is_painting = False
self._is_erasing = False
self._last_hit_uv = None
self._last_hit_world_pos = None
self._last_hit_triangle_idx = None
self._last_hit_barycentric = None
try:
self.paint_finished.emit()
except Exception:
pass
self._hide_brush_cursor()
# 接受事件,防止传递给鼠标事件处理
event.accept()
def wheelEvent(self, event):
delta = event.angleDelta().y() / 120.0
scale = 1.0 - delta * 0.1
self._cam_distance = float(np.clip(self._cam_distance * scale, 0.2, 100.0))
self.update()
def keyPressEvent(self, event):
if event.key() == Qt.Key_S:
self._s_pressed = True
# 锚定调整起点(与2D一致)
pos = self.mapFromGlobal(QCursor.pos())
if not self.rect().contains(pos):
pos = QPoint(self.width() // 2, self.height() // 2)
self._adjust_origin = pos
elif event.key() == Qt.Key_Space or event.key() == Qt.Key_F:
# Space或F键重置3D视图
self._cam_distance = 3.0
self._cam_yaw = 0.0
self._cam_pitch = 0.0
self._cam_target = np.array([0.0, 0.0, 0.0], dtype=np.float32)
self.update()
return
# S键的其余处理
if event.key() == Qt.Key_S:
try:
self._initial_brush_radius = float(getattr(self._canvas, 'brush_radius', 40.0))
self._initial_brush_strength = float(getattr(self._canvas, 'brush_strength', 0.5))
except Exception:
self._initial_brush_radius = 40.0
self._initial_brush_strength = 0.5
self._is_adjusting = True
try:
self._brush_cursor.set_adjusting_state(True)
except Exception:
pass
# 光标固定在锚点(与2D一致)
self._show_brush_cursor(self._adjust_origin)
super().keyPressEvent(event)
def keyReleaseEvent(self, event):
if event.key() == Qt.Key_S:
self._s_pressed = False
self._is_adjusting = False
try:
self._brush_cursor.set_adjusting_state(False)
except Exception:
pass
super().keyReleaseEvent(event)
# ---------- Painting helpers ----------
def _show_brush_cursor(self, pos):
try:
self._brush_cursor.radius = getattr(self._canvas, 'brush_radius', 40)
self._brush_cursor.set_position(pos)
if not self._brush_cursor.isVisible():
self._brush_cursor.show()
self._brush_cursor.update()
except Exception:
pass
def _hide_brush_cursor(self):
try:
self._brush_cursor.hide()
except Exception:
pass
# public for external coordination
def hide_brush_cursor(self):
self._hide_brush_cursor()
def _invoke_canvas_brush(self, last_uv, curr_uv):
if not hasattr(self, '_canvas') or self._canvas is None:
return
try:
# 计算UV空间中的移动距离(用于3D速度感应)
delta_u = curr_uv[0] - last_uv[0]
delta_v = curr_uv[1] - last_uv[1]
# 处理UV坐标的边界跨越(类似2D的四方连续处理)
if abs(delta_u) > 0.5:
delta_u = -np.sign(delta_u) * (1.0 - abs(delta_u))
if abs(delta_v) > 0.5:
delta_v = -np.sign(delta_v) * (1.0 - abs(delta_v))
# 计算UV空间中的移动长度(模拟2D中的flow vector长度)
uv_movement_length = np.sqrt(delta_u**2 + delta_v**2)
# 将UV移动距离转换为类似2D纹理像素的尺度(用于速度计算)
# 假设UV空间[0,1]对应纹理的宽高,转换为像素尺度的移动
canvas_tex_w, canvas_tex_h = getattr(self._canvas, 'texture_size', (1024, 1024))
pixel_movement_length = uv_movement_length * max(canvas_tex_w, canvas_tex_h)
# 在鼠标模式下,应用与2D相同的速度感应计算
if not getattr(self._canvas, 'is_tablet_input', False):
raw_speed_factor = min(1.0, pixel_movement_length / 100.0)
speed_sensitivity = getattr(self._canvas, 'speed_sensitivity', 0.7)
# 改进的压感算法:与2D保持一致
if speed_sensitivity < 0.01: # 接近0时,固定压力
self._canvas.current_pressure = 1.0
else:
min_factor = (1.0 - speed_sensitivity) * 0.8 # 更激进的衰减