之前参加了一个 “魔珐星云数字人” 的活动,当时是直接在 “魔珐星云数字人” 官网的上下载了一个DOME,并让Trae给我修改了一下,具体见: 花了两天,让Trae,给我用魔珐星云数字人写了个项目!

上次感觉自己做得很low(比起其他大佬而言,我是真的菜鸟),但还是得了一个最具商业价值的奖项,1000块钱 + 一个桌面全息仓。

这次菜鸟看到Trae的活动,感觉正好可以试试用SOLO让之前的项目不那么low了,最起码得能让人自己体验,还有就是得真的接入AI。

话不多说,直接让Trae SOLO 给我改代码!

一、让用户可以配置API Key使用我的项目

在这里插入图片描述

在这里插入图片描述

运行结果:

在这里插入图片描述

发现需要优化,继续提问

在这里插入图片描述

运行结果:

在这里插入图片描述

这次感觉不错,然后菜鸟就测试了一下,非常丝滑,基本上没有错误,不得不说新的SOLO真的很强大!!!

在这里插入图片描述

引入数字人

大家感兴趣可以自己换上自己的 “魔珐星云数字人” !具体操作如下:

1、 访问 https://c.c1nd.cn/9C9WW 登录并填写我的邀请码:J39AD7UJHB

2、注册后点击应用管理 – 创建应用 – 创建完成后可以查看密匙,把密匙复制进入 我的项目:https://modelscope.cn/studios/PBitWen/AI_with_you 即可体验!

在这里插入图片描述

二、添加上AI接入功能

菜鸟之前做的是文字匹配的功能,匹配到什么文字就回答我设置好的,现在需要匹配的还是可以匹配,但是没有的就去问AI!

在这里插入图片描述
每次修改完还会自己检查,真的好!!!

但是这里的报错没有解决,所以继续问
在这里插入图片描述
在这里插入图片描述

修复之后测试,还是连接不上豆包,这里菜鸟直接把豆包提供的实列给Trae SOLO,看其能不能修改正确

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

修复后测试,真的整个项目都可以正常运行了,运行结果如下:

XY接入AI

引入豆包

这里需要各位去开通火山引擎,还是比较好的,有免费的 50万token,偶尔用用还是够的,而且可以用很久!

这里只介绍怎么复制AI的Key。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

key是这个:
在这里插入图片描述

模型ID是这个:

在这里插入图片描述

商业价值

二、 项目背景与痛点

2.1 行业与社会背景

随着人工智能、大模型与数字人技术的快速发展,人机交互正从“屏幕点击式”向“自然语言式、具身交互式”演进。语音识别、语义理解与数字人形象的成熟,使 AI 不再只是信息工具,而逐步成为能够融入现实场景、主动提供服务的“智能伴随者”。

与此同时,大型公共空间与商业空间正变得愈发复杂。以医院、综合商场为代表的场所,普遍存在空间结构复杂、信息分散、服务节点多等特征。用户在进入这些场所后,往往面临“找不到路、问不到人、不知道下一步该做什么”的问题。

从社会结构来看,老龄化趋势明显。大量老年人对智能设备操作不熟悉,在医院等高频公共场景中,仍高度依赖人工指引甚至医陪服务,增加了家庭与社会成本。

在消费层面,用户对效率、体验与个性化服务的期待持续提升。传统依赖指示牌、人工咨询、被动检索的服务方式,已难以满足用户对“即时、准确、自然交互”的需求。

在此背景下,一个能够感知用户位置、理解用户意图,并以自然方式进行引导与服务的 AI 数字人交互系统,具备明确的现实价值与应用空间。

2.2 现有场景的核心痛点分析

(一)医院场景:信息复杂,对弱势群体不友好

医院是信息密度极高的公共空间,但其服务体系长期以“懂规则的人”为默认用户。

挂号流程复杂:科室划分专业性强,普通患者尤其是老年人,很难根据症状准确判断应挂哪个科室。

挂错号成本高:一旦挂错科室,往往需要重新排队、重新缴费,甚至改期就诊,造成时间与精力的双重浪费。

老年人操作困难:大量医院已转向自助机、App 挂号,但对老年人并不友好,催生了高成本的“医陪”需求。

