main.js 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316
  1. import * as THREE from "../js/three.module.js"
  2. import initTinyUSDZ from "../js/tinyusdz.js"
  3. import { OrbitControls } from "../js/OrbitControls.js"
  4. const cosBaseUrl = 'https://swkz-1332577016.cos.ap-guangzhou.myqcloud.com/'
  5. const params = new URLSearchParams(window.location.search)
  6. const modelParam = params.get("m")
  7. const langParam = (params.get("lang") || "").toLowerCase()
  8. const locale =
  9. langParam.startsWith("zh") ? "zh" :
  10. langParam.startsWith("en") ? "en" :
  11. (navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en"
  12. const messages = {
  13. zh: {
  14. pageTitle: "归藏",
  15. missingModel: "缺少模型地址。示例:<br>http://127.0.0.1:8080/?m=./models/demo.usdz",
  16. startMeasure: "开始测量",
  17. finishMeasure: "结束测量",
  18. measureSettings: "测量设置",
  19. measureLines: "测量线",
  20. measureColorAria: "测量线颜色 {color}",
  21. customMeasureColor: "自定义测量线颜色",
  22. expandPanel: "展开面板",
  23. collapsePanel: "收起面板",
  24. noMeasures: "暂无测量线",
  25. delete: "删除",
  26. measureName: "测量 {id}",
  27. },
  28. en: {
  29. pageTitle: "Viewer",
  30. missingModel: "Missing model URL. Example:<br>http://127.0.0.1:8080/?m=./models/demo.usdz",
  31. startMeasure: "Start Measuring",
  32. finishMeasure: "Finish Measuring",
  33. measureSettings: "Measurement Settings",
  34. measureLines: "Measurements",
  35. measureColorAria: "Measurement line color {color}",
  36. customMeasureColor: "Custom measurement line color",
  37. expandPanel: "Expand panel",
  38. collapsePanel: "Collapse panel",
  39. noMeasures: "No measurements yet",
  40. delete: "Delete",
  41. measureName: "Measurement {id}",
  42. },
  43. }
  44. const t = (key, vars = {}) => {
  45. const template = messages[locale]?.[key] ?? messages.zh[key] ?? key
  46. return template.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? ""))
  47. }
  48. document.documentElement.lang = locale
  49. document.title = t("pageTitle")
  50. const isAbsoluteUrl = (value) => /^https?:\/\//i.test(value)
  51. const isLocalPath = (value) =>
  52. value.startsWith("./") ||
  53. value.startsWith("/") ||
  54. value.startsWith("../") ||
  55. value.includes(".usdz")
  56. const resolveModelPath = (value) => {
  57. if (!value) return ""
  58. if (isAbsoluteUrl(value) || isLocalPath(value)) return value
  59. return cosBaseUrl + value
  60. }
  61. const showMissingModelMessage = () => {
  62. const loadingBoxDom = document.querySelector(".loadingBox")
  63. loadingBoxDom.style.width = "auto"
  64. loadingBoxDom.style.height = "auto"
  65. loadingBoxDom.style.padding = "14px 18px"
  66. loadingBoxDom.style.borderRadius = "6px"
  67. loadingBoxDom.style.background = "rgba(255, 255, 255, 0.92)"
  68. loadingBoxDom.style.font = "14px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
  69. loadingBoxDom.style.color = "#1f2933"
  70. loadingBoxDom.innerHTML = t("missingModel")
  71. }
  72. const measureUnits = {
  73. mm: { factor: 1000, precision: 0 },
  74. cm: { factor: 100, precision: 1 },
  75. m: { factor: 1, precision: 3 },
  76. }
  77. const measureColors = ["#ffd166", "#30e3f2", "#76e36b", "#ff6b6b", "#ffffff"]
  78. const trimNumber = (value) => value.replace(/\.?0+$/, "")
  79. const formatMeasureDistance = (meters, unit) => {
  80. const config = measureUnits[unit]
  81. return `${trimNumber((meters * config.factor).toFixed(config.precision))} ${unit}`
  82. }
  83. const createMeasureUI = () => {
  84. const style = document.createElement("style")
  85. style.textContent = `
  86. .measure-toggle {
  87. position: fixed;
  88. top: 16px;
  89. left: 16px;
  90. z-index: 20;
  91. height: 40px;
  92. padding: 0 14px;
  93. border: 1px solid rgba(255, 255, 255, 0.36);
  94. border-radius: 8px;
  95. background: rgba(20, 26, 34, 0.78);
  96. color: #fff;
  97. font: 14px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  98. cursor: pointer;
  99. box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
  100. backdrop-filter: blur(10px);
  101. }
  102. .measure-toggle.is-active {
  103. background: rgba(12, 113, 126, 0.9);
  104. border-color: rgba(148, 235, 245, 0.8);
  105. }
  106. .measure-units {
  107. position: fixed;
  108. top: 66px;
  109. left: 16px;
  110. z-index: 20;
  111. display: flex;
  112. flex-direction: column;
  113. align-items: flex-start;
  114. gap: 8px;
  115. padding: 8px;
  116. border: 1px solid rgba(255, 255, 255, 0.3);
  117. border-radius: 8px;
  118. background: rgba(20, 26, 34, 0.74);
  119. box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
  120. backdrop-filter: blur(10px);
  121. transition:
  122. opacity 0.2s ease,
  123. transform 0.2s ease,
  124. visibility 0.2s ease;
  125. }
  126. .measure-unit-row,
  127. .measure-color-row {
  128. display: flex;
  129. align-items: center;
  130. gap: 4px;
  131. min-height: 32px;
  132. }
  133. .measure-section-header {
  134. display: flex;
  135. align-items: center;
  136. justify-content: space-between;
  137. gap: 8px;
  138. width: 100%;
  139. }
  140. .measure-section-title {
  141. color: rgba(255, 255, 255, 0.9);
  142. font-size: 12px;
  143. font-weight: 600;
  144. }
  145. .measure-section-body {
  146. display: flex;
  147. flex-direction: column;
  148. gap: 8px;
  149. width: 100%;
  150. }
  151. .measure-unit {
  152. width: 46px;
  153. height: 32px;
  154. border: 0;
  155. border-radius: 6px;
  156. background: transparent;
  157. color: rgba(255, 255, 255, 0.82);
  158. font: 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  159. cursor: pointer;
  160. }
  161. .measure-unit.is-active {
  162. background: #fff;
  163. color: #15202b;
  164. font-weight: 600;
  165. }
  166. .measure-color {
  167. width: 28px;
  168. height: 28px;
  169. border: 2px solid transparent;
  170. border-radius: 50%;
  171. background: var(--measure-color);
  172. cursor: pointer;
  173. box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.28);
  174. }
  175. .measure-color.is-active {
  176. border-color: #fff;
  177. }
  178. .measure-custom-color {
  179. width: 30px;
  180. height: 30px;
  181. padding: 0;
  182. border: 2px solid transparent;
  183. border-radius: 50%;
  184. background: transparent;
  185. cursor: pointer;
  186. overflow: hidden;
  187. }
  188. .measure-custom-color.is-active {
  189. border-color: #fff;
  190. }
  191. .measure-custom-color::-webkit-color-swatch-wrapper {
  192. padding: 0;
  193. }
  194. .measure-custom-color::-webkit-color-swatch {
  195. border: 0;
  196. border-radius: 50%;
  197. }
  198. .measure-label-layer {
  199. position: fixed;
  200. inset: 0;
  201. z-index: 15;
  202. overflow: hidden;
  203. pointer-events: none;
  204. }
  205. .measure-label {
  206. position: absolute;
  207. min-width: 54px;
  208. padding: 4px 7px;
  209. border: 1px solid rgba(255, 255, 255, 0.36);
  210. border-radius: 4px;
  211. background: rgba(13, 18, 24, 0.82);
  212. color: #fff;
  213. font: 12px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  214. text-align: center;
  215. white-space: nowrap;
  216. transform: translate(-50%, -50%);
  217. box-shadow: 0 5px 14px rgba(0, 0, 0, 0.24);
  218. }
  219. .measure-start-marker {
  220. position: absolute;
  221. width: 16px;
  222. height: 16px;
  223. border: 2px solid rgba(255, 255, 255, 0.95);
  224. border-radius: 50%;
  225. background: rgba(48, 227, 242, 0.38);
  226. transform: translate(-50%, -50%);
  227. box-shadow:
  228. 0 0 0 0 rgba(48, 227, 242, 0.55),
  229. 0 0 12px rgba(48, 227, 242, 0.48);
  230. animation: measure-start-pulse 1.1s ease-out infinite;
  231. display: none;
  232. }
  233. @keyframes measure-start-pulse {
  234. 0% {
  235. box-shadow:
  236. 0 0 0 0 rgba(48, 227, 242, 0.55),
  237. 0 0 12px rgba(48, 227, 242, 0.48);
  238. transform: translate(-50%, -50%) scale(0.92);
  239. }
  240. 70% {
  241. box-shadow:
  242. 0 0 0 10px rgba(48, 227, 242, 0),
  243. 0 0 18px rgba(48, 227, 242, 0.28);
  244. transform: translate(-50%, -50%) scale(1.05);
  245. }
  246. 100% {
  247. box-shadow:
  248. 0 0 0 0 rgba(48, 227, 242, 0),
  249. 0 0 12px rgba(48, 227, 242, 0.18);
  250. transform: translate(-50%, -50%) scale(0.92);
  251. }
  252. }
  253. .measure-panel {
  254. position: fixed;
  255. top: 16px;
  256. right: 16px;
  257. z-index: 20;
  258. width: 250px;
  259. max-height: calc(100vh - 32px);
  260. padding: 10px;
  261. border: 1px solid rgba(255, 255, 255, 0.28);
  262. border-radius: 8px;
  263. background: rgba(20, 26, 34, 0.76);
  264. color: #fff;
  265. box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
  266. backdrop-filter: blur(10px);
  267. overflow: auto;
  268. font: 13px/1.35 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  269. transition:
  270. opacity 0.2s ease,
  271. transform 0.2s ease,
  272. visibility 0.2s ease;
  273. }
  274. .measure-floating.is-hidden {
  275. opacity: 0;
  276. visibility: hidden;
  277. pointer-events: none;
  278. transform: translateY(-8px);
  279. }
  280. .measure-floating.is-collapsed {
  281. gap: 0;
  282. }
  283. .measure-floating.is-collapsed .measure-section-body {
  284. display: none;
  285. }
  286. .measure-panel-title {
  287. color: rgba(255, 255, 255, 0.9);
  288. font-weight: 600;
  289. }
  290. .measure-empty {
  291. padding: 8px 0 2px;
  292. color: rgba(255, 255, 255, 0.58);
  293. }
  294. .measure-list {
  295. display: flex;
  296. flex-direction: column;
  297. gap: 7px;
  298. }
  299. .measure-item {
  300. display: grid;
  301. grid-template-columns: auto 1fr auto;
  302. gap: 6px;
  303. align-items: center;
  304. min-height: 38px;
  305. padding: 7px;
  306. border: 1px solid rgba(255, 255, 255, 0.16);
  307. border-radius: 6px;
  308. background: rgba(255, 255, 255, 0.08);
  309. cursor: pointer;
  310. transition:
  311. border-color 0.2s ease,
  312. background 0.2s ease,
  313. box-shadow 0.2s ease;
  314. }
  315. .measure-item:hover {
  316. background: rgba(255, 255, 255, 0.12);
  317. }
  318. .measure-item.is-active {
  319. border-color: rgba(255, 255, 255, 0.36);
  320. background: rgba(255, 255, 255, 0.16);
  321. box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08);
  322. }
  323. .measure-item-color {
  324. width: 10px;
  325. height: 30px;
  326. border-radius: 5px;
  327. background: var(--measure-color);
  328. box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.28);
  329. }
  330. .measure-item-main {
  331. min-width: 0;
  332. }
  333. .measure-item-name {
  334. color: rgba(255, 255, 255, 0.72);
  335. font-size: 12px;
  336. }
  337. .measure-item-distance {
  338. margin-top: 2px;
  339. overflow: hidden;
  340. color: #fff;
  341. font-weight: 600;
  342. text-overflow: ellipsis;
  343. white-space: nowrap;
  344. }
  345. .measure-action {
  346. height: 28px;
  347. min-width: 42px;
  348. padding: 0 8px;
  349. border: 1px solid rgba(255, 255, 255, 0.22);
  350. border-radius: 5px;
  351. background: rgba(255, 255, 255, 0.1);
  352. color: #fff;
  353. font: 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  354. cursor: pointer;
  355. }
  356. .measure-action:hover {
  357. background: rgba(255, 255, 255, 0.18);
  358. }
  359. .measure-action.is-danger {
  360. border-color: rgba(255, 135, 135, 0.4);
  361. color: #ffd5d5;
  362. }
  363. .measure-collapse {
  364. height: 26px;
  365. width: 26px;
  366. padding: 0;
  367. border: 1px solid rgba(255, 255, 255, 0.2);
  368. border-radius: 5px;
  369. background: rgba(255, 255, 255, 0.08);
  370. color: rgba(255, 255, 255, 0.82);
  371. font: 12px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  372. cursor: pointer;
  373. flex-shrink: 0;
  374. }
  375. .measure-collapse:hover {
  376. background: rgba(255, 255, 255, 0.14);
  377. }
  378. @media (max-width: 640px) {
  379. .measure-toggle {
  380. top: calc(env(safe-area-inset-top, 0px) + clamp(112px, 12vh + 16px, 148px));
  381. left: 12px;
  382. max-width: calc(100vw - 24px);
  383. }
  384. .measure-units {
  385. top: calc(env(safe-area-inset-top, 0px) + clamp(166px, 12vh + 70px, 202px));
  386. left: 12px;
  387. right: 12px;
  388. gap: 6px;
  389. padding: 7px;
  390. }
  391. .measure-unit-row,
  392. .measure-color-row {
  393. flex-wrap: wrap;
  394. }
  395. .measure-unit {
  396. flex: 1 1 46px;
  397. }
  398. .measure-panel {
  399. left: 12px;
  400. right: 12px;
  401. top: auto;
  402. bottom: 12px;
  403. width: auto;
  404. max-height: min(34vh, 240px);
  405. }
  406. }
  407. `
  408. document.head.appendChild(style)
  409. const toggleButton = document.createElement("button")
  410. toggleButton.type = "button"
  411. toggleButton.className = "measure-toggle"
  412. toggleButton.textContent = t("startMeasure")
  413. document.body.appendChild(toggleButton)
  414. const unitBar = document.createElement("div")
  415. unitBar.className = "measure-units measure-floating is-hidden"
  416. document.body.appendChild(unitBar)
  417. const unitHeader = document.createElement("div")
  418. unitHeader.className = "measure-section-header"
  419. unitBar.appendChild(unitHeader)
  420. const unitTitle = document.createElement("div")
  421. unitTitle.className = "measure-section-title"
  422. unitTitle.textContent = t("measureSettings")
  423. unitHeader.appendChild(unitTitle)
  424. const unitCollapseButton = document.createElement("button")
  425. unitCollapseButton.type = "button"
  426. unitCollapseButton.className = "measure-collapse"
  427. unitHeader.appendChild(unitCollapseButton)
  428. const unitBody = document.createElement("div")
  429. unitBody.className = "measure-section-body"
  430. unitBar.appendChild(unitBody)
  431. const unitRow = document.createElement("div")
  432. unitRow.className = "measure-unit-row"
  433. unitBody.appendChild(unitRow)
  434. const unitButtons = {}
  435. Object.keys(measureUnits).forEach((unit) => {
  436. const button = document.createElement("button")
  437. button.type = "button"
  438. button.className = "measure-unit"
  439. button.textContent = unit
  440. unitRow.appendChild(button)
  441. unitButtons[unit] = button
  442. })
  443. const colorRow = document.createElement("div")
  444. colorRow.className = "measure-color-row"
  445. unitBody.appendChild(colorRow)
  446. const colorButtons = {}
  447. measureColors.forEach((color) => {
  448. const button = document.createElement("button")
  449. button.type = "button"
  450. button.className = "measure-color"
  451. button.style.setProperty("--measure-color", color)
  452. button.setAttribute("aria-label", t("measureColorAria", { color }))
  453. colorRow.appendChild(button)
  454. colorButtons[color] = button
  455. })
  456. const customColorInput = document.createElement("input")
  457. customColorInput.type = "color"
  458. customColorInput.className = "measure-custom-color"
  459. customColorInput.value = measureColors[0]
  460. customColorInput.setAttribute("aria-label", t("customMeasureColor"))
  461. colorRow.appendChild(customColorInput)
  462. const labelLayer = document.createElement("div")
  463. labelLayer.className = "measure-label-layer"
  464. document.body.appendChild(labelLayer)
  465. const startMarker = document.createElement("div")
  466. startMarker.className = "measure-start-marker"
  467. labelLayer.appendChild(startMarker)
  468. const panel = document.createElement("div")
  469. panel.className = "measure-panel measure-floating is-hidden"
  470. panel.innerHTML = `
  471. <div class="measure-section-header">
  472. <div class="measure-panel-title">${t("measureLines")}</div>
  473. <button type="button" class="measure-collapse"></button>
  474. </div>
  475. <div class="measure-section-body">
  476. <div class="measure-list"></div>
  477. </div>
  478. `
  479. document.body.appendChild(panel)
  480. const panelCollapseButton = panel.querySelector(".measure-collapse")
  481. const setSectionCollapsed = (element, button, collapsed) => {
  482. element.classList.toggle("is-collapsed", collapsed)
  483. button.textContent = collapsed ? "▼" : "▲"
  484. button.setAttribute("aria-expanded", String(!collapsed))
  485. button.setAttribute("aria-label", collapsed ? t("expandPanel") : t("collapsePanel"))
  486. }
  487. unitCollapseButton.addEventListener("click", () => {
  488. setSectionCollapsed(
  489. unitBar,
  490. unitCollapseButton,
  491. !unitBar.classList.contains("is-collapsed")
  492. )
  493. })
  494. panelCollapseButton.addEventListener("click", () => {
  495. setSectionCollapsed(
  496. panel,
  497. panelCollapseButton,
  498. !panel.classList.contains("is-collapsed")
  499. )
  500. })
  501. const setActiveUnit = (unit) => {
  502. Object.entries(unitButtons).forEach(([buttonUnit, button]) => {
  503. button.classList.toggle("is-active", buttonUnit === unit)
  504. })
  505. }
  506. const setActiveColor = (color) => {
  507. Object.entries(colorButtons).forEach(([buttonColor, button]) => {
  508. button.classList.toggle("is-active", buttonColor === color)
  509. })
  510. customColorInput.classList.toggle("is-active", !measureColors.includes(color))
  511. customColorInput.value = color
  512. }
  513. const setControlsVisible = (visible) => {
  514. unitBar.classList.toggle("is-hidden", !visible)
  515. panel.classList.toggle("is-hidden", !visible)
  516. }
  517. setSectionCollapsed(unitBar, unitCollapseButton, false)
  518. setSectionCollapsed(panel, panelCollapseButton, window.matchMedia("(max-width: 640px)").matches)
  519. return {
  520. colorButtons,
  521. customColorInput,
  522. labelLayer,
  523. list: panel.querySelector(".measure-list"),
  524. panel,
  525. setControlsVisible,
  526. startMarker,
  527. toggleButton,
  528. unitBar,
  529. unitButtons,
  530. setActiveColor,
  531. setActiveUnit,
  532. }
  533. }
  534. const initMeasureTool = ({ scene, camera, renderer, target, modelSize }) => {
  535. const ui = createMeasureUI()
  536. const raycaster = new THREE.Raycaster()
  537. const pointer = new THREE.Vector2()
  538. const measures = []
  539. const dashSize = Math.max(modelSize * 0.004, 0.005)
  540. const gapSize = Math.max(modelSize * 0.0025, 0.003)
  541. const dashRadius = Math.max(modelSize * 0.001125, 0.001375)
  542. const labelGap = 10
  543. const minLabelOffset = 18
  544. const highlightDuration = 1600
  545. let active = false
  546. let currentColor = measureColors[0]
  547. let currentUnit = "m"
  548. let pendingStart = null
  549. let previewMeasure = null
  550. let clickStart = null
  551. let nextMeasureId = 1
  552. let selectedMeasureId = null
  553. ui.setActiveUnit(currentUnit)
  554. ui.setActiveColor(currentColor)
  555. ui.setControlsVisible(false)
  556. const createLabel = () => {
  557. const label = document.createElement("div")
  558. label.className = "measure-label"
  559. ui.labelLayer.appendChild(label)
  560. return label
  561. }
  562. const clearLineMeshes = (line) => {
  563. while (line.children.length) {
  564. const mesh = line.children.pop()
  565. mesh.geometry.dispose()
  566. line.remove(mesh)
  567. }
  568. }
  569. const createMeasureLine = (color, isPreview) => {
  570. const material = new THREE.MeshBasicMaterial({
  571. color,
  572. depthTest: false,
  573. transparent: true,
  574. opacity: isPreview ? 0.95 : 1,
  575. })
  576. const line = new THREE.Group()
  577. line.userData.material = material
  578. line.renderOrder = 10
  579. scene.add(line)
  580. return line
  581. }
  582. const setMeasureColor = (measure, color) => {
  583. measure.color = color
  584. measure.line.userData.material.color.set(color)
  585. }
  586. const setMeasureOpacity = (measure, opacity) => {
  587. measure.line.userData.material.opacity = opacity
  588. }
  589. const setLinePoints = (line, start, end) => {
  590. clearLineMeshes(line)
  591. const direction = end.clone().sub(start)
  592. const totalLength = direction.length()
  593. if (totalLength < 0.000001) return
  594. direction.normalize()
  595. const rotation = new THREE.Quaternion().setFromUnitVectors(
  596. new THREE.Vector3(0, 1, 0),
  597. direction
  598. )
  599. const stepSize = dashSize + gapSize
  600. for (let offset = 0; offset < totalLength; offset += stepSize) {
  601. const segmentLength = Math.min(dashSize, totalLength - offset)
  602. if (segmentLength <= 0) break
  603. const center = start
  604. .clone()
  605. .addScaledVector(direction, offset + segmentLength / 2)
  606. const geometry = new THREE.CylinderGeometry(
  607. dashRadius,
  608. dashRadius,
  609. segmentLength,
  610. 12
  611. )
  612. const mesh = new THREE.Mesh(geometry, line.userData.material)
  613. mesh.position.copy(center)
  614. mesh.quaternion.copy(rotation)
  615. mesh.renderOrder = 10
  616. line.add(mesh)
  617. }
  618. }
  619. const getModelDistance = (start, end) => {
  620. const localStart = target.worldToLocal(start.clone())
  621. const localEnd = target.worldToLocal(end.clone())
  622. return localStart.distanceTo(localEnd)
  623. }
  624. const getMidpoint = (start, end) => start.clone().add(end).multiplyScalar(0.5)
  625. const refreshMeasureText = (measure) => {
  626. measure.label.textContent = formatMeasureDistance(measure.distance, currentUnit)
  627. }
  628. const disposeMeasure = (measure) => {
  629. scene.remove(measure.line)
  630. clearLineMeshes(measure.line)
  631. measure.line.userData.material.dispose()
  632. measure.label.remove()
  633. }
  634. const clearPreview = () => {
  635. if (!previewMeasure) return
  636. disposeMeasure(previewMeasure)
  637. previewMeasure = null
  638. }
  639. const renderMeasureList = () => {
  640. ui.list.innerHTML = ""
  641. if (!measures.length) {
  642. const empty = document.createElement("div")
  643. empty.className = "measure-empty"
  644. empty.textContent = t("noMeasures")
  645. ui.list.appendChild(empty)
  646. return
  647. }
  648. measures.forEach((measure) => {
  649. const item = document.createElement("div")
  650. item.className = "measure-item"
  651. item.classList.toggle("is-active", measure.id === selectedMeasureId)
  652. item.style.setProperty("--measure-color", measure.color)
  653. const colorMark = document.createElement("div")
  654. colorMark.className = "measure-item-color"
  655. const main = document.createElement("div")
  656. main.className = "measure-item-main"
  657. const name = document.createElement("div")
  658. name.className = "measure-item-name"
  659. name.textContent = measure.name
  660. const distance = document.createElement("div")
  661. distance.className = "measure-item-distance"
  662. distance.textContent = formatMeasureDistance(measure.distance, currentUnit)
  663. main.appendChild(name)
  664. main.appendChild(distance)
  665. const deleteButton = document.createElement("button")
  666. deleteButton.type = "button"
  667. deleteButton.className = "measure-action is-danger"
  668. deleteButton.textContent = t("delete")
  669. deleteButton.addEventListener("click", (event) => {
  670. event.stopPropagation()
  671. removeSavedMeasure(measure)
  672. })
  673. item.addEventListener("click", () => {
  674. focusMeasure(measure)
  675. })
  676. item.appendChild(colorMark)
  677. item.appendChild(main)
  678. item.appendChild(deleteButton)
  679. ui.list.appendChild(item)
  680. })
  681. }
  682. const removeSavedMeasure = (measure) => {
  683. const index = measures.indexOf(measure)
  684. if (index === -1) return
  685. measures.splice(index, 1)
  686. if (selectedMeasureId === measure.id) selectedMeasureId = null
  687. disposeMeasure(measure)
  688. renderMeasureList()
  689. }
  690. const focusMeasure = (measure) => {
  691. selectedMeasureId = measure.id
  692. measure.highlightUntil = performance.now() + highlightDuration
  693. renderMeasureList()
  694. }
  695. const updatePreview = (end) => {
  696. if (!pendingStart) return
  697. if (!previewMeasure) {
  698. previewMeasure = {
  699. color: currentColor,
  700. line: createMeasureLine(currentColor, true),
  701. label: createLabel(),
  702. start: pendingStart.clone(),
  703. end: end.clone(),
  704. midpoint: new THREE.Vector3(),
  705. distance: 0,
  706. baseOpacity: 0.95,
  707. highlightUntil: 0,
  708. }
  709. }
  710. setLinePoints(previewMeasure.line, pendingStart, end)
  711. setMeasureColor(previewMeasure, currentColor)
  712. previewMeasure.start.copy(pendingStart)
  713. previewMeasure.end.copy(end)
  714. previewMeasure.midpoint.copy(getMidpoint(pendingStart, end))
  715. previewMeasure.distance = getModelDistance(pendingStart, end)
  716. refreshMeasureText(previewMeasure)
  717. }
  718. const addMeasure = (start, end) => {
  719. const measure = {
  720. id: nextMeasureId,
  721. name: t("measureName", { id: nextMeasureId }),
  722. color: currentColor,
  723. line: createMeasureLine(currentColor, false),
  724. label: createLabel(),
  725. start: start.clone(),
  726. end: end.clone(),
  727. midpoint: getMidpoint(start, end),
  728. distance: getModelDistance(start, end),
  729. baseOpacity: 1,
  730. highlightUntil: 0,
  731. }
  732. nextMeasureId += 1
  733. setLinePoints(measure.line, start, end)
  734. refreshMeasureText(measure)
  735. measures.push(measure)
  736. renderMeasureList()
  737. }
  738. const getHitPoint = (clientX, clientY) => {
  739. const rect = renderer.domElement.getBoundingClientRect()
  740. pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1
  741. pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1
  742. raycaster.setFromCamera(pointer, camera)
  743. const hits = raycaster.intersectObject(target, true)
  744. if (!hits.length) return null
  745. return hits[0].point.clone()
  746. }
  747. const setActive = (nextActive) => {
  748. active = nextActive
  749. pendingStart = null
  750. clearPreview()
  751. renderer.domElement.style.cursor = active ? "crosshair" : ""
  752. ui.toggleButton.classList.toggle("is-active", active)
  753. ui.toggleButton.textContent = active ? t("finishMeasure") : t("startMeasure")
  754. ui.setControlsVisible(active)
  755. renderMeasureList()
  756. }
  757. const updateAllMeasureText = () => {
  758. measures.forEach(refreshMeasureText)
  759. if (previewMeasure) refreshMeasureText(previewMeasure)
  760. renderMeasureList()
  761. }
  762. const handleMeasurePoint = (point) => {
  763. if (!pendingStart) {
  764. pendingStart = point
  765. clearPreview()
  766. return
  767. }
  768. if (pendingStart.distanceTo(point) < 0.000001) return
  769. addMeasure(pendingStart, point)
  770. pendingStart = null
  771. clearPreview()
  772. renderMeasureList()
  773. }
  774. renderer.domElement.addEventListener("pointerdown", (event) => {
  775. if (!active || event.button !== 0) return
  776. clickStart = {
  777. pointerId: event.pointerId,
  778. x: event.clientX,
  779. y: event.clientY,
  780. }
  781. })
  782. renderer.domElement.addEventListener("pointerup", (event) => {
  783. if (!active || !clickStart || clickStart.pointerId !== event.pointerId) return
  784. const moveDistance = Math.hypot(event.clientX - clickStart.x, event.clientY - clickStart.y)
  785. clickStart = null
  786. if (moveDistance > 6) return
  787. const point = getHitPoint(event.clientX, event.clientY)
  788. if (point) handleMeasurePoint(point)
  789. })
  790. renderer.domElement.addEventListener("pointermove", (event) => {
  791. if (!active || !pendingStart) return
  792. const point = getHitPoint(event.clientX, event.clientY)
  793. if (point) updatePreview(point)
  794. })
  795. ui.toggleButton.addEventListener("click", () => {
  796. setActive(!active)
  797. })
  798. Object.entries(ui.unitButtons).forEach(([unit, button]) => {
  799. button.addEventListener("click", () => {
  800. currentUnit = unit
  801. ui.setActiveUnit(unit)
  802. updateAllMeasureText()
  803. })
  804. })
  805. Object.entries(ui.colorButtons).forEach(([color, button]) => {
  806. button.addEventListener("click", () => {
  807. currentColor = color
  808. ui.setActiveColor(color)
  809. measures.forEach((measure) => setMeasureColor(measure, color))
  810. if (previewMeasure) setMeasureColor(previewMeasure, color)
  811. renderMeasureList()
  812. })
  813. })
  814. ui.customColorInput.addEventListener("input", () => {
  815. currentColor = ui.customColorInput.value
  816. ui.setActiveColor(currentColor)
  817. measures.forEach((measure) => setMeasureColor(measure, currentColor))
  818. if (previewMeasure) setMeasureColor(previewMeasure, currentColor)
  819. renderMeasureList()
  820. })
  821. renderMeasureList()
  822. const updateMeasureHighlights = () => {
  823. const now = performance.now()
  824. const visibleMeasures = previewMeasure ? [...measures, previewMeasure] : measures
  825. visibleMeasures.forEach((measure) => {
  826. const remaining = Math.max(0, (measure.highlightUntil || 0) - now)
  827. if (!remaining) {
  828. setMeasureOpacity(measure, measure.baseOpacity)
  829. measure.label.style.transform = "translate(-50%, -50%) scale(1)"
  830. return
  831. }
  832. const progress = 1 - remaining / highlightDuration
  833. const pulse = 0.5 + 0.5 * Math.sin(progress * Math.PI * 8)
  834. const opacityScale = 0.58 + pulse * 0.42
  835. setMeasureOpacity(measure, measure.baseOpacity * opacityScale)
  836. const scale = 1 + pulse * 0.12
  837. measure.label.style.transform = `translate(-50%, -50%) scale(${scale})`
  838. })
  839. }
  840. const updateLabelPositions = () => {
  841. updateMeasureHighlights()
  842. const rect = renderer.domElement.getBoundingClientRect()
  843. const visibleMeasures = previewMeasure ? [...measures, previewMeasure] : measures
  844. if (!active || !pendingStart) {
  845. ui.startMarker.style.display = "none"
  846. } else {
  847. const startPoint = pendingStart.clone().project(camera)
  848. const isVisible = startPoint.z >= -1 && startPoint.z <= 1
  849. ui.startMarker.style.display = isVisible ? "block" : "none"
  850. if (isVisible) {
  851. const x = rect.left + (startPoint.x * 0.5 + 0.5) * rect.width
  852. const y = rect.top + (-startPoint.y * 0.5 + 0.5) * rect.height
  853. ui.startMarker.style.left = `${x}px`
  854. ui.startMarker.style.top = `${y}px`
  855. }
  856. }
  857. visibleMeasures.forEach((measure) => {
  858. const startPoint = measure.start.clone().project(camera)
  859. const endPoint = measure.end.clone().project(camera)
  860. const midpoint = measure.midpoint.clone().project(camera)
  861. const isVisible =
  862. midpoint.z >= -1 &&
  863. midpoint.z <= 1 &&
  864. startPoint.z >= -1 &&
  865. startPoint.z <= 1 &&
  866. endPoint.z >= -1 &&
  867. endPoint.z <= 1
  868. measure.label.style.display = isVisible ? "block" : "none"
  869. if (!isVisible) return
  870. const startX = rect.left + (startPoint.x * 0.5 + 0.5) * rect.width
  871. const startY = rect.top + (-startPoint.y * 0.5 + 0.5) * rect.height
  872. const endX = rect.left + (endPoint.x * 0.5 + 0.5) * rect.width
  873. const endY = rect.top + (-endPoint.y * 0.5 + 0.5) * rect.height
  874. const midX = rect.left + (midpoint.x * 0.5 + 0.5) * rect.width
  875. const midY = rect.top + (-midpoint.y * 0.5 + 0.5) * rect.height
  876. const lineX = endX - startX
  877. const lineY = endY - startY
  878. const lineLength = Math.hypot(lineX, lineY)
  879. let normalX = 0
  880. let normalY = -1
  881. if (lineLength > 0.001) {
  882. normalX = -lineY / lineLength
  883. normalY = lineX / lineLength
  884. if (normalY > 0) {
  885. normalX *= -1
  886. normalY *= -1
  887. }
  888. }
  889. const labelRect = measure.label.getBoundingClientRect()
  890. const labelHalfSizeAlongNormal =
  891. Math.abs(normalX) * labelRect.width / 2 +
  892. Math.abs(normalY) * labelRect.height / 2
  893. const offsetDistance = Math.max(minLabelOffset, labelHalfSizeAlongNormal + labelGap)
  894. const offsetX = normalX * offsetDistance
  895. const offsetY = normalY * offsetDistance
  896. const x = midX + offsetX
  897. const y = midY + offsetY
  898. measure.label.style.left = `${x}px`
  899. measure.label.style.top = `${y}px`
  900. })
  901. }
  902. return {
  903. update: updateLabelPositions,
  904. }
  905. }
  906. // 测试打开这个链接
  907. // const USDZ_FILEPATH = `https://swkz-1332577016.cos.ap-guangzhou.myqcloud.com/kanzhan/2025/08/13/e8aecd235d7d40e49301b901c6c00d51`;
  908. // 正式打开这个链接
  909. const USDZ_FILEPATH = resolveModelPath(modelParam)
  910. document.addEventListener("DOMContentLoaded", async () => {
  911. if (!USDZ_FILEPATH) {
  912. showMissingModelMessage()
  913. return
  914. }
  915. const loadingBar = document.getElementById("loading") // 获取加载条元素
  916. loadingBar.style.display = "block" // 显示加载条
  917. const usd_res = await fetch(USDZ_FILEPATH)
  918. const totalBytes = parseInt(usd_res.headers.get("Content-Length"), 10) // 获取总字节数
  919. const reader = usd_res.body.getReader() // 获取读取器
  920. const chunks = [] // 存储数据块
  921. let receivedLength = 0 // 已接收字节数
  922. let timeOut = -1
  923. // 更新加载条的函数
  924. const updateLoadingBar = (loaded) => {
  925. let percentage = (loaded / totalBytes) * 100
  926. if (percentage >= 100) {
  927. percentage = 100
  928. // 隐藏加载条
  929. clearTimeout(timeOut)
  930. timeOut = setTimeout(() => {
  931. const loadingBoxDom = document.querySelector(".loadingBox")
  932. loadingBoxDom.style.opacity = 0
  933. loadingBoxDom.style.pointerEvents = "none"
  934. }, 300)
  935. }
  936. loadingBar.style.width = `${percentage}%` // 更新加载条宽度
  937. }
  938. // 读取数据流
  939. while (true) {
  940. const { done, value } = await reader.read() // 读取数据
  941. if (done) break // 如果读取完成,退出循环
  942. chunks.push(value) // 存储数据块
  943. receivedLength += value.length // 更新已接收字节数
  944. updateLoadingBar(receivedLength) // 更新加载条
  945. }
  946. const usd_data = new Uint8Array(receivedLength) // 创建最终数据数组
  947. let position = 0
  948. for (const chunk of chunks) {
  949. usd_data.set(chunk, position) // 将数据块写入最终数组
  950. position += chunk.length // 更新位置
  951. }
  952. // const usd_binary = new Uint8Array(usd_data);
  953. // 加载 TinyUSDZ
  954. initTinyUSDZ().then(function (TinyUSDZLoader) {
  955. const usd = new TinyUSDZLoader.TinyUSDZLoader(usd_data)
  956. // console.log(usd.numMeshes());
  957. // 隐藏加载条
  958. loadingBar.style.display = "none"
  959. const scene = new THREE.Scene()
  960. // 添加渐变背景
  961. const bgTexture = new THREE.TextureLoader().load('./bg.png')
  962. scene.background = bgTexture
  963. const camera = new THREE.PerspectiveCamera(
  964. 85,
  965. window.innerWidth / window.innerHeight,
  966. 0.1,
  967. 1000
  968. )
  969. const renderer = new THREE.WebGLRenderer({
  970. alpha: true, // 关键配置,设置背景透明
  971. antialias: true, // 可选抗锯齿
  972. })
  973. renderer.setClearColor(0x000000, 1) // 第二个参数为透明度(0表示完全透明)
  974. renderer.setSize(window.innerWidth, window.innerHeight)
  975. renderer.setAnimationLoop(animate)
  976. // 设置输出颜色空间为线性SRGB
  977. renderer.outputColorSpace = THREE.LinearSRGBColorSpace
  978. document.body.appendChild(renderer.domElement)
  979. // First mesh only
  980. const mesh = usd.getMesh(0)
  981. //console.log("usd", usd)
  982. //console.log("mesh", mesh);
  983. //const geometry = new THREE.BoxGeometry( 1, 1, 1 );
  984. const geometry = new THREE.BufferGeometry()
  985. geometry.setAttribute(
  986. "position",
  987. new THREE.BufferAttribute(mesh.points, 3)
  988. )
  989. // TODO: set normal from mesh
  990. if (mesh.hasOwnProperty("texcoords")) {
  991. // console.log(mesh.texcoords);
  992. geometry.setAttribute("uv", new THREE.BufferAttribute(mesh.texcoords, 2))
  993. }
  994. const usdMaterial = usd.getMaterial(mesh.materialId)
  995. //console.log("usdMat", usdMaterial);
  996. //if (usdMaterial.aaa) {
  997. // console.log("aaa");
  998. //}
  999. var material
  1000. if (usdMaterial.hasOwnProperty("diffuseColorTextureId")) {
  1001. const diffTex = usd.getTexture(usdMaterial.diffuseColorTextureId)
  1002. const img = usd.getImage(diffTex.textureImageId)
  1003. // console.log(img);
  1004. // assume RGBA for now.
  1005. let image8Array = new Uint8ClampedArray(img.data)
  1006. let imgData = new ImageData(image8Array, img.width, img.height)
  1007. const texture = new THREE.DataTexture(imgData, img.width, img.height)
  1008. texture.flipY = true
  1009. texture.needsUpdate = true
  1010. material = new THREE.MeshBasicMaterial({
  1011. map: texture,
  1012. })
  1013. } else {
  1014. material = new THREE.MeshNormalMaterial()
  1015. }
  1016. // Assume triangulated indices.
  1017. geometry.setIndex(
  1018. new THREE.Uint32BufferAttribute(mesh.faceVertexIndices, 1)
  1019. )
  1020. geometry.computeVertexNormals()
  1021. //const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
  1022. const cube = new THREE.Mesh(geometry, material)
  1023. // 缩放模型
  1024. cube.scale.set(7, 7, 7)
  1025. cube.updateMatrixWorld(true) // 强制更新世界矩阵[3](@ref)
  1026. // 计算包围盒
  1027. const box = new THREE.Box3().setFromObject(cube)
  1028. const center = new THREE.Vector3()
  1029. box.getCenter(center)
  1030. // 居中模型
  1031. cube.position.sub(center)
  1032. // 计算尺寸并调整相机
  1033. const size = box.getSize(new THREE.Vector3()).length()
  1034. camera.near = size / 100
  1035. camera.far = size * 100
  1036. camera.updateProjectionMatrix()
  1037. camera.position.set(size * 0.5, size * 0.5, size * 0.5)
  1038. camera.lookAt(0, 0, 0)
  1039. // 向上移动模型
  1040. // cube.position.y += 0.5 // 向上
  1041. scene.add(cube)
  1042. const controls = new OrbitControls(camera, renderer.domElement)
  1043. controls.enablePan = true // 禁用右键平移功能
  1044. controls.enableZoom = true // 必须禁用轨道控制器的默认缩放[1](@ref)
  1045. controls.enableDamping = true
  1046. controls.dampingFactor = 0.25
  1047. controls.screenSpacePanning = false
  1048. controls.minPolarAngle = Math.PI / 180 * 1 // 1 度(约 0.01745 弧度)
  1049. controls.maxPolarAngle = Math.PI - Math.PI / 180 * 1 // 179 度(约 3.124 弧度)
  1050. const measureTool = initMeasureTool({
  1051. scene,
  1052. camera,
  1053. renderer,
  1054. target: cube,
  1055. modelSize: size,
  1056. })
  1057. // 兼容鼠标滚轮与触摸屏双指缩放
  1058. // 兼容鼠标滚轮与触摸屏双指缩放
  1059. // const handleZoom = (delta) => {
  1060. // // console.log('--------',delta);
  1061. // cube.updateMatrixWorld(true);
  1062. // // 计算包围盒
  1063. // const box = new THREE.Box3().setFromObject(cube);
  1064. // const center = new THREE.Vector3();
  1065. // box.getCenter(center);
  1066. // // 居中模型
  1067. // cube.position.sub(center);
  1068. // cube.scale.multiplyScalar(delta);
  1069. // cube.scale.clampScalar(0.5, 4); // 限制缩放范围0.5-3倍[1,6](@ref)
  1070. // };
  1071. // 鼠标滚轮事件
  1072. // window.addEventListener(
  1073. // "wheel",
  1074. // (e) => {
  1075. // e.preventDefault();
  1076. // const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05;
  1077. // handleZoom(zoomFactor);
  1078. // },
  1079. // { passive: false }
  1080. // );
  1081. // 触摸屏双指缩放
  1082. // let initialDistance = 0;
  1083. // window.addEventListener("touchstart", (e) => {
  1084. // if (e.touches.length === 2) {
  1085. // initialDistance = Math.hypot(
  1086. // e.touches[0].pageX - e.touches[1].pageX,
  1087. // e.touches[0].pageY - e.touches[1].pageY
  1088. // );
  1089. // }
  1090. // });
  1091. // window.addEventListener("touchmove", (e) => {
  1092. // if (e.touches.length === 2) {
  1093. // const currentDistance = Math.hypot(
  1094. // e.touches[0].pageX - e.touches[1].pageX,
  1095. // e.touches[0].pageY - e.touches[1].pageY
  1096. // );
  1097. // const zoomFactor = currentDistance > initialDistance ? 1.01 : 0.99;
  1098. // handleZoom(zoomFactor);
  1099. // initialDistance = currentDistance;
  1100. // }
  1101. // });
  1102. function animate() {
  1103. //cube.rotation.x += 0.01;
  1104. // cube.rotation.y += 0.02;
  1105. controls.update()
  1106. renderer.render(scene, camera)
  1107. measureTool.update()
  1108. }
  1109. window.addEventListener("resize", () => {
  1110. camera.aspect = window.innerWidth / window.innerHeight
  1111. camera.updateProjectionMatrix()
  1112. renderer.setSize(window.innerWidth, window.innerHeight)
  1113. })
  1114. })
  1115. })