diff --git a/addDimLines.js b/addDimLines.js new file mode 100644 index 0000000..25d8dc4 --- /dev/null +++ b/addDimLines.js @@ -0,0 +1,157 @@ +export function initDimensionLines(modelViewer) { + // 2. Original Logic (Now guaranteed to find elements) + const checkbox = modelViewer.parentElement.querySelector("#show-dimensions"); + const dimensionLineContainer = modelViewer.parentElement.querySelector("#dimLines"); + + function setVisibility(element) { + if (checkbox.checked) { + element.classList.remove("hide"); + dimensionLineContainer.classList.add("loaded"); // show the lines + } else { + element.classList.add("hide"); + dimensionLineContainer.classList.remove("loaded"); // hide the lines + } + } + + checkbox.addEventListener("change", () => { + setVisibility(dimensionLineContainer); + modelViewer.querySelectorAll("button").forEach((hotspot) => { + setVisibility(hotspot); + }); + renderSVG(); + }); + + // Give each line a start and end point, + // Use the midpoint to check for visibility + + // update svg + function drawLine(svgLine, hotspot1Name, hotspot2Name, dimensionHotspotName) { + if (!svgLine) return; + + // Use queryHotspot to get real-time 2D positions and visibility + const hotspot1 = modelViewer.queryHotspot(hotspot1Name); + const hotspot2 = modelViewer.queryHotspot(hotspot2Name); + const dimensionHotspot = dimensionHotspotName ? modelViewer.queryHotspot(dimensionHotspotName) : null; + const dimHotspotEl = dimensionHotspotName ? modelViewer.querySelector(`button[slot="${dimensionHotspotName}"]`) : null; + + if (hotspot1 && hotspot2 && hotspot1.canvasPosition && hotspot2.canvasPosition) { + svgLine.setAttribute("x1", hotspot1.canvasPosition.x); + svgLine.setAttribute("y1", hotspot1.canvasPosition.y); + svgLine.setAttribute("x2", hotspot2.canvasPosition.x); + svgLine.setAttribute("y2", hotspot2.canvasPosition.y); + + // Manage visibility based on camera facing and checkbox state + const isVisible = checkbox.checked && (!dimensionHotspot || dimensionHotspot.facingCamera); + + if (isVisible) { + svgLine.style.display = "block"; + svgLine.style.stroke = "#16a5e6"; + svgLine.style.strokeWidth = "2"; + if (dimHotspotEl) dimHotspotEl.classList.add("visible"); + } else { + svgLine.style.display = "none"; + if (dimHotspotEl) dimHotspotEl.classList.remove("visible"); + } + } else { + svgLine.style.display = "none"; + if (dimHotspotEl) dimHotspotEl.classList.remove("visible"); + } + } + + const dimLines = modelViewer.parentElement.querySelectorAll("#dimLines line"); + + const renderSVG = () => { + drawLine(dimLines[0], "hotspot-dot+X-Y+Z", "hotspot-dot+X-Y-Z", "hotspot-dim+X-Y"); + drawLine(dimLines[1], "hotspot-dot+X-Y-Z", "hotspot-dot+X+Y-Z", "hotspot-dim+X-Z"); + drawLine(dimLines[2], "hotspot-dot+X+Y-Z", "hotspot-dot-X+Y-Z", "hotspot-dim+Y-Z"); + drawLine(dimLines[3], "hotspot-dot-X-Y+Z", "hotspot-dot+X-Y+Z", "hotspot-dim-Y+Z"); + drawLine(dimLines[4], "hotspot-dot-X+Y-Z", "hotspot-dot-X-Y-Z", "hotspot-dim-X-Z"); + drawLine(dimLines[5], "hotspot-dot-X-Y-Z", "hotspot-dot-X-Y+Z", "hotspot-dim-X-Y"); + }; + + // Continuous rendering loop for smooth synchronization (including auto-rotate) + const tick = () => { + if (checkbox.checked) { + renderSVG(); + } + requestAnimationFrame(tick); + }; + + // Set the positions of all the hotspots on page load + modelViewer.addEventListener("load", () => { + // 1. 使用 3.4.0 推荐的 API 获取尺寸和中心点 + const center = modelViewer.getBoundingBoxCenter(); + const size = modelViewer.getDimensions(); + const x2 = size.x / 2; + const y2 = size.y / 2; + const z2 = size.z / 2; + + // 2. 定义所有热点的新位置映射 + const hotspots = [ + { name: "hotspot-dot+X-Y+Z", pos: `${center.x + x2} ${center.y - y2} ${center.z + z2}` }, + { name: "hotspot-dim+X-Y", pos: `${center.x + x2} ${center.y - y2} ${center.z}` }, + { name: "hotspot-dot+X-Y-Z", pos: `${center.x + x2} ${center.y - y2} ${center.z - z2}` }, + { name: "hotspot-dim+X-Z", pos: `${center.x + x2} ${center.y} ${center.z - z2}` }, + { name: "hotspot-dot+X+Y-Z", pos: `${center.x + x2} ${center.y + y2} ${center.z - z2}` }, + { name: "hotspot-dim+Y-Z", pos: `${center.x} ${center.y + y2} ${center.z - z2}` }, + { name: "hotspot-dot-X+Y-Z", pos: `${center.x - x2} ${center.y + y2} ${center.z - z2}` }, + { name: "hotspot-dim-X-Z", pos: `${center.x - x2} ${center.y} ${center.z - z2}` }, + { name: "hotspot-dot-X-Y-Z", pos: `${center.x - x2} ${center.y - y2} ${center.z - z2}` }, + { name: "hotspot-dim-X-Y", pos: `${center.x - x2} ${center.y - y2} ${center.z}` }, + { name: "hotspot-dot-X-Y+Z", pos: `${center.x - x2} ${center.y - y2} ${center.z + z2}` }, + { name: "hotspot-dim-Y+Z", pos: `${center.x} ${center.y - y2} ${center.z + z2}` } + ]; + + // 3. 循环更新每一个 Hotspot + hotspots.forEach(hs => { + const el = modelViewer.querySelector(`button[slot="${hs.name}"]`); + if (el) { + el.dataset.position = hs.pos; + modelViewer.updateHotspot({ + name: hs.name, + position: hs.pos + }); + } + }); + + // 4. 更新文本标签内容 + drawLabels(size); + + // 5. 显示线容器 + dimensionLineContainer.classList.add("loaded"); + + // 6. 启动动画帧同步循环 + requestAnimationFrame(tick); + }); + + // Add the text in appropriate units based off of the radio button + const toInchesConversion = 39.3701; + + function drawLabels(size) { + if (document.getElementById("cms").checked == true) { + modelViewer.querySelector('button[slot="hotspot-dim+X-Y"]').textContent = `${(size.z * 100).toFixed(1)} cm`; + modelViewer.querySelector('button[slot="hotspot-dim+X-Z"]').textContent = `${(size.y * 100).toFixed(1)} cm`; + modelViewer.querySelector('button[slot="hotspot-dim+Y-Z"]').textContent = `${(size.x * 100).toFixed(1)} cm`; + modelViewer.querySelector('button[slot="hotspot-dim-X-Z"]').textContent = `${(size.y * 100).toFixed(1)} cm`; + modelViewer.querySelector('button[slot="hotspot-dim-X-Y"]').textContent = `${(size.z * 100).toFixed(1)} cm`; + modelViewer.querySelector('button[slot="hotspot-dim-Y+Z"]').textContent = `${(size.x * 100).toFixed(1)} cm`; + } else { + modelViewer.querySelector('button[slot="hotspot-dim+X-Y"]').textContent = `${(size.z * toInchesConversion).toFixed(1)} in`; + modelViewer.querySelector('button[slot="hotspot-dim+X-Z"]').textContent = `${(size.y * toInchesConversion).toFixed(1)} in`; + modelViewer.querySelector('button[slot="hotspot-dim+Y-Z"]').textContent = `${(size.x * toInchesConversion).toFixed(1)} in`; + modelViewer.querySelector('button[slot="hotspot-dim-X-Z"]').textContent = `${(size.y * toInchesConversion).toFixed(1)} in`; + modelViewer.querySelector('button[slot="hotspot-dim-X-Y"]').textContent = `${(size.z * toInchesConversion).toFixed(1)} in`; + modelViewer.querySelector('button[slot="hotspot-dim-Y+Z"]').textContent = `${(size.x * toInchesConversion).toFixed(1)} in`; + } + } + + // Update labels if dimensions change manually or via radio toggle + const updateDimensionLabels = () => drawLabels(modelViewer.getDimensions()); + + modelViewer.addEventListener("change", updateDimensionLabels); + + const cmsRadio = modelViewer.parentElement.querySelector("#cms"); + const inchesRadio = modelViewer.parentElement.querySelector("#inches"); + if (cmsRadio) cmsRadio.addEventListener("change", updateDimensionLabels); + if (inchesRadio) inchesRadio.addEventListener("change", updateDimensionLabels); +} diff --git a/ar_icon.png b/ar_icon.png new file mode 100644 index 0000000..761053c --- /dev/null +++ b/ar_icon.png @@ -0,0 +1 @@ +../.git/annex/objects/jP/v4/MD5E-s679--416a7e6684db1faa70f007820473fc30.png/MD5E-s679--416a7e6684db1faa70f007820473fc30.png \ No newline at end of file diff --git a/default.html b/default.html index 6fc9396..0e13877 100644 --- a/default.html +++ b/default.html @@ -11,6 +11,7 @@ +
@@ -52,6 +53,7 @@

CARCAS

- + + diff --git a/global.css b/global.css new file mode 100644 index 0000000..91a632c --- /dev/null +++ b/global.css @@ -0,0 +1,219 @@ +:not(:defined) > * { + display: none; +} + +body { + margin: 0; + padding: 0; + width: 100vw; + height: 100vh; +} + +model-viewer { + width: 100%; + height: 90%; + background-color: #ffffff; +} + +.progress-bar { + display: block; + width: 33%; + height: 10%; + max-height: 2%; + position: absolute; + left: 50%; + top: 50%; + transform: translate3d(-50%, -50%, 0); + border-radius: 25px; + box-shadow: + 0px 3px 10px 3px rgba(0, 0, 0, 0.5), + 0px 0px 5px 1px rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.9); + background-color: rgba(0, 0, 0, 0.5); +} + +.progress-bar.hide { + visibility: hidden; + transition: visibility 0.3s; +} + +.update-bar { + background-color: rgba(255, 255, 255, 0.9); + width: 0%; + height: 100%; + border-radius: 25px; + float: left; + transition: width 0.3s; +} + +#ar-button { + background-image: url(ar_icon.png); + background-repeat: no-repeat; + background-size: 20px 20px; + background-position: 12px 50%; + background-color: #fff; + position: absolute; + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + top: 16px; + padding: 0px 16px 0px 40px; + font-family: Roboto Regular, Helvetica Neue, sans-serif; + font-size: 14px; + color:#4285f4; + height: 36px; + line-height: 36px; + border-radius: 18px; + border: 1px solid #DADCE0; +} + +#ar-button:active { + background-color: #e8eaed; +} + +#ar-button:focus { + outline: none; +} + +#ar-button:focus-visible { + outline: 1px solid #4285f4; +} + +@keyframes circle { + from { + transform: translateX(-50%) rotate(0deg) translateX(50px) rotate(0deg); + } + to { + transform: translateX(-50%) rotate(360deg) translateX(50px) rotate(-360deg); + } +} + +@keyframes elongate { + from { + transform: translateX(100px); + } + to { + transform: translateX(-100px); + } +} + +model-viewer > #ar-prompt { + position: absolute; + left: 50%; + bottom: 60px; + animation: elongate 2s infinite ease-in-out alternate; + display: none; +} + +model-viewer[ar-status="session-started"] > #ar-prompt { + display: block; +} + +model-viewer > #ar-prompt > img { + animation: circle 4s linear infinite; +} + +#controls { + /* position: absolute; + top: 0; + left: 0; */ + max-width: unset; + transform: unset; + pointer-events: auto; + display: inline-block; +} + +#controlContainer { + width: 100%; + position: absolute; + text-align: center; + pointer-events: none; + z-index: 20; +} + +@media (min-width: 768px) { + #controlContainer { + top: 8px; + text-align: left; + left: 8px; + } +} + +@media (max-width: 767px) { + #controlContainer { + bottom: 8px; + left: 0; + } +} + +.dot { + display: block; + width: 12px; + height: 12px; + border-radius: 50%; + border: none; + box-sizing: border-box; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); + background: #fff; + pointer-events: none; + --min-hotspot-opacity: 0; +} + +.dim { + background: #fff; + border-radius: 4px; + border: none; + box-sizing: border-box; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); + color: rgba(0, 0, 0, 0.8); + display: inline-block; + font-family: + Futura, + Helvetica Neue, + sans-serif; + font-size: 18px; + font-weight: 700; + max-width: 128px; + overflow-wrap: break-word; + padding: 0.5em 1em; + /* position: absolute; */ + width: max-content; + height: max-content; + transform: translate3d(-50%, -50%, 0); + pointer-events: none; + --min-hotspot-opacity: 0; +} + +.dimensionLineContainer { + pointer-events: none; + display: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10; + /* display: block; */ +} + +.dimensionLineContainer.loaded { + display: block; +} + +.dimensionLine { + stroke: #16a5e6; + stroke-width: 2; + stroke-dasharray: 2; +} + +.show { + --min-hotspot-opacity: 1; +} + +.hide { + display: none; +} +/* This keeps child nodes hidden while the element loads */ +/* :not(:defined) > * { + display: none; +} */ diff --git a/script.js b/script.js index 8d84186..e60f2f6 100644 --- a/script.js +++ b/script.js @@ -1,6 +1,34 @@ +const MODEL_BASE_URL = 'https://3dviewer.sites.carleton.edu/carcas/carcas-models/models/'; +import { initDimensionLines } from './addDimLines.js'; + +// KNOWN_GLB_FILES and local index removed in favor of dynamic API data + document.addEventListener("DOMContentLoaded", async () => { const contentArea = document.getElementById("content-area"); - + const getGlbFileName = (item) => { + const link = item['Link to 3D Viewer']; + if (!link) return null; + + // 1. Remove .html extension and trim + let fileName = link.trim().replace(/\.html?$/i, ''); + + // 2. Replace hyphens with spaces + fileName = fileName.replace(/-/g, ' '); + + // 3. Title Case: Capitalize first letter of each word + // Also capitalize letter immediately after an opening parenthesis + fileName = fileName.replace(/(?:^|\s|\()\w/g, (match) => { + return match.toUpperCase(); + }); + + // 4. Ensure .glb suffix + if (!fileName.toLowerCase().endsWith('.glb')) { + fileName += '.glb'; + } + //this part will be removed after the dynamic API data is fixed + return fileName; + }; + // Global variables for specimen data let allSpecimens = []; let animalGroups = {}; @@ -43,6 +71,9 @@ document.addEventListener("DOMContentLoaded", async () => { console.log('Animal groups:', Object.keys(animalGroups)); console.log('Bone groups:', Object.keys(boneGroups)); + populateAnimalDropdown(); + populateBoneDropdown(); + } catch (error) { console.error('Error fetching specimen data:', error); } @@ -66,9 +97,22 @@ document.addEventListener("DOMContentLoaded", async () => { `; animalDropdown.appendChild(animalItem); @@ -93,9 +137,22 @@ document.addEventListener("DOMContentLoaded", async () => { `; boneDropdown.appendChild(boneItem); @@ -103,24 +160,94 @@ document.addEventListener("DOMContentLoaded", async () => { }; // Initialize data and populate dropdowns + // buildFileIndex() removed await fetchSpecimenData(); - populateAnimalDropdown(); - populateBoneDropdown(); + // Dropdowns are populated inside fetchSpecimenData on success const loadContent = (html) => { - contentArea.style.opacity = '0'; - setTimeout(() => { - contentArea.innerHTML = html; - contentArea.style.opacity = '1'; - - // If this is the specimens page, set up the search functionality - const searchInput = document.getElementById('specimen-search'); - if (searchInput) { - setupSearch(); - } - }, 300); + return new Promise((resolve) => { + contentArea.style.opacity = '0'; + setTimeout(() => { + contentArea.innerHTML = html; + contentArea.style.opacity = '1'; + + // If this is the specimens page, set up the search functionality + const searchInput = document.getElementById('specimen-search'); + if (searchInput) { + setupSearch(); + } + resolve(); + }, 300); + }); }; + + const loadGlbViewerFromSrc = async (src) => { + const baseUrl = 'https://3dviewer.sites.carleton.edu/carcas/carcas-models/models/'; + const fileName = decodeURIComponent(src); + const modelUrl = baseUrl + encodeURIComponent(fileName); + const title = fileName.replace(/\.glb$/i, ''); + + await loadContent(` +
+

${title}

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ +
+
+
+
+
+ `); + + + const modelViewer = document.querySelector('model-viewer'); + + + initDimensionLines(modelViewer); +}; +window.loadGlbViewerFromSrc = loadGlbViewerFromSrc; + const renderSpecimens = (filteredSpecimens) => { if (filteredSpecimens.length === 0) { return ` @@ -132,47 +259,34 @@ document.addEventListener("DOMContentLoaded", async () => { } return filteredSpecimens.map(specimen => { - // Handle both old format (hardcoded) and new format (API data) - if (specimen.name && specimen.file) { - // Old format - return ` -
-
- -
-
-

${specimen.name.charAt(0).toUpperCase() + specimen.name.slice(1)}

- + onerror="this.style.display='none'; this.parentElement.innerHTML='
No Preview
'" + />
+
+

${animalName}

+

${boneName}

+
- `; - } else { - // New format from API - const modelId = specimen['Link to 3D Viewer']; - const animalName = specimen['Common Name'] || 'Unknown'; - const boneName = specimen['Bone Display Name'] || ''; - - return ` -
-
- ${animalName} ${boneName} -
-
-

${animalName}

-

${boneName}

- -
-
- `; - } +
+ `; }).join(''); }; @@ -286,9 +400,11 @@ document.addEventListener("DOMContentLoaded", async () => { // Handle submenu item clicks (bone/specimen selection) if (e.target.classList.contains('submenu-item')) { e.preventDefault(); - const model = e.target.dataset.model; - if (model) { - loadModelViewer(model); + const filename = e.target.dataset.filename; + if (filename) { + window.location.href = `?src=${encodeURIComponent(filename)}`; + } else { + console.error("Missing GLB file for this item"); } } @@ -305,8 +421,12 @@ document.addEventListener("DOMContentLoaded", async () => { // Handle grid view model button clicks else if (e.target.classList.contains('scan-button')) { e.preventDefault(); - const model = e.target.dataset.model; - loadModelViewer(model); + const filename = e.target.dataset.filename; + if (filename) { + window.location.href = `?src=${encodeURIComponent(filename)}`; + } else { + console.error("Missing GLB file for this item"); + } } // Handle back button clicks @@ -331,9 +451,19 @@ document.addEventListener("DOMContentLoaded", async () => { } }); - // Load search page by default after everything is set up - loadSearchPage("Search", "Search through our specimen collection:"); + // Check for URL parameters to load a specific model directly + const urlParams = new URLSearchParams(window.location.search); + const srcParam = urlParams.get('src'); + + if (srcParam) { + // If ?src=xxx exists, load the model viewer directly + // decodeURIComponent handles spaces/special characters (e.g. "Alligator Skull.glb") + loadGlbViewerFromSrc(srcParam); + } else { + // Otherwise, load the default search page + loadSearchPage("Search", "Search through our specimen collection:"); + } - // Make loadSearchPage available globally if needed - window.loadSearchPage = loadSearchPage; -}); \ No newline at end of file + // Keep the global exposure for debugging + window.loadGlbViewerFromSrc = loadGlbViewerFromSrc; +}); diff --git a/styles.css b/styles.css index 7792986..67418c0 100644 --- a/styles.css +++ b/styles.css @@ -588,3 +588,55 @@ body::before { } +.dimensionLineContainer, +svg#dimLines { + pointer-events: none !important; + z-index: 100; +} + + + +.dimensionLine { + stroke: #16a5e6 !important; + stroke-width: 1.5px !important; + stroke-dasharray: 5, 5 !important; + opacity: 0.8 !important; +} + + +#controlContainer { + position: absolute !important; + top: 10px !important; + left: 10px !important; + width: auto !important; + height: auto !important; + z-index: 1000 !important; + pointer-events: auto !important; + opacity: 1 !important; +} + + +#controls { + pointer-events: auto !important; + background: rgba(255, 255, 255, 0.9); + padding: 10px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + display: block !important; + opacity: 1 !important; +} + + +#ar-button { + display: none !important; +} + +button.dim { + pointer-events: none; + opacity: 0; + transition: opacity 0.4s ease-in-out; +} + +button.dim.visible { + opacity: 1; +}