人工咨询压力大:导医台与人工窗口长期处于高负荷状态,服务质量难以保证。

痛点本质:医院缺少一个能够“用人话解释规则、替用户思考路径”的智能引导角色。

(二)大型商场场景:空间大、选择多、决策成本高

现代商业综合体体量不断扩大,但用户体验并未同步提升。

门店分布复杂:楼层多、动线绕,用户往往需要反复查看地图或询问工作人员。

找店效率低:想找某一家店,常常需要“走错—返回—再确认”,体验割裂。

消费决策困难:面对大量餐饮与品牌,用户不知道“哪家好吃”“哪家适合自己”。

用户粘性不足:商场难以持续与用户产生互动,只能依赖静态导览与被动推荐。

痛点本质:商场缺乏一个“能随时被询问、能结合场景做推荐”的智能交互入口。

(三)试衣与消费体验:流程繁琐,心理与时间成本高

在服装消费场景中,试衣环节长期存在明显痛点:

试衣过程麻烦:反复穿脱衣物耗时耗力,尤其在客流高峰期体验较差。

心理压力存在:部分用户对在公共试衣间反复试穿存在心理负担。

体验割裂:线下试衣与线上浏览、搭配之间缺乏连续性。

转化率受限:用户因试衣不便而放弃购买的情况普遍存在。

痛点本质:传统试衣方式效率低、体验重,不符合当前“便捷化、数字化”的消费趋势。

(四)公共空间中的“不知道该做什么”

在医院或商场中,用户经常并非目标明确,而是处于一种“被动探索”状态:

  1. 不知道下一步该去哪
  2. 不清楚当前有哪些服务或活动
  3. 不确定自己的选择是否最优

现有系统只能提供静态信息展示,无法主动理解用户需求并给出引导。

痛点本质:场所与用户之间缺乏持续、自然的互动连接。

2.3 项目切入价值总结

综合以上痛点可以看出,无论是医院还是商场,本质问题都不在于“信息不存在”,而在于:

  1. 信息分散、表达方式不友好
  2. 服务依赖人工,成本高且不可规模化
  3. 用户需要的是“被理解、被引导”,而非“自己去找答案”

AI 伴你「衣食行」项目,通过引入具备语音交互能力的 AI 数字人,将复杂的空间信息与服务流程,转化为自然对话式的引导体验,为用户提供:

  1. 随问随答的空间指引
  2. 基于语义理解的决策辅助
  3. 贴近真实人的交互方式
  4. 覆盖“衣、食、行、医”等高频生活场景的一体化服务

从而有效降低公共空间的使用门槛,提升服务效率与用户体验,同时为场地方构建新的智能服务入口与商业延展可能。

三、 产品核心功能

3.1 语音驱动的自然交互数字人

产品以 AI 数字人为核心交互载体,采用语音作为主要交互方式,用户无需复杂操作,仅通过自然语言即可完成信息获取与服务请求。

  1. 支持实时语音唤醒与连续对话
  2. 能理解口语化、非标准表达的用户提问
  3. 通过拟人化数字人形象进行反馈,降低技术使用门槛
  4. 特别适配老年人等对智能设备不熟悉的群体

功能价值
将传统“点按钮、看说明”的操作模式,转变为“像问一个人一样问系统”,显著提升公共空间中的服务可达性与亲和力。

3.2 基于位置感知的智能导航与指路服务(行)

产品可结合用户当前位置,为其提供直观、可理解的空间引导服务。

  1. 自动识别或确认用户所处位置
  2. 支持语音查询目的地(如科室、门店、服务点)
  3. 提供清晰的路线指引与方向说明
  4. 可在医院、商场等复杂空间中使用

应用场景

  1. 医院内寻找挂号窗口、科室、检查区域
  2. 商场内寻找具体门店、餐饮区域、服务设施

功能价值
帮助用户在复杂空间中快速建立方向感,减少迷路、反复确认和对人工咨询的依赖。

3.3 智能科室引导与就医辅助(医)

针对医院高频痛点,产品提供基于症状描述的科室引导能力。

  1. 用户可通过语音描述自身症状或就诊目的
  2. 系统对语义进行解析,并匹配对应科室类型
  3. 以“引导建议”的形式告知推荐科室方向
  4. 明确提示结果为辅助参考,避免误导性医疗判断

