add 3d to srebot (#1350)
This commit is contained in:
+1
-1
@@ -46,7 +46,7 @@ if (!fs.existsSync(OUTPUT_DIR)) {
|
|||||||
console.log('[BUILD] Starting JavaScript obfuscation...');
|
console.log('[BUILD] Starting JavaScript obfuscation...');
|
||||||
|
|
||||||
// Get all JS files in the public/js directory
|
// Get all JS files in the public/js directory
|
||||||
const SKIP_FILES = ['replay-canvas.js'];
|
const SKIP_FILES = ['replay-canvas.js', 'replay-canvas-3d.js'];
|
||||||
const jsFiles = fs.readdirSync(PUBLIC_JS_DIR).filter(file =>
|
const jsFiles = fs.readdirSync(PUBLIC_JS_DIR).filter(file =>
|
||||||
file.endsWith('.js') && !file.startsWith('.') && !SKIP_FILES.includes(file)
|
file.endsWith('.js') && !file.startsWith('.') && !SKIP_FILES.includes(file)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,696 @@
|
|||||||
|
// Pure 3D replay view. No DOM beyond its own <canvas>; driven entirely by an
|
||||||
|
// external clock (setTime) so it stays in sync with the 2D ReplayCanvasEngine.
|
||||||
|
// Ported from the standalone REPLAY_VIEWER three.js viewer, stripped of all of
|
||||||
|
// its own UI (labels, axes, guide lines, controls, hover/active-list panels).
|
||||||
|
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||||
|
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'
|
||||||
|
|
||||||
|
const MODEL_PATH = '/models/t34/'
|
||||||
|
const MINIMAP_URL = (level) => `/api/match/minimap/${level}`
|
||||||
|
const MINIMAP_FULL_URL = (level) => `/api/match/minimap/${level}?type=full`
|
||||||
|
|
||||||
|
const WIN_COLOR = '#00c800'
|
||||||
|
const LOSE_COLOR = '#dc1e1e'
|
||||||
|
const DRONE_COLOR = '#cbd5e1'
|
||||||
|
|
||||||
|
const DIRECTION_SAMPLE_MS = 700
|
||||||
|
const DIRECTION_BLEND = 0.22
|
||||||
|
const KILL_LINE_WINDOW_MS = 3000
|
||||||
|
const CAP_INTERP_MAX_GAP_MS = 4000
|
||||||
|
const FLIP_MAP_TEXTURE_Z = true
|
||||||
|
|
||||||
|
function coordsAreValid(c) {
|
||||||
|
return c &&
|
||||||
|
Number.isFinite(Number(c.x0)) && Number.isFinite(Number(c.z0)) &&
|
||||||
|
Number.isFinite(Number(c.x1)) && Number.isFinite(Number(c.z1)) &&
|
||||||
|
Number(c.x0) !== Number(c.x1) && Number(c.z0) !== Number(c.z1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCoords(c) {
|
||||||
|
if (!coordsAreValid(c)) return null
|
||||||
|
return { x0: Number(c.x0), z0: Number(c.z0), x1: Number(c.x1), z1: Number(c.z1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSourceRect(baseCoords, renderCoords) {
|
||||||
|
const base = normalizeCoords(baseCoords)
|
||||||
|
const render = normalizeCoords(renderCoords)
|
||||||
|
if (!base || !render) return { u: 0, v: 0, w: 1, h: 1 }
|
||||||
|
const dx = base.x1 - base.x0
|
||||||
|
const dz = base.z1 - base.z0
|
||||||
|
const xLo = Math.min(render.x0, render.x1)
|
||||||
|
const xHi = Math.max(render.x0, render.x1)
|
||||||
|
const zLo = Math.min(render.z0, render.z1)
|
||||||
|
const zHi = Math.max(render.z0, render.z1)
|
||||||
|
const u0 = (xLo - base.x0) / dx
|
||||||
|
const u1 = (xHi - base.x0) / dx
|
||||||
|
const v0 = (zLo - base.z0) / dz
|
||||||
|
const v1 = (zHi - base.z0) / dz
|
||||||
|
const uMin = Math.max(0, Math.min(1, Math.min(u0, u1)))
|
||||||
|
const uMax = Math.max(0, Math.min(1, Math.max(u0, u1)))
|
||||||
|
const vMin = Math.max(0, Math.min(1, Math.min(v0, v1)))
|
||||||
|
const vMax = Math.max(0, Math.min(1, Math.max(v0, v1)))
|
||||||
|
const w = uMax - uMin
|
||||||
|
const h = vMax - vMin
|
||||||
|
if (!(w > 0 && h > 0)) return { u: 0, v: 0, w: 1, h: 1 }
|
||||||
|
return { u: uMin, v: 1 - vMax, w, h }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackBoundsCoords(bounds) {
|
||||||
|
const pad = Math.max(bounds.planarSpan * 0.15, 250)
|
||||||
|
return { x0: bounds.minX - pad, z0: bounds.minZ - pad, x1: bounds.maxX + pad, z1: bounds.maxZ + pad }
|
||||||
|
}
|
||||||
|
|
||||||
|
function catmullRom(a, b, c, d, t) {
|
||||||
|
const t2 = t * t
|
||||||
|
const t3 = t2 * t
|
||||||
|
return 0.5 * ((2 * b) + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReplayCanvas3D {
|
||||||
|
constructor(container, data) {
|
||||||
|
this.container = container
|
||||||
|
this.data = data
|
||||||
|
this.disposed = false
|
||||||
|
this.currentT = 0
|
||||||
|
this.selectedPlayerId = null
|
||||||
|
this.smoothByEntity = new Map()
|
||||||
|
|
||||||
|
this.players = {}
|
||||||
|
for (const p of data.players || []) this.players[p.id] = p
|
||||||
|
this.teamWon = data.teamWon
|
||||||
|
|
||||||
|
this._buildEntities()
|
||||||
|
this._buildCaptureModel()
|
||||||
|
this.kills = (data.kills || []).filter((k) => k.killerPos && k.victimPos)
|
||||||
|
|
||||||
|
this.bounds = this._computeBounds()
|
||||||
|
this.scale = 1200 / this.bounds.planarSpan
|
||||||
|
|
||||||
|
this._initThree()
|
||||||
|
|
||||||
|
this._mode = 'ground'
|
||||||
|
this.mapInfo = this._resolveMapInfo('ground')
|
||||||
|
|
||||||
|
this._buildScene()
|
||||||
|
this._loadTankTemplate()
|
||||||
|
this._loadMapTexture()
|
||||||
|
|
||||||
|
this._animate = this._animate.bind(this)
|
||||||
|
this._raf = requestAnimationFrame(this._animate)
|
||||||
|
this.setTime(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- data adaptation -----------------------------------------------------
|
||||||
|
|
||||||
|
_isWinner(entity) {
|
||||||
|
if (entity.playerId > 0) return this.players[entity.playerId]?.team === this.teamWon
|
||||||
|
return entity.droneTeam === this.teamWon
|
||||||
|
}
|
||||||
|
|
||||||
|
_colorFor(entity) {
|
||||||
|
if (entity.playerId === 0 && entity.type === 'drone') return DRONE_COLOR
|
||||||
|
return this._isWinner(entity) ? WIN_COLOR : LOSE_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildEntities() {
|
||||||
|
this.entities = []
|
||||||
|
for (const e of this.data.entities || []) {
|
||||||
|
if (!Array.isArray(e.path) || !e.path.length) continue
|
||||||
|
const path = e.path.map((p) => ({ t: Number(p.t), x: Number(p.x), y: Number(p.y || 0), z: Number(p.z) }))
|
||||||
|
this.entities.push({
|
||||||
|
playerId: e.playerId,
|
||||||
|
entityIndex: e.entityIndex,
|
||||||
|
type: e.type,
|
||||||
|
droneTeam: e.droneTeam,
|
||||||
|
path,
|
||||||
|
startT: path[0].t,
|
||||||
|
endT: path[path.length - 1].t,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildCaptureModel() {
|
||||||
|
const areas = Array.isArray(this.data.captureAreas) ? this.data.captureAreas : []
|
||||||
|
const state = this.data.captureState || {}
|
||||||
|
this.captureZones = areas.map((area, index) => {
|
||||||
|
const letter = String.fromCharCode(65 + index)
|
||||||
|
const cap = Array.isArray(state[letter])
|
||||||
|
? state[letter].map(([t, v]) => [Number(t), Number(v)]).filter(([t, v]) => Number.isFinite(t) && Number.isFinite(v))
|
||||||
|
: []
|
||||||
|
const center = area.tm?.center || [area.x, 0, area.z]
|
||||||
|
return {
|
||||||
|
key: letter,
|
||||||
|
center: { x: Number(area.x ?? center[0]), y: Number(center[1] || 0), z: Number(area.z ?? center[2]) },
|
||||||
|
radius: Math.max(1, Number(area.radius) || 1),
|
||||||
|
cap,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeBounds() {
|
||||||
|
const b = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity }
|
||||||
|
for (const e of this.entities) {
|
||||||
|
for (const p of e.path) {
|
||||||
|
b.minX = Math.min(b.minX, p.x); b.maxX = Math.max(b.maxX, p.x)
|
||||||
|
b.minY = Math.min(b.minY, p.y); b.maxY = Math.max(b.maxY, p.y)
|
||||||
|
b.minZ = Math.min(b.minZ, p.z); b.maxZ = Math.max(b.maxZ, p.z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(b.minX)) { b.minX = 0; b.maxX = 1; b.minY = 0; b.maxY = 1; b.minZ = 0; b.maxZ = 1 }
|
||||||
|
b.centerX = (b.minX + b.maxX) / 2
|
||||||
|
b.centerZ = (b.minZ + b.maxZ) / 2
|
||||||
|
b.spanX = Math.max(1, b.maxX - b.minX)
|
||||||
|
b.spanZ = Math.max(1, b.maxZ - b.minZ)
|
||||||
|
b.planarSpan = Math.max(b.spanX, b.spanZ)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
_resolveMapInfo(mode) {
|
||||||
|
const level = this.data.mission?.level
|
||||||
|
const full = this.data.fullMapLevel
|
||||||
|
if (mode === 'air' && full && normalizeCoords(this.data.mapCoords)) {
|
||||||
|
const coords = normalizeCoords(this.data.mapCoords)
|
||||||
|
return { image: MINIMAP_FULL_URL(full), coords, baseCoords: coords, sourceRect: { u: 0, v: 0, w: 1, h: 1 } }
|
||||||
|
}
|
||||||
|
const coords = normalizeCoords(this.data.levelCoords) || fallbackBoundsCoords(this.bounds)
|
||||||
|
const baseCoords = normalizeCoords(this.data.tankMapCoords) || coords
|
||||||
|
return {
|
||||||
|
image: level ? MINIMAP_URL(level) : null,
|
||||||
|
coords,
|
||||||
|
baseCoords,
|
||||||
|
sourceRect: mapSourceRect(baseCoords, coords),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- coordinate transforms ----------------------------------------------
|
||||||
|
|
||||||
|
_toWorld(p) {
|
||||||
|
const b = this.bounds
|
||||||
|
return new THREE.Vector3(
|
||||||
|
(p.x - b.centerX) * this.scale,
|
||||||
|
(p.y - b.minY) * this.scale,
|
||||||
|
(p.z - b.centerZ) * this.scale,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderPoint(p) {
|
||||||
|
if (!FLIP_MAP_TEXTURE_Z) return p
|
||||||
|
const coords = normalizeCoords(this.mapInfo?.coords)
|
||||||
|
if (!coords) return p
|
||||||
|
return { ...p, z: coords.z0 + coords.z1 - p.z }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- three.js setup ------------------------------------------------------
|
||||||
|
|
||||||
|
_initThree() {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.className = 'rc3d-canvas'
|
||||||
|
this.canvasEl = canvas
|
||||||
|
this.container.appendChild(canvas)
|
||||||
|
|
||||||
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
|
||||||
|
this.renderer.setClearColor(0x0b0d10, 1)
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene()
|
||||||
|
this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 20000)
|
||||||
|
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
|
||||||
|
this.controls.enableDamping = true
|
||||||
|
this.controls.dampingFactor = 0.08
|
||||||
|
this.controls.screenSpacePanning = false
|
||||||
|
this.controls.maxPolarAngle = Math.PI * 0.49
|
||||||
|
this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE
|
||||||
|
this.controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY
|
||||||
|
this.controls.mouseButtons.RIGHT = THREE.MOUSE.PAN
|
||||||
|
|
||||||
|
this.root = new THREE.Group()
|
||||||
|
this.mapGroup = new THREE.Group()
|
||||||
|
this.pathGroup = new THREE.Group()
|
||||||
|
this.zoneGroup = new THREE.Group()
|
||||||
|
this.killLineGroup = new THREE.Group()
|
||||||
|
this.markerGroup = new THREE.Group()
|
||||||
|
this.root.add(this.mapGroup, this.pathGroup, this.zoneGroup, this.killLineGroup, this.markerGroup)
|
||||||
|
this.scene.add(this.root)
|
||||||
|
|
||||||
|
this.scene.add(new THREE.AmbientLight(0xffffff, 0.72))
|
||||||
|
const sun = new THREE.DirectionalLight(0xffffff, 1.1)
|
||||||
|
sun.position.set(240, 500, 180)
|
||||||
|
this.scene.add(sun)
|
||||||
|
|
||||||
|
this.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
_worldMapExtents(coords = this.mapInfo?.coords) {
|
||||||
|
const c = normalizeCoords(coords)
|
||||||
|
if (!c) return null
|
||||||
|
const a = this._toWorld({ x: c.x0, y: this.bounds.minY, z: c.z0 })
|
||||||
|
const b = this._toWorld({ x: c.x1, y: this.bounds.minY, z: c.z1 })
|
||||||
|
return {
|
||||||
|
minX: Math.min(a.x, b.x), maxX: Math.max(a.x, b.x),
|
||||||
|
minZ: Math.min(a.z, b.z), maxZ: Math.max(a.z, b.z),
|
||||||
|
width: Math.abs(b.x - a.x), depth: Math.abs(b.z - a.z),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_resetCamera() {
|
||||||
|
const ext = this._worldMapExtents()
|
||||||
|
const span = Math.max(ext?.width || 1400, ext?.depth || 1400, 100)
|
||||||
|
this.camera.position.set(span * 0.45, span * 0.55, span * 0.72)
|
||||||
|
this.controls.target.set(0, 0, 0)
|
||||||
|
this.controls.minDistance = Math.max(18, span * 0.025)
|
||||||
|
this.controls.maxDistance = Math.max(320, span * 1.8)
|
||||||
|
this.camera.far = Math.max(9000, span * 8)
|
||||||
|
this.camera.updateProjectionMatrix()
|
||||||
|
this.controls.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- scene construction --------------------------------------------------
|
||||||
|
|
||||||
|
_buildScene() {
|
||||||
|
this._clearGroup(this.mapGroup)
|
||||||
|
this._clearGroup(this.pathGroup)
|
||||||
|
this._clearGroup(this.markerGroup)
|
||||||
|
this._clearGroup(this.zoneGroup)
|
||||||
|
this._clearGroup(this.killLineGroup)
|
||||||
|
this._buildMapPlane()
|
||||||
|
this._buildCaptureZones()
|
||||||
|
this._buildKillLines()
|
||||||
|
this.visualEntities = this.entities.map((e) => this._makeVisualEntity(e))
|
||||||
|
this._applyHighlight()
|
||||||
|
this._resetCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearGroup(group) {
|
||||||
|
for (const child of [...group.children]) {
|
||||||
|
group.remove(child)
|
||||||
|
child.traverse?.((c) => {
|
||||||
|
if (c.geometry) c.geometry.dispose()
|
||||||
|
if (c.material) (Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildMapPlane() {
|
||||||
|
const info = this.mapInfo
|
||||||
|
if (!info || !coordsAreValid(info.coords)) return
|
||||||
|
const x0 = Math.min(info.coords.x0, info.coords.x1)
|
||||||
|
const x1 = Math.max(info.coords.x0, info.coords.x1)
|
||||||
|
const z0 = Math.min(info.coords.z0, info.coords.z1)
|
||||||
|
const z1 = Math.max(info.coords.z0, info.coords.z1)
|
||||||
|
const width = Math.max(1, (x1 - x0) * this.scale)
|
||||||
|
const depth = Math.max(1, (z1 - z0) * this.scale)
|
||||||
|
const center = this._toWorld({ x: (x0 + x1) / 2, y: this.bounds.minY, z: (z0 + z1) / 2 })
|
||||||
|
|
||||||
|
const geometry = new THREE.PlaneGeometry(width, depth, 1, 1)
|
||||||
|
this._applyPlaneUvs(geometry, info.sourceRect || { u: 0, v: 0, w: 1, h: 1 })
|
||||||
|
geometry.rotateX(-Math.PI / 2)
|
||||||
|
|
||||||
|
const material = new THREE.MeshBasicMaterial({
|
||||||
|
color: 0x18202a, transparent: true, opacity: 0.92, depthWrite: false, side: THREE.DoubleSide,
|
||||||
|
})
|
||||||
|
const plane = new THREE.Mesh(geometry, material)
|
||||||
|
plane.position.set(center.x, -0.12, center.z)
|
||||||
|
plane.renderOrder = -10
|
||||||
|
this.mapGroup.add(plane)
|
||||||
|
this._mapMaterial = material
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyPlaneUvs(geometry, rect) {
|
||||||
|
const u0 = rect.u
|
||||||
|
const u1 = rect.u + rect.w
|
||||||
|
const vTop = rect.v
|
||||||
|
const vBottom = rect.v + rect.h
|
||||||
|
const uv = geometry.attributes.uv
|
||||||
|
if (FLIP_MAP_TEXTURE_Z) {
|
||||||
|
uv.setXY(0, u0, 1 - vTop); uv.setXY(1, u1, 1 - vTop)
|
||||||
|
uv.setXY(2, u0, 1 - vBottom); uv.setXY(3, u1, 1 - vBottom)
|
||||||
|
} else {
|
||||||
|
uv.setXY(0, u0, 1 - vBottom); uv.setXY(1, u1, 1 - vBottom)
|
||||||
|
uv.setXY(2, u0, 1 - vTop); uv.setXY(3, u1, 1 - vTop)
|
||||||
|
}
|
||||||
|
uv.needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadMapTexture() {
|
||||||
|
if (!this.mapInfo.image || !this._mapMaterial) return
|
||||||
|
const material = this._mapMaterial
|
||||||
|
new THREE.TextureLoader().load(
|
||||||
|
this.mapInfo.image,
|
||||||
|
(texture) => {
|
||||||
|
if (this.disposed) { texture.dispose(); return }
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace
|
||||||
|
texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
|
||||||
|
material.map = texture
|
||||||
|
material.color.setHex(0xffffff)
|
||||||
|
material.needsUpdate = true
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
() => { material.color.setHex(0x1f2933); material.opacity = 0.6 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeVisualEntity(entity) {
|
||||||
|
const color = this._colorFor(entity)
|
||||||
|
const points = entity.path.map((p) => this._toWorld(this._renderPoint(p)))
|
||||||
|
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points)
|
||||||
|
const lineMaterial = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.42 })
|
||||||
|
const line = new THREE.Line(lineGeometry, lineMaterial)
|
||||||
|
this.pathGroup.add(line)
|
||||||
|
|
||||||
|
const radius = 4.4
|
||||||
|
let model
|
||||||
|
if (this.unitTemplate && entity.type === 'ground') {
|
||||||
|
model = this.unitTemplate.clone(true)
|
||||||
|
model.traverse((child) => {
|
||||||
|
if (!child.isMesh) return
|
||||||
|
child.material = new THREE.MeshStandardMaterial({
|
||||||
|
color, roughness: 0.72, metalness: 0.06, flatShading: true,
|
||||||
|
emissive: new THREE.Color(color).multiplyScalar(0.22),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const geo = new THREE.SphereGeometry(radius, 18, 12)
|
||||||
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
|
color, emissive: new THREE.Color(color).multiplyScalar(0.24), roughness: 0.45, metalness: 0.08,
|
||||||
|
})
|
||||||
|
model = new THREE.Mesh(geo, mat)
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrow = new THREE.ArrowHelper(
|
||||||
|
new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0),
|
||||||
|
radius * 4.6, new THREE.Color(color), radius * 1.55, radius * 0.9,
|
||||||
|
)
|
||||||
|
arrow.line.material.transparent = true
|
||||||
|
arrow.line.material.opacity = 0.78
|
||||||
|
arrow.cone.material.transparent = true
|
||||||
|
arrow.cone.material.opacity = 0.95
|
||||||
|
|
||||||
|
const group = new THREE.Group()
|
||||||
|
group.add(model, arrow)
|
||||||
|
this.markerGroup.add(group)
|
||||||
|
|
||||||
|
return { entity, line, group, model, arrow, radius, smoothedDirection: null, lastDirectionT: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async _loadTankTemplate() {
|
||||||
|
try {
|
||||||
|
const object = await new Promise((resolve, reject) => {
|
||||||
|
const loader = new OBJLoader()
|
||||||
|
loader.setPath(MODEL_PATH)
|
||||||
|
loader.load('t_34_obj.obj', resolve, undefined, reject)
|
||||||
|
})
|
||||||
|
if (this.disposed) return
|
||||||
|
|
||||||
|
const planes = []
|
||||||
|
object.traverse((child) => {
|
||||||
|
if (!child.isMesh) return
|
||||||
|
child.castShadow = false
|
||||||
|
child.receiveShadow = false
|
||||||
|
if (/^Plane/i.test(child.name || '')) { planes.push(child); return }
|
||||||
|
})
|
||||||
|
for (const plane of planes) plane.removeFromParent()
|
||||||
|
|
||||||
|
const box = new THREE.Box3()
|
||||||
|
object.traverse((child) => { if (child.isMesh) box.expandByObject(child) })
|
||||||
|
if (box.isEmpty()) box.setFromObject(object)
|
||||||
|
const size = box.getSize(new THREE.Vector3())
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z, 1)
|
||||||
|
const scale = 70 / maxDim
|
||||||
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
|
|
||||||
|
const pivot = new THREE.Group()
|
||||||
|
object.position.x -= center.x
|
||||||
|
object.position.z -= center.z
|
||||||
|
object.position.y -= box.min.y
|
||||||
|
pivot.add(object)
|
||||||
|
pivot.scale.setScalar(scale)
|
||||||
|
this.unitTemplate = pivot
|
||||||
|
|
||||||
|
// Rebuild ground entities now that the model is available.
|
||||||
|
this._buildScene()
|
||||||
|
this.setTime(this.currentT)
|
||||||
|
} catch {
|
||||||
|
// No model -> spheres are used; nothing else to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- capture zones -------------------------------------------------------
|
||||||
|
|
||||||
|
_buildCaptureZones() {
|
||||||
|
for (const zone of this.captureZones) {
|
||||||
|
const center = this._toWorld(this._renderPoint(zone.center))
|
||||||
|
const radius = Math.max(8, zone.radius * this.scale)
|
||||||
|
const group = new THREE.Group()
|
||||||
|
group.position.set(center.x, 1.05, center.z)
|
||||||
|
|
||||||
|
const ringGeo = new THREE.RingGeometry(radius * 0.92, radius, 96)
|
||||||
|
ringGeo.rotateX(-Math.PI / 2)
|
||||||
|
const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xdde6ee, transparent: true, opacity: 0.7, depthWrite: false, side: THREE.DoubleSide,
|
||||||
|
}))
|
||||||
|
ring.renderOrder = 8
|
||||||
|
|
||||||
|
const fillGeo = new THREE.CircleGeometry(radius * 0.82, 96)
|
||||||
|
fillGeo.rotateX(-Math.PI / 2)
|
||||||
|
const fill = new THREE.Mesh(fillGeo, new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xd8dee9, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide,
|
||||||
|
}))
|
||||||
|
fill.position.y = 0.02
|
||||||
|
fill.renderOrder = 7
|
||||||
|
|
||||||
|
group.add(fill, ring)
|
||||||
|
group.userData = { zone, fill }
|
||||||
|
this.zoneGroup.add(group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_captureValueAt(zone, t) {
|
||||||
|
if (!zone.cap.length) return 0
|
||||||
|
if (t <= zone.cap[0][0]) return zone.cap[0][1]
|
||||||
|
for (let i = 1; i < zone.cap.length; i++) {
|
||||||
|
const prev = zone.cap[i - 1]
|
||||||
|
const cur = zone.cap[i]
|
||||||
|
if (t <= cur[0]) {
|
||||||
|
const gap = cur[0] - prev[0]
|
||||||
|
if (gap > CAP_INTERP_MAX_GAP_MS) return prev[1]
|
||||||
|
const alpha = (t - prev[0]) / Math.max(1, gap)
|
||||||
|
return THREE.MathUtils.lerp(prev[1], cur[1], THREE.MathUtils.clamp(alpha, 0, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zone.cap[zone.cap.length - 1][1]
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateCaptureZones() {
|
||||||
|
for (const group of this.zoneGroup.children) {
|
||||||
|
const { zone, fill } = group.userData
|
||||||
|
if (!zone || !fill) continue
|
||||||
|
const value = THREE.MathUtils.clamp(this._captureValueAt(zone, this.currentT), -100, 100)
|
||||||
|
const amount = Math.abs(value) / 100
|
||||||
|
fill.visible = value !== 0
|
||||||
|
fill.material.color.set(value > 0 ? LOSE_COLOR : value < 0 ? WIN_COLOR : 0xd8dee9)
|
||||||
|
fill.material.opacity = value === 0 ? 0 : 0.18 + amount * 0.34
|
||||||
|
const s = value === 0 ? 0 : Math.sqrt(amount)
|
||||||
|
fill.scale.set(s, s, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- kill lines ----------------------------------------------------------
|
||||||
|
|
||||||
|
_buildKillLines() {
|
||||||
|
this.killLines = []
|
||||||
|
for (const k of this.kills) {
|
||||||
|
const start = this._toWorld(this._renderPoint({ x: k.killerPos.x, y: this.bounds.minY, z: k.killerPos.z }))
|
||||||
|
const end = this._toWorld(this._renderPoint({ x: k.victimPos.x, y: this.bounds.minY, z: k.victimPos.z }))
|
||||||
|
start.y += 12; end.y += 12
|
||||||
|
const vec = end.clone().sub(start)
|
||||||
|
const length = vec.length()
|
||||||
|
if (length < 0.5) continue
|
||||||
|
const color = new THREE.Color(this.players[k.killerId]?.team === this.teamWon ? WIN_COLOR : LOSE_COLOR)
|
||||||
|
const arrow = new THREE.ArrowHelper(vec.clone().normalize(), start, length, color,
|
||||||
|
Math.min(28, Math.max(10, length * 0.12)), Math.min(16, Math.max(7, length * 0.055)))
|
||||||
|
for (const m of [arrow.line.material, arrow.cone.material]) {
|
||||||
|
m.transparent = true; m.opacity = 0; m.depthTest = false; m.depthWrite = false
|
||||||
|
}
|
||||||
|
arrow.renderOrder = 18
|
||||||
|
arrow.visible = false
|
||||||
|
this.killLineGroup.add(arrow)
|
||||||
|
this.killLines.push({ time: k.time, arrow })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateKillLines() {
|
||||||
|
for (const { time, arrow } of this.killLines) {
|
||||||
|
const age = this.currentT - time
|
||||||
|
const visible = age >= 0 && age <= KILL_LINE_WINDOW_MS
|
||||||
|
arrow.visible = visible
|
||||||
|
if (!visible) continue
|
||||||
|
const opacity = 1 - age / KILL_LINE_WINDOW_MS
|
||||||
|
arrow.line.material.opacity = 0.18 + opacity * 0.62
|
||||||
|
arrow.cone.material.opacity = 0.24 + opacity * 0.72
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- interpolation -------------------------------------------------------
|
||||||
|
|
||||||
|
_findSegment(entity, t) {
|
||||||
|
const path = entity.path
|
||||||
|
if (t < entity.startT || t > entity.endT) return null
|
||||||
|
let lo = 0, hi = path.length - 1
|
||||||
|
while (lo < hi - 1) {
|
||||||
|
const mid = (lo + hi) >> 1
|
||||||
|
if (path[mid].t <= t) lo = mid; else hi = mid
|
||||||
|
}
|
||||||
|
return { index: lo, a: path[lo], b: path[Math.min(lo + 1, path.length - 1)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
_interp(entity, t) {
|
||||||
|
const seg = this._findSegment(entity, t)
|
||||||
|
if (!seg) return null
|
||||||
|
const { index, a, b } = seg
|
||||||
|
const path = entity.path
|
||||||
|
const span = Math.max(1, b.t - a.t)
|
||||||
|
const alpha = Math.min(1, Math.max(0, (t - a.t) / span))
|
||||||
|
const before = path[Math.max(0, index - 1)]
|
||||||
|
const after = path[Math.min(path.length - 1, index + 2)]
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
x: catmullRom(before.x, a.x, b.x, after.x, alpha),
|
||||||
|
y: catmullRom(before.y, a.y, b.y, after.y, alpha),
|
||||||
|
z: catmullRom(before.z, a.z, b.z, after.z, alpha),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_directionAt(entity, t) {
|
||||||
|
const beforeT = Math.max(entity.startT, t - DIRECTION_SAMPLE_MS)
|
||||||
|
const afterT = Math.min(entity.endT, t + DIRECTION_SAMPLE_MS)
|
||||||
|
if (afterT <= beforeT) return null
|
||||||
|
const before = this._interp(entity, beforeT)
|
||||||
|
const after = this._interp(entity, afterT)
|
||||||
|
if (!before || !after) return null
|
||||||
|
const dir = this._toWorld(this._renderPoint(after)).sub(this._toWorld(this._renderPoint(before)))
|
||||||
|
if (dir.lengthSq() < 0.0001) return null
|
||||||
|
return dir.normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
_blendedDirection(visual, t) {
|
||||||
|
const raw = this._directionAt(visual.entity, t)
|
||||||
|
if (!raw) return visual.smoothedDirection
|
||||||
|
const reset = visual.lastDirectionT == null || Math.abs(t - visual.lastDirectionT) > 1800
|
||||||
|
if (reset || !visual.smoothedDirection) visual.smoothedDirection = raw.clone()
|
||||||
|
else visual.smoothedDirection.lerp(raw, DIRECTION_BLEND).normalize()
|
||||||
|
visual.lastDirectionT = t
|
||||||
|
return visual.smoothedDirection
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- highlight / focus ---------------------------------------------------
|
||||||
|
|
||||||
|
_applyHighlight() {
|
||||||
|
const sel = this.selectedPlayerId
|
||||||
|
if (!this.visualEntities) return
|
||||||
|
for (const visual of this.visualEntities) {
|
||||||
|
const isSel = sel != null && visual.entity.playerId === sel
|
||||||
|
const teamColor = this._colorFor(visual.entity)
|
||||||
|
const mat = visual.line.material
|
||||||
|
if (sel == null) {
|
||||||
|
mat.opacity = 0.42; mat.color.set(teamColor); visual.line.renderOrder = 0
|
||||||
|
} else if (isSel) {
|
||||||
|
mat.opacity = 1; mat.color.set(teamColor).lerp(new THREE.Color(0xffffff), 0.35); visual.line.renderOrder = 30
|
||||||
|
} else {
|
||||||
|
mat.opacity = 0.08; mat.color.set(teamColor); visual.line.renderOrder = 0
|
||||||
|
}
|
||||||
|
if (visual.model?.isGroup) {
|
||||||
|
visual.model.traverse((child) => {
|
||||||
|
if (child.isMesh && child.material?.emissive) child.material.emissiveIntensity = isSel ? 2.6 : 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(playerId) {
|
||||||
|
this.selectedPlayerId = this.selectedPlayerId === playerId ? null : playerId
|
||||||
|
this._applyHighlight()
|
||||||
|
if (this.selectedPlayerId == null) return
|
||||||
|
const visual = (this.visualEntities || []).find((v) => v.entity.playerId === playerId && v.group.visible)
|
||||||
|
|| (this.visualEntities || []).find((v) => v.entity.playerId === playerId)
|
||||||
|
if (!visual) return
|
||||||
|
const p = this._interp(visual.entity, this.currentT)
|
||||||
|
if (!p) return
|
||||||
|
const world = this._toWorld(this._renderPoint(p))
|
||||||
|
const offset = this.camera.position.clone().sub(this.controls.target)
|
||||||
|
this.controls.target.copy(world)
|
||||||
|
this.camera.position.copy(world.clone().add(offset))
|
||||||
|
this.controls.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- public API ----------------------------------------------------------
|
||||||
|
|
||||||
|
setTime(t) {
|
||||||
|
this.currentT = t
|
||||||
|
if (!this.visualEntities) return
|
||||||
|
for (const visual of this.visualEntities) {
|
||||||
|
const p = this._interp(visual.entity, t)
|
||||||
|
visual.group.visible = Boolean(p)
|
||||||
|
if (!p) continue
|
||||||
|
visual.group.position.copy(this._toWorld(this._renderPoint(p)))
|
||||||
|
const dir = this._blendedDirection(visual, t)
|
||||||
|
visual.arrow.visible = Boolean(dir)
|
||||||
|
if (dir) {
|
||||||
|
visual.arrow.setDirection(dir)
|
||||||
|
if (visual.model?.isGroup) {
|
||||||
|
const flat = dir.clone(); flat.y = 0
|
||||||
|
if (flat.lengthSq() > 0) {
|
||||||
|
flat.normalize()
|
||||||
|
visual.model.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), flat)
|
||||||
|
visual.model.rotateY(Math.PI)
|
||||||
|
visual.model.rotateY(-Math.PI / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._updateCaptureZones()
|
||||||
|
this._updateKillLines()
|
||||||
|
}
|
||||||
|
|
||||||
|
setMode(mode) {
|
||||||
|
const next = mode === 'air' && normalizeCoords(this.data.mapCoords) ? 'air' : 'ground'
|
||||||
|
if (next === this._mode) return
|
||||||
|
this._mode = next
|
||||||
|
this.mapInfo = this._resolveMapInfo(next)
|
||||||
|
this.smoothByEntity.clear()
|
||||||
|
this._buildScene()
|
||||||
|
this._loadMapTexture()
|
||||||
|
this.setTime(this.currentT)
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
const w = this.container.clientWidth || this.container.offsetWidth || 720
|
||||||
|
const h = this.container.clientHeight || w
|
||||||
|
this.renderer.setSize(w, h, false)
|
||||||
|
this.camera.aspect = w / Math.max(1, h)
|
||||||
|
this.camera.updateProjectionMatrix()
|
||||||
|
}
|
||||||
|
|
||||||
|
_animate() {
|
||||||
|
if (this.disposed) return
|
||||||
|
this.controls.update()
|
||||||
|
this.renderer.render(this.scene, this.camera)
|
||||||
|
this._raf = requestAnimationFrame(this._animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true
|
||||||
|
if (this._raf) cancelAnimationFrame(this._raf)
|
||||||
|
this.controls?.dispose()
|
||||||
|
this._clearGroup(this.mapGroup)
|
||||||
|
this._clearGroup(this.pathGroup)
|
||||||
|
this._clearGroup(this.markerGroup)
|
||||||
|
this._clearGroup(this.zoneGroup)
|
||||||
|
this._clearGroup(this.killLineGroup)
|
||||||
|
this.renderer?.dispose()
|
||||||
|
if (this.canvasEl?.parentNode) this.canvasEl.parentNode.removeChild(this.canvasEl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ReplayCanvas3D = ReplayCanvas3D
|
||||||
@@ -53,6 +53,9 @@ class ReplayCanvas {
|
|||||||
this._teamNames = (data.teamNames && typeof data.teamNames === 'object') ? data.teamNames : {};
|
this._teamNames = (data.teamNames && typeof data.teamNames === 'object') ? data.teamNames : {};
|
||||||
this._winnerSlot = Number(data.winnerSlot) || 0; // winning team slot (1/2)
|
this._winnerSlot = Number(data.winnerSlot) || 0; // winning team slot (1/2)
|
||||||
this._mode = 'ground';
|
this._mode = 'ground';
|
||||||
|
this._viewMode = '2d';
|
||||||
|
this.view3d = null;
|
||||||
|
this.supports3d = false;
|
||||||
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
|
const hasAircraft = data.entities.some(e => e.type === 'aircraft');
|
||||||
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
|
this.hasAirMode = !!(this._airCoords && this._fullMapLevel && hasAircraft);
|
||||||
|
|
||||||
@@ -166,6 +169,9 @@ class ReplayCanvas {
|
|||||||
async init() {
|
async init() {
|
||||||
this._buildDOM();
|
this._buildDOM();
|
||||||
await Promise.all([this._loadMap(), this._loadEntityIcons()]);
|
await Promise.all([this._loadMap(), this._loadEntityIcons()]);
|
||||||
|
this._initView3d();
|
||||||
|
this._onResize = () => { if (this._viewMode === '3d') this.view3d?.resize(); };
|
||||||
|
window.addEventListener('resize', this._onResize);
|
||||||
this.playing = true;
|
this.playing = true;
|
||||||
this.playBtn.innerHTML = '<i class="fas fa-pause"></i>';
|
this.playBtn.innerHTML = '<i class="fas fa-pause"></i>';
|
||||||
this.lastFrameTime = performance.now();
|
this.lastFrameTime = performance.now();
|
||||||
@@ -173,6 +179,42 @@ class ReplayCanvas {
|
|||||||
this.animFrameId = requestAnimationFrame(this._tick);
|
this.animFrameId = requestAnimationFrame(this._tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_initView3d() {
|
||||||
|
try {
|
||||||
|
if (typeof window.ReplayCanvas3D !== 'function') return;
|
||||||
|
this.view3d = new window.ReplayCanvas3D(this.view3dContainer, this.data);
|
||||||
|
this.supports3d = true;
|
||||||
|
} catch (e) {
|
||||||
|
this.view3d = null;
|
||||||
|
this.supports3d = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderActive() {
|
||||||
|
if (this._viewMode === '3d') { this.view3d?.setTime(this.currentTime); }
|
||||||
|
else { this.render(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
setViewMode(mode) {
|
||||||
|
const next = mode === '3d' && this.view3d ? '3d' : '2d';
|
||||||
|
if (next === this._viewMode) return;
|
||||||
|
this._viewMode = next;
|
||||||
|
const is3d = next === '3d';
|
||||||
|
this.canvas.classList.toggle('rc-hidden', is3d);
|
||||||
|
this.view3dContainer.classList.toggle('rc-hidden', !is3d);
|
||||||
|
if (is3d) {
|
||||||
|
this.view3d.setMode(this._mode);
|
||||||
|
this.view3d.resize();
|
||||||
|
this.view3d.setTime(this.currentTime);
|
||||||
|
} else {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(playerId) {
|
||||||
|
if (this._viewMode === '3d') this.view3d?.focus(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
_buildDOM() {
|
_buildDOM() {
|
||||||
this.container.innerHTML = '';
|
this.container.innerHTML = '';
|
||||||
const layout = document.createElement('div');
|
const layout = document.createElement('div');
|
||||||
@@ -190,12 +232,18 @@ class ReplayCanvas {
|
|||||||
// Tickets meter above the battle view (its top aligns with the team panels)
|
// Tickets meter above the battle view (its top aligns with the team panels)
|
||||||
this._buildTicketsBar(center);
|
this._buildTicketsBar(center);
|
||||||
|
|
||||||
|
const stage = document.createElement('div');
|
||||||
|
stage.className = 'rc-stage';
|
||||||
this.canvas = document.createElement('canvas');
|
this.canvas = document.createElement('canvas');
|
||||||
this.canvas.width = this.canvasSize;
|
this.canvas.width = this.canvasSize;
|
||||||
this.canvas.height = this.canvasSize;
|
this.canvas.height = this.canvasSize;
|
||||||
this.canvas.className = 'rc-canvas';
|
this.canvas.className = 'rc-canvas';
|
||||||
this.ctx = this.canvas.getContext('2d');
|
this.ctx = this.canvas.getContext('2d');
|
||||||
center.appendChild(this.canvas);
|
stage.appendChild(this.canvas);
|
||||||
|
this.view3dContainer = document.createElement('div');
|
||||||
|
this.view3dContainer.className = 'rc-3d-container rc-hidden';
|
||||||
|
stage.appendChild(this.view3dContainer);
|
||||||
|
center.appendChild(stage);
|
||||||
|
|
||||||
// Controls
|
// Controls
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
@@ -243,7 +291,7 @@ class ReplayCanvas {
|
|||||||
this.currentTime = this.tStart + (this.scrubber.value / 1000) * (this.tEnd - this.tStart);
|
this.currentTime = this.tStart + (this.scrubber.value / 1000) * (this.tEnd - this.tStart);
|
||||||
this._updatePanelDeathStates();
|
this._updatePanelDeathStates();
|
||||||
this._updateBattleLog();
|
this._updateBattleLog();
|
||||||
this.render();
|
this._renderActive();
|
||||||
this._updateTicketsBar(this.currentTime);
|
this._updateTicketsBar(this.currentTime);
|
||||||
});
|
});
|
||||||
controls.querySelectorAll('.rc-sp').forEach(btn => {
|
controls.querySelectorAll('.rc-sp').forEach(btn => {
|
||||||
@@ -331,6 +379,7 @@ class ReplayCanvas {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
row.addEventListener('mouseleave', () => this._setHighlight(null));
|
row.addEventListener('mouseleave', () => this._setHighlight(null));
|
||||||
|
row.addEventListener('click', () => this.focus(parseInt(row.dataset.playerId)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,8 +922,9 @@ class ReplayCanvas {
|
|||||||
this._drawMapToCanvas();
|
this._drawMapToCanvas();
|
||||||
// Recompute death positions in new coordinate space
|
// Recompute death positions in new coordinate space
|
||||||
this._computeDeaths();
|
this._computeDeaths();
|
||||||
|
this.view3d?.setMode(mode);
|
||||||
// Render immediately
|
// Render immediately
|
||||||
this.render();
|
this._renderActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
_togglePlay() {
|
_togglePlay() {
|
||||||
@@ -897,7 +947,7 @@ class ReplayCanvas {
|
|||||||
this.playBtn.innerHTML = '<i class="fas fa-play"></i>';
|
this.playBtn.innerHTML = '<i class="fas fa-play"></i>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.render();
|
this._renderActive();
|
||||||
this._updateTicketsBar(this.currentTime);
|
this._updateTicketsBar(this.currentTime);
|
||||||
this._updateControls();
|
this._updateControls();
|
||||||
// Update panel death states + battle log every ~250ms
|
// Update panel death states + battle log every ~250ms
|
||||||
@@ -1145,6 +1195,9 @@ class ReplayCanvas {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.animFrameId) { cancelAnimationFrame(this.animFrameId); this.animFrameId = null; }
|
if (this.animFrameId) { cancelAnimationFrame(this.animFrameId); this.animFrameId = null; }
|
||||||
|
if (this._onResize) window.removeEventListener('resize', this._onResize);
|
||||||
|
this.view3d?.dispose();
|
||||||
|
this.view3d = null;
|
||||||
this.container.innerHTML = '';
|
this.container.innerHTML = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# 3ds Max Wavefront OBJ Exporter v0.97b - (c)2007 guruware
|
||||||
|
# File Created: 27.04.2017 18:23:39
|
||||||
|
|
||||||
|
newmtl wire_022022022
|
||||||
|
Ns 32
|
||||||
|
d 1
|
||||||
|
Tr 0
|
||||||
|
Tf 1 1 1
|
||||||
|
illum 2
|
||||||
|
Ka 0.0863 0.0863 0.0863
|
||||||
|
Kd 0.0863 0.0863 0.0863
|
||||||
|
Ks 0.3500 0.3500 0.3500
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+955
@@ -0,0 +1,955 @@
|
|||||||
|
import {
|
||||||
|
BufferGeometry,
|
||||||
|
FileLoader,
|
||||||
|
Float32BufferAttribute,
|
||||||
|
Group,
|
||||||
|
LineBasicMaterial,
|
||||||
|
LineSegments,
|
||||||
|
Loader,
|
||||||
|
Material,
|
||||||
|
Mesh,
|
||||||
|
MeshPhongMaterial,
|
||||||
|
Points,
|
||||||
|
PointsMaterial,
|
||||||
|
Vector3,
|
||||||
|
Color,
|
||||||
|
SRGBColorSpace
|
||||||
|
} from 'three';
|
||||||
|
|
||||||
|
// o object_name | g group_name
|
||||||
|
const _object_pattern = /^[og]\s*(.+)?/;
|
||||||
|
// mtllib file_reference
|
||||||
|
const _material_library_pattern = /^mtllib /;
|
||||||
|
// usemtl material_name
|
||||||
|
const _material_use_pattern = /^usemtl /;
|
||||||
|
// usemap map_name
|
||||||
|
const _map_use_pattern = /^usemap /;
|
||||||
|
const _face_vertex_data_separator_pattern = /\s+/;
|
||||||
|
|
||||||
|
const _vA = new Vector3();
|
||||||
|
const _vB = new Vector3();
|
||||||
|
const _vC = new Vector3();
|
||||||
|
|
||||||
|
const _ab = new Vector3();
|
||||||
|
const _cb = new Vector3();
|
||||||
|
|
||||||
|
const _color = new Color();
|
||||||
|
|
||||||
|
function ParserState() {
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
objects: [],
|
||||||
|
object: {},
|
||||||
|
|
||||||
|
vertices: [],
|
||||||
|
normals: [],
|
||||||
|
colors: [],
|
||||||
|
uvs: [],
|
||||||
|
|
||||||
|
materials: {},
|
||||||
|
materialLibraries: [],
|
||||||
|
|
||||||
|
startObject: function ( name, fromDeclaration ) {
|
||||||
|
|
||||||
|
// If the current object (initial from reset) is not from a g/o declaration in the parsed
|
||||||
|
// file. We need to use it for the first parsed g/o to keep things in sync.
|
||||||
|
if ( this.object && this.object.fromDeclaration === false ) {
|
||||||
|
|
||||||
|
this.object.name = name;
|
||||||
|
this.object.fromDeclaration = ( fromDeclaration !== false );
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousMaterial = ( this.object && typeof this.object.currentMaterial === 'function' ? this.object.currentMaterial() : undefined );
|
||||||
|
|
||||||
|
if ( this.object && typeof this.object._finalize === 'function' ) {
|
||||||
|
|
||||||
|
this.object._finalize( true );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
this.object = {
|
||||||
|
name: name || '',
|
||||||
|
fromDeclaration: ( fromDeclaration !== false ),
|
||||||
|
|
||||||
|
geometry: {
|
||||||
|
vertices: [],
|
||||||
|
normals: [],
|
||||||
|
colors: [],
|
||||||
|
uvs: [],
|
||||||
|
hasUVIndices: false
|
||||||
|
},
|
||||||
|
materials: [],
|
||||||
|
smooth: true,
|
||||||
|
|
||||||
|
startMaterial: function ( name, libraries ) {
|
||||||
|
|
||||||
|
const previous = this._finalize( false );
|
||||||
|
|
||||||
|
// New usemtl declaration overwrites an inherited material, except if faces were declared
|
||||||
|
// after the material, then it must be preserved for proper MultiMaterial continuation.
|
||||||
|
if ( previous && ( previous.inherited || previous.groupCount <= 0 ) ) {
|
||||||
|
|
||||||
|
this.materials.splice( previous.index, 1 );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = {
|
||||||
|
index: this.materials.length,
|
||||||
|
name: name || '',
|
||||||
|
mtllib: ( Array.isArray( libraries ) && libraries.length > 0 ? libraries[ libraries.length - 1 ] : '' ),
|
||||||
|
smooth: ( previous !== undefined ? previous.smooth : this.smooth ),
|
||||||
|
groupStart: ( previous !== undefined ? previous.groupEnd : 0 ),
|
||||||
|
groupEnd: - 1,
|
||||||
|
groupCount: - 1,
|
||||||
|
inherited: false,
|
||||||
|
|
||||||
|
clone: function ( index ) {
|
||||||
|
|
||||||
|
const cloned = {
|
||||||
|
index: ( typeof index === 'number' ? index : this.index ),
|
||||||
|
name: this.name,
|
||||||
|
mtllib: this.mtllib,
|
||||||
|
smooth: this.smooth,
|
||||||
|
groupStart: 0,
|
||||||
|
groupEnd: - 1,
|
||||||
|
groupCount: - 1,
|
||||||
|
inherited: false
|
||||||
|
};
|
||||||
|
cloned.clone = this.clone.bind( cloned );
|
||||||
|
return cloned;
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.materials.push( material );
|
||||||
|
|
||||||
|
return material;
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
currentMaterial: function () {
|
||||||
|
|
||||||
|
if ( this.materials.length > 0 ) {
|
||||||
|
|
||||||
|
return this.materials[ this.materials.length - 1 ];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
_finalize: function ( end ) {
|
||||||
|
|
||||||
|
const lastMultiMaterial = this.currentMaterial();
|
||||||
|
if ( lastMultiMaterial && lastMultiMaterial.groupEnd === - 1 ) {
|
||||||
|
|
||||||
|
lastMultiMaterial.groupEnd = this.geometry.vertices.length / 3;
|
||||||
|
lastMultiMaterial.groupCount = lastMultiMaterial.groupEnd - lastMultiMaterial.groupStart;
|
||||||
|
lastMultiMaterial.inherited = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore objects tail materials if no face declarations followed them before a new o/g started.
|
||||||
|
if ( end && this.materials.length > 1 ) {
|
||||||
|
|
||||||
|
for ( let mi = this.materials.length - 1; mi >= 0; mi -- ) {
|
||||||
|
|
||||||
|
if ( this.materials[ mi ].groupCount <= 0 ) {
|
||||||
|
|
||||||
|
this.materials.splice( mi, 1 );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee at least one empty material, this makes the creation later more straight forward.
|
||||||
|
if ( end && this.materials.length === 0 ) {
|
||||||
|
|
||||||
|
this.materials.push( {
|
||||||
|
name: '',
|
||||||
|
smooth: this.smooth
|
||||||
|
} );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastMultiMaterial;
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inherit previous objects material.
|
||||||
|
// Spec tells us that a declared material must be set to all objects until a new material is declared.
|
||||||
|
// If a usemtl declaration is encountered while this new object is being parsed, it will
|
||||||
|
// overwrite the inherited material. Exception being that there was already face declarations
|
||||||
|
// to the inherited material, then it will be preserved for proper MultiMaterial continuation.
|
||||||
|
|
||||||
|
if ( previousMaterial && previousMaterial.name && typeof previousMaterial.clone === 'function' ) {
|
||||||
|
|
||||||
|
const declared = previousMaterial.clone( 0 );
|
||||||
|
declared.inherited = true;
|
||||||
|
this.object.materials.push( declared );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
this.objects.push( this.object );
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
finalize: function () {
|
||||||
|
|
||||||
|
if ( this.object && typeof this.object._finalize === 'function' ) {
|
||||||
|
|
||||||
|
this.object._finalize( true );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
parseVertexIndex: function ( value, len ) {
|
||||||
|
|
||||||
|
const index = parseInt( value, 10 );
|
||||||
|
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
parseNormalIndex: function ( value, len ) {
|
||||||
|
|
||||||
|
const index = parseInt( value, 10 );
|
||||||
|
return ( index >= 0 ? index - 1 : index + len / 3 ) * 3;
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
parseUVIndex: function ( value, len ) {
|
||||||
|
|
||||||
|
const index = parseInt( value, 10 );
|
||||||
|
return ( index >= 0 ? index - 1 : index + len / 2 ) * 2;
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addVertex: function ( a, b, c ) {
|
||||||
|
|
||||||
|
const src = this.vertices;
|
||||||
|
const dst = this.object.geometry.vertices;
|
||||||
|
|
||||||
|
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
|
||||||
|
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
|
||||||
|
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addVertexPoint: function ( a ) {
|
||||||
|
|
||||||
|
const src = this.vertices;
|
||||||
|
const dst = this.object.geometry.vertices;
|
||||||
|
|
||||||
|
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addVertexLine: function ( a ) {
|
||||||
|
|
||||||
|
const src = this.vertices;
|
||||||
|
const dst = this.object.geometry.vertices;
|
||||||
|
|
||||||
|
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addNormal: function ( a, b, c ) {
|
||||||
|
|
||||||
|
const src = this.normals;
|
||||||
|
const dst = this.object.geometry.normals;
|
||||||
|
|
||||||
|
dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
|
||||||
|
dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
|
||||||
|
dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addFaceNormal: function ( a, b, c ) {
|
||||||
|
|
||||||
|
const src = this.vertices;
|
||||||
|
const dst = this.object.geometry.normals;
|
||||||
|
|
||||||
|
_vA.fromArray( src, a );
|
||||||
|
_vB.fromArray( src, b );
|
||||||
|
_vC.fromArray( src, c );
|
||||||
|
|
||||||
|
_cb.subVectors( _vC, _vB );
|
||||||
|
_ab.subVectors( _vA, _vB );
|
||||||
|
_cb.cross( _ab );
|
||||||
|
|
||||||
|
_cb.normalize();
|
||||||
|
|
||||||
|
dst.push( _cb.x, _cb.y, _cb.z );
|
||||||
|
dst.push( _cb.x, _cb.y, _cb.z );
|
||||||
|
dst.push( _cb.x, _cb.y, _cb.z );
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addColor: function ( a, b, c ) {
|
||||||
|
|
||||||
|
const src = this.colors;
|
||||||
|
const dst = this.object.geometry.colors;
|
||||||
|
|
||||||
|
if ( src[ a ] !== undefined ) dst.push( src[ a + 0 ], src[ a + 1 ], src[ a + 2 ] );
|
||||||
|
if ( src[ b ] !== undefined ) dst.push( src[ b + 0 ], src[ b + 1 ], src[ b + 2 ] );
|
||||||
|
if ( src[ c ] !== undefined ) dst.push( src[ c + 0 ], src[ c + 1 ], src[ c + 2 ] );
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addUV: function ( a, b, c ) {
|
||||||
|
|
||||||
|
const src = this.uvs;
|
||||||
|
const dst = this.object.geometry.uvs;
|
||||||
|
|
||||||
|
dst.push( src[ a + 0 ], src[ a + 1 ] );
|
||||||
|
dst.push( src[ b + 0 ], src[ b + 1 ] );
|
||||||
|
dst.push( src[ c + 0 ], src[ c + 1 ] );
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addDefaultUV: function () {
|
||||||
|
|
||||||
|
const dst = this.object.geometry.uvs;
|
||||||
|
|
||||||
|
dst.push( 0, 0 );
|
||||||
|
dst.push( 0, 0 );
|
||||||
|
dst.push( 0, 0 );
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addUVLine: function ( a ) {
|
||||||
|
|
||||||
|
const src = this.uvs;
|
||||||
|
const dst = this.object.geometry.uvs;
|
||||||
|
|
||||||
|
dst.push( src[ a + 0 ], src[ a + 1 ] );
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addFace: function ( a, b, c, ua, ub, uc, na, nb, nc ) {
|
||||||
|
|
||||||
|
const vLen = this.vertices.length;
|
||||||
|
|
||||||
|
let ia = this.parseVertexIndex( a, vLen );
|
||||||
|
let ib = this.parseVertexIndex( b, vLen );
|
||||||
|
let ic = this.parseVertexIndex( c, vLen );
|
||||||
|
|
||||||
|
this.addVertex( ia, ib, ic );
|
||||||
|
this.addColor( ia, ib, ic );
|
||||||
|
|
||||||
|
// normals
|
||||||
|
|
||||||
|
if ( na !== undefined && na !== '' ) {
|
||||||
|
|
||||||
|
const nLen = this.normals.length;
|
||||||
|
|
||||||
|
ia = this.parseNormalIndex( na, nLen );
|
||||||
|
ib = this.parseNormalIndex( nb, nLen );
|
||||||
|
ic = this.parseNormalIndex( nc, nLen );
|
||||||
|
|
||||||
|
this.addNormal( ia, ib, ic );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
this.addFaceNormal( ia, ib, ic );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// uvs
|
||||||
|
|
||||||
|
if ( ua !== undefined && ua !== '' ) {
|
||||||
|
|
||||||
|
const uvLen = this.uvs.length;
|
||||||
|
|
||||||
|
ia = this.parseUVIndex( ua, uvLen );
|
||||||
|
ib = this.parseUVIndex( ub, uvLen );
|
||||||
|
ic = this.parseUVIndex( uc, uvLen );
|
||||||
|
|
||||||
|
this.addUV( ia, ib, ic );
|
||||||
|
|
||||||
|
this.object.geometry.hasUVIndices = true;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// add placeholder values (for inconsistent face definitions)
|
||||||
|
|
||||||
|
this.addDefaultUV();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addPointGeometry: function ( vertices ) {
|
||||||
|
|
||||||
|
this.object.geometry.type = 'Points';
|
||||||
|
|
||||||
|
const vLen = this.vertices.length;
|
||||||
|
|
||||||
|
for ( let vi = 0, l = vertices.length; vi < l; vi ++ ) {
|
||||||
|
|
||||||
|
const index = this.parseVertexIndex( vertices[ vi ], vLen );
|
||||||
|
|
||||||
|
this.addVertexPoint( index );
|
||||||
|
this.addColor( index );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
addLineGeometry: function ( vertices, uvs ) {
|
||||||
|
|
||||||
|
this.object.geometry.type = 'Line';
|
||||||
|
|
||||||
|
const vLen = this.vertices.length;
|
||||||
|
const uvLen = this.uvs.length;
|
||||||
|
|
||||||
|
for ( let vi = 0, l = vertices.length; vi < l; vi ++ ) {
|
||||||
|
|
||||||
|
this.addVertexLine( this.parseVertexIndex( vertices[ vi ], vLen ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
for ( let uvi = 0, l = uvs.length; uvi < l; uvi ++ ) {
|
||||||
|
|
||||||
|
this.addUVLine( this.parseUVIndex( uvs[ uvi ], uvLen ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
state.startObject( '', false );
|
||||||
|
|
||||||
|
return state;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A loader for the OBJ format.
|
||||||
|
*
|
||||||
|
* The [OBJ format](https://en.wikipedia.org/wiki/Wavefront_.obj_file) is a simple data-format that
|
||||||
|
* represents 3D geometry in a human readable format as the position of each vertex, the UV position of
|
||||||
|
* each texture coordinate vertex, vertex normals, and the faces that make each polygon defined as a list
|
||||||
|
* of vertices, and texture vertices.
|
||||||
|
*
|
||||||
|
* ```js
|
||||||
|
* const loader = new OBJLoader();
|
||||||
|
* const object = await loader.loadAsync( 'models/monster.obj' );
|
||||||
|
* scene.add( object );
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @augments Loader
|
||||||
|
* @three_import import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||||
|
*/
|
||||||
|
class OBJLoader extends Loader {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new OBJ loader.
|
||||||
|
*
|
||||||
|
* @param {LoadingManager} [manager] - The loading manager.
|
||||||
|
*/
|
||||||
|
constructor( manager ) {
|
||||||
|
|
||||||
|
super( manager );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reference to a material creator.
|
||||||
|
*
|
||||||
|
* @type {?MaterialCreator}
|
||||||
|
* @default null
|
||||||
|
*/
|
||||||
|
this.materials = null;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts loading from the given URL and passes the loaded OBJ asset
|
||||||
|
* to the `onLoad()` callback.
|
||||||
|
*
|
||||||
|
* @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
|
||||||
|
* @param {function(Group)} onLoad - Executed when the loading process has been finished.
|
||||||
|
* @param {onProgressCallback} onProgress - Executed while the loading is in progress.
|
||||||
|
* @param {onErrorCallback} onError - Executed when errors occur.
|
||||||
|
*/
|
||||||
|
load( url, onLoad, onProgress, onError ) {
|
||||||
|
|
||||||
|
const scope = this;
|
||||||
|
|
||||||
|
const loader = new FileLoader( this.manager );
|
||||||
|
loader.setPath( this.path );
|
||||||
|
loader.setRequestHeader( this.requestHeader );
|
||||||
|
loader.setWithCredentials( this.withCredentials );
|
||||||
|
loader.load( url, function ( text ) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
onLoad( scope.parse( text ) );
|
||||||
|
|
||||||
|
} catch ( e ) {
|
||||||
|
|
||||||
|
if ( onError ) {
|
||||||
|
|
||||||
|
onError( e );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
console.error( e );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.manager.itemError( url );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}, onProgress, onError );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the material creator for this OBJ. This object is loaded via {@link MTLLoader}.
|
||||||
|
*
|
||||||
|
* @param {MaterialCreator} materials - An object that creates the materials for this OBJ.
|
||||||
|
* @return {OBJLoader} A reference to this loader.
|
||||||
|
*/
|
||||||
|
setMaterials( materials ) {
|
||||||
|
|
||||||
|
this.materials = materials;
|
||||||
|
|
||||||
|
return this;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the given OBJ data and returns the resulting group.
|
||||||
|
*
|
||||||
|
* @param {string} text - The raw OBJ data as a string.
|
||||||
|
* @return {Group} The parsed OBJ.
|
||||||
|
*/
|
||||||
|
parse( text ) {
|
||||||
|
|
||||||
|
const state = new ParserState();
|
||||||
|
|
||||||
|
if ( text.indexOf( '\r\n' ) !== - 1 ) {
|
||||||
|
|
||||||
|
// This is faster than String.split with regex that splits on both
|
||||||
|
text = text.replace( /\r\n/g, '\n' );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( text.indexOf( '\\\n' ) !== - 1 ) {
|
||||||
|
|
||||||
|
// join lines separated by a line continuation character (\)
|
||||||
|
text = text.replace( /\\\n/g, '' );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = text.split( '\n' );
|
||||||
|
let result = [];
|
||||||
|
|
||||||
|
for ( let i = 0, l = lines.length; i < l; i ++ ) {
|
||||||
|
|
||||||
|
const line = lines[ i ].trimStart();
|
||||||
|
|
||||||
|
if ( line.length === 0 ) continue;
|
||||||
|
|
||||||
|
const lineFirstChar = line.charAt( 0 );
|
||||||
|
|
||||||
|
// @todo invoke passed in handler if any
|
||||||
|
if ( lineFirstChar === '#' ) continue; // skip comments
|
||||||
|
|
||||||
|
if ( lineFirstChar === 'v' ) {
|
||||||
|
|
||||||
|
const data = line.split( _face_vertex_data_separator_pattern );
|
||||||
|
|
||||||
|
switch ( data[ 0 ] ) {
|
||||||
|
|
||||||
|
case 'v':
|
||||||
|
state.vertices.push(
|
||||||
|
parseFloat( data[ 1 ] ),
|
||||||
|
parseFloat( data[ 2 ] ),
|
||||||
|
parseFloat( data[ 3 ] )
|
||||||
|
);
|
||||||
|
if ( data.length >= 7 ) {
|
||||||
|
|
||||||
|
_color.setRGB(
|
||||||
|
parseFloat( data[ 4 ] ),
|
||||||
|
parseFloat( data[ 5 ] ),
|
||||||
|
parseFloat( data[ 6 ] ),
|
||||||
|
SRGBColorSpace
|
||||||
|
);
|
||||||
|
|
||||||
|
state.colors.push( _color.r, _color.g, _color.b );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// if no colors are defined, add placeholders so color and vertex indices match
|
||||||
|
|
||||||
|
state.colors.push( undefined, undefined, undefined );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'vn':
|
||||||
|
state.normals.push(
|
||||||
|
parseFloat( data[ 1 ] ),
|
||||||
|
parseFloat( data[ 2 ] ),
|
||||||
|
parseFloat( data[ 3 ] )
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'vt':
|
||||||
|
state.uvs.push(
|
||||||
|
parseFloat( data[ 1 ] ),
|
||||||
|
parseFloat( data[ 2 ] )
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if ( lineFirstChar === 'f' ) {
|
||||||
|
|
||||||
|
const lineData = line.slice( 1 ).trim();
|
||||||
|
const vertexData = lineData.split( _face_vertex_data_separator_pattern );
|
||||||
|
const faceVertices = [];
|
||||||
|
|
||||||
|
// Parse the face vertex data into an easy to work with format
|
||||||
|
|
||||||
|
for ( let j = 0, jl = vertexData.length; j < jl; j ++ ) {
|
||||||
|
|
||||||
|
const vertex = vertexData[ j ];
|
||||||
|
|
||||||
|
if ( vertex.length > 0 ) {
|
||||||
|
|
||||||
|
const vertexParts = vertex.split( '/' );
|
||||||
|
faceVertices.push( vertexParts );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw an edge between the first vertex and all subsequent vertices to form an n-gon
|
||||||
|
|
||||||
|
const v1 = faceVertices[ 0 ];
|
||||||
|
|
||||||
|
for ( let j = 1, jl = faceVertices.length - 1; j < jl; j ++ ) {
|
||||||
|
|
||||||
|
const v2 = faceVertices[ j ];
|
||||||
|
const v3 = faceVertices[ j + 1 ];
|
||||||
|
|
||||||
|
state.addFace(
|
||||||
|
v1[ 0 ], v2[ 0 ], v3[ 0 ],
|
||||||
|
v1[ 1 ], v2[ 1 ], v3[ 1 ],
|
||||||
|
v1[ 2 ], v2[ 2 ], v3[ 2 ]
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if ( lineFirstChar === 'l' ) {
|
||||||
|
|
||||||
|
const lineParts = line.substring( 1 ).trim().split( ' ' );
|
||||||
|
let lineVertices = [];
|
||||||
|
const lineUVs = [];
|
||||||
|
|
||||||
|
if ( line.indexOf( '/' ) === - 1 ) {
|
||||||
|
|
||||||
|
lineVertices = lineParts;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
for ( let li = 0, llen = lineParts.length; li < llen; li ++ ) {
|
||||||
|
|
||||||
|
const parts = lineParts[ li ].split( '/' );
|
||||||
|
|
||||||
|
if ( parts[ 0 ] !== '' ) lineVertices.push( parts[ 0 ] );
|
||||||
|
if ( parts[ 1 ] !== '' ) lineUVs.push( parts[ 1 ] );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
state.addLineGeometry( lineVertices, lineUVs );
|
||||||
|
|
||||||
|
} else if ( lineFirstChar === 'p' ) {
|
||||||
|
|
||||||
|
const lineData = line.slice( 1 ).trim();
|
||||||
|
const pointData = lineData.split( ' ' );
|
||||||
|
|
||||||
|
state.addPointGeometry( pointData );
|
||||||
|
|
||||||
|
} else if ( ( result = _object_pattern.exec( line ) ) !== null ) {
|
||||||
|
|
||||||
|
// o object_name
|
||||||
|
// or
|
||||||
|
// g group_name
|
||||||
|
|
||||||
|
// WORKAROUND: https://bugs.chromium.org/p/v8/issues/detail?id=2869
|
||||||
|
// let name = result[ 0 ].slice( 1 ).trim();
|
||||||
|
const name = ( ' ' + result[ 0 ].slice( 1 ).trim() ).slice( 1 );
|
||||||
|
|
||||||
|
state.startObject( name );
|
||||||
|
|
||||||
|
} else if ( _material_use_pattern.test( line ) ) {
|
||||||
|
|
||||||
|
// material
|
||||||
|
|
||||||
|
state.object.startMaterial( line.substring( 7 ).trim(), state.materialLibraries );
|
||||||
|
|
||||||
|
} else if ( _material_library_pattern.test( line ) ) {
|
||||||
|
|
||||||
|
// mtl file
|
||||||
|
|
||||||
|
state.materialLibraries.push( line.substring( 7 ).trim() );
|
||||||
|
|
||||||
|
} else if ( _map_use_pattern.test( line ) ) {
|
||||||
|
|
||||||
|
// the line is parsed but ignored since the loader assumes textures are defined MTL files
|
||||||
|
// (according to https://www.okino.com/conv/imp_wave.htm, 'usemap' is the old-style Wavefront texture reference method)
|
||||||
|
|
||||||
|
console.warn( 'THREE.OBJLoader: Rendering identifier "usemap" not supported. Textures must be defined in MTL files.' );
|
||||||
|
|
||||||
|
} else if ( lineFirstChar === 's' ) {
|
||||||
|
|
||||||
|
result = line.split( ' ' );
|
||||||
|
|
||||||
|
// smooth shading
|
||||||
|
|
||||||
|
// @todo Handle files that have varying smooth values for a set of faces inside one geometry,
|
||||||
|
// but does not define a usemtl for each face set.
|
||||||
|
// This should be detected and a dummy material created (later MultiMaterial and geometry groups).
|
||||||
|
// This requires some care to not create extra material on each smooth value for "normal" obj files.
|
||||||
|
// where explicit usemtl defines geometry groups.
|
||||||
|
// Example asset: examples/models/obj/cerberus/Cerberus.obj
|
||||||
|
|
||||||
|
/*
|
||||||
|
* http://paulbourke.net/dataformats/obj/
|
||||||
|
*
|
||||||
|
* From chapter "Grouping" Syntax explanation "s group_number":
|
||||||
|
* "group_number is the smoothing group number. To turn off smoothing groups, use a value of 0 or off.
|
||||||
|
* Polygonal elements use group numbers to put elements in different smoothing groups. For free-form
|
||||||
|
* surfaces, smoothing groups are either turned on or off; there is no difference between values greater
|
||||||
|
* than 0."
|
||||||
|
*/
|
||||||
|
if ( result.length > 1 ) {
|
||||||
|
|
||||||
|
const value = result[ 1 ].trim().toLowerCase();
|
||||||
|
state.object.smooth = ( value !== '0' && value !== 'off' );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// ZBrush can produce "s" lines #11707
|
||||||
|
state.object.smooth = true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = state.object.currentMaterial();
|
||||||
|
if ( material ) material.smooth = state.object.smooth;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// Handle null terminated files without exception
|
||||||
|
if ( line === '\0' ) continue;
|
||||||
|
|
||||||
|
console.warn( 'THREE.OBJLoader: Unexpected line: "' + line + '"' );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
state.finalize();
|
||||||
|
|
||||||
|
const container = new Group();
|
||||||
|
container.materialLibraries = [].concat( state.materialLibraries );
|
||||||
|
|
||||||
|
const hasPrimitives = ! ( state.objects.length === 1 && state.objects[ 0 ].geometry.vertices.length === 0 );
|
||||||
|
|
||||||
|
if ( hasPrimitives === true ) {
|
||||||
|
|
||||||
|
for ( let i = 0, l = state.objects.length; i < l; i ++ ) {
|
||||||
|
|
||||||
|
const object = state.objects[ i ];
|
||||||
|
const geometry = object.geometry;
|
||||||
|
const materials = object.materials;
|
||||||
|
const isLine = ( geometry.type === 'Line' );
|
||||||
|
const isPoints = ( geometry.type === 'Points' );
|
||||||
|
let hasVertexColors = false;
|
||||||
|
|
||||||
|
// Skip o/g line declarations that did not follow with any faces
|
||||||
|
if ( geometry.vertices.length === 0 ) continue;
|
||||||
|
|
||||||
|
const buffergeometry = new BufferGeometry();
|
||||||
|
|
||||||
|
buffergeometry.setAttribute( 'position', new Float32BufferAttribute( geometry.vertices, 3 ) );
|
||||||
|
|
||||||
|
if ( geometry.normals.length > 0 ) {
|
||||||
|
|
||||||
|
buffergeometry.setAttribute( 'normal', new Float32BufferAttribute( geometry.normals, 3 ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( geometry.colors.length > 0 ) {
|
||||||
|
|
||||||
|
hasVertexColors = true;
|
||||||
|
buffergeometry.setAttribute( 'color', new Float32BufferAttribute( geometry.colors, 3 ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( geometry.hasUVIndices === true ) {
|
||||||
|
|
||||||
|
buffergeometry.setAttribute( 'uv', new Float32BufferAttribute( geometry.uvs, 2 ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create materials
|
||||||
|
|
||||||
|
const createdMaterials = [];
|
||||||
|
|
||||||
|
for ( let mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
|
||||||
|
|
||||||
|
const sourceMaterial = materials[ mi ];
|
||||||
|
const materialHash = sourceMaterial.name + '_' + sourceMaterial.smooth + '_' + hasVertexColors;
|
||||||
|
let material = state.materials[ materialHash ];
|
||||||
|
|
||||||
|
if ( this.materials !== null ) {
|
||||||
|
|
||||||
|
material = this.materials.create( sourceMaterial.name );
|
||||||
|
|
||||||
|
// mtl etc. loaders probably can't create line materials correctly, copy properties to a line material.
|
||||||
|
if ( isLine && material && ! ( material instanceof LineBasicMaterial ) ) {
|
||||||
|
|
||||||
|
const materialLine = new LineBasicMaterial();
|
||||||
|
Material.prototype.copy.call( materialLine, material );
|
||||||
|
materialLine.color.copy( material.color );
|
||||||
|
material = materialLine;
|
||||||
|
|
||||||
|
} else if ( isPoints && material && ! ( material instanceof PointsMaterial ) ) {
|
||||||
|
|
||||||
|
const materialPoints = new PointsMaterial( { size: 10, sizeAttenuation: false } );
|
||||||
|
Material.prototype.copy.call( materialPoints, material );
|
||||||
|
materialPoints.color.copy( material.color );
|
||||||
|
materialPoints.map = material.map;
|
||||||
|
material = materialPoints;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( material === undefined ) {
|
||||||
|
|
||||||
|
if ( isLine ) {
|
||||||
|
|
||||||
|
material = new LineBasicMaterial();
|
||||||
|
|
||||||
|
} else if ( isPoints ) {
|
||||||
|
|
||||||
|
material = new PointsMaterial( { size: 1, sizeAttenuation: false } );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
material = new MeshPhongMaterial();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
material.name = sourceMaterial.name;
|
||||||
|
material.flatShading = sourceMaterial.smooth ? false : true;
|
||||||
|
material.vertexColors = hasVertexColors;
|
||||||
|
|
||||||
|
state.materials[ materialHash ] = material;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
createdMaterials.push( material );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mesh
|
||||||
|
|
||||||
|
let mesh;
|
||||||
|
|
||||||
|
if ( createdMaterials.length > 1 ) {
|
||||||
|
|
||||||
|
for ( let mi = 0, miLen = materials.length; mi < miLen; mi ++ ) {
|
||||||
|
|
||||||
|
const sourceMaterial = materials[ mi ];
|
||||||
|
buffergeometry.addGroup( sourceMaterial.groupStart, sourceMaterial.groupCount, mi );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( isLine ) {
|
||||||
|
|
||||||
|
mesh = new LineSegments( buffergeometry, createdMaterials );
|
||||||
|
|
||||||
|
} else if ( isPoints ) {
|
||||||
|
|
||||||
|
mesh = new Points( buffergeometry, createdMaterials );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
mesh = new Mesh( buffergeometry, createdMaterials );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
if ( isLine ) {
|
||||||
|
|
||||||
|
mesh = new LineSegments( buffergeometry, createdMaterials[ 0 ] );
|
||||||
|
|
||||||
|
} else if ( isPoints ) {
|
||||||
|
|
||||||
|
mesh = new Points( buffergeometry, createdMaterials[ 0 ] );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
mesh = new Mesh( buffergeometry, createdMaterials[ 0 ] );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
mesh.name = object.name;
|
||||||
|
|
||||||
|
container.add( mesh );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// if there is only the default parser state object with no geometry data, interpret data as point cloud
|
||||||
|
|
||||||
|
if ( state.vertices.length > 0 ) {
|
||||||
|
|
||||||
|
const material = new PointsMaterial( { size: 1, sizeAttenuation: false } );
|
||||||
|
|
||||||
|
const buffergeometry = new BufferGeometry();
|
||||||
|
|
||||||
|
buffergeometry.setAttribute( 'position', new Float32BufferAttribute( state.vertices, 3 ) );
|
||||||
|
|
||||||
|
if ( state.colors.length > 0 && state.colors[ 0 ] !== undefined ) {
|
||||||
|
|
||||||
|
buffergeometry.setAttribute( 'color', new Float32BufferAttribute( state.colors, 3 ) );
|
||||||
|
material.vertexColors = true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = new Points( buffergeometry, material );
|
||||||
|
container.add( points );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export { OBJLoader };
|
||||||
+19552
File diff suppressed because one or more lines are too long
@@ -488,6 +488,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.rc-stage {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
.rc-stage .rc-canvas,
|
||||||
|
.rc-stage .rc-3d-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
.rc-canvas {
|
.rc-canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -496,6 +508,23 @@
|
|||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
border: 1px solid rgba(255,255,255,0.06);
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
}
|
}
|
||||||
|
.rc-3d-container {
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0b0d10;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.rc-3d-container canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.rc-hidden { display: none !important; }
|
||||||
|
.rc-mode-btn-disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.rc-tickets {
|
.rc-tickets {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -695,6 +724,10 @@
|
|||||||
<button class="rc-mode-btn active" data-mode="ground" onclick="switchReplayMode('ground')"><i class="fas fa-crosshairs" style="margin-right: 4px;"></i><%= t('games.modeGround') %></button>
|
<button class="rc-mode-btn active" data-mode="ground" onclick="switchReplayMode('ground')"><i class="fas fa-crosshairs" style="margin-right: 4px;"></i><%= t('games.modeGround') %></button>
|
||||||
<button class="rc-mode-btn" data-mode="air" onclick="switchReplayMode('air')"><i class="fas fa-plane" style="margin-right: 4px;"></i><%= t('games.modeAir') %></button>
|
<button class="rc-mode-btn" data-mode="air" onclick="switchReplayMode('air')"><i class="fas fa-plane" style="margin-right: 4px;"></i><%= t('games.modeAir') %></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="rc-mode-toggle" id="rcViewToggle" onclick="event.stopPropagation()">
|
||||||
|
<button class="rc-mode-btn active" data-view="2d" onclick="switchReplayView('2d')">2D</button>
|
||||||
|
<button class="rc-mode-btn" data-view="3d" onclick="switchReplayView('3d')">3D</button>
|
||||||
|
</div>
|
||||||
<i class="fas fa-chevron-down" id="replayViewerToggleIcon"></i>
|
<i class="fas fa-chevron-down" id="replayViewerToggleIcon"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-body" id="replayViewerBody" style="max-height: none; padding: 1rem; font-family: inherit;">
|
<div class="log-body" id="replayViewerBody" style="max-height: none; padding: 1rem; font-family: inherit;">
|
||||||
@@ -749,6 +782,15 @@
|
|||||||
<script src="/js/api-client.js?v=3"></script>
|
<script src="/js/api-client.js?v=3"></script>
|
||||||
<script src="/js/vehicle-i18n.js"></script>
|
<script src="/js/vehicle-i18n.js"></script>
|
||||||
<script src="/js/header-search.js?v=2"></script>
|
<script src="/js/header-search.js?v=2"></script>
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "/vendor/three/three.module.js",
|
||||||
|
"three/addons/": "/vendor/three/addons/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/js/replay-canvas-3d.js?v=1"></script>
|
||||||
<script src="/js/replay-canvas.js?v=15"></script>
|
<script src="/js/replay-canvas.js?v=15"></script>
|
||||||
<script>
|
<script>
|
||||||
const sessionId = '<%= sessionId %>';
|
const sessionId = '<%= sessionId %>';
|
||||||
@@ -939,6 +981,15 @@
|
|||||||
if (replayViewer.hasAirMode) {
|
if (replayViewer.hasAirMode) {
|
||||||
document.getElementById('rcModeToggle').classList.add('visible');
|
document.getElementById('rcModeToggle').classList.add('visible');
|
||||||
}
|
}
|
||||||
|
// Always show the 2D/3D toggle; dim 3D if unsupported.
|
||||||
|
const viewToggle = document.getElementById('rcViewToggle');
|
||||||
|
viewToggle.classList.add('visible');
|
||||||
|
if (!replayViewer.supports3d) {
|
||||||
|
const btn3d = viewToggle.querySelector('[data-view="3d"]');
|
||||||
|
btn3d.disabled = true;
|
||||||
|
btn3d.classList.add('rc-mode-btn-disabled');
|
||||||
|
btn3d.title = '3D view not supported on this device';
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loading.style.display = 'block';
|
loading.style.display = 'block';
|
||||||
loading.innerHTML = '<div class="video-fallback" style="color:#ff6666;">' + (err.message || 'Unknown error') + '</div>';
|
loading.innerHTML = '<div class="video-fallback" style="color:#ff6666;">' + (err.message || 'Unknown error') + '</div>';
|
||||||
@@ -948,11 +999,20 @@
|
|||||||
function switchReplayMode(mode) {
|
function switchReplayMode(mode) {
|
||||||
if (!replayViewer) return;
|
if (!replayViewer) return;
|
||||||
replayViewer.setMode(mode);
|
replayViewer.setMode(mode);
|
||||||
document.querySelectorAll('.rc-mode-btn').forEach(btn => {
|
document.querySelectorAll('#rcModeToggle .rc-mode-btn').forEach(btn => {
|
||||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchReplayView(view) {
|
||||||
|
if (!replayViewer) return;
|
||||||
|
replayViewer.setViewMode(view);
|
||||||
|
const active = replayViewer._viewMode;
|
||||||
|
document.querySelectorAll('#rcViewToggle .rc-mode-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.view === active);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toggleLog(logId) {
|
function toggleLog(logId) {
|
||||||
const body = document.getElementById(logId + 'Body');
|
const body = document.getElementById(logId + 'Body');
|
||||||
const icon = document.getElementById(logId + 'Icon');
|
const icon = document.getElementById(logId + 'Icon');
|
||||||
|
|||||||
Reference in New Issue
Block a user