diff --git a/docs/.vitepress/theme/components/XingshuCampusMap.vue b/docs/.vitepress/theme/components/XingshuCampusMap.vue index a61c2c7..d9fd891 100644 --- a/docs/.vitepress/theme/components/XingshuCampusMap.vue +++ b/docs/.vitepress/theme/components/XingshuCampusMap.vue @@ -24,6 +24,21 @@ type SemanticLayer = { sourceWayCount: number; }; +type ReferenceMap = { + uri: string; + bytes: number; + sha256: string; + source: string; + defaultVisible: boolean; + imageSizePx: [number, number]; + fitRmsM: number; + pixelToLocalAffine: { + x: [number, number, number]; + y: [number, number, number]; + }; + campusBoundaryLocalEN: Array<[number, number]>; +}; + type CampusManifest = { status: string; presentation: { @@ -36,6 +51,7 @@ type CampusManifest = { triangles: number; featureCount: number; }; + referenceMap?: ReferenceMap; semanticLayers: SemanticLayer[]; features: CampusFeature[]; attribution: Array<{ @@ -57,6 +73,9 @@ const isFullscreen = ref(false); const showGround = ref(true); const showCampusRoads = ref(true); const showOuterRoads = ref(true); +const showReferenceMap = ref(false); +const referenceLoading = ref(false); +const referenceError = ref(''); const features = computed(() => [...(manifest.value?.features ?? [])].sort((left, right) => @@ -80,6 +99,9 @@ let animationFrame = 0; let modelRoot: THREE.Object3D | undefined; let campusBounds: THREE.Box3 | undefined; let selectionHelper: THREE.Box3Helper | undefined; +let manifestAssetUrl: URL | undefined; +let referenceMapMesh: THREE.Mesh | undefined; +let referenceMapTexture: THREE.Texture | undefined; const featureRoots = new Map(); const semanticNodes = new Map(); const selectableMeshes: THREE.Mesh[] = []; @@ -180,6 +202,111 @@ function applyLayerVisibility() { if (outerRoads) outerRoads.visible = showOuterRoads.value; } +function pointInBoundary(point: [number, number], boundary: Array<[number, number]>) { + let inside = false; + for (let index = 0, previous = boundary.length - 1; index < boundary.length; previous = index++) { + const [currentX, currentY] = boundary[index]; + const [previousX, previousY] = boundary[previous]; + const crosses = + currentY > point[1] !== previousY > point[1] && + point[0] < + ((previousX - currentX) * (point[1] - currentY)) / (previousY - currentY) + currentX; + if (crosses) inside = !inside; + } + return inside; +} + +async function createReferenceMap() { + const reference = manifest.value?.referenceMap; + if (!reference || !manifestAssetUrl || !renderer || !scene) return undefined; + + const textureUrl = new URL(reference.uri, manifestAssetUrl).href; + const texture = await new THREE.TextureLoader().loadAsync(textureUrl); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy()); + referenceMapTexture = texture; + + const positions: number[] = []; + const uvs: number[] = []; + const cells = 28; + const [width, height] = reference.imageSizePx; + const mapPixel = (pixelX: number, pixelY: number): [number, number] => [ + reference.pixelToLocalAffine.x[0] * pixelX + + reference.pixelToLocalAffine.x[1] * pixelY + + reference.pixelToLocalAffine.x[2], + reference.pixelToLocalAffine.y[0] * pixelX + + reference.pixelToLocalAffine.y[1] * pixelY + + reference.pixelToLocalAffine.y[2], + ]; + + for (let row = 0; row < cells; row += 1) { + for (let column = 0; column < cells; column += 1) { + const x0 = (column / cells) * width; + const x1 = ((column + 1) / cells) * width; + const y0 = (row / cells) * height; + const y1 = ((row + 1) / cells) * height; + const center = mapPixel((x0 + x1) / 2, (y0 + y1) / 2); + if (!pointInBoundary(center, reference.campusBoundaryLocalEN)) continue; + + const corners: Array<[number, number]> = [ + [x0, y0], + [x1, y0], + [x1, y1], + [x0, y1], + ]; + const mapped = corners.map(([pixelX, pixelY]) => mapPixel(pixelX, pixelY)); + for (const cornerIndex of [0, 1, 2, 0, 2, 3]) { + const [localX, localNorth] = mapped[cornerIndex]; + const [pixelX, pixelY] = corners[cornerIndex]; + positions.push(localX, 0.16, -localNorth); + uvs.push(pixelX / width, 1 - pixelY / height); + } + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + const material = new THREE.MeshBasicMaterial({ + map: texture, + opacity: 0.56, + polygonOffset: true, + polygonOffsetFactor: -3, + polygonOffsetUnits: -3, + side: THREE.DoubleSide, + transparent: true, + depthTest: true, + depthWrite: false, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.name = 'reference-campus-map'; + mesh.renderOrder = 12; + mesh.visible = false; + scene.add(mesh); + return mesh; +} + +async function applyReferenceMapVisibility() { + if (!showReferenceMap.value) { + if (referenceMapMesh) referenceMapMesh.visible = false; + return; + } + if (!manifest.value?.referenceMap || !scene) return; + + referenceLoading.value = true; + referenceError.value = ''; + try { + referenceMapMesh ??= await createReferenceMap(); + if (!referenceMapMesh) throw new Error('参考图配置不完整'); + referenceMapMesh.visible = true; + } catch (error) { + referenceError.value = error instanceof Error ? error.message : '参考图加载失败'; + showReferenceMap.value = false; + } finally { + referenceLoading.value = false; + } +} + function resizeRenderer() { if (!host.value || !renderer || !camera) return; const width = Math.max(1, host.value.clientWidth); @@ -228,9 +355,11 @@ async function initializeMap() { if (!host.value) return; try { const manifestUrl = new URL(withBase('/maps/xingshu-campus/manifest.json'), location.href); + manifestAssetUrl = manifestUrl; const response = await fetch(manifestUrl); if (!response.ok) throw new Error(`地图清单加载失败(HTTP ${response.status})`); manifest.value = (await response.json()) as CampusManifest; + showReferenceMap.value = manifest.value.referenceMap?.defaultVisible ?? false; if (manifest.value.presentation.renderedRoofCards) { throw new Error('地图包意外包含屋顶字牌,已停止加载。'); } @@ -334,6 +463,12 @@ function disposeMap() { } controls?.dispose(); if (selectionHelper) disposeHelper(selectionHelper); + if (referenceMapMesh) { + scene?.remove(referenceMapMesh); + referenceMapMesh.geometry.dispose(); + referenceMapMesh.material.dispose(); + } + referenceMapTexture?.dispose(); for (const outline of generatedOutlines) outline.geometry.dispose(); outlineMaterial.dispose(); modelRoot?.traverse((object) => { @@ -346,6 +481,7 @@ function disposeMap() { } watch([showGround, showCampusRoads, showOuterRoads], applyLayerVisibility); +watch(showReferenceMap, () => void applyReferenceMapVisibility()); onMounted(initializeMap); onBeforeUnmount(disposeMap); @@ -408,7 +544,16 @@ onBeforeUnmount(disposeMap); + + {{ referenceError }}
{{ manifest?.model.featureCount ?? 0 }} 栋 {{ manifest?.model.triangles.toLocaleString() ?? '—' }} 三角面 @@ -669,6 +814,17 @@ onBeforeUnmount(disposeMap); accent-color: var(--map-seal); } +.xingshu-map-layers input:disabled { + cursor: wait; + opacity: 0.55; +} + +.xingshu-map-layer-error { + color: var(--map-seal); + font-size: 10px; + font-weight: 700; +} + .xingshu-map-stats { grid-column: 1 / -1; } diff --git a/docs/public/maps/xingshu-campus/manifest.json b/docs/public/maps/xingshu-campus/manifest.json index 3ca5f5b..23b0311 100644 --- a/docs/public/maps/xingshu-campus/manifest.json +++ b/docs/public/maps/xingshu-campus/manifest.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "datasetId": "cwnu-xingshu", - "releaseId": "s14-site-draft", + "releaseId": "s14-site-reference", "status": "needs-review", "generatedAt": "2026-08-02T13:19:46.602Z", "coordinateSystem": { @@ -33,6 +33,41 @@ "meshes": 50, "featureCount": 42 }, + "referenceMap": { + "uri": "xingshu-campus-reference.png", + "bytes": 1574946, + "sha256": "906de61faf9b6ddb85618ebfacfa0c116a02786635b9b1329893064f1b5e3885", + "source": "网络来源", + "defaultVisible": false, + "imageSizePx": [1280, 1329], + "fitRmsM": 6.297, + "pixelToLocalAffine": { + "x": [0.497944055, 0.019691733, -303.734147], + "y": [-0.0141137944, -0.481156996, 602.420555] + }, + "campusBoundaryLocalEN": [ + [-143.5301025105873, 566.8832062925212], + [-206.76443840039428, 369.2943924115971], + [-237.3207488671178, 251.24063476361334], + [-267.3956757232081, 136.76076205493882], + [0, 0], + [39.35447751090396, 113.40685947332531], + [202.3825763502391, 69.4223837070167], + [251.08777147287037, 225.61720975954086], + [201.57798497809563, 237.92723582871258], + [230.5007169349119, 337.5113919307478], + [190.3230805749772, 349.52420246461406], + [152.9643743833294, 360.9327372414991], + [161.519735801965, 395.2437522010878], + [127.6297509855358, 436.6909156870097], + [116.94227564590983, 462.3356916070916], + [76.53639700484928, 473.2826128178276], + [25.46947207581252, 420.3812082638033], + [-12.552449640934356, 435.10912372218445], + [34.47880723909475, 518.3972451174632], + [-109.13522775517777, 587.7331380210817] + ] + }, "semanticLayers": [ { "id": "campus-ground", @@ -684,6 +719,6 @@ "limitations": [ "学生维护的非官方白模,不是测绘成果,也不能代替校园导航或应急疏散图。", "建筑名称、用途、轮廓与高度仍有待核验项;请以学校现场标识和正式通知为准。", - "模型不包含全景图片、功能图、校徽、个人信息或任何受限参考图层。" + "模型不包含全景图片、校徽或个人信息;参考原图默认关闭且仅用于目视参考。" ] } diff --git a/docs/public/maps/xingshu-campus/xingshu-campus-reference.png b/docs/public/maps/xingshu-campus/xingshu-campus-reference.png new file mode 100644 index 0000000..f20a725 Binary files /dev/null and b/docs/public/maps/xingshu-campus/xingshu-campus-reference.png differ diff --git a/docs/start/xingshu-campus-map.md b/docs/start/xingshu-campus-map.md index fcb54b5..9d9e6a9 100644 --- a/docs/start/xingshu-campus-map.md +++ b/docs/start/xingshu-campus-map.md @@ -36,13 +36,13 @@ sources: - 本图不是学校发布的校园导航图、测绘成果或应急疏散图。 - 建筑名称、用途、轮廓、高度和现状仍有待核验项,请以现场标识和学校正式通知为准。 -- 模型不包含全景图片、校徽、功能图、水印、个人信息或受限参考图层。 +- 模型不包含全景图片、校徽或个人信息;参考原图默认关闭且仅用于目视参考。 - 可发布道路与地面语义主要依据 OpenStreetMap 矢量数据,并保留贡献者署名。 - 为避免遮挡和方向错误,建筑名称只显示在选择器与信息面板,不放置屋顶字牌。 ## 模型范围 当前版本包含 42 栋可点选白模、校内与外围道路、运动场、水体和校园地面。 -模型采用宣纸水墨风格,主要用于新生熟悉空间关系和后续共同校对。 +模型采用宣纸水墨风格,并提供默认关闭的参考原图图层,主要用于新生熟悉空间关系和后续共同校对。 如果发现建筑缺失、名称错误或位置明显偏差,欢迎通过页面底部的 GitHub 编辑入口提交修正。