功能价值
降低因不了解医学专业划分而造成的挂错号概率,减少患者时间成本,缓解医院导医压力,尤其对老年患者具有显著帮助。

3.4 商场智能推荐与即时问答(食)

在商业场景中,产品可作为“随行导购式数字人”,提供即时咨询与推荐服务。

  1. 支持询问商场内“吃什么”“去哪逛”等问题
  2. 可根据餐饮类别、位置等进行推荐
  3. 通过对话方式帮助用户快速做出选择
  4. 减少用户在商场内的决策成本与犹豫时间

功能价值
让用户在商场中“随时有人可问”,提升消费体验,同时为商场构建更具互动性的服务入口。

3.5 虚拟试衣与数字分身体验(衣)

产品支持通过数字人技术,构建用户的虚拟形象,实现虚拟试衣体验。

  1. 可生成与用户外形相近的数字分身
  2. 在无需实际更换衣物的情况下进行服装展示
  3. 支持多款式、多颜色的快速切换对比
  4. 可应用于服装零售、品牌展示等场景

功能价值
减少试衣流程带来的时间与心理成本,提升服装消费效率,为线下商业场景引入更具吸引力的数字化体验。

3.6 多场景融合的一体化服务能力(衣 · 食 · 行)

“AI 伴你『衣食行』”并非单一功能工具,而是围绕用户在公共与商业空间中的完整行动路径,提供连续性的服务体验。

  1. 同一交互入口,覆盖导航、咨询、推荐与体验
  2. 根据场景不同切换服务重点
  3. 数字人形象统一,降低用户学习成本
  4. 可根据不同场所进行定制化部署

功能价值
实现从“被动信息查询”到“主动智能陪伴”的转变,打造可持续扩展的场景级智能交互平台。

3.7 核心功能总结

通过将语音交互、位置感知、语义理解与数字人形态进行融合,产品构建了一个面向真实空间的智能服务系统,使用户在医院与商场等高频场景中:

  1. 找得到路
  2. 问得到人
  3. 做得出决定
  4. 得到更好的体验

部分代码

直接按照官网 https://xingyun3d.com/developers/52-187 ,下载一个demo示例,然后将需求和Trae SOLO说,就基本可以完成!

菜鸟感觉这些代码都没什么太大的参考价值 (DEMO中实现了很多),大家最关心的都是楼层那一块的实现,那个并不是视频,而是用 Three.js 完成的!

<template>
  <div ref="root" class="route-floor-3d"></div>
</template>

<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from "vue";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { appState } from "../stores/app";
defineProps<{ durationSec?: number }>();

const root = ref<HTMLDivElement | null>(null);
let renderer: THREE.WebGLRenderer | null = null;
let scene: THREE.Scene | null = null;
let camera: THREE.PerspectiveCamera | null = null;
let arrow: THREE.Mesh | null = null;
let walker: THREE.Group | null = null;
let controls: OrbitControls | null = null;
let rafId = 0;
let segs: { a: THREE.Vector3; b: THREE.Vector3 }[] | null = null;
let segLens: number[] = [];
let sentenceActive = false;
let sentenceStart = 0;
let sentenceEstimatedMs = 0;
let travelAccum = 0;
let segmentStartTravel = 0;
let blockTargetIdx = 0;
let blockTargetTravel = 0;
let step1EndIdx = 0;
let step2EndIdx = 0;
let step3EndIdx = 0;
let step4EndIdx = 0;
const travelOfIdx = (idx: number) =>
  segLens
    .slice(0, Math.max(0, Math.min(idx + 1, segLens.length)))
    .reduce((a, b) => a + b, 0);
let lastSubtitle = "";
let lastStepIdx = 0;
function detectStepIdx(text: string): number {
  const guide = appState.ui.routeGuide?.steps ?? [];
  const cleaned = text.replace(/^\s*\d+[、\.]/, "").replace(/\s+/g, "");
  for (let i = 0; i < guide.length; i++) {
    const g = guide[i].replace(/\s+/g, "");
    if (cleaned.includes(g) || text.includes(guide[i])) return i + 1;
  }

  const t = cleaned;
  if (/入口/.test(t)) return 1;
  if (/按照图中/.test(t)) return 2;
  if (/乘电梯/.test(t)) return 3;
  if (/走廊/.test(t)) return 4;
  return 0;
}

