diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 0000000..3b270a2 --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -0,0 +1,27 @@ +# Task 2 Report + +Status: DONE_WITH_CONCERNS + +Files changed: +- `src/data/adapters/sgsSdkGuideAdapter.ts` +- `src/domain/museum.ts` + +Commands run: +- `node scripts/check-sgs-hall-poi-adapter.mjs` + - Output: `SGS hall POI adapter smoke check passed` +- `pnpm type-check` + - Output summary: `vue-tsc --noEmit` completed successfully. + +Commits created: +- `08a6bcb` in implementer worktree: `feat: expand SGS hall POI adaptation` +- Main workspace integrated manually because cherry-pick was blocked by existing local changes to the same files. + +Self-review notes: +- Adapter now includes public exhibition-space whitelist and keyword checks. +- `toMuseumHallPoisFromSgs` now supports entrance anchors, space-center fallback, standalone space fallback, and diagnostics helper. +- Main integration preserves stronger Task 1 semantics approved by user: `backend-sgs-sdk-hall-entrance` requires both entrance coordinates and route node, and later real entrance anchors can upgrade a space-center POI. +- `src/domain/museum.ts` already had/keeps typed hall fields (`MuseumPoiKind`, `MuseumPoiEntrance`, hall/source fields, preview fields) required by adapter output. + +Concerns: +- Manual integration was required because the main workspace already had overlapping uncommitted changes in adapter/domain files. +- Task 3 will add repository diagnostics consumption of `createSgsHallPoiDiagnostics`. diff --git a/docs/superpowers/plans/2026-06-29-sgs-hall-poi-display.md b/docs/superpowers/plans/2026-06-29-sgs-hall-poi-display.md new file mode 100644 index 0000000..e03a47c --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-sgs-hall-poi-display.md @@ -0,0 +1,902 @@ +# SGS Hall POI Display Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make SGS SDK mode generate and display exhibition hall/public exhibition-space POIs reliably in the single-floor 3D model, with diagnostics for silent data failures. + +**Architecture:** Keep the existing provider/adapter/repository/renderer boundary. Expand SGS `spaces + navigablePlaces` adaptation inside `sgsSdkGuideAdapter.ts`, keep `GuideModelRepository` as the model-POI source, and keep `ThreeMap` consuming `GuideRenderPoi` without reading SGS payloads directly. + +**Tech Stack:** Vue 3, uni-app H5, TypeScript 5.3, Three.js 0.163, pnpm, `vue-tsc`, ESLint. + +## Global Constraints + +- Default runtime target is H5; do not add mini-program/mp-weixin work. +- Pages and presentation components must not import or parse `/app-api/gis/sdk/*` response shapes. +- Keep guide capability wording as “位置预览” / “查看位置”; do not claim certified navigation. +- `spaces` represent museum spaces; `navigablePlaces` represent entrances/reachable destinations; route planning can only use entrance route nodes. +- Do not add dependencies. +- Avoid unrelated refactors and broad data cleanup. +- Validation baseline after code changes: `pnpm type-check`, `pnpm lint`, `pnpm build:h5`. + +--- + +## File Structure + +- Modify: `src/data/adapters/sgsSdkGuideAdapter.ts` + - Owns SGS payload-to-domain conversion. + - Add public helpers for eligible exhibition-space classification, hall-place matching, space center fallback, and hall POI diagnostics. + - Keep raw SGS fields contained in this adapter. + +- Modify: `src/repositories/GuideModelRepository.ts` + - Owns model render package and floor render POI loading. + - Add development diagnostics around failed SGS floor data requests and hall POI generation counts. + - Continue returning only `GuideRenderPoi[]` to `ThreeMap`. + +- Modify: `src/components/map/ThreeMap.vue` + - Owns 3D presentation and POI visibility. + - Confirm floor/detail mode does not hide valid hall POIs through distance/limit pruning. If necessary, add a focused guard so hall POIs remain visible in floor mode only. + +- Create: `scripts/check-sgs-hall-poi-adapter.mjs` + - Node-based smoke script for the adapter algorithm using representative SGS payload fixtures. + - It does not import TypeScript project files, so no new test runner is required. + - It verifies expected hall/public-space POI outputs, classification behavior, entrance-vs-space-center source semantics, entrance-anchor upgrade behavior, and ID-based assertions. + +- Reference: `docs/superpowers/specs/2026-06-29-sgs-hall-poi-display-design.md` + - Implementation must match this spec. + +--- + +### Task 1: Adapter classification and fallback smoke coverage + +**Files:** +- Create: `scripts/check-sgs-hall-poi-adapter.mjs` + +**Interfaces:** +- Consumes: No project imports. Uses local JS functions mirroring the intended adapter behavior. +- Produces: A runnable smoke command: `node scripts/check-sgs-hall-poi-adapter.mjs`. + +- [ ] **Step 1: Create the failing smoke script** + +Create `scripts/check-sgs-hall-poi-adapter.mjs` with this exact content: + +```js +const assert = require('node:assert/strict') + +const exhibitionSpaceTypeWhitelist = new Set([ + 'exhibition_hall', + 'theater', + 'education_activity', + 'ramp' +]) + +const exhibitionSpaceKeywords = [ + '展厅', + '临展', + '展览', + '影院', + '球幕', + '巨幕', + '报告厅', + '活动室', + '科普', + '实验室', + '展览坡道', + '宇宙', + '地球', + '演化', + '恐龙', + '人类', + '生物', + '生态', + '家园' +] + +const normalizedText = (value) => (value || '').trim() +const normalizedHallName = (value) => normalizedText(value).replace(/[\s()()【】\[\]_-]/g, '') +const hasExhibitionKeyword = (value) => exhibitionSpaceKeywords.some((keyword) => normalizedText(value).includes(keyword)) + +const isEligiblePublicExhibitionSpace = (space) => { + const type = normalizedText(space.type).toLowerCase() + const name = normalizedText(space.name) + + if (type === 'exhibition_hall') return true + if (type === 'theater') return hasExhibitionKeyword(name) + if (type === 'education_activity') return hasExhibitionKeyword(name) + if (type === 'ramp') return name.includes('展览坡道') + + return false +} + +const placeHallName = (place) => ( + normalizedText(place.ownerName) + || normalizedText(place.name) + .replace(/(?:主)?(?:出入口|入口|出口|门)\s*[A-Za-z0-9一二三四五六七八九十号-]*$/u, '') + .trim() + || normalizedText(place.name) +) + +const isExhibitionHallNavigablePlace = (place) => { + const searchableText = [ + place.name, + place.ownerName, + place.category, + place.type, + place.typeCode, + place.typeName + ] + .map(normalizedText) + .filter(Boolean) + .join(' ') + + return hasExhibitionKeyword(searchableText) +} + +const findMatchedHallSpace = (place, spaces) => { + const placeName = normalizedHallName(placeHallName(place)) + if (!placeName) return undefined + + return spaces.find((space) => { + const spaceName = normalizedHallName(space.name) + return spaceName && (placeName.includes(spaceName) || spaceName.includes(placeName)) + }) +} + +const normalizePositionSource = (source) => { + const x = Number(source?.x) + const y = Number(source?.y) + const z = Number(source?.z) + if (![x, y, z].every(Number.isFinite)) return undefined + return [x, y, z] +} + +const normalizePlacePosition = (place) => normalizePositionSource( + place.position + ? { x: place.position.x, y: place.position.y ?? 0, z: place.position.z } + : { x: place.x, y: place.y ?? 0, z: place.z } +) + +const normalizeSpaceCenter = (space) => normalizePositionSource(space.center || {}) + +const createHallPoiId = (space, place, fallbackIndex) => { + if (space?.id) return `hall-${space.id}` + const ownerName = normalizedHallName(placeHallName(place)) + if (ownerName) return `hall-place-${ownerName}` + return `hall-place-${place.id || fallbackIndex + 1}` +} + +const toHallPois = (spaces, navigablePlaces, fallbackFloorId) => { + const hallSpaces = spaces.filter(isEligiblePublicExhibitionSpace) + const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace) + const halls = new Map() + + hallPlaces.forEach((place, index) => { + const matchedSpace = findMatchedHallSpace(place, hallSpaces) + const entrancePosition = normalizePlacePosition(place) + const fallbackPosition = matchedSpace ? normalizeSpaceCenter(matchedSpace) : undefined + const position = entrancePosition || fallbackPosition + const poiId = createHallPoiId(matchedSpace, place, index) + + if (!position) return + + const existing = halls.get(poiId) + if (existing) { + existing.entrances.push({ name: place.name, positionGltf: entrancePosition }) + return + } + + halls.set(poiId, { + id: poiId, + name: matchedSpace?.name || placeHallName(place), + floorId: String(place.floorId || matchedSpace?.floorId || fallbackFloorId), + primaryCategory: 'exhibition_hall', + positionGltf: position, + sourceConfidence: entrancePosition ? 'backend-sgs-sdk-hall-entrance' : 'backend-sgs-sdk-space-center', + entrances: [{ name: place.name, positionGltf: entrancePosition }] + }) + }) + + hallSpaces.forEach((space, index) => { + const poiId = `hall-${space.id}` + if (halls.has(poiId)) return + const position = normalizeSpaceCenter(space) + if (!position) return + halls.set(poiId, { + id: poiId, + name: space.name, + floorId: String(space.floorId || fallbackFloorId), + primaryCategory: 'exhibition_hall', + positionGltf: position, + sourceConfidence: 'backend-sgs-sdk-space-center', + entrances: [] + }) + }) + + return Array.from(halls.values()) +} + +const spaces = [ + { id: 1, name: '展厅1宇宙厅', type: 'exhibition_hall', floorId: 'B2', center: { x: 10, y: 0, z: 20 } }, + { id: 2, name: '巨幕影院', type: 'theater', floorId: 'L1', center: { x: 30, y: 0, z: 40 } }, + { id: 3, name: '博物馆之友活动室', type: 'education_activity', floorId: 'L4', center: { x: 50, y: 0, z: 60 } }, + { id: 4, name: '展览坡道', type: 'ramp', floorId: 'L1.5', center: { x: 70, y: 0, z: 80 } }, + { id: 5, name: '茶水间', type: 'service', floorId: 'L1', center: { x: 90, y: 0, z: 100 } } +] + +const places = [ + { id: 101, name: '展厅1宇宙厅 出入口 1', ownerName: '展厅1宇宙厅', floorId: 'B2', position: { x: 11, y: 0, z: 21 }, nodeId: 'n101' }, + { id: 102, name: '巨幕影院 出入口 1', ownerName: '巨幕影院', floorId: 'L1', position: { x: 31, y: 0, z: 41 }, nodeId: 'n102' } +] + +const result = toHallPois(spaces, places, 'B2') +const byName = new Map(result.map((poi) => [poi.name, poi])) + +assert.equal(result.length, 4) +assert.deepEqual(byName.get('展厅1宇宙厅').positionGltf, [11, 0, 21]) +assert.deepEqual(byName.get('巨幕影院').positionGltf, [31, 0, 41]) +assert.deepEqual(byName.get('博物馆之友活动室').positionGltf, [50, 0, 60]) +assert.deepEqual(byName.get('展览坡道').positionGltf, [70, 0, 80]) +assert.equal(byName.has('茶水间'), false) +assert.equal(byName.get('博物馆之友活动室').sourceConfidence, 'backend-sgs-sdk-space-center') + +console.log('SGS hall POI adapter smoke check passed') +``` + +- [ ] **Step 2: Run the smoke script and verify current intended behavior is captured** + +Run: + +```powershell +node scripts/check-sgs-hall-poi-adapter.mjs +``` + +Expected output: + +```text +SGS hall POI adapter smoke check passed +``` + +- [ ] **Step 3: Commit the smoke coverage** + +```powershell +git add scripts/check-sgs-hall-poi-adapter.mjs docs/superpowers/specs/2026-06-29-sgs-hall-poi-display-design.md +git commit -m "test: cover SGS hall POI display rules" +``` + +If the working tree already contains unrelated user changes, commit only the two listed files. + +--- + +### Task 2: Extend SGS hall-space adapter behavior + +**Files:** +- Modify: `src/data/adapters/sgsSdkGuideAdapter.ts:122-428` +- Test: `scripts/check-sgs-hall-poi-adapter.mjs` + +**Interfaces:** +- Consumes: `SgsSpacePayload.center?: SgsPositionPayload | null`, `SgsNavigablePlacePayload`, and existing `toMuseumHallPoisFromSgs(spaces, navigablePlaces, floors, fallbackFloorId): MuseumPoi[]`. +- Produces: + - `isExhibitionHallSpace(space: SgsSpacePayload): boolean` now returns true for public exhibition spaces. + - `toMuseumHallPoisFromSgs(...)` generates hall POIs from entrance coordinates and space-center fallback. + - `createSgsHallPoiDiagnostics(spaces, navigablePlaces, hallPois): SgsHallPoiDiagnostics` for repository diagnostics. + +- [ ] **Step 1: Add classification constants and diagnostics types** + +In `src/data/adapters/sgsSdkGuideAdapter.ts`, replace the current `exhibitionHallKeywords` block at lines around `263-274` with: + +```ts +const exhibitionSpaceTypeWhitelist = new Set([ + 'exhibition_hall', + 'theater', + 'education_activity', + 'ramp' +]) + +const exhibitionHallKeywords = [ + '展厅', + '临展', + '展览', + '影院', + '球幕', + '巨幕', + '报告厅', + '活动室', + '科普', + '实验室', + '展览坡道', + '宇宙', + '地球', + '演化', + '恐龙', + '人类', + '生物', + '生态', + '家园' +] + +export interface SgsHallPoiDiagnostics { + spaceCount: number + eligibleSpaceCount: number + navigablePlaceCount: number + hallPlaceCount: number + hallPoiCount: number + hallPoiWithPositionCount: number + skippedSpaceCount: number + skippedPlaceCount: number +} +``` + +- [ ] **Step 2: Add keyword and center helpers** + +Immediately after the constants from Step 1, add: + +```ts +const hasExhibitionKeyword = (value?: string | null) => ( + exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword)) +) + +const searchableSpaceText = (space: SgsSpacePayload) => [ + space.name, + space.type, + space.sourceNodeName +] + .map((value) => normalizedText(value)) + .filter(Boolean) + .join(' ') + +const normalizeSpaceCenter = (space: SgsSpacePayload): [number, number, number] | undefined => normalizePositionSource( + space.center || { + x: undefined, + y: undefined, + z: undefined + } +) +``` + +- [ ] **Step 3: Replace `isExhibitionHallSpace` implementation** + +Replace the existing function: + +```ts +export const isExhibitionHallSpace = (space: SgsSpacePayload) => ( + normalizedText(space.type).toLowerCase() === 'exhibition_hall' +) +``` + +with: + +```ts +export const isExhibitionHallSpace = (space: SgsSpacePayload) => { + const type = normalizedText(space.type).toLowerCase() + const name = normalizedText(space.name) + const searchableText = searchableSpaceText(space) + + if (!exhibitionSpaceTypeWhitelist.has(type)) return false + if (type === 'exhibition_hall') return true + if (type === 'ramp') return name.includes('展览坡道') + + return hasExhibitionKeyword(searchableText) +} +``` + +This keeps non-public service spaces such as 茶水间 out of hall POIs. + +- [ ] **Step 4: Update `isExhibitionHallNavigablePlace` to use the shared keyword helper** + +Inside `isExhibitionHallNavigablePlace`, replace: + +```ts +return exhibitionHallKeywords.some((keyword) => searchableText.includes(keyword)) +``` + +with: + +```ts +return hasExhibitionKeyword(searchableText) +``` + +- [ ] **Step 5: Add space fallback hall POI helper** + +After `createHallPoiId(...)`, add: + +```ts +const createSpaceFallbackHallPoi = ( + space: SgsSpacePayload, + floors: SgsSdkFloorSummaryPayload[], + fallbackFloorId: string +): MuseumPoi | null => { + const floorId = stringifyId(space.floorId) || fallbackFloorId + const position = normalizeSpaceCenter(space) + const hallId = stringifyId(space.id) + + if (!hallId || !normalizedText(space.name) || !position) return null + + return { + id: `hall-${hallId}`, + name: normalizedText(space.name), + floorId, + floorLabel: floorLabelForSgs(floorId, floors), + primaryCategory: hallCategory, + categories: [ + hallCategory + ], + positionGltf: position, + sourceObjectName: space.sourceNodeName || undefined, + sourceConfidence: 'backend-sgs-sdk-space-center', + navigationReadiness: '位置预览', + accessible: false, + kind: 'hall', + hallId, + hallName: normalizedText(space.name), + spaceId: hallId, + sourceSpaceId: hallId, + entrances: [] + } +} +``` + +- [ ] **Step 6: Update `toMuseumHallPoisFromSgs` to use fallback positions and space-only POIs** + +Inside `toMuseumHallPoisFromSgs`, keep the existing function signature. Replace the body with: + +```ts + const hallSpaces = spaces.filter(isExhibitionHallSpace) + const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace) + const halls = new Map() + + hallPlaces.forEach((place, index) => { + const matchedSpace = findMatchedHallSpace(place, hallSpaces) + const entrance = createHallEntrance(place, floors, fallbackFloorId) + const fallbackPosition = matchedSpace ? normalizeSpaceCenter(matchedSpace) : undefined + const floorId = entrance.floorId || stringifyId(matchedSpace?.floorId) || fallbackFloorId + const hallId = stringifyId(matchedSpace?.id) || undefined + const poiId = createHallPoiId(matchedSpace, place, index) + const hallName = normalizedText(matchedSpace?.name) || placeHallName(place) || entrance.name + const existing = halls.get(poiId) + + if (existing) { + existing.entrances = [ + ...(existing.entrances || []), + entrance + ] + if (!existing.positionGltf && (entrance.positionGltf || fallbackPosition)) { + existing.positionGltf = entrance.positionGltf || fallbackPosition + existing.floorId = floorId + existing.floorLabel = entrance.floorLabel + existing.sourceObjectName = entrance.sourceObjectName || matchedSpace?.sourceNodeName || undefined + existing.sourcePlaceId = entrance.sourcePlaceId + } + return + } + + const positionGltf = entrance.positionGltf || fallbackPosition + if (!positionGltf) return + + halls.set(poiId, { + id: poiId, + name: hallName, + floorId, + floorLabel: entrance.floorLabel, + primaryCategory: hallCategory, + categories: [ + hallCategory, + hallEntranceCategory + ], + positionGltf, + sourceObjectName: entrance.sourceObjectName || matchedSpace?.sourceNodeName || undefined, + sourceConfidence: entrance.positionGltf + ? 'backend-sgs-sdk-hall-entrance' + : 'backend-sgs-sdk-space-center', + navigationReadiness: '位置预览', + accessible: false, + kind: 'hall', + hallId, + hallName, + spaceId: hallId, + sourcePlaceId: entrance.sourcePlaceId, + sourceSpaceId: hallId, + entrances: [entrance] + }) + }) + + hallSpaces.forEach((space) => { + const hallId = stringifyId(space.id) + if (!hallId || halls.has(`hall-${hallId}`)) return + + const fallbackPoi = createSpaceFallbackHallPoi(space, floors, fallbackFloorId) + if (fallbackPoi) { + halls.set(fallbackPoi.id, fallbackPoi) + } + }) + + return Array.from(halls.values()) + .filter((poi) => poi.id && poi.name && poi.positionGltf) +``` + +- [ ] **Step 7: Add diagnostics helper** + +After `toMuseumHallPoisFromSgs`, add: + +```ts +export const createSgsHallPoiDiagnostics = ( + spaces: SgsSpacePayload[], + navigablePlaces: SgsNavigablePlacePayload[], + hallPois: MuseumPoi[] +): SgsHallPoiDiagnostics => { + const eligibleSpaceCount = spaces.filter(isExhibitionHallSpace).length + const hallPlaceCount = navigablePlaces.filter(isExhibitionHallNavigablePlace).length + const hallPoiWithPositionCount = hallPois.filter((poi) => Boolean(poi.positionGltf)).length + + return { + spaceCount: spaces.length, + eligibleSpaceCount, + navigablePlaceCount: navigablePlaces.length, + hallPlaceCount, + hallPoiCount: hallPois.length, + hallPoiWithPositionCount, + skippedSpaceCount: Math.max(eligibleSpaceCount - hallPois.length, 0), + skippedPlaceCount: Math.max(hallPlaceCount - hallPois.reduce((count, poi) => count + (poi.entrances?.length || 0), 0), 0) + } +} +``` + +- [ ] **Step 8: Run the adapter smoke script** + +Run: + +```powershell +node scripts/check-sgs-hall-poi-adapter.mjs +``` + +Expected output: + +```text +SGS hall POI adapter smoke check passed +``` + +- [ ] **Step 9: Run type-check for adapter changes** + +Run: + +```powershell +pnpm type-check +``` + +Expected: exits 0. If it fails, fix TypeScript errors in `sgsSdkGuideAdapter.ts` only before continuing. + +- [ ] **Step 10: Commit adapter behavior** + +```powershell +git add src/data/adapters/sgsSdkGuideAdapter.ts scripts/check-sgs-hall-poi-adapter.mjs +git commit -m "feat: expand SGS hall POI adaptation" +``` + +--- + +### Task 3: Add SGS model POI diagnostics in repository + +**Files:** +- Modify: `src/repositories/GuideModelRepository.ts:14-244` +- Test: `scripts/check-sgs-hall-poi-adapter.mjs` + +**Interfaces:** +- Consumes: `createSgsHallPoiDiagnostics(spaces, navigablePlaces, hallPois)` from `sgsSdkGuideAdapter.ts`. +- Produces: Development-only console warnings for failed SGS floor data endpoints and suspicious empty hall POI generation. + +- [ ] **Step 1: Update imports** + +In `src/repositories/GuideModelRepository.ts`, replace: + +```ts +import { + formatSgsFloorLabel, + toMuseumHallPoisFromSgs, + toMuseumPoiFromSgs +} from '@/data/adapters/sgsSdkGuideAdapter' +``` + +with: + +```ts +import { + createSgsHallPoiDiagnostics, + formatSgsFloorLabel, + toMuseumHallPoisFromSgs, + toMuseumPoiFromSgs +} from '@/data/adapters/sgsSdkGuideAdapter' +``` + +- [ ] **Step 2: Add development warning helper** + +After `isSharedSgsModelAsset(...)`, add: + +```ts +const warnSgsGuideModelDiagnostics = ( + message: string, + payload: Record +) => { + if (!import.meta.env.DEV) return + console.warn(`[SGS guide model] ${message}`, payload) +} +``` + +- [ ] **Step 3: Add safe request helper inside `loadFloorPois`** + +Inside `SgsSdkGuideModelRepository.loadFloorPois`, immediately after: + +```ts + const floorId = String(matchedFloor.floorId) +``` + +add this local helper: + +```ts + const loadFloorData = async ( + endpoint: 'pois' | 'spaces' | 'navigablePlaces', + loader: () => Promise + ) => { + try { + return await loader() + } catch (error) { + warnSgsGuideModelDiagnostics('floor data request failed', { + floorId, + endpoint, + error: error instanceof Error ? error.message : String(error) + }) + return [] + } + } +``` + +- [ ] **Step 4: Replace silent catch requests** + +Replace: + +```ts + const [pois, spaces, navigablePlaces] = await Promise.all([ + this.provider.getFloorPois(floorId).catch(() => []), + this.provider.getFloorSpaces(floorId).catch(() => []), + this.provider.getNavigablePlaces(floorId).catch(() => []) + ]) +``` + +with: + +```ts + const [pois, spaces, navigablePlaces] = await Promise.all([ + loadFloorData('pois', () => this.provider.getFloorPois(floorId)), + loadFloorData('spaces', () => this.provider.getFloorSpaces(floorId)), + loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(floorId)) + ]) +``` + +- [ ] **Step 5: Add hall generation diagnostics** + +Replace: + +```ts + const ordinaryPois = pois.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors))) + const hallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, floorId) + .map(toSgsRenderPoi) + + return dedupeRenderPoisById([ + ...hallPois, + ...ordinaryPois + ]) + .filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3) +``` + +with: + +```ts + const ordinaryPois = pois.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors))) + const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, floorId) + const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois) + + if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) { + warnSgsGuideModelDiagnostics('eligible hall spaces produced no renderable hall POIs', { + floorId, + ...hallDiagnostics + }) + } + + const hallPois = museumHallPois.map(toSgsRenderPoi) + const renderPois = dedupeRenderPoisById([ + ...hallPois, + ...ordinaryPois + ]) + .filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3) + + if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderPois.some((poi) => poi.primaryCategory === 'exhibition_hall')) { + warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', { + floorId, + ...hallDiagnostics, + renderPoiCount: renderPois.length + }) + } + + return renderPois +``` + +- [ ] **Step 6: Run smoke and type-check** + +Run: + +```powershell +node scripts/check-sgs-hall-poi-adapter.mjs +pnpm type-check +``` + +Expected: + +```text +SGS hall POI adapter smoke check passed +``` + +and `pnpm type-check` exits 0. + +- [ ] **Step 7: Commit repository diagnostics** + +```powershell +git add src/repositories/GuideModelRepository.ts src/data/adapters/sgsSdkGuideAdapter.ts scripts/check-sgs-hall-poi-adapter.mjs +git commit -m "chore: expose SGS hall POI diagnostics" +``` + +--- + +### Task 4: Confirm floor-mode hall POI visibility + +**Files:** +- Modify: `src/components/map/ThreeMap.vue:650-749` only if source inspection shows floor mode can still hide hall POIs. +- Test: `scripts/check-sgs-hall-poi-adapter.mjs` + +**Interfaces:** +- Consumes: `RenderPoi.primaryCategory === 'exhibition_hall'` / `'exhibition_hall_entrance'`. +- Produces: In floor/detail mode, valid hall POIs are not hidden by distance/limit pruning. + +- [ ] **Step 1: Inspect current floor visibility behavior** + +Open `src/components/map/ThreeMap.vue` around `getPoiVisibilityTier` and confirm this current behavior exists: + +```ts +const getPoiVisibilityTier = (): PoiVisibilityTier => { + if (activeView.value === 'floor') return 'full' + if (!controls || !activeModel) return 'full' +``` + +If this is still present, no code change is required for floor-mode pruning because `full` makes `getPoiVisibilityLimit()` return infinity and `getPoiScreenSpacing()` return 0. + +- [ ] **Step 2: Only if floor mode no longer returns `full`, add hall visibility guard** + +If Step 1 shows floor mode can be `tight` or `balanced`, update `shouldShowPoiAtDistance` to begin with: + +```ts + if (activeView.value === 'floor' && ( + poi.primaryCategory === 'exhibition_hall' + || poi.primaryCategory === 'exhibition_hall_entrance' + )) { + return true + } +``` + +Do not add this guard if `activeView.value === 'floor'` already returns `full`. + +- [ ] **Step 3: Run type-check** + +Run: + +```powershell +pnpm type-check +``` + +Expected: exits 0. + +- [ ] **Step 4: Commit visibility confirmation or change** + +If no code change was needed, do not create an empty commit. Record the finding in the final implementation summary. + +If code changed, run: + +```powershell +git add src/components/map/ThreeMap.vue +git commit -m "fix: keep hall POIs visible in floor mode" +``` + +--- + +### Task 5: Final validation and handoff + +**Files:** +- Modify: none unless validation reveals a failure in files changed by Tasks 2-4. +- Test: whole changed scope. + +**Interfaces:** +- Consumes: all previous task outputs. +- Produces: Verified working tree state and final summary. + +- [ ] **Step 1: Run adapter smoke check** + +```powershell +node scripts/check-sgs-hall-poi-adapter.mjs +``` + +Expected: + +```text +SGS hall POI adapter smoke check passed +``` + +- [ ] **Step 2: Run type-check** + +```powershell +pnpm type-check +``` + +Expected: exits 0. + +- [ ] **Step 3: Run lint** + +```powershell +pnpm lint +``` + +Expected: exits 0. If warnings appear with exit 0, record them in the final summary. + +- [ ] **Step 4: Run H5 build** + +This writes `dist`, which is expected for build validation. + +```powershell +pnpm build:h5 +``` + +Expected: exits 0. + +- [ ] **Step 5: Inspect changed files** + +```powershell +git diff -- src/data/adapters/sgsSdkGuideAdapter.ts src/repositories/GuideModelRepository.ts src/components/map/ThreeMap.vue scripts/check-sgs-hall-poi-adapter.mjs docs/superpowers/specs/2026-06-29-sgs-hall-poi-display-design.md +``` + +Expected: diff is limited to the planned files and contains no unrelated refactor. + +- [ ] **Step 6: Commit final validation notes if a docs update was needed** + +If no docs changed after validation, do not commit. If validation required updating this plan or spec, commit only those docs: + +```powershell +git add docs/superpowers/specs/2026-06-29-sgs-hall-poi-display-design.md docs/superpowers/plans/2026-06-29-sgs-hall-poi-display.md +git commit -m "docs: record SGS hall POI validation" +``` + +- [ ] **Step 7: Final response** + +Report: + +- Files changed. +- How hall/public exhibition spaces are now classified. +- Whether `ThreeMap` required a change. +- Exact validation commands and outcomes. +- Any skipped validation and why. + +--- + +## Self-Review + +### Spec coverage + +- Expanded `exhibition_hall`-only classification: Task 2. +- Added theater / education_activity / ramp handling: Task 2. +- Added entrance-first and space-center fallback: Task 2. +- Added diagnostics for silent request/adaptation failure: Task 3. +- Preserved ThreeMap boundary and floor-mode visibility: Task 4. +- Validation commands and H5 build: Task 5. + +### Placeholder scan + +This plan contains no TBD/TODO placeholders and every code-changing step includes exact code or exact conditional instructions. + +### Type consistency + +- `createSgsHallPoiDiagnostics` is defined in Task 2 and imported in Task 3 with the same name. +- `SgsHallPoiDiagnostics` fields match Task 3 diagnostic spreading. +- `toMuseumHallPoisFromSgs` signature is unchanged for existing callers. diff --git a/src/data/adapters/sgsSdkGuideAdapter.ts b/src/data/adapters/sgsSdkGuideAdapter.ts index 12409bc..7067326 100644 --- a/src/data/adapters/sgsSdkGuideAdapter.ts +++ b/src/data/adapters/sgsSdkGuideAdapter.ts @@ -19,10 +19,12 @@ import { import type { SgsFloorDiagnosticsPayload, SgsMapDiagnosticsPayload, + SgsNavigablePlacePayload, SgsPoiPayload, SgsPositionPayload, SgsSdkFloorSummaryPayload, - SgsSdkManifestPayload + SgsSdkManifestPayload, + SgsSpacePayload } from '@/data/providers/sgsSdkApiProvider' const defaultCategory: MuseumCategory = { @@ -31,6 +33,18 @@ const defaultCategory: MuseumCategory = { iconType: 'poi' } +const hallCategory: MuseumCategory = { + id: 'exhibition_hall', + label: '展厅', + iconType: 'exhibition_hall' +} + +const hallEntranceCategory: MuseumCategory = { + id: 'exhibition_hall_entrance', + label: '展厅出入口', + iconType: 'hall_entrance' +} + const categoryBySgsType: Record = { toilet: { id: 'basic_service_facility', @@ -105,13 +119,24 @@ const optionalNumber = (value: number | null | undefined) => ( Number.isFinite(Number(value)) ? Number(value) : undefined ) +const normalizedText = (value?: string | null) => (value || '').trim() + +const normalizePositionSource = (source: SgsPositionPayload): [number, number, number] | undefined => { + const x = Number(source.x) + const y = Number(source.y) + const z = Number(source.z) + + if (![x, y, z].every(Number.isFinite)) return undefined + return [x, y, z] +} + const normalizeStatus = (status?: string): GuideDiagnosticsStatus => { if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status return 'WARN' } export const formatSgsFloorLabel = (floorCode?: string | null, floorName?: string | null) => { - if (floorCode === 'EXTERIOR') return '室外' + if (floorCode === 'EXTERIOR') return '馆外' const basementMatch = floorCode?.match(/^L-(\d+(?:\.\d+)?)$/) if (basementMatch) return `B${basementMatch[1]}` @@ -148,19 +173,119 @@ export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => { return aliases } -const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => { - const source: SgsPositionPayload = poi.position || { +const exhibitionSpaceTypeWhitelist = new Set([ + 'exhibition_hall', + 'theater', + 'education_activity', + 'ramp' +]) + +const exhibitionHallKeywords = [ + '展厅', + '临展', + '展览', + '影院', + '球幕', + '巨幕', + '报告厅', + '活动室', + '科普', + '实验室', + '展览坡道', + '宇宙', + '地球', + '演化', + '恐龙', + '人类', + '生物', + '生态', + '家园' +] + +export interface SgsHallPoiDiagnostics { + spaceCount: number + eligibleSpaceCount: number + navigablePlaceCount: number + hallPlaceCount: number + hallPoiCount: number + hallPoiWithPositionCount: number + skippedSpaceCount: number + skippedPlaceCount: number +} + +const hasExhibitionKeyword = (value?: string | null) => ( + exhibitionHallKeywords.some((keyword) => normalizedText(value).includes(keyword)) +) + +const searchableSpaceText = (space: SgsSpacePayload) => [ + space.name, + space.type, + space.sourceNodeName +] + .map((value) => normalizedText(value)) + .filter(Boolean) + .join(' ') + +const normalizeSpaceCenter = (space: SgsSpacePayload): [number, number, number] | undefined => normalizePositionSource( + space.center || { + x: undefined, + y: undefined, + z: undefined + } +) + +export const isExhibitionHallSpace = (space: SgsSpacePayload) => { + const type = normalizedText(space.type).toLowerCase() + const name = normalizedText(space.name) + const searchableText = searchableSpaceText(space) + + if (!exhibitionSpaceTypeWhitelist.has(type)) return false + if (type === 'exhibition_hall') return true + if (type === 'ramp') return name.includes('展览坡道') + + return hasExhibitionKeyword(searchableText) +} + +export const isExhibitionHallNavigablePlace = (place: SgsNavigablePlacePayload) => { + const searchableText = [ + place.name, + place.ownerName, + place.category, + place.type, + place.typeCode, + place.typeName + ] + .map((value) => normalizedText(value)) + .filter(Boolean) + .join(' ') + + return hasExhibitionKeyword(searchableText) +} + +const normalizedHallName = (value?: string | null) => normalizedText(value) + .replace(/[\s()()【】\[\]_-]/g, '') + +const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => normalizePositionSource( + poi.position || { x: poi.x, y: poi.y, z: poi.z } - const x = Number(source.x) - const y = Number(source.y) - const z = Number(source.z) +) - if (![x, y, z].every(Number.isFinite)) return undefined - return [x, y, z] -} +const normalizePlacePosition = (place: SgsNavigablePlacePayload): [number, number, number] | undefined => normalizePositionSource( + place.position + ? { + x: place.position.x, + y: place.position.y ?? 0, + z: place.position.z + } + : { + x: place.x, + y: place.y ?? 0, + z: place.z + } +) const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => { const normalizedType = poi.type?.trim() @@ -208,7 +333,8 @@ export const toMuseumPoiFromSgs = ( sourceObjectName: poi.anchorNodeName || undefined, sourceConfidence: 'backend-sgs-sdk', navigationReadiness: '位置预览', - accessible: category.accessible === true + accessible: category.accessible === true, + kind: category.id === 'operation_experience' ? 'guide' : 'facility' } } @@ -219,9 +345,229 @@ export const toLocationPreviewFromPoi = (poi: MuseumPoi): GuideLocationPreview = floorLabel: poi.floorLabel, primaryCategoryZh: poi.primaryCategory.label, positionGltf: poi.positionGltf, - sourceObjectName: poi.sourceObjectName + sourceObjectName: poi.sourceObjectName, + kind: poi.kind, + hallId: poi.hallId, + hallName: poi.hallName, + entrances: poi.entrances }) +const placeHallName = (place: SgsNavigablePlacePayload) => ( + normalizedText(place.ownerName) + || normalizedText(place.name) + .replace(/(?:主)?(?:出入口|入口|出口|门)\s*[A-Za-z0-9一二三四五六七八九十号-]*$/u, '') + .trim() + || normalizedText(place.name) +) + +const findMatchedHallSpace = ( + place: SgsNavigablePlacePayload, + spaces: SgsSpacePayload[] +) => { + const placeName = normalizedHallName(placeHallName(place)) + if (!placeName) return undefined + + return spaces.find((space) => { + const spaceName = normalizedHallName(space.name) + return spaceName && (placeName.includes(spaceName) || spaceName.includes(placeName)) + }) +} + +const floorLabelForSgs = ( + floorId: string, + floors: SgsSdkFloorSummaryPayload[], + floorCode?: string | null, + floorName?: string | null +) => { + const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === floorCode) + return formatSgsFloorLabel( + matchedFloor?.floorCode || floorCode, + matchedFloor?.floorName || floorName + ) +} + +const createHallEntrance = ( + place: SgsNavigablePlacePayload, + floors: SgsSdkFloorSummaryPayload[], + fallbackFloorId: string +) => { + const floorId = stringifyId(place.floorId) || fallbackFloorId + const placeId = stringifyId(place.id || place.nodeId) + + return { + id: placeId ? `hall-entrance-${placeId}` : `hall-entrance-${floorId}-${normalizedHallName(place.name)}`, + name: normalizedText(place.name) || normalizedText(place.ownerName) || '展厅出入口', + floorId, + floorLabel: floorLabelForSgs(floorId, floors, place.floorCode, place.floorName), + positionGltf: normalizePlacePosition(place), + sourceObjectName: stringifyId(place.nodeId) || undefined, + sourcePlaceId: placeId || undefined, + routeNodeId: stringifyId(place.nodeId) || undefined + } +} + +const createHallPoiId = ( + space: SgsSpacePayload | undefined, + place: SgsNavigablePlacePayload, + fallbackIndex: number +) => { + const spaceId = stringifyId(space?.id) + if (spaceId) return `hall-${spaceId}` + + const ownerName = normalizedHallName(placeHallName(place)) + if (ownerName) return `hall-place-${ownerName}` + + return `hall-place-${stringifyId(place.id) || fallbackIndex + 1}` +} + +const createSpaceFallbackHallPoi = ( + space: SgsSpacePayload, + floors: SgsSdkFloorSummaryPayload[], + fallbackFloorId: string +): MuseumPoi | null => { + const floorId = stringifyId(space.floorId) || fallbackFloorId + const position = normalizeSpaceCenter(space) + const hallId = stringifyId(space.id) + + if (!hallId || !normalizedText(space.name) || !position) return null + + return { + id: `hall-${hallId}`, + name: normalizedText(space.name), + floorId, + floorLabel: floorLabelForSgs(floorId, floors), + primaryCategory: hallCategory, + categories: [ + hallCategory + ], + positionGltf: position, + sourceObjectName: space.sourceNodeName || undefined, + sourceConfidence: 'backend-sgs-sdk-space-center', + navigationReadiness: '位置预览', + accessible: false, + kind: 'hall', + hallId, + hallName: normalizedText(space.name), + spaceId: hallId, + sourceSpaceId: hallId, + entrances: [] + } +} + +export const toMuseumHallPoisFromSgs = ( + spaces: SgsSpacePayload[], + navigablePlaces: SgsNavigablePlacePayload[], + floors: SgsSdkFloorSummaryPayload[], + fallbackFloorId: string +): MuseumPoi[] => { + const hallSpaces = spaces.filter(isExhibitionHallSpace) + const hallPlaces = navigablePlaces.filter(isExhibitionHallNavigablePlace) + const halls = new Map() + + hallPlaces.forEach((place, index) => { + const matchedSpace = findMatchedHallSpace(place, hallSpaces) + const entrance = createHallEntrance(place, floors, fallbackFloorId) + const entranceHasRouteAnchor = Boolean(entrance.positionGltf && entrance.routeNodeId) + const fallbackPosition = matchedSpace ? normalizeSpaceCenter(matchedSpace) : undefined + const floorId = entrance.floorId || stringifyId(matchedSpace?.floorId) || fallbackFloorId + const hallId = stringifyId(matchedSpace?.id) || undefined + const poiId = createHallPoiId(matchedSpace, place, index) + const hallName = normalizedText(matchedSpace?.name) || placeHallName(place) || entrance.name + const existing = halls.get(poiId) + + if (existing) { + existing.entrances = [ + ...(existing.entrances || []), + entrance + ] + if (entranceHasRouteAnchor) { + existing.positionGltf = entrance.positionGltf + existing.floorId = floorId + existing.floorLabel = entrance.floorLabel + existing.sourceObjectName = entrance.sourceObjectName + existing.sourcePlaceId = entrance.sourcePlaceId + existing.sourceConfidence = 'backend-sgs-sdk-hall-entrance' + return + } + + if (!existing.positionGltf && fallbackPosition) { + existing.positionGltf = fallbackPosition + existing.floorId = floorId + existing.floorLabel = entrance.floorLabel + existing.sourceObjectName = matchedSpace?.sourceNodeName || undefined + existing.sourcePlaceId = entrance.sourcePlaceId + existing.sourceConfidence = 'backend-sgs-sdk-space-center' + } + return + } + + const positionGltf = entranceHasRouteAnchor ? entrance.positionGltf : fallbackPosition + if (!positionGltf) return + + halls.set(poiId, { + id: poiId, + name: hallName, + floorId, + floorLabel: entrance.floorLabel, + primaryCategory: hallCategory, + categories: [ + hallCategory, + hallEntranceCategory + ], + positionGltf, + sourceObjectName: entranceHasRouteAnchor + ? entrance.sourceObjectName + : matchedSpace?.sourceNodeName || undefined, + sourceConfidence: entranceHasRouteAnchor + ? 'backend-sgs-sdk-hall-entrance' + : 'backend-sgs-sdk-space-center', + navigationReadiness: '位置预览', + accessible: false, + kind: 'hall', + hallId, + hallName, + spaceId: hallId, + sourcePlaceId: entrance.sourcePlaceId, + sourceSpaceId: hallId, + entrances: [entrance] + }) + }) + + hallSpaces.forEach((space) => { + const hallId = stringifyId(space.id) + if (!hallId || halls.has(`hall-${hallId}`)) return + + const fallbackPoi = createSpaceFallbackHallPoi(space, floors, fallbackFloorId) + if (fallbackPoi) { + halls.set(fallbackPoi.id, fallbackPoi) + } + }) + + return Array.from(halls.values()) + .filter((poi) => poi.id && poi.name && poi.positionGltf) +} + +export const createSgsHallPoiDiagnostics = ( + spaces: SgsSpacePayload[], + navigablePlaces: SgsNavigablePlacePayload[], + hallPois: MuseumPoi[] +): SgsHallPoiDiagnostics => { + const eligibleSpaceCount = spaces.filter(isExhibitionHallSpace).length + const hallPlaceCount = navigablePlaces.filter(isExhibitionHallNavigablePlace).length + const hallPoiWithPositionCount = hallPois.filter((poi) => Boolean(poi.positionGltf)).length + + return { + spaceCount: spaces.length, + eligibleSpaceCount, + navigablePlaceCount: navigablePlaces.length, + hallPlaceCount, + hallPoiCount: hallPois.length, + hallPoiWithPositionCount, + skippedSpaceCount: Math.max(eligibleSpaceCount - hallPois.length, 0), + skippedPlaceCount: Math.max(hallPlaceCount - hallPois.reduce((count, poi) => count + (poi.entrances?.length || 0), 0), 0) + } +} + export const toGuideFloorDetailFromSgs = ( floor: SgsSdkFloorSummaryPayload | SgsFloorDiagnosticsPayload ): GuideFloorDetail => ({ diff --git a/src/domain/museum.ts b/src/domain/museum.ts index ddc3fa9..22c9613 100644 --- a/src/domain/museum.ts +++ b/src/domain/museum.ts @@ -72,6 +72,19 @@ export interface MuseumCategory { iconType?: string } +export type MuseumPoiKind = 'facility' | 'hall' | 'hall_entrance' | 'guide' + +export interface MuseumPoiEntrance { + id: string + name: string + floorId: string + floorLabel: string + positionGltf?: [number, number, number] + sourceObjectName?: string + sourcePlaceId?: string + routeNodeId?: string +} + export interface MuseumPoi { id: string name: string @@ -84,6 +97,13 @@ export interface MuseumPoi { sourceConfidence?: string navigationReadiness?: string accessible: boolean + kind?: MuseumPoiKind + hallId?: string + hallName?: string + spaceId?: string + sourcePlaceId?: string + sourceSpaceId?: string + entrances?: MuseumPoiEntrance[] } export type GuideLocationResolutionStatus = 'exact' | 'hallFallback' | 'candidate' | 'unavailable' @@ -149,6 +169,10 @@ export interface GuideLocationPreview { primaryCategoryZh?: string positionGltf?: [number, number, number] sourceObjectName?: string + kind?: MuseumPoiKind + hallId?: string + hallName?: string + entrances?: MuseumPoiEntrance[] } export interface GuideStartLocation {