用TRAE SOLO完善得奖作品 —— AI伴你‘衣食行医‘
文章目录
之前参加了一个 “魔珐星云数字人” 的活动,当时是直接在 “魔珐星云数字人” 官网的上下载了一个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 挂号,但对老年人并不友好,催生了高成本的“医陪”需求。
人工咨询压力大:导医台与人工窗口长期处于高负荷状态,服务质量难以保证。
痛点本质:医院缺少一个能够“用人话解释规则、替用户思考路径”的智能引导角色。
(二)大型商场场景:空间大、选择多、决策成本高
现代商业综合体体量不断扩大,但用户体验并未同步提升。
门店分布复杂:楼层多、动线绕,用户往往需要反复查看地图或询问工作人员。
找店效率低:想找某一家店,常常需要“走错—返回—再确认”,体验割裂。
消费决策困难:面对大量餐饮与品牌,用户不知道“哪家好吃”“哪家适合自己”。
用户粘性不足:商场难以持续与用户产生互动,只能依赖静态导览与被动推荐。
痛点本质:商场缺乏一个“能随时被询问、能结合场景做推荐”的智能交互入口。
(三)试衣与消费体验:流程繁琐,心理与时间成本高
在服装消费场景中,试衣环节长期存在明显痛点:
试衣过程麻烦:反复穿脱衣物耗时耗力,尤其在客流高峰期体验较差。
心理压力存在:部分用户对在公共试衣间反复试穿存在心理负担。
体验割裂:线下试衣与线上浏览、搭配之间缺乏连续性。
转化率受限:用户因试衣不便而放弃购买的情况普遍存在。
痛点本质:传统试衣方式效率低、体验重,不符合当前“便捷化、数字化”的消费趋势。
(四)公共空间中的“不知道该做什么”
在医院或商场中,用户经常并非目标明确,而是处于一种“被动探索”状态:
- 不知道下一步该去哪
- 不清楚当前有哪些服务或活动
- 不确定自己的选择是否最优
现有系统只能提供静态信息展示,无法主动理解用户需求并给出引导。
痛点本质:场所与用户之间缺乏持续、自然的互动连接。
2.3 项目切入价值总结
综合以上痛点可以看出,无论是医院还是商场,本质问题都不在于“信息不存在”,而在于:
- 信息分散、表达方式不友好
- 服务依赖人工,成本高且不可规模化
- 用户需要的是“被理解、被引导”,而非“自己去找答案”
AI 伴你「衣食行」项目,通过引入具备语音交互能力的 AI 数字人,将复杂的空间信息与服务流程,转化为自然对话式的引导体验,为用户提供:
- 随问随答的空间指引
- 基于语义理解的决策辅助
- 贴近真实人的交互方式
- 覆盖“衣、食、行、医”等高频生活场景的一体化服务
从而有效降低公共空间的使用门槛,提升服务效率与用户体验,同时为场地方构建新的智能服务入口与商业延展可能。
三、 产品核心功能
3.1 语音驱动的自然交互数字人
产品以 AI 数字人为核心交互载体,采用语音作为主要交互方式,用户无需复杂操作,仅通过自然语言即可完成信息获取与服务请求。
- 支持实时语音唤醒与连续对话
- 能理解口语化、非标准表达的用户提问
- 通过拟人化数字人形象进行反馈,降低技术使用门槛
- 特别适配老年人等对智能设备不熟悉的群体
功能价值:
将传统“点按钮、看说明”的操作模式,转变为“像问一个人一样问系统”,显著提升公共空间中的服务可达性与亲和力。
3.2 基于位置感知的智能导航与指路服务(行)
产品可结合用户当前位置,为其提供直观、可理解的空间引导服务。
- 自动识别或确认用户所处位置
- 支持语音查询目的地(如科室、门店、服务点)
- 提供清晰的路线指引与方向说明
- 可在医院、商场等复杂空间中使用
应用场景:
- 医院内寻找挂号窗口、科室、检查区域
- 商场内寻找具体门店、餐饮区域、服务设施
功能价值:
帮助用户在复杂空间中快速建立方向感,减少迷路、反复确认和对人工咨询的依赖。
3.3 智能科室引导与就医辅助(医)
针对医院高频痛点,产品提供基于症状描述的科室引导能力。
- 用户可通过语音描述自身症状或就诊目的
- 系统对语义进行解析,并匹配对应科室类型
- 以“引导建议”的形式告知推荐科室方向
- 明确提示结果为辅助参考,避免误导性医疗判断
功能价值:
降低因不了解医学专业划分而造成的挂错号概率,减少患者时间成本,缓解医院导医压力,尤其对老年患者具有显著帮助。
3.4 商场智能推荐与即时问答(食)
在商业场景中,产品可作为“随行导购式数字人”,提供即时咨询与推荐服务。
- 支持询问商场内“吃什么”“去哪逛”等问题
- 可根据餐饮类别、位置等进行推荐
- 通过对话方式帮助用户快速做出选择
- 减少用户在商场内的决策成本与犹豫时间
功能价值:
让用户在商场中“随时有人可问”,提升消费体验,同时为商场构建更具互动性的服务入口。
3.5 虚拟试衣与数字分身体验(衣)
产品支持通过数字人技术,构建用户的虚拟形象,实现虚拟试衣体验。
- 可生成与用户外形相近的数字分身
- 在无需实际更换衣物的情况下进行服装展示
- 支持多款式、多颜色的快速切换对比
- 可应用于服装零售、品牌展示等场景
功能价值:
减少试衣流程带来的时间与心理成本,提升服装消费效率,为线下商业场景引入更具吸引力的数字化体验。
3.6 多场景融合的一体化服务能力(衣 · 食 · 行)
“AI 伴你『衣食行』”并非单一功能工具,而是围绕用户在公共与商业空间中的完整行动路径,提供连续性的服务体验。
- 同一交互入口,覆盖导航、咨询、推荐与体验
- 根据场景不同切换服务重点
- 数字人形象统一,降低用户学习成本
- 可根据不同场所进行定制化部署
功能价值:
实现从“被动信息查询”到“主动智能陪伴”的转变,打造可持续扩展的场景级智能交互平台。
3.7 核心功能总结
通过将语音交互、位置感知、语义理解与数字人形态进行融合,产品构建了一个面向真实空间的智能服务系统,使用户在医院与商场等高频场景中:
- 找得到路
- 问得到人
- 做得出决定
- 得到更好的体验
部分代码
直接按照官网 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>
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)