function buildScene(el: HTMLDivElement) {
  scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf7fbff);

  const width = el.clientWidth;
  const height = el.clientHeight || 220;
  camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
  camera.position.set(120, 100, 180);
  camera.lookAt(0, 0, 0);

  renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(width, height);
  el.appendChild(renderer.domElement);

  controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.dampingFactor = 0.08;
  controls.minDistance = 60;
  controls.maxDistance = 400;
  controls.target.set(0, 10, 0);
  controls.update();

  const ambient = new THREE.AmbientLight(0xffffff, 0.8);
  scene.add(ambient);
  const dir = new THREE.DirectionalLight(0xffffff, 0.5);
  dir.position.set(50, 100, 50);
  scene.add(dir);

  // 仅保留路径
  const pathWidth = 3.5;
  const pathThicknessY = 0.6;
  const pathColor = 0x0a84ff;

  segs = [
    // 1F - “5”字型路径
    { a: new THREE.Vector3(-60, 2, -20), b: new THREE.Vector3(0, 2, -20) }, // 顶横
    { a: new THREE.Vector3(0, 2, -20), b: new THREE.Vector3(0, 2, 0) }, // 下竖
    { a: new THREE.Vector3(0, 2, 0), b: new THREE.Vector3(-60, 2, 0) }, // 中横
    { a: new THREE.Vector3(-60, 2, 0), b: new THREE.Vector3(-60, 2, 20) }, // 下竖
    { a: new THREE.Vector3(-60, 2, 20), b: new THREE.Vector3(40, 2, 20) }, // 底横到右侧
    { a: new THREE.Vector3(40, 2, 20), b: new THREE.Vector3(40, 2, 0) }, // 回到电梯口
    // 电梯上升段
    { a: new THREE.Vector3(40, 2, 0), b: new THREE.Vector3(40, 18, 0) },
    // 2F - 一条直线到终点
    { a: new THREE.Vector3(40, 18, 0), b: new THREE.Vector3(-25, 18, 0) },
  ];

  function addFloorPlane(yLevel: number) {
    let minX = Infinity,
      maxX = -Infinity,
      minZ = Infinity,
      maxZ = -Infinity;
    for (const s of segs!) {
      const ay = s.a.y,
        by = s.b.y;
      if (Math.abs(ay - yLevel) < 1e-3 && Math.abs(by - yLevel) < 1e-3) {
        minX = Math.min(minX, s.a.x, s.b.x);
        maxX = Math.max(maxX, s.a.x, s.b.x);
        minZ = Math.min(minZ, s.a.z, s.b.z);
        maxZ = Math.max(maxZ, s.a.z, s.b.z);
      }
    }
    if (!isFinite(minX) || !isFinite(minZ)) return;
    const outward = pathWidth / 2 + pathWidth + 12 + 3;
    const w = Math.max(1, maxX - minX + outward * 2);
    const h = Math.max(1, maxZ - minZ + outward * 2);
    const geo = new THREE.PlaneGeometry(w, h);
    const mat = new THREE.MeshBasicMaterial({
      color: 0x99b6ff,
      transparent: true,
      opacity: 0.18,
      side: THREE.DoubleSide,
    });
    const plane = new THREE.Mesh(geo, mat);
    plane.rotation.x = -Math.PI / 2;
    plane.position.set((minX + maxX) / 2, yLevel - 1, (minZ + maxZ) / 2);
    scene!.add(plane);
  }

  function addRectPath(seg: { a: THREE.Vector3; b: THREE.Vector3 }) {
    const dx = seg.b.x - seg.a.x;
    const dy = seg.b.y - seg.a.y;
    const dz = seg.b.z - seg.a.z;
    const isVertical =
      Math.abs(dy) > 0 && Math.abs(dx) === 0 && Math.abs(dz) === 0;
    if (isVertical) {
      const len = Math.abs(dy);
      const geo = new THREE.BoxGeometry(pathWidth, len, pathWidth);
      const mat = new THREE.MeshPhongMaterial({ color: pathColor });
      const pillar = new THREE.Mesh(geo, mat);
      pillar.position.set(seg.a.x, (seg.a.y + seg.b.y) / 2, seg.a.z);
      scene!.add(pillar);
      return;
    }
    const len = Math.sqrt(dx * dx + dz * dz);
    const isAlongX = Math.abs(dx) > 0 && Math.abs(dz) === 0;
    const join = Math.max(0.5, pathWidth * 0.125);
    const geo = new THREE.BoxGeometry(
      isAlongX ? len + join : pathWidth,
      pathThicknessY,
      isAlongX ? pathWidth : len + join,
    );
    const mat = new THREE.MeshPhongMaterial({ color: pathColor });
    const box = new THREE.Mesh(geo, mat);
    box.position.set(
      (seg.a.x + seg.b.x) / 2,
      seg.a.y + 0.1,
      (seg.a.z + seg.b.z) / 2,
    );
    scene!.add(box);
  }
  function addCornerPlug(curr: { a: THREE.Vector3; b: THREE.Vector3 }) {
    const joinPos = curr.a;
    const geo = new THREE.BoxGeometry(pathWidth, pathThicknessY, pathWidth);
    const mat = new THREE.MeshPhongMaterial({ color: pathColor });
    const plug = new THREE.Mesh(geo, mat);
    plug.position.set(joinPos.x, joinPos.y + 0.1, joinPos.z);
    scene!.add(plug);
  }
  for (let i = 0; i < segs.length; i++) {
    const s = segs[i];
    addRectPath(s);
    if (i > 0) addCornerPlug(s);
  }
  addFloorPlane(2);
  addFloorPlane(18);
  segLens = segs.map((s) => s.a.distanceTo(s.b));
  step1EndIdx = 0;
  step2EndIdx = 5;
  step3EndIdx = 6;
  step4EndIdx = (segs?.length ?? 1) - 1;
  blockTargetIdx = 0;
  blockTargetTravel = travelOfIdx(0);
  sentenceActive = false;
  travelAccum = appState.ui.routeTravel || 0;
  segmentStartTravel = travelAccum;

  const startMarker = new THREE.Mesh(
    new THREE.SphereGeometry(2.5, 16, 16),
    new THREE.MeshPhongMaterial({ color: 0xff0000 }),
  );
  startMarker.position.copy(segs[0].a);
  scene.add(startMarker);

  const endMarker = new THREE.Mesh(
    new THREE.SphereGeometry(2.5, 16, 16),
    new THREE.MeshPhongMaterial({ color: 0xff0000 }),
  );
  endMarker.position.copy(segs[segs.length - 1].b);
  scene.add(endMarker);

  // 仅保留路径与箭头
  arrow = new THREE.Mesh(
    new THREE.ConeGeometry(3, 8, 16),
    new THREE.MeshPhongMaterial({ color: 0x0a84ff }),
  );
  arrow.rotation.x = Math.PI / 2;

  // 初始位置按持久化路程设置
  {
    let acc = 0;
    let idx = 0;
    while (idx < segs.length && acc + segLens[idx] < travelAccum) {
      acc += segLens[idx];
      idx++;
    }
    const seg = segs[Math.min(idx, segs.length - 1)];
    const segProg = idx >= segs.length ? 1 : (travelAccum - acc) / segLens[idx];
    const pos = new THREE.Vector3().lerpVectors(
      seg.a,
      seg.b,
      isFinite(segProg) ? Math.max(0, Math.min(1, segProg)) : 0,
    );
    arrow.position.copy(pos);
    const dirVec = new THREE.Vector3().subVectors(seg.b, seg.a).normalize();
    const up = new THREE.Vector3(0, 1, 0);
    const q = new THREE.Quaternion().setFromUnitVectors(up, dirVec);
    arrow.quaternion.copy(q);
  }
  scene.add(arrow);

  // 恢复小人
  {
    const body = new THREE.Mesh(
      new THREE.CylinderGeometry(2, 2, 6, 16),
      new THREE.MeshPhongMaterial({ color: 0x333333 }),
    );
    const head = new THREE.Mesh(
      new THREE.SphereGeometry(2.2, 16, 16),
      new THREE.MeshPhongMaterial({ color: 0x555555 }),
    );
    head.position.y = 4.5;
    walker = new THREE.Group();
    walker.add(body);
    walker.add(head);
    walker.position.copy(
      arrow.position.clone().add(new THREE.Vector3(0, 3, 0)),
    );
    scene.add(walker);
  }

  // 路径附近少量白方块(两侧少量,表现走廊感)
  let feePlaced = false;
  let roomCount1F = 0;
  let labCount2F = 0;
  const blockH = 8;
  const lateralThick = 12;
  const leaveGap = pathWidth; // 保留与路径、科室之间的空隙等于路径宽
  function createEdgeBlock(
    text: string,
    pos: THREE.Vector3,
    dims: { w: number; h: number; d: number },
  ) {
    const box = new THREE.Mesh(
      new THREE.BoxGeometry(dims.w, dims.h, dims.d),
      new THREE.MeshPhongMaterial({ color: 0xffffff }),
    );
    box.position.copy(pos);
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d")!;
    const font = "bold 16px sans-serif";
    ctx.font = font;
    const paddingX = 6;
    const paddingY = 4;
    const tW = Math.ceil(ctx.measureText(text).width);
    const tH = 20;
    canvas.width = tW + paddingX * 2;
    canvas.height = tH + paddingY * 2;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#ffffff";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#222";
    ctx.textBaseline = "middle";
    ctx.font = font;
    ctx.fillText(text, (canvas.width - tW) / 2, canvas.height / 2);
    const tex = new THREE.CanvasTexture(canvas);
    const labelW = 22;
    const labelD = 12;
    const plane = new THREE.Mesh(
      new THREE.PlaneGeometry(labelW, labelD),
      new THREE.MeshBasicMaterial({ map: tex, transparent: true }),
    );
    plane.rotation.x = -Math.PI / 2;
    plane.position.set(pos.x, pos.y + dims.h / 2 + 0.05, pos.z);
    const group = new THREE.Group();
    group.add(box);
    group.add(plane);
    scene!.add(group);
  }
  const minLenForBlock = pathWidth * 8;
  const placedAABBs: {
    minX: number;
    maxX: number;
    minZ: number;
    maxZ: number;
    y: number;
  }[] = [];
  segs!.forEach((s) => {
    const dx = s.b.x - s.a.x;
    const dy = s.b.y - s.a.y;
    const dz = s.b.z - s.a.z;
    const isVertical =
      Math.abs(dy) > 0 && Math.abs(dx) === 0 && Math.abs(dz) === 0;
    if (isVertical) return;
    const len = Math.sqrt(dx * dx + dz * dz);
    if (len < minLenForBlock) return;
    const isAlongX = Math.abs(dx) > 0 && Math.abs(dz) === 0;
    const cy = s.a.y;
    const is2F = cy > 10;
    const placeFeeLeft = !is2F && !feePlaced;
    if (placeFeeLeft) feePlaced = true;
    const tryTs = [0.35, 0.65, 0.5];
    function intersects(
      minX: number,
      maxX: number,
      minZ: number,
      maxZ: number,
      y: number,
    ) {
      for (const b of placedAABBs) {
        if (Math.abs(b.y - y) > 0.5) continue;
        if (
          minX <= b.maxX &&
          maxX >= b.minX &&
          minZ <= b.maxZ &&
          maxZ >= b.minZ
        )
          return true;
      }
      return false;
    }
    if (isAlongX) {
      const blockLen = Math.max(4, (len - 2 * leaveGap) * 0.9);
      const dims = { w: blockLen, h: blockH, d: lateralThick };
      const offset = pathWidth / 2 + leaveGap + dims.d / 2;
      for (const t of tryTs) {
        const cx = s.a.x + dx * t;
        const czL = s.a.z + dz * t + offset;
        const minXL = cx - dims.w / 2,
          maxXL = cx + dims.w / 2;
        const minZL = czL - dims.d / 2,
          maxZL = czL + dims.d / 2;
        if (!intersects(minXL, maxXL, minZL, maxZL, cy)) {
          const textL = placeFeeLeft
            ? "收费室"
            : is2F
              ? `化验室${++labCount2F}`
              : `科室${++roomCount1F}`;
          createEdgeBlock(textL, new THREE.Vector3(cx, cy, czL), dims);
          placedAABBs.push({
            minX: minXL,
            maxX: maxXL,
            minZ: minZL,
            maxZ: maxZL,
            y: cy,
          });
          break;
        }
      }
      if (!placeFeeLeft) {
        for (const t of tryTs) {
          const cx = s.a.x + dx * t;
          const czR = s.a.z + dz * t - offset;
          const minXR = cx - dims.w / 2,
            maxXR = cx + dims.w / 2;
          const minZR = czR - dims.d / 2,
            maxZR = czR + dims.d / 2;
          if (!intersects(minXR, maxXR, minZR, maxZR, cy)) {
            const textR = is2F
              ? `化验室${++labCount2F}`
              : `科室${++roomCount1F}`;
            createEdgeBlock(textR, new THREE.Vector3(cx, cy, czR), dims);
            placedAABBs.push({
              minX: minXR,
              maxX: maxXR,
              minZ: minZR,
              maxZ: maxZR,
              y: cy,
            });
            break;
          }
        }
      }
    } else {
      const blockLen = Math.max(4, (len - 2 * leaveGap) * 0.9);
      const dims = { w: lateralThick, h: blockH, d: blockLen };
      const offset = pathWidth / 2 + leaveGap + dims.w / 2;
      for (const t of tryTs) {
        const cz = s.a.z + dz * t;
        const cxL = s.a.x + dx * t + offset;
        const minXL = cxL - dims.w / 2,
          maxXL = cxL + dims.w / 2;
        const minZL = cz - dims.d / 2,
          maxZL = cz + dims.d / 2;
        if (!intersects(minXL, maxXL, minZL, maxZL, cy)) {
          const textL = placeFeeLeft
            ? "收费室"
            : is2F
              ? `化验室${++labCount2F}`
              : `科室${++roomCount1F}`;
          createEdgeBlock(textL, new THREE.Vector3(cxL, cy, cz), dims);
          placedAABBs.push({
            minX: minXL,
            maxX: maxXL,
            minZ: minZL,
            maxZ: maxZL,
            y: cy,
          });
          break;
        }
      }
      if (!placeFeeLeft) {
        for (const t of tryTs) {
          const cz = s.a.z + dz * t;
          const cxR = s.a.x + dx * t - offset;
          const minXR = cxR - dims.w / 2,
            maxXR = cxR + dims.w / 2;
          const minZR = cz - dims.d / 2,
            maxZR = cz + dims.d / 2;
          if (!intersects(minXR, maxXR, minZR, maxZR, cy)) {
            const textR = is2F
              ? `化验室${++labCount2F}`
              : `科室${++roomCount1F}`;
            createEdgeBlock(textR, new THREE.Vector3(cxR, cy, cz), dims);
            placedAABBs.push({
              minX: minXR,
              maxX: maxXR,
              minZ: minZR,
              maxZ: maxZR,
              y: cy,
            });
            break;
          }
        }
      }
    }
  });
  animate();
}

