|
|
@@ -5,28 +5,13 @@ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
|
|
|
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'
|
|
|
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'
|
|
|
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'
|
|
|
-import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js'
|
|
|
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'
|
|
|
|
|
|
import type { CameraInfo, PartMeta, PartStatus } from '../types'
|
|
|
|
|
|
-const LARGE_THRESHOLD = 1500
|
|
|
const HIGHLIGHT = new THREE.Color(0x4da3ff)
|
|
|
const REF_MEDIAN_Z = -22.46
|
|
|
|
|
|
-export interface ViewPreset {
|
|
|
- label: string
|
|
|
- camera?: [number, number, number]
|
|
|
- target?: [number, number, number]
|
|
|
- axis?: [number, number, number]
|
|
|
-}
|
|
|
-
|
|
|
-export const VIEW_PRESETS: ViewPreset[] = [
|
|
|
- { label: '俯视', camera: [2.0, 22.0, -3.2], target: [2.0, 0.0, -22.0] },
|
|
|
- { label: '角度A', camera: [5.4, 16.7, -6.3], target: [9.4, 0.0, -22.5] },
|
|
|
- { label: '短边', axis: [1, 0, 0] },
|
|
|
-]
|
|
|
-
|
|
|
interface PartHandle {
|
|
|
id: string
|
|
|
label: string
|
|
|
@@ -41,7 +26,7 @@ interface PartHandle {
|
|
|
}
|
|
|
|
|
|
interface ViewerCallbacks {
|
|
|
- onParts?: (parts: PartMeta[], info: { meshCount: number; largeMode: boolean; medianZ: number }) => void
|
|
|
+ onParts?: (parts: PartMeta[]) => void
|
|
|
onProgress?: (text: string) => void
|
|
|
onHover?: (id: string | null, label: string | null) => void
|
|
|
onPick?: (id: string | null, additive: boolean) => void
|
|
|
@@ -69,7 +54,6 @@ export class Viewer {
|
|
|
private controls!: OrbitControls | TrackballControls
|
|
|
private freeMode = false
|
|
|
private composer!: EffectComposer
|
|
|
- private outline!: OutlinePass
|
|
|
private grid: THREE.GridHelper | null = null
|
|
|
private loader = new GLTFLoader()
|
|
|
private raycaster = new THREE.Raycaster()
|
|
|
@@ -84,7 +68,6 @@ export class Viewer {
|
|
|
|
|
|
private parts: PartHandle[] = []
|
|
|
private byId = new Map<string, PartHandle>()
|
|
|
- private largeMode = false
|
|
|
private instancedMeshes: THREE.InstancedMesh[] = []
|
|
|
private instMaterial = new THREE.MeshStandardMaterial({
|
|
|
color: 0xffffff, metalness: 0.2, roughness: 0.7, side: THREE.DoubleSide,
|
|
|
@@ -137,18 +120,8 @@ export class Viewer {
|
|
|
|
|
|
const composer = new EffectComposer(renderer)
|
|
|
composer.addPass(new RenderPass(scene, camera))
|
|
|
- const outline = new OutlinePass(
|
|
|
- new THREE.Vector2(this.container.clientWidth, this.container.clientHeight), scene, camera,
|
|
|
- )
|
|
|
- outline.edgeStrength = 4
|
|
|
- outline.edgeGlow = 0.2
|
|
|
- outline.edgeThickness = 1.2
|
|
|
- outline.visibleEdgeColor = new THREE.Color(0x4da3ff)
|
|
|
- outline.hiddenEdgeColor = new THREE.Color(0x000000)
|
|
|
- composer.addPass(outline)
|
|
|
composer.addPass(new OutputPass())
|
|
|
this.composer = composer
|
|
|
- this.outline = outline
|
|
|
|
|
|
this.bindEvents()
|
|
|
this.updateAspect()
|
|
|
@@ -184,14 +157,9 @@ export class Viewer {
|
|
|
this.camera.updateProjectionMatrix()
|
|
|
this.renderer.setSize(w, h)
|
|
|
this.composer.setSize(w, h)
|
|
|
- this.outline.setSize(w, h)
|
|
|
this.trackball?.handleResize()
|
|
|
}
|
|
|
|
|
|
- private get modelMedianZValue(): number {
|
|
|
- return this.modelMedianZ
|
|
|
- }
|
|
|
-
|
|
|
private emitCamInfo = (): void => {
|
|
|
const off = this.camera.position.clone().sub(this.controls.target)
|
|
|
if (off.lengthSq() === 0) return
|
|
|
@@ -220,18 +188,12 @@ export class Viewer {
|
|
|
this.modelGroup = group
|
|
|
this.scene.add(group)
|
|
|
|
|
|
+ // 一律使用实例化(大模型)模式加载
|
|
|
const meshCount = this.countMeshNodes(group)
|
|
|
- this.largeMode = meshCount > LARGE_THRESHOLD
|
|
|
- if (this.largeMode) {
|
|
|
- this.cb.onProgress?.(`大模型模式:正在合并 ${meshCount} 个部件…`)
|
|
|
- group.visible = false
|
|
|
- this.collectPartsInstanced(group)
|
|
|
- } else {
|
|
|
- this.collectParts(group)
|
|
|
- }
|
|
|
- this.cb.onParts?.(this.parts.map((p) => p.meta), {
|
|
|
- meshCount, largeMode: this.largeMode, medianZ: this.modelMedianZValue,
|
|
|
- })
|
|
|
+ this.cb.onProgress?.(`正在合并 ${meshCount} 个部件…`)
|
|
|
+ group.visible = false
|
|
|
+ this.collectPartsInstanced(group)
|
|
|
+ this.cb.onParts?.(this.parts.map((p) => p.meta))
|
|
|
this.cb.onProgress?.('')
|
|
|
}
|
|
|
|
|
|
@@ -253,8 +215,6 @@ export class Viewer {
|
|
|
this.byId.clear()
|
|
|
this.selectedIds.clear()
|
|
|
this.prevSelected = new Set()
|
|
|
- this.largeMode = false
|
|
|
- this.outline.selectedObjects = []
|
|
|
this.hoveredId = null
|
|
|
}
|
|
|
|
|
|
@@ -264,41 +224,6 @@ export class Viewer {
|
|
|
return n
|
|
|
}
|
|
|
|
|
|
- private ensureOwnMaterial(obj: THREE.Mesh): void {
|
|
|
- if (Array.isArray(obj.material)) obj.material = obj.material.map((m) => m.clone())
|
|
|
- else if (obj.material) obj.material = obj.material.clone()
|
|
|
- eachMaterial(obj, (m) => { m.needsUpdate = true })
|
|
|
- }
|
|
|
-
|
|
|
- private collectParts(group: THREE.Group): void {
|
|
|
- group.updateMatrixWorld(true)
|
|
|
- const seen = new Map<string, number>()
|
|
|
- group.traverse((obj) => {
|
|
|
- const mesh = obj as THREE.Mesh
|
|
|
- if (!mesh.isMesh || !mesh.geometry) return
|
|
|
- this.ensureOwnMaterial(mesh)
|
|
|
- const base = mesh.name && mesh.name.trim() ? mesh.name.trim() : '部件'
|
|
|
- const n = (seen.get(base) || 0) + 1
|
|
|
- seen.set(base, n)
|
|
|
- const defaults: string[] = []
|
|
|
- eachMaterial(mesh, (m) => {
|
|
|
- const anyM = m as THREE.MeshStandardMaterial
|
|
|
- const hex = toHex(anyM.color || 0xcccccc)
|
|
|
- defaults.push(hex)
|
|
|
- anyM.color.set(hex)
|
|
|
- })
|
|
|
- const id = `${base}#${n}`
|
|
|
- const handle: PartHandle = {
|
|
|
- id, label: n > 1 ? `${base} [${n}]` : base, name: base, defaults,
|
|
|
- obj: mesh, status: null, colorHex: null, meta: null as unknown as PartMeta,
|
|
|
- }
|
|
|
- handle.meta = this.buildMeta(handle)
|
|
|
- this.parts.push(handle)
|
|
|
- this.byId.set(id, handle)
|
|
|
- })
|
|
|
- this.sortParts()
|
|
|
- }
|
|
|
-
|
|
|
private collectPartsInstanced(group: THREE.Group): void {
|
|
|
group.updateMatrixWorld(true)
|
|
|
const byGeom = new Map<string, { geometry: THREE.BufferGeometry; entries: { matrix: THREE.Matrix4; handle: PartHandle }[] }>()
|
|
|
@@ -436,33 +361,29 @@ export class Viewer {
|
|
|
return vals[Math.floor(vals.length / 2)]
|
|
|
}
|
|
|
|
|
|
- activateView(index: number): void {
|
|
|
- const view = VIEW_PRESETS[index]
|
|
|
- if (!view || !this.modelDist) return
|
|
|
- let target: THREE.Vector3
|
|
|
- let cam: THREE.Vector3
|
|
|
- if (view.axis) {
|
|
|
- const dir = new THREE.Vector3(...view.axis).normalize()
|
|
|
- const ai = view.axis.findIndex((x) => x !== 0)
|
|
|
- const cross = Math.max(...[0, 1, 2].filter((i) => i !== ai).map((i) => this.modelSize.getComponent(i)))
|
|
|
- const fovRad = THREE.MathUtils.degToRad(this.camera.fov)
|
|
|
- const crossFit = (cross / (2 * Math.tan(fovRad / 2))) * 1.2
|
|
|
- const halfAxis = this.modelSize.getComponent(ai) / 2
|
|
|
- const dist = halfAxis + crossFit
|
|
|
- target = this.modelCenter.clone()
|
|
|
- cam = this.modelCenter.clone().addScaledVector(dir, dist)
|
|
|
- } else {
|
|
|
- const dz = this.modelMedianZ - REF_MEDIAN_Z
|
|
|
- target = new THREE.Vector3(view.target![0], view.target![1], view.target![2] + dz)
|
|
|
- cam = new THREE.Vector3(view.camera![0], view.camera![1], view.camera![2] + dz)
|
|
|
- }
|
|
|
+ /** 应用“参考系”相机/目标(Z 叠加随模型变化的 dz)。 */
|
|
|
+ applyCamera(camera: [number, number, number], target: [number, number, number]): void {
|
|
|
+ const dz = this.modelMedianZ - REF_MEDIAN_Z
|
|
|
+ const cam = new THREE.Vector3(camera[0], camera[1], camera[2] + dz)
|
|
|
+ const tgt = new THREE.Vector3(target[0], target[1], target[2] + dz)
|
|
|
this.camera.position.copy(cam)
|
|
|
- this.camera.lookAt(target)
|
|
|
- this.controls.target.copy(target)
|
|
|
+ this.camera.lookAt(tgt)
|
|
|
+ this.controls.target.copy(tgt)
|
|
|
this.controls.update()
|
|
|
this.emitCamInfo()
|
|
|
}
|
|
|
|
|
|
+ /** 返回“参考系”下的当前相机/目标(Z 去掉 dz)。 */
|
|
|
+ getCameraState(): { camera: [number, number, number]; target: [number, number, number] } {
|
|
|
+ const dz = this.modelMedianZ - REF_MEDIAN_Z
|
|
|
+ const p = this.camera.position
|
|
|
+ const t = this.controls.target
|
|
|
+ return {
|
|
|
+ camera: [p.x, p.y, p.z - dz],
|
|
|
+ target: [t.x, t.y, t.z - dz],
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
moveForward(sign: number): void {
|
|
|
const dir = new THREE.Vector3().subVectors(this.controls.target, this.camera.position).normalize()
|
|
|
const step = (this.modelDist || 10) * 0.08
|
|
|
@@ -535,18 +456,10 @@ export class Viewer {
|
|
|
this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1
|
|
|
this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1
|
|
|
this.raycaster.setFromCamera(this.pointer, this.camera)
|
|
|
- if (this.largeMode) {
|
|
|
- if (!this.instancedMeshes.length) return null
|
|
|
- const hits = this.raycaster.intersectObjects(this.instancedMeshes, false)
|
|
|
- if (!hits.length) return null
|
|
|
- return this.idFromInstance(hits[0].object as THREE.InstancedMesh, hits[0].instanceId ?? 0)
|
|
|
- }
|
|
|
- const meshes = this.parts.filter((p) => p.obj).map((p) => p.obj!)
|
|
|
- const hits = this.raycaster.intersectObjects(meshes, false)
|
|
|
+ if (!this.instancedMeshes.length) return null
|
|
|
+ const hits = this.raycaster.intersectObjects(this.instancedMeshes, false)
|
|
|
if (!hits.length) return null
|
|
|
- const obj = hits[0].object as THREE.Mesh
|
|
|
- const found = this.parts.find((p) => p.obj === obj)
|
|
|
- return found ? found.id : null
|
|
|
+ return this.idFromInstance(hits[0].object as THREE.InstancedMesh, hits[0].instanceId ?? 0)
|
|
|
}
|
|
|
|
|
|
private idFromInstance(inst: THREE.InstancedMesh, instanceId: number): string | null {
|
|
|
@@ -557,18 +470,11 @@ export class Viewer {
|
|
|
/* ---------------- 选择 / 上色 ---------------- */
|
|
|
setSelectedIds(ids: string[]): void {
|
|
|
this.selectedIds = new Set(ids)
|
|
|
- if (this.largeMode) {
|
|
|
- this.outline.selectedObjects = []
|
|
|
- const changed = new Set<string>()
|
|
|
- this.selectedIds.forEach((id) => { if (!this.prevSelected.has(id)) changed.add(id) })
|
|
|
- this.prevSelected.forEach((id) => { if (!this.selectedIds.has(id)) changed.add(id) })
|
|
|
- changed.forEach((id) => this.applyDisplay(id))
|
|
|
- this.prevSelected = new Set(this.selectedIds)
|
|
|
- } else {
|
|
|
- this.outline.selectedObjects = ids
|
|
|
- .map((id) => this.byId.get(id)?.obj)
|
|
|
- .filter((o): o is THREE.Mesh => Boolean(o))
|
|
|
- }
|
|
|
+ const changed = new Set<string>()
|
|
|
+ this.selectedIds.forEach((id) => { if (!this.prevSelected.has(id)) changed.add(id) })
|
|
|
+ this.prevSelected.forEach((id) => { if (!this.selectedIds.has(id)) changed.add(id) })
|
|
|
+ changed.forEach((id) => this.applyDisplay(id))
|
|
|
+ this.prevSelected = new Set(this.selectedIds)
|
|
|
}
|
|
|
|
|
|
setPartColor(id: string, hex: string, status: PartStatus | null): void {
|