| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316 |
- import * as THREE from "../js/three.module.js"
- import initTinyUSDZ from "../js/tinyusdz.js"
- import { OrbitControls } from "../js/OrbitControls.js"
- const cosBaseUrl = 'https://swkz-1332577016.cos.ap-guangzhou.myqcloud.com/'
- const params = new URLSearchParams(window.location.search)
- const modelParam = params.get("m")
- const langParam = (params.get("lang") || "").toLowerCase()
- const locale =
- langParam.startsWith("zh") ? "zh" :
- langParam.startsWith("en") ? "en" :
- (navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en"
- const messages = {
- zh: {
- pageTitle: "归藏",
- missingModel: "缺少模型地址。示例:<br>http://127.0.0.1:8080/?m=./models/demo.usdz",
- startMeasure: "开始测量",
- finishMeasure: "结束测量",
- measureSettings: "测量设置",
- measureLines: "测量线",
- measureColorAria: "测量线颜色 {color}",
- customMeasureColor: "自定义测量线颜色",
- expandPanel: "展开面板",
- collapsePanel: "收起面板",
- noMeasures: "暂无测量线",
- delete: "删除",
- measureName: "测量 {id}",
- },
- en: {
- pageTitle: "Viewer",
- missingModel: "Missing model URL. Example:<br>http://127.0.0.1:8080/?m=./models/demo.usdz",
- startMeasure: "Start Measuring",
- finishMeasure: "Finish Measuring",
- measureSettings: "Measurement Settings",
- measureLines: "Measurements",
- measureColorAria: "Measurement line color {color}",
- customMeasureColor: "Custom measurement line color",
- expandPanel: "Expand panel",
- collapsePanel: "Collapse panel",
- noMeasures: "No measurements yet",
- delete: "Delete",
- measureName: "Measurement {id}",
- },
- }
- const t = (key, vars = {}) => {
- const template = messages[locale]?.[key] ?? messages.zh[key] ?? key
- return template.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? ""))
- }
- document.documentElement.lang = locale
- document.title = t("pageTitle")
- const isAbsoluteUrl = (value) => /^https?:\/\//i.test(value)
- const isLocalPath = (value) =>
- value.startsWith("./") ||
- value.startsWith("/") ||
- value.startsWith("../") ||
- value.includes(".usdz")
- const resolveModelPath = (value) => {
- if (!value) return ""
- if (isAbsoluteUrl(value) || isLocalPath(value)) return value
- return cosBaseUrl + value
- }
- const showMissingModelMessage = () => {
- const loadingBoxDom = document.querySelector(".loadingBox")
- loadingBoxDom.style.width = "auto"
- loadingBoxDom.style.height = "auto"
- loadingBoxDom.style.padding = "14px 18px"
- loadingBoxDom.style.borderRadius = "6px"
- loadingBoxDom.style.background = "rgba(255, 255, 255, 0.92)"
- loadingBoxDom.style.font = "14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
- loadingBoxDom.style.color = "#1f2933"
- loadingBoxDom.innerHTML = t("missingModel")
- }
- const measureUnits = {
- mm: { factor: 1000, precision: 0 },
- cm: { factor: 100, precision: 1 },
- m: { factor: 1, precision: 3 },
- }
- const measureColors = ["#ffd166", "#30e3f2", "#76e36b", "#ff6b6b", "#ffffff"]
- const trimNumber = (value) => value.replace(/\.?0+$/, "")
- const formatMeasureDistance = (meters, unit) => {
- const config = measureUnits[unit]
- return `${trimNumber((meters * config.factor).toFixed(config.precision))} ${unit}`
- }
- const createMeasureUI = () => {
- const style = document.createElement("style")
- style.textContent = `
- .measure-toggle {
- position: fixed;
- top: 16px;
- left: 16px;
- z-index: 20;
- height: 40px;
- padding: 0 14px;
- border: 1px solid rgba(255, 255, 255, 0.36);
- border-radius: 8px;
- background: rgba(20, 26, 34, 0.78);
- color: #fff;
- font: 14px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- cursor: pointer;
- box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
- backdrop-filter: blur(10px);
- }
- .measure-toggle.is-active {
- background: rgba(12, 113, 126, 0.9);
- border-color: rgba(148, 235, 245, 0.8);
- }
- .measure-units {
- position: fixed;
- top: 66px;
- left: 16px;
- z-index: 20;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 8px;
- padding: 8px;
- border: 1px solid rgba(255, 255, 255, 0.3);
- border-radius: 8px;
- background: rgba(20, 26, 34, 0.74);
- box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
- backdrop-filter: blur(10px);
- transition:
- opacity 0.2s ease,
- transform 0.2s ease,
- visibility 0.2s ease;
- }
- .measure-unit-row,
- .measure-color-row {
- display: flex;
- align-items: center;
- gap: 4px;
- min-height: 32px;
- }
- .measure-section-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- width: 100%;
- }
- .measure-section-title {
- color: rgba(255, 255, 255, 0.9);
- font-size: 12px;
- font-weight: 600;
- }
- .measure-section-body {
- display: flex;
- flex-direction: column;
- gap: 8px;
- width: 100%;
- }
- .measure-unit {
- width: 46px;
- height: 32px;
- border: 0;
- border-radius: 6px;
- background: transparent;
- color: rgba(255, 255, 255, 0.82);
- font: 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- cursor: pointer;
- }
- .measure-unit.is-active {
- background: #fff;
- color: #15202b;
- font-weight: 600;
- }
- .measure-color {
- width: 28px;
- height: 28px;
- border: 2px solid transparent;
- border-radius: 50%;
- background: var(--measure-color);
- cursor: pointer;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.28);
- }
- .measure-color.is-active {
- border-color: #fff;
- }
- .measure-custom-color {
- width: 30px;
- height: 30px;
- padding: 0;
- border: 2px solid transparent;
- border-radius: 50%;
- background: transparent;
- cursor: pointer;
- overflow: hidden;
- }
- .measure-custom-color.is-active {
- border-color: #fff;
- }
- .measure-custom-color::-webkit-color-swatch-wrapper {
- padding: 0;
- }
- .measure-custom-color::-webkit-color-swatch {
- border: 0;
- border-radius: 50%;
- }
- .measure-label-layer {
- position: fixed;
- inset: 0;
- z-index: 15;
- overflow: hidden;
- pointer-events: none;
- }
- .measure-label {
- position: absolute;
- min-width: 54px;
- padding: 4px 7px;
- border: 1px solid rgba(255, 255, 255, 0.36);
- border-radius: 4px;
- background: rgba(13, 18, 24, 0.82);
- color: #fff;
- font: 12px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- text-align: center;
- white-space: nowrap;
- transform: translate(-50%, -50%);
- box-shadow: 0 5px 14px rgba(0, 0, 0, 0.24);
- }
- .measure-start-marker {
- position: absolute;
- width: 16px;
- height: 16px;
- border: 2px solid rgba(255, 255, 255, 0.95);
- border-radius: 50%;
- background: rgba(48, 227, 242, 0.38);
- transform: translate(-50%, -50%);
- box-shadow:
- 0 0 0 0 rgba(48, 227, 242, 0.55),
- 0 0 12px rgba(48, 227, 242, 0.48);
- animation: measure-start-pulse 1.1s ease-out infinite;
- display: none;
- }
- @keyframes measure-start-pulse {
- 0% {
- box-shadow:
- 0 0 0 0 rgba(48, 227, 242, 0.55),
- 0 0 12px rgba(48, 227, 242, 0.48);
- transform: translate(-50%, -50%) scale(0.92);
- }
- 70% {
- box-shadow:
- 0 0 0 10px rgba(48, 227, 242, 0),
- 0 0 18px rgba(48, 227, 242, 0.28);
- transform: translate(-50%, -50%) scale(1.05);
- }
- 100% {
- box-shadow:
- 0 0 0 0 rgba(48, 227, 242, 0),
- 0 0 12px rgba(48, 227, 242, 0.18);
- transform: translate(-50%, -50%) scale(0.92);
- }
- }
- .measure-panel {
- position: fixed;
- top: 16px;
- right: 16px;
- z-index: 20;
- width: 250px;
- max-height: calc(100vh - 32px);
- padding: 10px;
- border: 1px solid rgba(255, 255, 255, 0.28);
- border-radius: 8px;
- background: rgba(20, 26, 34, 0.76);
- color: #fff;
- box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
- backdrop-filter: blur(10px);
- overflow: auto;
- font: 13px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- transition:
- opacity 0.2s ease,
- transform 0.2s ease,
- visibility 0.2s ease;
- }
- .measure-floating.is-hidden {
- opacity: 0;
- visibility: hidden;
- pointer-events: none;
- transform: translateY(-8px);
- }
- .measure-floating.is-collapsed {
- gap: 0;
- }
- .measure-floating.is-collapsed .measure-section-body {
- display: none;
- }
- .measure-panel-title {
- color: rgba(255, 255, 255, 0.9);
- font-weight: 600;
- }
- .measure-empty {
- padding: 8px 0 2px;
- color: rgba(255, 255, 255, 0.58);
- }
- .measure-list {
- display: flex;
- flex-direction: column;
- gap: 7px;
- }
- .measure-item {
- display: grid;
- grid-template-columns: auto 1fr auto;
- gap: 6px;
- align-items: center;
- min-height: 38px;
- padding: 7px;
- border: 1px solid rgba(255, 255, 255, 0.16);
- border-radius: 6px;
- background: rgba(255, 255, 255, 0.08);
- cursor: pointer;
- transition:
- border-color 0.2s ease,
- background 0.2s ease,
- box-shadow 0.2s ease;
- }
- .measure-item:hover {
- background: rgba(255, 255, 255, 0.12);
- }
- .measure-item.is-active {
- border-color: rgba(255, 255, 255, 0.36);
- background: rgba(255, 255, 255, 0.16);
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
- }
- .measure-item-color {
- width: 10px;
- height: 30px;
- border-radius: 5px;
- background: var(--measure-color);
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.28);
- }
- .measure-item-main {
- min-width: 0;
- }
- .measure-item-name {
- color: rgba(255, 255, 255, 0.72);
- font-size: 12px;
- }
- .measure-item-distance {
- margin-top: 2px;
- overflow: hidden;
- color: #fff;
- font-weight: 600;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .measure-action {
- height: 28px;
- min-width: 42px;
- padding: 0 8px;
- border: 1px solid rgba(255, 255, 255, 0.22);
- border-radius: 5px;
- background: rgba(255, 255, 255, 0.1);
- color: #fff;
- font: 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- cursor: pointer;
- }
- .measure-action:hover {
- background: rgba(255, 255, 255, 0.18);
- }
- .measure-action.is-danger {
- border-color: rgba(255, 135, 135, 0.4);
- color: #ffd5d5;
- }
- .measure-collapse {
- height: 26px;
- width: 26px;
- padding: 0;
- border: 1px solid rgba(255, 255, 255, 0.2);
- border-radius: 5px;
- background: rgba(255, 255, 255, 0.08);
- color: rgba(255, 255, 255, 0.82);
- font: 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- cursor: pointer;
- flex-shrink: 0;
- }
- .measure-collapse:hover {
- background: rgba(255, 255, 255, 0.14);
- }
- @media (max-width: 640px) {
- .measure-toggle {
- top: calc(env(safe-area-inset-top, 0px) + clamp(112px, 12vh + 16px, 148px));
- left: 12px;
- max-width: calc(100vw - 24px);
- }
- .measure-units {
- top: calc(env(safe-area-inset-top, 0px) + clamp(166px, 12vh + 70px, 202px));
- left: 12px;
- right: 12px;
- gap: 6px;
- padding: 7px;
- }
- .measure-unit-row,
- .measure-color-row {
- flex-wrap: wrap;
- }
- .measure-unit {
- flex: 1 1 46px;
- }
- .measure-panel {
- left: 12px;
- right: 12px;
- top: auto;
- bottom: 12px;
- width: auto;
- max-height: min(34vh, 240px);
- }
- }
- `
- document.head.appendChild(style)
- const toggleButton = document.createElement("button")
- toggleButton.type = "button"
- toggleButton.className = "measure-toggle"
- toggleButton.textContent = t("startMeasure")
- document.body.appendChild(toggleButton)
- const unitBar = document.createElement("div")
- unitBar.className = "measure-units measure-floating is-hidden"
- document.body.appendChild(unitBar)
- const unitHeader = document.createElement("div")
- unitHeader.className = "measure-section-header"
- unitBar.appendChild(unitHeader)
- const unitTitle = document.createElement("div")
- unitTitle.className = "measure-section-title"
- unitTitle.textContent = t("measureSettings")
- unitHeader.appendChild(unitTitle)
- const unitCollapseButton = document.createElement("button")
- unitCollapseButton.type = "button"
- unitCollapseButton.className = "measure-collapse"
- unitHeader.appendChild(unitCollapseButton)
- const unitBody = document.createElement("div")
- unitBody.className = "measure-section-body"
- unitBar.appendChild(unitBody)
- const unitRow = document.createElement("div")
- unitRow.className = "measure-unit-row"
- unitBody.appendChild(unitRow)
- const unitButtons = {}
- Object.keys(measureUnits).forEach((unit) => {
- const button = document.createElement("button")
- button.type = "button"
- button.className = "measure-unit"
- button.textContent = unit
- unitRow.appendChild(button)
- unitButtons[unit] = button
- })
- const colorRow = document.createElement("div")
- colorRow.className = "measure-color-row"
- unitBody.appendChild(colorRow)
- const colorButtons = {}
- measureColors.forEach((color) => {
- const button = document.createElement("button")
- button.type = "button"
- button.className = "measure-color"
- button.style.setProperty("--measure-color", color)
- button.setAttribute("aria-label", t("measureColorAria", { color }))
- colorRow.appendChild(button)
- colorButtons[color] = button
- })
- const customColorInput = document.createElement("input")
- customColorInput.type = "color"
- customColorInput.className = "measure-custom-color"
- customColorInput.value = measureColors[0]
- customColorInput.setAttribute("aria-label", t("customMeasureColor"))
- colorRow.appendChild(customColorInput)
- const labelLayer = document.createElement("div")
- labelLayer.className = "measure-label-layer"
- document.body.appendChild(labelLayer)
- const startMarker = document.createElement("div")
- startMarker.className = "measure-start-marker"
- labelLayer.appendChild(startMarker)
- const panel = document.createElement("div")
- panel.className = "measure-panel measure-floating is-hidden"
- panel.innerHTML = `
- <div class="measure-section-header">
- <div class="measure-panel-title">${t("measureLines")}</div>
- <button type="button" class="measure-collapse"></button>
- </div>
- <div class="measure-section-body">
- <div class="measure-list"></div>
- </div>
- `
- document.body.appendChild(panel)
- const panelCollapseButton = panel.querySelector(".measure-collapse")
- const setSectionCollapsed = (element, button, collapsed) => {
- element.classList.toggle("is-collapsed", collapsed)
- button.textContent = collapsed ? "▼" : "▲"
- button.setAttribute("aria-expanded", String(!collapsed))
- button.setAttribute("aria-label", collapsed ? t("expandPanel") : t("collapsePanel"))
- }
- unitCollapseButton.addEventListener("click", () => {
- setSectionCollapsed(
- unitBar,
- unitCollapseButton,
- !unitBar.classList.contains("is-collapsed")
- )
- })
- panelCollapseButton.addEventListener("click", () => {
- setSectionCollapsed(
- panel,
- panelCollapseButton,
- !panel.classList.contains("is-collapsed")
- )
- })
- const setActiveUnit = (unit) => {
- Object.entries(unitButtons).forEach(([buttonUnit, button]) => {
- button.classList.toggle("is-active", buttonUnit === unit)
- })
- }
- const setActiveColor = (color) => {
- Object.entries(colorButtons).forEach(([buttonColor, button]) => {
- button.classList.toggle("is-active", buttonColor === color)
- })
- customColorInput.classList.toggle("is-active", !measureColors.includes(color))
- customColorInput.value = color
- }
- const setControlsVisible = (visible) => {
- unitBar.classList.toggle("is-hidden", !visible)
- panel.classList.toggle("is-hidden", !visible)
- }
- setSectionCollapsed(unitBar, unitCollapseButton, false)
- setSectionCollapsed(panel, panelCollapseButton, window.matchMedia("(max-width: 640px)").matches)
- return {
- colorButtons,
- customColorInput,
- labelLayer,
- list: panel.querySelector(".measure-list"),
- panel,
- setControlsVisible,
- startMarker,
- toggleButton,
- unitBar,
- unitButtons,
- setActiveColor,
- setActiveUnit,
- }
- }
- const initMeasureTool = ({ scene, camera, renderer, target, modelSize }) => {
- const ui = createMeasureUI()
- const raycaster = new THREE.Raycaster()
- const pointer = new THREE.Vector2()
- const measures = []
- const dashSize = Math.max(modelSize * 0.004, 0.005)
- const gapSize = Math.max(modelSize * 0.0025, 0.003)
- const dashRadius = Math.max(modelSize * 0.001125, 0.001375)
- const labelGap = 10
- const minLabelOffset = 18
- const highlightDuration = 1600
- let active = false
- let currentColor = measureColors[0]
- let currentUnit = "m"
- let pendingStart = null
- let previewMeasure = null
- let clickStart = null
- let nextMeasureId = 1
- let selectedMeasureId = null
- ui.setActiveUnit(currentUnit)
- ui.setActiveColor(currentColor)
- ui.setControlsVisible(false)
- const createLabel = () => {
- const label = document.createElement("div")
- label.className = "measure-label"
- ui.labelLayer.appendChild(label)
- return label
- }
- const clearLineMeshes = (line) => {
- while (line.children.length) {
- const mesh = line.children.pop()
- mesh.geometry.dispose()
- line.remove(mesh)
- }
- }
- const createMeasureLine = (color, isPreview) => {
- const material = new THREE.MeshBasicMaterial({
- color,
- depthTest: false,
- transparent: true,
- opacity: isPreview ? 0.95 : 1,
- })
- const line = new THREE.Group()
- line.userData.material = material
- line.renderOrder = 10
- scene.add(line)
- return line
- }
- const setMeasureColor = (measure, color) => {
- measure.color = color
- measure.line.userData.material.color.set(color)
- }
- const setMeasureOpacity = (measure, opacity) => {
- measure.line.userData.material.opacity = opacity
- }
- const setLinePoints = (line, start, end) => {
- clearLineMeshes(line)
- const direction = end.clone().sub(start)
- const totalLength = direction.length()
- if (totalLength < 0.000001) return
- direction.normalize()
- const rotation = new THREE.Quaternion().setFromUnitVectors(
- new THREE.Vector3(0, 1, 0),
- direction
- )
- const stepSize = dashSize + gapSize
- for (let offset = 0; offset < totalLength; offset += stepSize) {
- const segmentLength = Math.min(dashSize, totalLength - offset)
- if (segmentLength <= 0) break
- const center = start
- .clone()
- .addScaledVector(direction, offset + segmentLength / 2)
- const geometry = new THREE.CylinderGeometry(
- dashRadius,
- dashRadius,
- segmentLength,
- 12
- )
- const mesh = new THREE.Mesh(geometry, line.userData.material)
- mesh.position.copy(center)
- mesh.quaternion.copy(rotation)
- mesh.renderOrder = 10
- line.add(mesh)
- }
- }
- const getModelDistance = (start, end) => {
- const localStart = target.worldToLocal(start.clone())
- const localEnd = target.worldToLocal(end.clone())
- return localStart.distanceTo(localEnd)
- }
- const getMidpoint = (start, end) => start.clone().add(end).multiplyScalar(0.5)
- const refreshMeasureText = (measure) => {
- measure.label.textContent = formatMeasureDistance(measure.distance, currentUnit)
- }
- const disposeMeasure = (measure) => {
- scene.remove(measure.line)
- clearLineMeshes(measure.line)
- measure.line.userData.material.dispose()
- measure.label.remove()
- }
- const clearPreview = () => {
- if (!previewMeasure) return
- disposeMeasure(previewMeasure)
- previewMeasure = null
- }
- const renderMeasureList = () => {
- ui.list.innerHTML = ""
- if (!measures.length) {
- const empty = document.createElement("div")
- empty.className = "measure-empty"
- empty.textContent = t("noMeasures")
- ui.list.appendChild(empty)
- return
- }
- measures.forEach((measure) => {
- const item = document.createElement("div")
- item.className = "measure-item"
- item.classList.toggle("is-active", measure.id === selectedMeasureId)
- item.style.setProperty("--measure-color", measure.color)
- const colorMark = document.createElement("div")
- colorMark.className = "measure-item-color"
- const main = document.createElement("div")
- main.className = "measure-item-main"
- const name = document.createElement("div")
- name.className = "measure-item-name"
- name.textContent = measure.name
- const distance = document.createElement("div")
- distance.className = "measure-item-distance"
- distance.textContent = formatMeasureDistance(measure.distance, currentUnit)
- main.appendChild(name)
- main.appendChild(distance)
- const deleteButton = document.createElement("button")
- deleteButton.type = "button"
- deleteButton.className = "measure-action is-danger"
- deleteButton.textContent = t("delete")
- deleteButton.addEventListener("click", (event) => {
- event.stopPropagation()
- removeSavedMeasure(measure)
- })
- item.addEventListener("click", () => {
- focusMeasure(measure)
- })
- item.appendChild(colorMark)
- item.appendChild(main)
- item.appendChild(deleteButton)
- ui.list.appendChild(item)
- })
- }
- const removeSavedMeasure = (measure) => {
- const index = measures.indexOf(measure)
- if (index === -1) return
- measures.splice(index, 1)
- if (selectedMeasureId === measure.id) selectedMeasureId = null
- disposeMeasure(measure)
- renderMeasureList()
- }
- const focusMeasure = (measure) => {
- selectedMeasureId = measure.id
- measure.highlightUntil = performance.now() + highlightDuration
- renderMeasureList()
- }
- const updatePreview = (end) => {
- if (!pendingStart) return
- if (!previewMeasure) {
- previewMeasure = {
- color: currentColor,
- line: createMeasureLine(currentColor, true),
- label: createLabel(),
- start: pendingStart.clone(),
- end: end.clone(),
- midpoint: new THREE.Vector3(),
- distance: 0,
- baseOpacity: 0.95,
- highlightUntil: 0,
- }
- }
- setLinePoints(previewMeasure.line, pendingStart, end)
- setMeasureColor(previewMeasure, currentColor)
- previewMeasure.start.copy(pendingStart)
- previewMeasure.end.copy(end)
- previewMeasure.midpoint.copy(getMidpoint(pendingStart, end))
- previewMeasure.distance = getModelDistance(pendingStart, end)
- refreshMeasureText(previewMeasure)
- }
- const addMeasure = (start, end) => {
- const measure = {
- id: nextMeasureId,
- name: t("measureName", { id: nextMeasureId }),
- color: currentColor,
- line: createMeasureLine(currentColor, false),
- label: createLabel(),
- start: start.clone(),
- end: end.clone(),
- midpoint: getMidpoint(start, end),
- distance: getModelDistance(start, end),
- baseOpacity: 1,
- highlightUntil: 0,
- }
- nextMeasureId += 1
- setLinePoints(measure.line, start, end)
- refreshMeasureText(measure)
- measures.push(measure)
- renderMeasureList()
- }
- const getHitPoint = (clientX, clientY) => {
- const rect = renderer.domElement.getBoundingClientRect()
- pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1
- pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1
- raycaster.setFromCamera(pointer, camera)
- const hits = raycaster.intersectObject(target, true)
- if (!hits.length) return null
- return hits[0].point.clone()
- }
- const setActive = (nextActive) => {
- active = nextActive
- pendingStart = null
- clearPreview()
- renderer.domElement.style.cursor = active ? "crosshair" : ""
- ui.toggleButton.classList.toggle("is-active", active)
- ui.toggleButton.textContent = active ? t("finishMeasure") : t("startMeasure")
- ui.setControlsVisible(active)
- renderMeasureList()
- }
- const updateAllMeasureText = () => {
- measures.forEach(refreshMeasureText)
- if (previewMeasure) refreshMeasureText(previewMeasure)
- renderMeasureList()
- }
- const handleMeasurePoint = (point) => {
- if (!pendingStart) {
- pendingStart = point
- clearPreview()
- return
- }
- if (pendingStart.distanceTo(point) < 0.000001) return
- addMeasure(pendingStart, point)
- pendingStart = null
- clearPreview()
- renderMeasureList()
- }
- renderer.domElement.addEventListener("pointerdown", (event) => {
- if (!active || event.button !== 0) return
- clickStart = {
- pointerId: event.pointerId,
- x: event.clientX,
- y: event.clientY,
- }
- })
- renderer.domElement.addEventListener("pointerup", (event) => {
- if (!active || !clickStart || clickStart.pointerId !== event.pointerId) return
- const moveDistance = Math.hypot(event.clientX - clickStart.x, event.clientY - clickStart.y)
- clickStart = null
- if (moveDistance > 6) return
- const point = getHitPoint(event.clientX, event.clientY)
- if (point) handleMeasurePoint(point)
- })
- renderer.domElement.addEventListener("pointermove", (event) => {
- if (!active || !pendingStart) return
- const point = getHitPoint(event.clientX, event.clientY)
- if (point) updatePreview(point)
- })
- ui.toggleButton.addEventListener("click", () => {
- setActive(!active)
- })
- Object.entries(ui.unitButtons).forEach(([unit, button]) => {
- button.addEventListener("click", () => {
- currentUnit = unit
- ui.setActiveUnit(unit)
- updateAllMeasureText()
- })
- })
- Object.entries(ui.colorButtons).forEach(([color, button]) => {
- button.addEventListener("click", () => {
- currentColor = color
- ui.setActiveColor(color)
- measures.forEach((measure) => setMeasureColor(measure, color))
- if (previewMeasure) setMeasureColor(previewMeasure, color)
- renderMeasureList()
- })
- })
- ui.customColorInput.addEventListener("input", () => {
- currentColor = ui.customColorInput.value
- ui.setActiveColor(currentColor)
- measures.forEach((measure) => setMeasureColor(measure, currentColor))
- if (previewMeasure) setMeasureColor(previewMeasure, currentColor)
- renderMeasureList()
- })
- renderMeasureList()
- const updateMeasureHighlights = () => {
- const now = performance.now()
- const visibleMeasures = previewMeasure ? [...measures, previewMeasure] : measures
- visibleMeasures.forEach((measure) => {
- const remaining = Math.max(0, (measure.highlightUntil || 0) - now)
- if (!remaining) {
- setMeasureOpacity(measure, measure.baseOpacity)
- measure.label.style.transform = "translate(-50%, -50%) scale(1)"
- return
- }
- const progress = 1 - remaining / highlightDuration
- const pulse = 0.5 + 0.5 * Math.sin(progress * Math.PI * 8)
- const opacityScale = 0.58 + pulse * 0.42
- setMeasureOpacity(measure, measure.baseOpacity * opacityScale)
- const scale = 1 + pulse * 0.12
- measure.label.style.transform = `translate(-50%, -50%) scale(${scale})`
- })
- }
- const updateLabelPositions = () => {
- updateMeasureHighlights()
- const rect = renderer.domElement.getBoundingClientRect()
- const visibleMeasures = previewMeasure ? [...measures, previewMeasure] : measures
- if (!active || !pendingStart) {
- ui.startMarker.style.display = "none"
- } else {
- const startPoint = pendingStart.clone().project(camera)
- const isVisible = startPoint.z >= -1 && startPoint.z <= 1
- ui.startMarker.style.display = isVisible ? "block" : "none"
- if (isVisible) {
- const x = rect.left + (startPoint.x * 0.5 + 0.5) * rect.width
- const y = rect.top + (-startPoint.y * 0.5 + 0.5) * rect.height
- ui.startMarker.style.left = `${x}px`
- ui.startMarker.style.top = `${y}px`
- }
- }
- visibleMeasures.forEach((measure) => {
- const startPoint = measure.start.clone().project(camera)
- const endPoint = measure.end.clone().project(camera)
- const midpoint = measure.midpoint.clone().project(camera)
- const isVisible =
- midpoint.z >= -1 &&
- midpoint.z <= 1 &&
- startPoint.z >= -1 &&
- startPoint.z <= 1 &&
- endPoint.z >= -1 &&
- endPoint.z <= 1
- measure.label.style.display = isVisible ? "block" : "none"
- if (!isVisible) return
- const startX = rect.left + (startPoint.x * 0.5 + 0.5) * rect.width
- const startY = rect.top + (-startPoint.y * 0.5 + 0.5) * rect.height
- const endX = rect.left + (endPoint.x * 0.5 + 0.5) * rect.width
- const endY = rect.top + (-endPoint.y * 0.5 + 0.5) * rect.height
- const midX = rect.left + (midpoint.x * 0.5 + 0.5) * rect.width
- const midY = rect.top + (-midpoint.y * 0.5 + 0.5) * rect.height
- const lineX = endX - startX
- const lineY = endY - startY
- const lineLength = Math.hypot(lineX, lineY)
- let normalX = 0
- let normalY = -1
- if (lineLength > 0.001) {
- normalX = -lineY / lineLength
- normalY = lineX / lineLength
- if (normalY > 0) {
- normalX *= -1
- normalY *= -1
- }
- }
- const labelRect = measure.label.getBoundingClientRect()
- const labelHalfSizeAlongNormal =
- Math.abs(normalX) * labelRect.width / 2 +
- Math.abs(normalY) * labelRect.height / 2
- const offsetDistance = Math.max(minLabelOffset, labelHalfSizeAlongNormal + labelGap)
- const offsetX = normalX * offsetDistance
- const offsetY = normalY * offsetDistance
- const x = midX + offsetX
- const y = midY + offsetY
- measure.label.style.left = `${x}px`
- measure.label.style.top = `${y}px`
- })
- }
- return {
- update: updateLabelPositions,
- }
- }
- // 测试打开这个链接
- // const USDZ_FILEPATH = `https://swkz-1332577016.cos.ap-guangzhou.myqcloud.com/kanzhan/2025/08/13/e8aecd235d7d40e49301b901c6c00d51`;
- // 正式打开这个链接
- const USDZ_FILEPATH = resolveModelPath(modelParam)
- document.addEventListener("DOMContentLoaded", async () => {
- if (!USDZ_FILEPATH) {
- showMissingModelMessage()
- return
- }
- const loadingBar = document.getElementById("loading") // 获取加载条元素
- loadingBar.style.display = "block" // 显示加载条
- const usd_res = await fetch(USDZ_FILEPATH)
- const totalBytes = parseInt(usd_res.headers.get("Content-Length"), 10) // 获取总字节数
- const reader = usd_res.body.getReader() // 获取读取器
- const chunks = [] // 存储数据块
- let receivedLength = 0 // 已接收字节数
- let timeOut = -1
- // 更新加载条的函数
- const updateLoadingBar = (loaded) => {
- let percentage = (loaded / totalBytes) * 100
- if (percentage >= 100) {
- percentage = 100
- // 隐藏加载条
- clearTimeout(timeOut)
- timeOut = setTimeout(() => {
- const loadingBoxDom = document.querySelector(".loadingBox")
- loadingBoxDom.style.opacity = 0
- loadingBoxDom.style.pointerEvents = "none"
- }, 300)
- }
- loadingBar.style.width = `${percentage}%` // 更新加载条宽度
- }
- // 读取数据流
- while (true) {
- const { done, value } = await reader.read() // 读取数据
- if (done) break // 如果读取完成,退出循环
- chunks.push(value) // 存储数据块
- receivedLength += value.length // 更新已接收字节数
- updateLoadingBar(receivedLength) // 更新加载条
- }
- const usd_data = new Uint8Array(receivedLength) // 创建最终数据数组
- let position = 0
- for (const chunk of chunks) {
- usd_data.set(chunk, position) // 将数据块写入最终数组
- position += chunk.length // 更新位置
- }
- // const usd_binary = new Uint8Array(usd_data);
- // 加载 TinyUSDZ
- initTinyUSDZ().then(function (TinyUSDZLoader) {
- const usd = new TinyUSDZLoader.TinyUSDZLoader(usd_data)
- // console.log(usd.numMeshes());
- // 隐藏加载条
- loadingBar.style.display = "none"
- const scene = new THREE.Scene()
- // 添加渐变背景
- const bgTexture = new THREE.TextureLoader().load('./bg.png')
- scene.background = bgTexture
- const camera = new THREE.PerspectiveCamera(
- 85,
- window.innerWidth / window.innerHeight,
- 0.1,
- 1000
- )
- const renderer = new THREE.WebGLRenderer({
- alpha: true, // 关键配置,设置背景透明
- antialias: true, // 可选抗锯齿
- })
- renderer.setClearColor(0x000000, 1) // 第二个参数为透明度(0表示完全透明)
- renderer.setSize(window.innerWidth, window.innerHeight)
- renderer.setAnimationLoop(animate)
- // 设置输出颜色空间为线性SRGB
- renderer.outputColorSpace = THREE.LinearSRGBColorSpace
- document.body.appendChild(renderer.domElement)
- // First mesh only
- const mesh = usd.getMesh(0)
- //console.log("usd", usd)
- //console.log("mesh", mesh);
- //const geometry = new THREE.BoxGeometry( 1, 1, 1 );
- const geometry = new THREE.BufferGeometry()
- geometry.setAttribute(
- "position",
- new THREE.BufferAttribute(mesh.points, 3)
- )
- // TODO: set normal from mesh
- if (mesh.hasOwnProperty("texcoords")) {
- // console.log(mesh.texcoords);
- geometry.setAttribute("uv", new THREE.BufferAttribute(mesh.texcoords, 2))
- }
- const usdMaterial = usd.getMaterial(mesh.materialId)
- //console.log("usdMat", usdMaterial);
- //if (usdMaterial.aaa) {
- // console.log("aaa");
- //}
- var material
- if (usdMaterial.hasOwnProperty("diffuseColorTextureId")) {
- const diffTex = usd.getTexture(usdMaterial.diffuseColorTextureId)
- const img = usd.getImage(diffTex.textureImageId)
- // console.log(img);
- // assume RGBA for now.
- let image8Array = new Uint8ClampedArray(img.data)
- let imgData = new ImageData(image8Array, img.width, img.height)
- const texture = new THREE.DataTexture(imgData, img.width, img.height)
- texture.flipY = true
- texture.needsUpdate = true
- material = new THREE.MeshBasicMaterial({
- map: texture,
- })
- } else {
- material = new THREE.MeshNormalMaterial()
- }
- // Assume triangulated indices.
- geometry.setIndex(
- new THREE.Uint32BufferAttribute(mesh.faceVertexIndices, 1)
- )
- geometry.computeVertexNormals()
- //const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
- const cube = new THREE.Mesh(geometry, material)
- // 缩放模型
- cube.scale.set(7, 7, 7)
- cube.updateMatrixWorld(true) // 强制更新世界矩阵[3](@ref)
- // 计算包围盒
- const box = new THREE.Box3().setFromObject(cube)
- const center = new THREE.Vector3()
- box.getCenter(center)
- // 居中模型
- cube.position.sub(center)
- // 计算尺寸并调整相机
- const size = box.getSize(new THREE.Vector3()).length()
- camera.near = size / 100
- camera.far = size * 100
- camera.updateProjectionMatrix()
- camera.position.set(size * 0.5, size * 0.5, size * 0.5)
- camera.lookAt(0, 0, 0)
- // 向上移动模型
- // cube.position.y += 0.5 // 向上
- scene.add(cube)
- const controls = new OrbitControls(camera, renderer.domElement)
- controls.enablePan = true // 禁用右键平移功能
- controls.enableZoom = true // 必须禁用轨道控制器的默认缩放[1](@ref)
- controls.enableDamping = true
- controls.dampingFactor = 0.25
- controls.screenSpacePanning = false
- controls.minPolarAngle = Math.PI / 180 * 1 // 1 度(约 0.01745 弧度)
- controls.maxPolarAngle = Math.PI - Math.PI / 180 * 1 // 179 度(约 3.124 弧度)
- const measureTool = initMeasureTool({
- scene,
- camera,
- renderer,
- target: cube,
- modelSize: size,
- })
- // 兼容鼠标滚轮与触摸屏双指缩放
- // 兼容鼠标滚轮与触摸屏双指缩放
- // const handleZoom = (delta) => {
- // // console.log('--------',delta);
- // cube.updateMatrixWorld(true);
- // // 计算包围盒
- // const box = new THREE.Box3().setFromObject(cube);
- // const center = new THREE.Vector3();
- // box.getCenter(center);
- // // 居中模型
- // cube.position.sub(center);
- // cube.scale.multiplyScalar(delta);
- // cube.scale.clampScalar(0.5, 4); // 限制缩放范围0.5-3倍[1,6](@ref)
- // };
- // 鼠标滚轮事件
- // window.addEventListener(
- // "wheel",
- // (e) => {
- // e.preventDefault();
- // const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05;
- // handleZoom(zoomFactor);
- // },
- // { passive: false }
- // );
- // 触摸屏双指缩放
- // let initialDistance = 0;
- // window.addEventListener("touchstart", (e) => {
- // if (e.touches.length === 2) {
- // initialDistance = Math.hypot(
- // e.touches[0].pageX - e.touches[1].pageX,
- // e.touches[0].pageY - e.touches[1].pageY
- // );
- // }
- // });
- // window.addEventListener("touchmove", (e) => {
- // if (e.touches.length === 2) {
- // const currentDistance = Math.hypot(
- // e.touches[0].pageX - e.touches[1].pageX,
- // e.touches[0].pageY - e.touches[1].pageY
- // );
- // const zoomFactor = currentDistance > initialDistance ? 1.01 : 0.99;
- // handleZoom(zoomFactor);
- // initialDistance = currentDistance;
- // }
- // });
- function animate() {
- //cube.rotation.x += 0.01;
- // cube.rotation.y += 0.02;
- controls.update()
- renderer.render(scene, camera)
- measureTool.update()
- }
- window.addEventListener("resize", () => {
- camera.aspect = window.innerWidth / window.innerHeight
- camera.updateProjectionMatrix()
- renderer.setSize(window.innerWidth, window.innerHeight)
- })
- })
- })
|