watch(
  () => appState.ui.subTitleText,
  (text) => {
    const now = performance.now();
    if (text && text.length > 0) {
      // 仅在编号段落时推进;前导“好的...”不推进
      const stepIdx = detectStepIdx(text);
      if (stepIdx === 0) {
        sentenceActive = false;
        lastSubtitle = text;
        return;
      }
      if (lastSubtitle && text !== lastSubtitle && lastStepIdx > 0) {
        travelAccum = blockTargetTravel;
        segmentStartTravel = travelAccum;
      }
      if (stepIdx === 1) blockTargetIdx = step1EndIdx;
      else if (stepIdx === 2) blockTargetIdx = step2EndIdx;
      else if (stepIdx === 3) blockTargetIdx = step3EndIdx;
      else blockTargetIdx = step4EndIdx;
      blockTargetTravel = travelOfIdx(blockTargetIdx);
      if (blockTargetTravel < segmentStartTravel) {
        blockTargetTravel = segmentStartTravel;
      }
      sentenceActive = true;
      sentenceStart = now;
      const len = text.length;
      let speechMul = 1;
      let speedUPS = 25;
      if (stepIdx === 1) {
        speechMul = 1.8;
        speedUPS = 18;
      } else if (stepIdx === 2) {
        speechMul = 0.8;
        speedUPS = 60;
      } else if (stepIdx === 3) {
        speechMul = 2.0;
        speedUPS = 18;
      } else if (stepIdx === 4) {
        speechMul = 1.4;
        speedUPS = 26;
      }
      const deltaTravel = Math.max(0, blockTargetTravel - segmentStartTravel);
      const distanceMs = (deltaTravel / Math.max(1, speedUPS)) * 1000;
      const textMs = len * 120 * speechMul;
      const estimate = Math.max(distanceMs, textMs);
      sentenceEstimatedMs = Math.max(1200, Math.min(12000, estimate));
      segmentStartTravel = travelAccum;
      lastSubtitle = text;
      lastStepIdx = stepIdx;
    } else {
      if (sentenceActive) {
        sentenceActive = false;
        travelAccum = blockTargetTravel;
        segmentStartTravel = travelAccum;
      }
      lastSubtitle = "";
      lastStepIdx = 0;
    }
  },
);

function animate() {
  if (!renderer || !scene || !camera || !arrow) return;
  const t = performance.now();
  let travel = travelAccum;
  if (segs && sentenceActive) {
    const p = Math.min(
      1,
      (t - sentenceStart) / Math.max(1, sentenceEstimatedMs),
    );
    travel = segmentStartTravel + (blockTargetTravel - segmentStartTravel) * p;
  }
  if (segs && segs.length) {
    let acc = 0;
    let idx = 0;
    while (idx < segs.length && acc + segLens[idx] < travel) {
      acc += segLens[idx];
      idx++;
    }
    const seg = segs[Math.min(idx, segs.length - 1)];
    const segProg = idx >= segs.length ? 1 : (travel - acc) / segLens[idx];
    const pos = new THREE.Vector3().lerpVectors(seg.a, seg.b, segProg);
    arrow.position.copy(pos);
    const dirVec = new THREE.Vector3().subVectors(seg.b, seg.a).normalize();
    const up = new THREE.Vector3(0, 1, 0);
    const q = new THREE.Quaternion().setFromUnitVectors(up, dirVec);
    arrow.quaternion.copy(q);
    if (walker) {
      walker.position.copy(pos.clone().add(new THREE.Vector3(0, 3, 0)));
      const yaw = Math.atan2(dirVec.x, dirVec.z);
      walker.rotation.set(0, yaw, 0);
    }
  }

  controls && controls.update();
  renderer.render(scene, camera);
  rafId = requestAnimationFrame(animate);
  // 持久化当前路程,保证再次发送时从当前位置继续
  appState.ui.routeTravel = travel;
  travelAccum = travel;
}

onMounted(() => {
  if (root.value) buildScene(root.value);
});

watch(
  () => appState.ui.routeResetToken,
  () => {
    // 重置到起点
    travelAccum = 0;
    segmentStartTravel = 0;
    sentenceActive = false;
    blockTargetIdx = 0;
    blockTargetTravel = 0;
    lastSubtitle = "";
    lastStepIdx = 0;
    appState.ui.routeTravel = 0;
    if (segs && segs.length && arrow) {
      const pos = segs[0].a.clone();
      arrow.position.copy(pos);
      const dirVec = new THREE.Vector3()
        .subVectors(segs[0].b, segs[0].a)
        .normalize();
      const up = new THREE.Vector3(0, 1, 0);
      const q = new THREE.Quaternion().setFromUnitVectors(up, dirVec);
      arrow.quaternion.copy(q);
      if (walker) {
        walker.position.copy(pos.clone().add(new THREE.Vector3(0, 3, 0)));
        walker.rotation.set(0, Math.atan2(dirVec.x, dirVec.z), 0);
      }
    }
  },
);

onBeforeUnmount(() => {
  cancelAnimationFrame(rafId);
  if (renderer) {
    renderer.dispose();
  }
  scene = null;
  camera = null;
  arrow = null;
  controls = null;
});
</script>

<style scoped>
.route-floor-3d {
  position: absolute;
  top: 64px;
  left: 0;
  right: 0;
  bottom: 0;
  width: 100%;
  height: 100%;
}
</style>
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