Lucas Academy Inception Space
中英文总执行计划 / Bilingual Master Execution Plan
- 状态 / Status: Execution source of truth
- 日期 / Date: 2026-07-31
- 适用对象 / Audience: Owner, CC/Claude Code, engineering, curriculum, art educators
中文计划
0. 给 CC 的执行指令
- 先完整阅读
AGENTS.md、本计划和docs/decisions/0003-engine-bakeoff-and-lpr.md。 - 第一次执行只审计仓库现状,并在本计划的“当前状态”中记录证据;不要假设文档中的未来工作已经完成。
- 按阶段顺序执行。每一阶段必须通过验收条件后才能进入下一阶段。
- 不删除 Painterly Chameleon 继承代码、房间、资产、测试或文档。第一版通过、owner 批准清理清单后才清理。
- 不部署、不创建云资源、不建立公开仓库、不接入真实学生数据,也不向
painterly-source推送。 - 引擎选择是显式决策闸门。先完成 Three.js/Babylon.js 同场景对照,不得只凭偏好选定。
- 所有学生/LLM 可创建的发布数据必须是声明式、有限、可验证的;运行时禁止任意 JavaScript、shader 或模型脚本。
- 每次实施后运行现有完整检查,并为新模块增加独立测试和基准。保留测量原始数据,不只写“感觉流畅”。
可直接交给 CC 的任务提示:
请依据
AGENTS.md和docs/INCEPTION-SPACE-EXECUTION-PLAN.zh-en.md,先审计仓库与计划的差距,再执行下一个尚未完成的阶段。不得跳过决策闸门,不得删除继承内容,不得部署。用文件、测试和基准结果证明完成情况,并在阶段结束时报告仍存在的风险。
1. 当前状态
已完成:
- 已从 Painterly Chameleon 创建保留 Git 历史的新项目。
- 新项目名、README、仓库约束和 Cloudflare 配置已隔离。
- 旧生产 D1、域名、邮件、限流 namespace 和部署脚本没有被继承。
painterly-source只可 fetch,push URL 已禁用。- 继承的
.lpp、Live Painting renderer、资产和旧应用暂时完整保留。 - 继承基线通过 TypeScript/Vite build、
.lpp检查和 105 项测试;npm audit为 0。
尚未完成:
- Three.js/Babylon.js 尚未加入产品运行时。
- 尚无 3D 走廊、房间、角色或家具技术切片。
- 尚未定义正式
.lprruntime schema/compiler。 - 尚未把 Live Painting 作为 3D 动态纹理做真机性能测试。
- 尚未选择视觉方案、正式后端或 AI provider。
审计与进展记录
2026-07-31(CC 首次执行:审计 + Phase 1 切片)
- 审计证据:继承基线
npm run check通过(105 项测试;npm audit0);wrangler.jsonc无 D1/域名/邮件/限流绑定;git remote -v显示painterly-sourcepush 已禁用;three/babylon 当时不在依赖中;不存在src/inception/、index.html、docs/benchmarks/——与本节原始描述一致。 - Phase 1 对照切片已实现(纯新增,未改旧入口,未删除任何继承内容):
index.html+src/inception/(共享 manifest/接口 + 确定性移动/碰撞 + 15 部件 procedural mannequin(6 个可调色材质分区、idle/walk)+ Three.js 与 Babylon.js 两个 spike + 4 幅 512×512 Live Painting 动态纹理(fixtures 1a/1b/1c + 1a 时间偏移实例)+ 20 件低模道具 + 传送门 + 键盘/触摸行走 + 基准采样与 JSON 报告导出)。three@0.185.1、@babylonjs/core@9.19.0加入依赖;新增 25 项测试;npm run check= 130 项全部通过。 - Bundle 证据(vite build):threeWorld chunk 517.8 kB(gzip 131.0); babylonWorld chunk 1036.5 kB(gzip 246.9)+ 约 230 kB 惰性 shader/loader。
- 仍阻塞引擎决策闸门:桌面/Chromebook/iPad Safari 真机基准(owner 按
docs/benchmarks/ENGINE-BAKEOFF.md采集,原始 JSON 存docs/benchmarks/data/);引擎选择 ADR;CC0 rigged character 的下载与 provenance 审核(待 owner 批准;procedural mannequin 已可用,见content/inception/assets/PROVENANCE.md)。
2026-07-31(同日追加:owner 批准 Quaternius 下载 + provenance 登记)
- Quaternius Universal Animation Library [Standard](CC0)已下载并入库:
content/inception/assets/quaternius/UAL1_Standard.glb(13,744 三角形、 65 骨骼、Idle_Loop/Walk_Loop、米制 Y-up 脚点原点、无纹理双材质),许可证 快照与哈希登记在content/inception/assets/PROVENANCE.md,结构验收由tests/inception-avatar-glb.test.js自动化(含 GLB 哈希 ↔ provenance 一致性守卫)。Universal Base Characters [Standard] 已下载并记录哈希, 留待后续服装绘画阶段再入库。 - 两个 spike 均支持 GLB 角色(默认):three 走 GLTFLoader + AnimationMixer 双 clip 权重混合;babylon 走 AssetContainer + AnimationGroup 权重混合 (glTF loader 采用瘦身注册,省 ~166 kB gzip)。
?avatar=mannequin强制 procedural mannequin,GLB 加载失败自动回退并在报告scene.avatar如实 记录。分区调色同一 API 覆盖两种角色。 npm run check= 136 项全部通过(新增 5 项 GLB 验收测试)。含 loader 的 bundle:threeWorld 633.3 kB(gzip 162.4);babylonWorld 1266.3 kB (gzip 302.3)。引擎闸门现在只剩真机基准与 ADR。
2026-08-01(owner 指示:房间整体包裹 Live Painting)
- 场景规格变更(§3.4 已同步更新):作品不再是挂在墙上的 2×2m 画框,而是 立方体房间的每个面本身就是 Live Painting——四面墙 + 地面 + 房顶共 6 个整面 512×512 动态表面(3 套继承 fixtures 各用两次、时间偏移错开)。 门洞墙由三块 UV 分段共享同一张纹理,画面跨门洞连续;所有 quad 双面渲染 以消除两引擎平面朝向差异。每帧 6 次纹理上传,比原四幅方案更重,是有意的 最坏情况压测。
- manifest 新增
artworkQuads(surface 引用 + UV 子矩形校验、每个 surface 必须被放置);两个引擎的 artwork 构建改为 per-surface 纹理 + per-quad 平面。npm run check= 136 项全部通过;bundle 基本不变 (threeWorld 633.6 kB gzip 162.5;babylonWorld 1267.6 kB gzip 302.7)。 - 房顶当前相机看不到(owner 确认不影响测试);后续视角调整是独立工作。
2026-08-01(第二批:MacBook Safari 基准数据 + 产品方向文档)
- 首批真机数据入库:
docs/benchmarks/data/macbook-safari-{three,babylon}-30s.json。 稳态 p50/p95:three 51/62ms,babylon 56/69ms;p99 248 vs 2114ms;两者都有 多次秒级卡顿(three 最差 8.8s,babylon 最差 24.3s)。结论与计划一致: 瓶颈是每帧 6 次整面重绘+上传的最坏情况路径,引擎换不掉它(.lpr+ scheduler 才是解法);单设备证据倾向 Three.js。分析见docs/benchmarks/ENGINE-BAKEOFF.md。 - 发现并修复:两份报告
scene.avatar均为 mannequin fallback——GLB URL 用了 动态模板字符串,vite 构建未打包 GLB → 产线 404。已改为静态字面量 (dist 已验证含 GLB)+ manifest 一致性断言。 - Owner 决定:暂不做更多 benchmark;引擎 ADR 推迟到产品形态场景。产品方向 (深空博物馆、无尽玻璃走廊两侧开传送门、逐房间加载、0 重力自由视角、 桌面高 DPI/移动低 DPI、WebSocket 多人、Codex 管美术微调/CC 管主程序、 未来扩展游戏性/更多 pose/PropHunt 分支)记录于
docs/SPACE-MUSEUM-DIRECTION.md(英文)。 - 同日执行核心(
index.html?world=museum,Three.js 先行,引擎 ADR 仍 未关):确定性程序化星空天空盒(NASA SVS 图待批准下载后可替换)、无尽 玻璃走廊(空气墙 + 两侧传送门,2 开放房 + 4 封闭槽位)、逐房间流式 加载/卸载(仅所在房间的 6 个整面重绘)、0 重力飞行 + V 键一/三人称切换、 漂浮姿态角色(GLB Swim_Idle_Loop / mannequin 回退)、桌面 dpr2 / 移动 dpr1 策略。多人与全部 [FUTURE] 项仅设计未动工。npm run check= 150 项 全部通过(新增 9 项 flight/museum 测试)。
2026-08-03(Baby Astronaut 正式接入 + 学生角色制作路径)
- Lucas Academy 原创
baby-astronaut-v1.glb替换 Quaternius 成为博物馆默认 角色;Quaternius 保留为 benchmark/reference,procedural mannequin 继续作为 自动回退。当前模型为 18,280 三角面、7 关节、约 3.36 MiB、三个内嵌纹理 (服装布料、球面五官与球面透明头发);头是单一圆球,五官贴合正面球面, 头发则是一个从球顶连续展开到短前发、两侧与长后发的极坐标球面发壳, 不含照片或 face scan。 - 正式动作状态为
Idle_Loop、Walk_Loop、Float_Loop、Dive_Loop:水平 移动、上浮、下潜分别混合到对应动作。Desktop Space 默认上浮,到顶后同一次 按压转为下潜;mobile Float/Dive 可与方向摇杆同时使用。 - 学生未来可在 Nomad Sculpt(平板、易上手)或 Blender Sculpt(桌面、 完整工具)编辑原创角色;推荐 Nomad/Blender 雕刻 → Blender 减面、UV、绑定、 四套动作与 GLB 导出 → 教师审核 → 导入。预算、安全和命名规则记录在
docs/SPACE-MUSEUM-DIRECTION.md§6b。 - 2026-08-03 视觉修订:根据 owner 的第二张私人参考,用纯文字生成、无照片像素 的透明发片纹理替换实体尖刺头发,发片主要覆盖后脑勺与头顶后侧,正面保持 干净;新增可单独换色的绿色几何蝴蝶结(双环、 褶皱、垂带和中心结)。氧气罩保持更易辨认的淡粉透明材质;游戏显示比例从 1/3 改为 1/6,碰撞体和上下边界间距由同一个比例同步缩小。
9. 下一步路线图与已确认决策(2026-08-02)
Phase 1 已基本收尾(深空天空盒由 Codex 完成并登记 provenance)。以下顺序 按"解锁面最大 / 阻塞最少"排列,理由见每行;决策来自 owner 2026-08-02。
| 顺序 | 事项 | 状态 / 归属 | 为什么在这个位置 |
|---|---|---|---|
| 1 | Lucas Account 统一登录服务 | 代码已完成(独立 repo playground/lucas-account,10 项测试 + wrangler dry-run 通过;未部署、未建任何云资源) | 编辑器保存、多人身份、Render 部署都卡在它上面;同时消灭 painterly/art-lab/lucas-academy 三份重复 OTP。设计见 docs/decisions/0004-lucas-account-service.md |
| 2 | 编辑器 MVP | 第一版已完成(登录 + 房间文档 + 摆放/换画/导出 JSON;本地草稿,无后端) | 先做纯本地版(摆放/参数/导出 JSON),不依赖后端;"保存/发布"再挂到登录后面。白名单放 config 文件([email removed]),服务端写接口必须再查一次 |
| 3 | Render 部署 | 已上线 2026-08-02(is.lucasacademy.org + inception-space-api + museum-db;运行手册 docs/DEPLOY-RENDER.md) | 博物馆是纯静态 Vite 产物;参考 snake-lab/render.yaml。邮件与登录留在 Cloudflare Worker |
| 4 | WebSocket 多人在场 | 首版已完成 2026-08-03(transform-only presence,与 API 同进程;见当日审计条目;owner 部署后真机验证) | 建议放 Render 的 Node + ws(复用 snake-lab 模式),不再优先考虑 Cloudflare DO;依赖第 1 项提供身份 |
| 5 | .lpr 编译器 + 中央调度器(Phase 2/3,即"性能优化") | 待开工 | 现在不紧急:同时只有一个房间在绘制,1024² 实测流畅。但它是学生作品的存储格式,必须赶在学生批量产出内容之前完成 |
| 6 | 公开快照 + 搜索/AI 引擎可发现性 | 进行中(房间文本孪生 + llms.txt + sitemap 已建 2026-08-03;同日加 /docs/ 文档站;作品级公开快照未做) | 新增受众要求见 docs/DISCOVERY-SEO-GEO.md:3D 画布对爬虫和 AI 完全不可见,需要每个房间/作品的文本孪生页 + 结构化数据 + llms.txt;隐私红线优先,非策展快照一律 noindex |
同期的小事:引擎选择 ADR 应当收口(证据倾向 Three.js,博物馆事实上已是 Three-only);Babylon spike 暂时保留但不再演进。
已确认决策(owner 2026-08-02):
- 账号服务:独立 repo、独立域名、独立部署;当前只做博物馆一个客户端。
- JWT:owner 要求"永久"。记录为 一年有效期 +
kid密钥轮换——因为 JWT 是浏览器里的用户凭证(Render secret 存的是签名私钥,不是用户 token),真正永久意味着笔记本丢失后无法吊销;轮换密钥即可一键全部失效。 - 搜索引擎 / AI 引擎是第一等受众,不是发布后的补丁(owner 提醒:删除 painterly 时差点把这条需求一起丢了)。
2026-08-02(Phase 2 开工:.lpr v1 规范 + 编译器 + 参考渲染器)
- 新增
src/inception/lpr/:schema.js(opcode 白名单 + 参数范围 + 预算 + 能力协商)、buffers.js(marks 以 8 个 Float32 列 + 6 个字节列打包, 不再是逐 mark JSON 对象)、behaviors.js(逐 mark 纯函数,就是"运动" 的规范本身,GPU renderer 将照它移植并用同一批 fixture 比对)、compile.js(curated JSON →.lpr,确定性哈希)、canvasRenderer.js(Canvas2D 参考渲染器,只声明marks能力)。规范:docs/LPR-FORMAT.md。 - 硬边界按 §5.2 落实:无任何可执行内容;预算(20000 marks / 400 strokes / 8 layers / 4096px / 64 behaviors);固定时钟 + 烘焙 seed;拒绝而非静默 降级——严格模式下三个继承作品全部被拒并逐条说明原因(ripple/growth 需要 采样底图、curve-current 需要路径缓冲、liquid-warp 是整面位移),
--partial才产出并把缺口写进包内refusals。 - 抓到一个真 bug:
JSON.stringify(obj, keysArray)的第二参数是全局键 过滤器,会丢掉嵌套键——导致改了行为参数哈希却不变(等于哈希没覆盖内容)。 已换成递归排序的规范化序列化,并加测试钉住"改一个参数/挪一个 mark 都必须 改变哈希"。 - 新增
npm run lpr:compile/lpr:verify(后者已接进npm run check: 源改了没重编、或编译器悄悄改变输出,都会让构建失败)。产物提交在content/lpr/*.lpr.json。npm run check= 81 项通过。 - 同日 owner 决定:Phase 2 暂停——"
.lpr还是要保证和.lpp无缝衔接, 而且.lpp可扩展性比较强,我先做一些实例"。因此lpr:verify已从npm run check摘出(源文件即将频繁变动,陈旧包报错只会挡路),.lpr代码不接进运行时(房间仍由继承 renderer 绘制),暂停零成本。恢复前要先 解决两件事,已写入docs/LPR-FORMAT.md:(1) 只留一道缝——编译器应 直接读.lpp,而不是中间的 curated JSON;(2) 可扩展而不破坏——.lpp会自由长出新画笔,.lpr必须能承载新行为而不作废旧包(按 id + version 的注册表,以及"遇到没见过的行为怎么办"的规则;今天的答案"拒绝" 在安全上正确,但创作节奏快起来后太生硬)。做实例期间,严格模式的拒绝 清单就是格式待办:node scripts/compile-lpr.mjs打印的每一行,都是真实 作品需要而格式还没有的能力。
2026-08-02(owner QA:传送门与地板)
- 传送门去掉门框(走廊侧与房间内的返回门都去),门膜紧贴地板 (中心 = 门高的一半,不再抬高 0.35/0.4m),脉动改为可以完全消失:
minLevel0、周期放慢到 0.22Hz,且透明度参与呼吸(不只是变暗,低谷时 material.visible=false)。 - 修"某些视角地板消失":原先玻璃地板上方 4cm 还铺了一层"地面提示面",两个 半透明面几乎共面 → 深度排序每帧/每角度都可能翻转,看上去就是地板忽隐忽现。 现在合并为一层(
glassFloor,稍高不透明度以便在 0 重力里读出"地面"), 并给透明面固定renderOrder(地板 -3 → 外壳 -2 → 传送门 2),不再依赖 距离排序。npm run check= 81 项通过。
2026-08-02(Live 开关 + 天空/传送门的 Live 接缝)
- Menu 新增 Live effects 滑块(0% = 全部冻结在最后一帧,100% = 满速, 中间为降频),localStorage 记忆,另有
?live=0..1便于基准锁定变量。 它作用于所有 Live Painting,不只是房间六面。 - Owner 随后决定 Space 背景不使用 Live Painting:运行时改为通用文件名
background.png的 4096×2048 静态 2:1 全景,启动时上传一次,不进入 paint scheduler,也不受 Live 滑块影响。LIVE_TARGETS.sky恢复为 null, v5.lpp、静态 adapter 与 shell 仅保留为历史创作/审计素材,未来有明确的 空间动态构想时再重新评估。4K 解码纹理约 32 MiB(不含 mipmap);高于 4K 必须先做 iPad 内存与加载测试。
2026-08-03(marks 上 GPU:4K 深空天幕活了) ← 请 QA
- 读完三支天空笔刷后发现它们都很"便宜":
move()不产生位移、也不采样 照片,只按 (mark.index, seed, t) 算出大小/透明度/颜色。所以不需要重绘, 85 颗星作为 instance 属性上传一次,全部动画在 vertex shader 里跑, 每帧只写一个 uniform。同一张画走 canvas 路线要每次重传约 32MB。 markShader.js逐行转写:Messier 的 8 个颜色家族、亮星的 8 个家族 + 8 条 呼吸曲线、星云的双正弦呼吸;亮星一颗由多个 mark 组成(核心 / 衍射芒 / 光晕),所以 fragment 分别画dot/streak(按作者角度拉长旋转)/ring。- 顺带挖出两个真 bug:
ring根本不在 shape 表里;compileLpp 把所有 shape 都打包成 0(mark 里是"streak"字符串,packMarks 要的是序号,Number("streak")=NaN→0),所以此前每个光晕和衍射芒都被压成了普通圆点。 - 现在天幕包 complete(85 marks / 3 behaviors / 只需要
marks能力), 开场自动应用;?sky=off可退回静态全景做对比。136 项中 134 通过。 - ⚠️ 仍红的两项是 owner 自己的
inception-live-sky.test.js:它们钉住的是 旧 curated 记录,而新的 4K.lpp里有 importer 审批表里没有的笔刷 revision。这条 legacy 管线已经被.lpr取代(live:check改为校验 .lpr, 旧的保留为live:legacy-check)——要么补审批表重生成,要么退休那份记录, 由 owner 决定。
2026-08-03(HUD 默认关闭;跟上 owner 并行的改动)
- 澄清:owner 说的 "?debug" 指的是右上角的 HUD 面板(fps/draws/tris…), 不是 bake-off 链接。现在 HUD 默认不显示,
?debug、?hud=1或?bench=N时才出现(跑分需要它来出报告),?hud=0仍然有效。 - 跟进 owner 并行的改动:avatar 换成
avatar/baby-astronaut-v1.glb(新增 Float/Dive/Swim_Idle 三个循环)→ 已登记 hash 与内容,但来源与授权 仍需 owner 补一行;AVATAR_SCALE1/3→1/6 → 把那条测试从"钉死 1/3"改为 "钉住由它推导出来的间隙";room-a 换成van-gogh-student-gallery-v1/六张 学生画(owner 已把 roomImages 改成递归 glob);文本孪生页不再把静态画说成 "living painting"。127 项通过。
2026-08-03(引擎 ADR 收口 + 首页精简 + 可被搜索/AI 读到)
- ADR-0006:Three.js 就是博物馆的引擎。Babylon 切片保留但只在
?debug下出现。理由不是跑分(0003 拒绝用合成场景定案是对的),而是过去 一个月所有产品形态的东西都长在 Three 上,且真正的瓶颈是 canvas→GPU 上传, 与引擎无关。 - 首页按 owner 要求只留两样:进入博物馆的链接 + 操作说明;其余(介绍、 房间列表、bake-off)挪到
?debug。 - favicon:
public/favicon.svg+ 64×64 PNG 兜底(深空圆角砖 + 发光传送门)。 - 发现层落地(DISCOVERY-SEO-GEO R1–R4/R6):
npm run build之后运行scripts/prerender-discovery.mjs,用世界读的同一份 manifest 与房间文档 生成dist/rooms/<id>.html(仅 public 房间)、sitemap.xml、robots.txt、 以及给 AI 读的llms.txt;根页与每个房间页都带 JSON-LD(绝不含 Person)。 两条隐私测试:产物中不得出现任何 owner 邮箱或 "password";房间出现在 sitemap 里当且仅当它是 public。124 项通过。
2026-08-03(v7 天幕:星云会一直呼吸下去) ← 请 QA
- Owner 交来
space-museum-corridor-starlight-v7.lpp("加了很多星云笔刷效果"), 已归档进 corpus,天幕改为它:770 marks(48 颗远星 + 20 个亮星 mark + 702 个星云 mark),v5 的 85 marks 作为上一版留在 corpus 里对比。 - 编译第一次是 1 个 gap,而这正是"身份 = 代码形状"应有的表现:Photo Nebula Breath 这次改的是代码——双正弦呼吸之外多了一条永不停止的包络 (暗歇 → 淡入 → 亮住 → 淡出 → 循环),6 个新
@ui控件(淡入/亮住/淡出/ 暗歇/云朵错开/暗时保留的光),11 个 revision 收敛成 1 个待审形状。读完 源码后登记为photo-nebula-breath的 version 2,v5 仍然叫@1、外观 不变。 - 两处格式上的结论:参数属于版本,不属于行为(schema 支持
paramsByVersion,编译器与校验器都走paramsFor(kind, version)——否则每个 v1 包都会被判定"缺 6 个参数");shader 按behavior@version派发 (MARK_BEHAVIORS改成带版本的键,因为"画得不一样"才是版本的意义)。 把 v2 的数据当 v1 replay 现在是校验错误,而不是循环悄悄停掉。 nebulaLoopLevel()是 shader 里nebulaFadeCycle的 JS 孪生(plan §5.3 的 规矩),测试钉住 owner 真正要的性质:4 个 seed × 6 个周期内,每朵云都会 完整亮起、也会回到暗歇——没有任何设置能把一朵云永久关掉;错开为 0 时 所有云同步呼吸;"暗时保留的光"抬底不动峰值(并且和笔刷一样在 60% 处夹住)。- v7 的 shell 与museum 正在用的 4K 全景逐字节相同(测试按 hash 校验), 所以云的颜色就是从这张图上采的。包本身 73KB JSON,随 bundle 内联。 141 项全绿。
- Owner QA:"特效能看见了,但错位了"→ 已修:mark 的 canvas y 从画的 顶部往下数,而球面纹理 v 从底部往上数,直接透传把整个天幕上下镜像 了——每朵云都坐在错误的星云下面(v5 的 85 颗星其实也翻了,只是星点太小、 背景又全是星,看不出来)。
buildMarkInstances现在传1 - y/height, 新测试钉住"画在哪个像素上、就落在那个像素上":合成包顶部→天顶、底部→ 天底,再对真实 v7 包逐 mark 校验高度公式。142 项全绿。
2026-08-03(多人在场 WebSocket + /docs/ 文档站) ← 部署后请双端验证
- 路线图第 4 项落地:presence 与 museum API 同进程(ADR-0005 预留的正是 这个位置)。
server/presence.js= 纯 hub(zone → 成员表,无 socket,可直接 单测)+ws薄壳,挂在同一个 node:http server 的/presence上。分片与 房间加载一一对应:走廊一个频道、每房间一个频道,客户端只订阅自己所在的 空间。 - 协议只有变换:六个夹取取整后的数字
{x,y,z,yaw,hs,vm}过白名单净化 函数;join 只带 zone/token/pass。不存在任何可输入文字的字段(AGENTS.md 安全线);身份是服务器发的随机 id,邮箱永不广播。防洪:帧率限速 + 4KB 消息上限 + 每 zone 24 人 + 总连接 200 + 心跳清死连接 + Origin 校验。 - 私密房间的频道遵守与
GET /api/rooms/:id完全相同的读规则(直接复用authorizeRead:owner token 或 room pass)——"个人边界在实时层同样成立"。 库里没有的房间按公开处理:静态构建已经把文档发到每位访客手里,频道里 没有更多可保护的东西;一旦 owner 把它存成私密,闸门即刻关上。 - 客户端:
net/presenceClient.ts(VITE_MUSEUM_API→ wss;退避重连;?presence=off诊断开关;dev 下没起 API 时只安静地试一次)+net/remotePresence.js纯平滑(指数逼近、最短弧 yaw、8m 以上视为传送 直接落位、5 秒无样本转 idle)。远端形象 = 本地 GLB 的 SkeletonUtils 克隆(geometry/材质共享、mixer 独立,walk/float/dive/idle 权重混合与 本地同一套规则),GLB 加载失败时与本地一致回退 mannequin。每帧一次syncRemoteAvatars对账;不配 API 时博物馆就是原样的单人博物馆(纯增量)。 /docs/文档站(owner:像 lucasacademy.org/doc 那样渲染 md,服务人 / 搜索引擎 / 没有仓库上下文的 AI,好让任何人能靠它做出扎实的计划):12 份 真实文档(README、愿景、方向、双语执行计划、LPR 格式、性能、发现层、 ADR-0002..0006)在 build 时由scripts/prerender-discovery.mjs静态渲染成/docs/<slug>.html,渲染器是自写的无依赖小型 Markdown 渲染器 (src/inception/discovery/markdown.js,先整体转义再变换);每页并排一份.md原文镜像。邮箱一律渲染时脱敏为[email removed],隐私测试扩展 到 /docs 全部产物。sitemap / robots /llms.txt全部收录(llms.txt 直接 给.md链接)。首页多一个可展开的 "Project docs"。DEPLOY-RENDER(运维手册)与 AGENTS/CLAUDE(工具指令)有意不发布。ws@8入依赖;新增 24 项测试(协议净化 / hub 规则 / 真 socket 端到端 / 平滑 / Markdown 渲染 / 目录与隐私)。npm run check= 166 项全绿。 presence 不需要任何新的云资源;owner 部署后开两个浏览器互看即可验证。
2026-08-03(两个跟进:桌面 Shift 直接下潜;/docs/ 在 dev server 可用)
- Owner:桌面加 Shift = 下潜,Space 保持原样。实现为纯输入映射:Shift (左右任一)并入触屏 Dive 按钮已有的
diveHeld通道,verticalFromControls纯规则一行未改——落地下潜自动归零(r6 规则)、 可与 WASD 同按;按住 Space 时优先级不变(上浮、触顶转下潜)。首页 Controls 文案同步。 - Owner:
localhost:5173/docs/打不开——文档页原先只在构建时写进 dist。 页面渲染整体抽到src/inception/discovery/docsPages.js(prerender 脚本与 dev server 共用同一份函数),vite.config.ts 加一个 dev-only middleware:/docs/、/docs/<slug>.html、/docs/<slug>.md现场渲染当前工作区的 文档(改文档刷新即见),脱敏同一条路径;生产仍走 dist 预渲染文件。 已 curl 实测 5173 三类路径均 200。167 项全绿。
2026-08-06(首页三层标题 + 进馆自动全屏 + Space Travel 的闪烁提示) ← 请 QA
- 首页:只有一个正式房间,"Choose a room" 就没有意义了——房间卡片区 改为
hidden,脚本只在ROOM_CATALOG.length > 1时才建卡并放出来(加 第二个房间仍然只是改数据)。标题三层:大 "Lucas Academy Inception Space"、中 "Deep-Space Museum"(取自房间的新字段subtitle,将来多 房间时它就是被选中的那个房间的名字)、小 "Free admission — step inside art made by kids, for kids, and with kids."。计数去掉了房间数。 - Paint 按钮:studio 本来就要登录(
avatarStudio的 gate),所以门口 就说清楚:未登录时按钮是 "Sign in to paint 🔒",已登录恢复 "Paint <名字>"。判断用的是 studio 自己那套currentSession()?.email(认过期、认本地 dev 快捷登录),且每次刷新标签时重读——在 studio 登录 后按浏览器返回,锁会自己打开。 - 进馆自动全屏:浏览器不肯凭空全屏,它要一个手势,而首页那一下点击 已经花在跳转上了——所以由馆内第一次触摸/点击/按键带上这个请求,只 尝试一次(Esc 或菜单退出全屏后不会被拽回去),
?debug&fullscreen=0可 关掉。菜单里的 Full screen 与它共用同一个FULLSCREEN助手(webkit 前 缀、不支持就不提供按钮),两处不可能再各说各话。桌面上那一下点击同时还 在要指针锁,而浏览器可能在视口变化时把锁丢掉——馆里把"丢锁"读成 Esc,本来会当着访客的面弹出菜单。所以请求之后有 1.2 秒的缓冲期 (fullscreenSettleUntil):这期间丢锁只意味着"再点一下",不是"暂停"; Esc 本身的行为一字未改。 - Space Travel 的提示终于出现在桌面上——还是状态行,还是原来那句话。 屏幕中央的闪烁提示做过两次,两次都按 owner 意见撤回(2026-08-07): 文案不变,触摸设备
Space Travel — tap to fly on,桌面Space Travel — press Space to fly on,位置就是 iPad 一直显示的那条状 态行。不加新元素,不闪。 - 桌面从来就没显示过这句话,而且不是故意的:选中这一项会先
closeFloorDialog(),关闭对话框会把指针锁还给桌面,pointerlockchange在锁生效时清空状态行——恰好在startSpaceTravel()写下这句话之后一拍。触摸设备没有指针锁,所以只有 iPad 一直看得见。于是 状态行现在可以被按住:holdStatusLine()记下"世界坚持要说的话", 指针锁改为恢复它而不是清空(heldStatus ?? "")。舱在等的时候按住这句 话,一起飞就松开;状态行的其他用法(包括 "Click to capture the mouse") 一字未改。 npm run check全绿(owner 并行提交后为 286 项)。未在真机自测: pane 里点击已验证"第一个手势恰好请求一次全屏、第二次不再请求";但 pane 是嵌套文档,全屏和指针锁都会被浏览器拒绝——而丢失桌面提示的机制正 是指针锁,所以那一条是照代码路径推出来的,不是看见的。请 owner 在真机 确认:桌面坐舱时的那行字、iPad 的全屏、以及进馆不再弹出菜单。
2026-08-07(客人能进房间,却看不到画架和马:登记表回答了另一个问题) ← 请 QA
- 先把线上真正验清楚,因为我上一次的判断是错的。 我说过"静态站没有重新 构建"——它构建了:
is.lucasacademy.org上的museumMain-BhCb3DGI.js与 本地构建64556f5的产物逐字节相同(308,266 B),读路径就在里面。我之前 grep 的是 46 KB 的入口文件,而博物馆是另一个按需加载的 chunk。另外两个也 是我自己制造的假警报,记下来免得下次重犯:/api/worlds/**museum**/...会 404 是因为 world id 是space-museum;无头 pane 的网络面板根本不记录 跨域 fetch(要自己包window.fetch)。 - 以客人身份端到端验证:用
?debug&manual=1无头驱动线上博物馆,走进 room-a 会发出GET /api/worlds/space-museum/rooms/room-a→ 200;线上 两个房间都与仓库文件不同(room-a 多了仓库没有的p4/p5,room-b 的p5转角是-4.977,仓库是-1.5708)。读路径是通的。 - 真正的 bug 出在权属模型上:
GET /api/objects只返回"这个人可以用"的 物件,而渲染器正是从这份清单里学到 GLB 物件的字节在哪。easelArt和paintedHorse是 owner 的私有物件,客人从来没被告知它们的地址,房间就少 了这两样——旁边两栋学生小屋却在,因为代码生成的物件不需要任何字节。owner 看到的是完整的房间,别人看到的是空了一半的房间。 - "看得见"和"能使用"现在是两个答案。 登记表列出所有物件,并带上
usable,由权属决定这个标记;offeredObjects会把usable: false挡在 编辑器之外(旧版 API 没有这个字段时仍视为可用,这样部署过程中的浏览器不会 拿到一个空编辑器)。边界没有变,仍然在写路径:server/rooms.js依据listObjectsFor(不是这份清单)拒绝写入不属于自己的 archetype。代价是私有 物件的桶地址对所有人可见——桶本来就是 public-read 的设计 (docs/OBJECT-LIBRARY.md),另一种做法(按房间解析字节)买到的是"不易 发现",不是保护。 - 随后 owner 把这条规则的名字说准了,而这是"用词"问题,不是 schema 问题: "我说的 private 其实是可用性(只有 owner 能用),visibility 应该是:所有物件 对所有人可见。" 所以两个资产登记表都不再输出
visibility——列保留、不需要 迁移,只是这个词对资产来说是错的。对房间它仍然名副其实:private 房间确实 隐藏文档。已作为 ADR-0007 的修订记录在案。 - avatar 也一样,而且更需要。
GET /api/avatars现在把每个 avatar 都列出 并带usable,因为在场系统要画别人:客人列不出来的 avatar,就是客人画不 出来的同伴。这也暴露了一个真的漏洞——avatar 这边过滤后的选择器就是唯一 的规则,服务端什么都没校验。POST /api/paintings现在会拒avatar_not_yours, 因为"给 avatar 上色"就是"穿上它"这件事的写入动作。登记表根本不认识的 avatar 仍然放行:构建与数据库不一致不是判决。 - 同一个症状的第二个成因,也一并消掉:GLB 物件"是哪个文件"属于构建的 事实,但这一行只在插入时写过一次——所以painted horse 重新导出(带动画的 那次)之后,线上仍指向上一次上传,只能靠人记得跑一条
UPDATE。现在 boot 会把source: 'r2'的行重新指向构建声明的 ref,并在日志里说明。内容寻址 的命名让这件事安全:新导出是旧文件旁边的新文件。权属依然从不改动。 - 三个 GLB 都已确认按构建声明的 ref 在桶里(
paintedhorse-9d0a735819f9318 KB、easel-c7fb1f1f7edf595 KB、nativity-c22216b50f9a877 KB)。 - 仍然要 owner 动手的,现在只剩一件:博物馆自带的参数化 easel 因为我 2026-08-07 的 id 冲突在线上被标成了 private ——
UPDATE objects SET visibility='public', owners=ARRAY[]::TEXT[] WHERE id='easel';。 boot 会修它的source/ref,但故意不碰权属。 - 新增 4 个测试、重写 3 个(人人都看得见每个物件、由权属决定谁能拿来搭;只 能看不能用的物件会带着字节地址出现在清单里、但不进编辑器;重新导出的 GLB 跟着构建走,不需要 SQL;别人拥有的 avatar 会被列出但仍不可选;不能给不属于 自己的 avatar 上色)。304 项通过,typecheck 与 build 干净。未部署。
- "本地还是不显示"(owner 当天晚些时候)——笔记本上有两个成因,都不在 代码树里。 直接探测了正在运行的本地 API(:8788,没有动它): (1) 它跑的是旧代码——匿名
/api/objects仍只回 8 个公共 id,没有usable;(2) 它没有OBJECT_CDN_BASE,所以就算列出了 GLB,url也是 null。这个环境变量现在默认指向线上公共桶(用||而不是??,这样 blueprint 那类value: ""的事故也会落到默认值上)——笔记本零配置。 "不设置就是惰性"只在桶还不存在的时候是对的。 - 用一对一次性的替身(API :8799,灌入 owner 一模一样的已保存 room-a; Vite :5174)端到端验证,owner 的两个进程全程没碰:博物馆请求了
/api/objects和房间文档,并且两个 GLB 的 url 都发起了抓取——只被 桶的 CORS 挡住,而桶允许的是localhost:5173(按 origin 各 curl 一次 确认:5173 有access-control-allow-origin,5174 没有)。所以在真实端口 上闭环成立。剩下唯一一步是 owner 的:重启本地 API 进程,让它跑上今天 的代码。 Vite dev server 什么都不用做——它实时提供当前源码。304 项 通过。
2026-08-07(保存的房间终于是所有人看到的房间:读路径接上数据库) ← 请 QA
- owner 报的"别人看不到我保存的设置":写路径 8-06 就通了(PUT 存进 Postgres,线上确实有两个房间的文档),但从来没有人读过它。房间是 这样拼出来的:本浏览器的 draft → 构建时被 Vite 内联进 JS 包的仓库 文件 → 代码默认值。所以 owner 看到的是自己的 draft,其他人看到的是 上次构建时仓库里的样子。
- 按 ADR-0007 新增的"How a room is read"落地:Postgres 是事实来源; 仓库文件降级为种子(启动时只补没有行的房间,绝不覆盖已保存的); 服务端把文档放在内存里(保存时写穿、未命中时回源、30 秒 TTL —— 这 只是优化,多实例时用 Redis);客户端进房间时读一次 API(过渡舱那段 正好覆盖这次请求,感知为零);读不到就用打包的那份(无 API 也能开馆); 保存成功后清掉本地 draft(否则 owner 永远只看得见自己那份,正是这个 bug 隐藏这么久的原因)。
- 地板成为房间的设置(owner:地板一般也该属于房间):房间文档新增
floor(缺省即默认,老文档一字不改),编辑器面板加一个循环按钮; orb 的现场切换与投票仍然是临时的,不写库 —— The Sky 的控制台是一个 玩法,不是要存的状态。进房间的顺序:本人这间房的选择 > 房间自己的floor> 默认。 - 私有房间维持现状(owner:private 只是"别人别看",不是敏感数据), 所以仓库文件照旧全部进包;API 的读仍然走
authorizeRead。 - 实测(真客户端、无 draft、无个人选择):让本地 API 提供一个和仓库不同的 room-b(glass 地板、1 个道具),museum 进去就是玻璃地板 —— 读的是 数据库那份,不是包里那份。
- 顺带补上开发一致性:内存模式也 seed 房间了(否则本地 API 对每个房间 都回"没有",客户端悄悄退回包里的版本 —— 正是这个 bug 的形状)。
- 6 项新测试(仓库种子可读且合法、保存后另一个人读到同一份、四种"问不到" 都安静地回退、私有房间仍然要 owner 或口令、房间 floor 的读写与默认不落 字段)。300 项全绿。
- 注意一个既有的小错位:
npm run rooms:export写到content/rooms/, 而种子读的是content/inception/rooms/。备份脚本对不上种子目录,改天 统一(没动,属于 owner 的脚本)。
2026-08-07(马会跑了;物件可以单独改大小;编辑器选中会跳的 bug) ← 请 QA
- 编辑器选中乱跳(owner 报告:选中画架,一按方向键就变成选中马)—— 真正的原因找到了(第一次我猜错了,猜的是焦点在
<select>上被方向键 吃掉;blur 那版没解决)。 真凶在applyEdit:add把selected写进了"文档"里,而其它每个动作都{...doc}把它一路带下去;commit()又对每次编辑都执行if (next.selected) this.select(...)—— 于是移动任何东西都会重新选中最后添加的那个。owner 最后加的是马, 所以选画架一动就跳到马。修复:applyEdit每个动作都从"去掉 selected 的 文档"重建,只有add现给一个;编辑器用完即删,不写进草稿、不写进 提交的 JSON(光标是编辑器的状态,不是房间的数据)。blur 那两处保留, 它们本身也是对的。 - 大小范围按 owner 改成 0.1–10。
- 马的动画:是我的规格把它删掉的。 之前的导出规格写着"props 不会动, 不要动画",所以 Codex 的脚本
export_animations=False并删掉了骨架 —— 桶里那版 0 clip / 0 joint,而 owner 的 Blender 工作文件一直有metarigAction(3.08 s、147 条轨道、49 根骨)。规格已改(允许动画, 但要循环、保留骨架、joints ≤ 64)。 - 我自己重新导出了(之前说"Blender 不在 PATH 所以做不了"是错的: 直接跑
/Applications/Blender.app/Contents/MacOS/Blender -b -P就行): 保留骨架与 clip,按整段循环的最高点缩放到真实 1.70 m,鼻子转向 glTF +Z 与静态版一致,并按整段循环里最低的那只蹄子落地(跑动时四蹄轮流抬起, 只按一帧落地会让其它帧陷进地里)。变换挂在根物件上,不烘进顶点——烘了 蒙皮就散了。实测(按渲染器看骨骼盒、跑遍整圈):宽 0.51 / 长 2.11 / 高 1.70 m,没有任何一帧低于地面。文件在~/Desktop/objects-export/paintedHorse-animated.glb。 - 渲染端:模板保留 clips,每个摆放各自一个 AnimationMixer,起始相位 由 prop key 决定(两匹马不会像上了发条一样同步);蒙皮模型用
SkeletonUtils.clone(普通 clone 会共用模板骨架 —— 所有副本一起动)。 真机验证:147 轨/49 骨,两个摆放 0.4 s 内分别转了 6.96 / 10.24 弧度, 彼此相差 4.83。 - 物件大小:
props[].scale(缺省即原尺寸,老文档不变),0.1–10, 编辑器里选中后按 - / =,列表显示×1.5。碰撞盒同步缩放, 包括飞行解算用的精确旋转足迹 —— 否则缩小的物件会留下一堵隐形墙。 - 顺手:
objects:plan现在报告 clip 数与骨骼数,预算加joints ≤ 64; 重新导出同一个 GLB 物件不再被 id 守卫拒绝(只拒绝与参数化家具撞名)。 - 294 项全绿。
2026-08-07(本地能看到、线上看不到:blueprint 把环境变量清空了) ← 请 QA
- 根因(我写的 render.yaml 的错):
OBJECT_CDN_BASE我写成了value: ""。Render 的 blueprint 会"管理"每一个写了 value 的变量, 每次 push 都重新下发 —— 于是 deploy 任务在 dashboard 里设好的桶地址, 被下一次 push 悄悄清空了。线上每个source:'r2'行解析成 url null, 按新规则就不再提供,也就画不出来;而本地是自己起的 API,不受影响。 - 修好:改成
sync: false(声明 key、值归 dashboard),并加tests/deploy-blueprint.test.js钉住两条:这个 key 必须 sync:false 且 不得有 value;render.yaml 里任何地方都不得再出现value: ""。 DEPLOY-RENDER.md 增"声明了值就是被管理的值"一节。 - 确认方法(登录状态下在部署站点控制台跑):
fetch(API+"/api/objects", {headers:{authorization:"Bearer "+localStorage.getItem("inception.authToken")}})—— 没有这两行 = API 还没带新代码重新部署;有行但 url 为 null = 变量还没设回去;有 url = 应该就能画出来了。 - 292 项全绿。
2026-08-07(owner 在 localhost 看到选项但画不出来 —— 两个真问题) ← 请 QA
- 问题 1(我的设计错):登记表问不到时,我把整份目录都放出来——这对 参数化家具是对的(永远能画),对 GLB 物件是错的:没有 URL 就没有字节, 于是"选项在、房间空、什么也不解释"。现在
offeredObjects收一个needsBytes谓词:GLB 物件只有在登记表既列出它、又给出 url 时才提供; 参数化物件的兜底不变。 - 问题 2:bucket ref 只存在于线上数据库,所以本地 API(内存 store) 永远解析不出这两个物件。现在 ref 写进 manifest(archetype 的
blobRef)——内容寻址 + 桶是公开读,写进构建不泄露任何东西——因此 全新数据库或本地内存 store 都能自己解析,不需要手写 SQL。 副作用(好的):线上下次部署会自动建出正确的easelArt行,所以 你只剩一条 SQL 要跑(把easel恢复成 public/code/NULL)。 - 本地怎么看到:
OBJECT_CDN_BASE=https://objects.lucasacademy.org npm run api+ 用邮箱验证码登录(dev 快捷登录没有签名 token,API 只能当访客)。 已实测:以 owner 身份解析出两个 URL、访客只拿到 8 件公开家具。 - 2 项新测试(GLB 只在有 url 时提供;seed 的 r2/ref 形状)。290 项全绿。
2026-08-07(GLB 物件真的进房间了;顺手修掉一个线上权限 bug) ← 请 QA
- 线上 bug:
easelid 撞车(我的错)。 我给的操作指令让 GLB 物件用了easel这个 id,而它本来就是构建自带的参数化家具;ON CONFLICT DO UPDATE于是把博物馆自己的画架改成了 private R2 物件 —— 线上/api/objects公开列表从 8 个变成 7 个,除 owner 外没人能用画架, 别人的房间文档里带 easel 也存不回去。三处修好:(1)scripts/objects.mjs拒绝与 manifest 已有 archetype 同名的 id; (2) 启动时reconcileCodeObject把"代码生成的物件却指向桶"这种矛盾 改回 code/NULL(故意不动 owners/visibility —— 授权是数据库的决定, 一次部署不该悄悄收回);(3) GLB 画架改名easelArt。 还差一条你要跑的 SQL(我没有线上库的连接串),见交付说明。 - GLB 物件渲染路径落地:
PROP_ARCHETYPES新增easelArt/nativity两个customRenderer: "glb"的 archetype —— 代码里声明 label / 碰撞 footprint / 高度(碰撞、文档校验、编辑器列表都在取字节之前跑), URL 由物件登记表在运行时给。threeMuseum 每个 archetype 只加载一次模板, 按摆放 clone;模板贴图按头像同一规则压到 1024;克隆共享几何与材质, 所以两处房间拆卸都跳过带userData.sharedAsset的节点(否则第一次离开 房间就把后面所有房间的物件 dispose 掉 —— 2026-08-05 远端头像那个教训)。 URL 在 boot 时先取一次再进房间(否则开局直接进的房间画不出物件), 并与 2.5 秒计时器 race,免得休眠的免费实例卡住开场。 - 真机验证(桶是活的):用博物馆自己的 GLTFLoader 从
objects.lucasacademy.org跨域取两个文件 —— 各约 360 ms,easelArt 210 三角 / 1024px 贴图 / y 0..2 / 1.02×1.05 m,nativity 20,000 三角 / 2048px / y 0..0.306 / 0.47×0.50 m,与复审导出的字节数一致。 房间里 A/B:空房间 119,170 三角 → 放两个物件 159,950(+40,420)。 注意这个场景每帧统计两遍(单个 orb 的 manifest 估算是 180,实测 +360),所以 +40,420 正好等于一个 easel + 一个 nativity 各一份。 - 新增 6 项测试(id 撞车的自愈与"不动授权"、GLB archetype 在没有字节时也 声明齐全、
archetypeHeight读声明值、房间文档可以摆放它们且碰撞成立、 只有 GLB 物件expectsBlob)。289 项全绿。
2026-08-07(R2 那一层写好了,但故意是"惰性"的;两个 GLB 先不入库) ← owner 要先建桶
- owner 的决定:先等 R2,再把两个物件放进去。所以这一轮只做代码侧 R2 需要的东西,不碰云资源(AGENTS.md:未经 owner 批准不创建托管资源; 这个会话也没有 Cloudflare 授权)。
- 确认一件事:R2 在这个仓库里原本一行代码都没有(只有我昨天加的 两列和注释)。API 跑在 Render 上,所以 R2 还需要:桶 + 自有域名 + CORS(three.js 用 XHR 取 GLB,跨域没 CORS 就是空房间)+ 服务端一个
OBJECT_CDN_BASE。 - 落地的部分:
world/objectAssets.js(纯函数:OBJECT_BUDGET25,000 三角/4 MB/2048px;objectRefIsSafe只认objects/*.glb、拒绝 跳出前缀;objectRefFor(id, sha256)= 按内容命名objects/<id>-<hash12>.glb, 改了导出就是新 ref,房间信任的字节不会被悄悄换掉;objectAssetUrl解析 'code' | 'r2');GET /api/objects现在返回{id, visibility, source, url}——URL 在服务端解析,换桶不用重建前端,仍然永不带邮箱。 - 惰性是设计:
OBJECT_CDN_BASE没设 → 每个source:'r2'行解析成 null → 干脆不提供;博物馆照常用构建自带的物件。render.yaml 里那一项 故意留空,两个服务都没有 R2 凭据(这里没有任何代码往桶里写)。 npm run objects:plan <id> <file.glb>:读 GLB、量三角/贴图/字节、 算 sha256 与 bucket ref、给出预算判定,然后打印操作员要跑的四步 (provenance → wrangler 上传 → 建行 → curl 验证)。它什么都不写。- 仍然没有上传端点(内容安全,和 avatar 同一条理由);GLB 道具的 渲染路径也还没做——等真有文件再做,否则没法验证。
- 两个文件实测(都超预算):easel 210 三角 / 4 张 PNG(最大 1600px)/ 6.18 MB / 跨度 −1.00…1.00 m(原点在中间,会陷进地板);nativity 82,587 三角 / 一张 4096px JPEG / 3.54 MB / 0.00…0.31 m(脚已在 0)。 导出规格与两者的许可/来历写进 docs/OBJECT-LIBRARY.md:easel = CC BY 4.0,作者 Olivercharlton11(署名是许可条件,必须进 provenance 与公开致谢);nativity = owner 用 Kiri Engine 扫描的圣诞马槽 + 自己 孩子的画,与 my-angel/学生小屋同一类自有资产。
- 5 项新测试(内容命名与拒绝坏 id/坏 hash、ref 形状 10 种拒绝、无桶=不 提供且尾斜杠不重复、code 行不需要桶、预算判定含这两个文件的真实数字) + API 投影多了 source/url 的断言。286 项全绿。
2026-08-07(物件库也有主人了:migration 3 objects 登记表;GLB/R2 的接缝一并留好) ← 请 QA
- Owner 的规则:*"objects 属于房间和它的 owner,不是所有人都能用。"* 这里要分清两件事:摆放一直是有主的(
props[]在房间文档里,受该 房间owners保护,别人动不了你的家具);物件库过去没有——编辑器 给每个房间都列出全部 10 个 archetype。 objects登记表(migration 3,追加而非改旧)与avatars完全同形:id / owners[] / visibility('public','private')。默认策略写在src/inception/world/props.js(不含邮箱):博物馆自己的 8 件参数化 家具 = public;两栈学生小屋是 owner 亲手做的美术资产 = private, 归 WORLD_OWNERS。- 看得见 ≠ 用得着:公开房间照常显示它里面的一切(room-a 的小屋对所有 访客可见),登记表只决定谁能拿它盖东西。
- 三层都落地:
GET /api/objects只返回{id, visibility}(R6:面向 孩子的页面永不带邮箱);编辑器的"add an object…"按登记表过滤(问不到 就照常给全部——空编辑器比不过滤更糟);真正的边界在服务端:authorizeWrite检查文档里每个 archetype,不属于你的直接 403object_not_yours。谓词式注入,所以没有登记表的测试/内存 store 行为 不变。 - 顺手修掉一个开发体验坑:没有 DATABASE_URL 时内存 store 从不 seed,
/api/objects与/api/avatars都返回空 → 客户端读成"没意见"→ 本地 永远看不出效果。现在main()在内存模式下也 seed 同样的登记表,本地 与线上行为一致。 - 将来 GLB 进 R2(owner 同日):表里已经带
source('code' | 'r2') 与ref(R2 key),并有CHECK (source <> 'r2' OR ref IS NOT NULL)。 那天只需要加行,不需要新 migration;字节仍然不进 Postgres(ADR-0005), 也仍然没有上传端点——物件走仓库的 provenance + 预算审查,和 avatar 一条路。 - 实测:本地起 API 后访客拿到 8 件(两栈小屋被扣下),客户端模块过滤后 也是 8/10;ADR-0007 加了 amendment +
objects条目。7 项新测试 (seed 策略、migration 3 形状含 R2 约束、登记表投影+大小写、写入拒绝 与"无登记表=不表态"、房间照常渲染他人美术;客户端过滤与三种兜底)。 281 项全绿。
2026-08-06(旧草稿不再毁掉整个房间;门的溶解方案已按 owner 意见撤回) ← 请 QA
- owner 报的 "no v2 package":那是浏览器里
localStorage["inception.roomDoc.room-b"]里一份旧编辑草稿还指着sky-floor-water-v2(草稿优先级高于提交文件、硬刷新不会清、无痕窗口 没有草稿——所以"无痕能进、正常窗口不能进")。两处修好: (1)docArtIsAvailable(doc, hasPackage)—— chooseRoomDoc 与 loadDraftForSlot 都会拒绝"指向本次构建没有的包"的文档(谓词注入, 因为服务端校验房间文档时根本没有 bundle);(2) threeMuseum 对这种 孤儿 face(有 image、包没了)改为按静图渲染,而不是抛错——原来loadLprSurface一抛就把整个房间加载搞挂、把人弹回走廊。 已复现并验证:种一份 v2 草稿进去,控制台打出"ignoring a draft…",房间 正常打开在 v5。卡住的浏览器一行解决:localStorage.removeItem("inception.roomDoc.room-b")。 - 门的"从中间溶解"做过一版,owner 看过后不要,已整体撤回:当时把 owner 的
center-out-breath-door-512x640.lpp(编译出 5,160 个 dot mark) 改写成了一个 shader(逐像素、每帧只一个 uniform),离屏跑过 GLSL 验证 溶解确实从中间往两边走。owner 的判断是不要这个设计,所以world/portalDoor.js、它的测试、导入的.lpp与编译包、以及相关文档 段落全部删除,门恢复成原来那块会呼吸的平面光。留下的只有一条经验: 画布只能靠"很多小块"来溶解,同样的效果放在一块平面门上其实是 x 的 逐像素函数——下次再有这类需求,先问"这真的需要 marks 吗"。 - 2 项新测试(指向已删包的草稿被拒;服务端无谓词时不管)。274 项全绿。
2026-08-06(导入 sky-floor-water-v5,并按 owner 要求删掉前四个版本) ← 请 QA
- v5 上线:
content/live-projects/sky-floor-water-v5.lpp(7,098,481 B) 编译 complete(1 个 warp + 2 条鱼,无 gap),room-b 的 floor 改指sky-floor-water-v5。第一条鱼换成了新图(PNG 代替原来的 JPG, 1,313,696 B),两条鱼都重新调过:custom-object-1的 objectScale 0.85→0.1、尾巴/身体摆动加大,两条鱼的到达/离开涟漪都 关掉了(0)。图片按包里的 sha256 抽到assets/rooms/sky-floor-water-v5/,运行时逐字节校验。实测房间里aliveScale [0.1, 0.3],控制台无告警。 - 删除 v1–v4 的
.lpp、.lpr.json和assets/rooms/sky-floor-water-v4/(git 历史里都在,git restore可取回)。留下的只有 v5 + 那张 shell 静图sky-floor-water-plants-v1.png(v5 的 photo 与它同 sha256)。 - 要注意的一件事:v1 是唯一带 Fade Veil(source-veil)的工程, 三个测试文件靠它做端到端夹具。veil 行为仍然在 registry 里、仍然会被 渲染,所以不能连测试一起删——新增
tests/fixtures/water-with-veil.js:在 v5 的包上按 v1 的参数手工挂一 条 veil 场(复用 current 的真实 mask),warp-frame / warp-shader 的 veil 断言照旧。唯一真正失去的覆盖是"veil 能从真实.lpp编译出 来"这一条;纯函数(sourceVeilAlpha / veilCoverage / 羽化)全部保留。 - 顺手把两处写死艺术选择的断言改成测规则:涟漪强度改用"把涟漪调大" 的参数副本来验证行为,再单独断言"v5 的鱼确实不带涟漪";canvas 渲染器 拒绝这张地板的报错现在列全(aliveLayer/aliveObject/warpField)。 PROVENANCE.md 的四条水面条目合并成一条 v5,并把 v1–v5 的来历写成文字 谱系(文件删了,出处不能删)。
- 273 项全绿(
npm run check:5 个包全部 compile+verify 通过)。
2026-08-06(QA 跟进:鱼跟着水面走 / 鱼的大小终于是 studio 里那个 / 投票 27 秒) ← 请 QA
- 鱼不再游在玻璃地板上:cut-out object 是盖在地面之上的独立 mesh, three 的 visible 不会从兄弟节点继承,
applyFloorMode以前只管 paintedFloor/glass/floorProps → 换成 Space glass 或 Deep Dive 后两条鱼 照游。房间现在记住属于画上的那些 mesh(floorAlive),跟着水面一 起显示/隐藏。实测(真的走了 orb → 投票 → 落地这条路):painting 2 条 可见 → glass 0 → open 0 → 回到 painting 又是 2 条。 objectScale一直被编译器丢掉(owner 报告"动作变了、大小还是旧 的")。.lpp里这个控制项和 mask 放在一起,不是 brush 的 ALL-CAPS 常量,所以readParams根本看不到它:v3 的 0.55、v4 的 0.3 全部按 1 画。现在 lppProject 读出来 → compileLpp 写成alives[].scale(按ALIVE_SCALE0.05–4 夹紧、四位小数)→ schema 校验 → 渲染端乘进每一帧 的 pose。只有 studio 记录过这个值才写字段,所以 v1/v2/celestial-rain 等旧包字节不变,只有 water v3/v4 重新编译。实测 room-b 的两条鱼现在是aliveScale [0.85, 0.3](改之前是 [1, 1])。教训写进 LPR-FORMAT.md: studio 能改的每一项,编译器都必须读 —— 编译器忽略的控制项,在作者 那边看起来就是"保存坏了"。- 投票 27 秒(原 60):一分钟站着不动会让 orb 显得是坏的。常量在
presenceProtocol.js,对话框那句话现在直接引用它(不会再和服务器说的 不一样),投票测试改成按常量推时间点,所以以后再调只改一个数。 - 新的诊断口(配合
?manual=1的 probe):__museum.probe().floor= 当前 floor 模式 / painted / glass / cut-out 数量 / 其中可见几个 / 每条 鱼的 alpha 和 scale。截图看不出"鱼是淡出中还是根本没有",这个能。 - 顺带记下一个既有限制:Live 滑杆拉到 0(或进编辑器冻结世界)时
advanceAliveObjects不跑,而鱼的 alpha 从 0 开始 → 所有 floor 模式 下都看不到鱼。已经改成建对象时先摆一次 pose(用当前 worldSeconds), 所以中途进房间还看得见;但开局就冻结仍然是空的(它们的周期本来就从 透明开始)。要不要让冻结时停在周期中段,等 owner 定。 - 12 项 → 13 项测试(新增"作者画的大小要跟着包走":studio 值、包里的值、 已提交文件里的值三者一致 + 越界拒绝 + 老包没有该字段仍然合法)。 273 项全绿。
2026-08-06(持久层落地:画作真的进库;房间可以存回博物馆;门与过渡舱归房主) ← 请 QA
- 归属关系(owner 定):房主不只拥有墙后的空间——走廊上通向这个 房间的门(连同名牌)和进房时乘坐的过渡舱(目前是共用的 celestial-rain 舱)都属于房主。二者都写进房间文档,因此继承房间的 owners,
authorizeWrite已经能挡住外人;没有房间的门位与走廊本身仍归 world owners。ADR-0007 加了"How far ownership reaches"一节。 - 门要变成
.lpr:新文档docs/archive/ROOM-DOOR-AND-TRANSIT.md写清了作画 需要的全部数字——门 = 2.4 m × 3.0 m 的平面(4:5 竖构图),站在地上 (中心 y=1.5),无边框、双面,走廊侧在 x=±2.92;房内返回门同尺寸。 颜色:开启 =#59d7ff(每帧被 0.22 Hz 的呼吸驱动:opacity0.9×level, 颜色再乘 0.35→1.0,谷底完全消失);封闭门位 =#17435a@0.5 不呼吸; 名牌 3.0×0.75 m 在 y=4.35。建议画布 512×640(4:5),省电档 256×320 ——不要画正方形(LIVE_TARGETS.portal现在写的 256×256 与几何不符, 第一个真门包落地时一起改)。文档还记了过渡舱的尺寸(半径 3、长 14、 画布 2048×1024、桶身占 0.75)供房主自制到达体验时参照。文档还给出 房间文档里door/transit两个接缝(现在只声明,稍后实现)。 - 画作持久化(ADR-0007
avatar_paintings):POST /api/paintings现在必须带账号 token(verifyBearer,与房间写入同一把锁),dev 会话直接 403;先touchUser建全局 user 行(画作外键指向它),再putPainting。中继缓存仍在前面当快路径。GET /api/paintings/:id缓存未命中时回落到数据库——重启不再丢别人的涂装。 - 字节仍然对得上 id:JSONB 不保留键序,所以服务端用与 studio 完全 相同的 canonical 规则(v → avatarId → materials 按键排序)把行重新 序列化,取回来的字节哈希仍等于同伴要的那个 id(有测试钉住)。
- 数据库故障不算保存失败:写库失败只回
{id, persisted:false}并 记 error,孩子的 Save 照常成功(缓存已有,本次会话同伴照样看得见)。 - Studio / 博物馆启动都带 token;没有 token(访客或局域网 dev 登录) 就不上传,Save 会说"已存本机——用邮箱验证码登录才能存进博物馆"。
- 头像目录接上所有权登记:落地页向
GET /api/avatars要注册表,只 展示"公开 + 自己拥有的私有"头像;问不到(无 API/休眠/失败)就照常展示 仓库里的全部——空的选择器比不过滤更糟。仍然没有上传端点。 - 编辑器 → API:编辑面板新增 "Save to museum",PUT
/api/worlds/space-museum/rooms/<id>(net/roomApi.js,每种拒绝一句 人话:未登录 / dev 登录 / 不是房主 / 文档非法 / 连不上)。Copy JSON 与 Download 原样保留,草稿仍是本地优先。MUSEUM_WORLD_ID收敛到world/museum.js一处,server/store.js 改为再导出。 - 仍缺(下一步):换设备拿回自己的画。行按 email 存着、
store.paintingFor(email)也能查,但还没有"把我的画还给我"的端点, 所以新 iPad 登录仍是出厂皮肤。 - 新增 12 项测试(登录门/dev 会话/入库+user 行/一人一幅替换/重启后按字节 还原/写库失败不失败 Save/非法画作/canonical 往返;房间保存 URL+token+ 五种拒绝文案;注册表过滤+兜底)。265 项全绿(
npm run check: live:check + build + node --test,Node 22)。
2026-08-05(The Sky 的地板成为一个选择:中心球体 = 地板控制台) ← 请 QA
- Owner:飞到 The Sky 正中央 (0,0) 的球体旁按 E,弹出对话框选地板: 1 Water painting(现在的
.lpr水面地板,家具照旧)、2 Space glass(与走廊同一块玻璃,家具照旧)、3 Deep Dive!(没有地板,可以 一路下潜到底,地板上除了中心球体什么都不留)、4 Space Travel(见下 条)。对话框只列名字,不写说明——owner:名字已经说清楚了。 - "没有地板"是把半球补成整球:
roomVolumes给 dome 体积加一个depth,flight.js的domeReach在地板线以下镜像同一条曲线—— 越往下越收窄,收到一个点,那个点就是"底"(floorOf随之下移,脚底与 重力都落在那里)。depth缺省 0,所以有地板的房间与从前一模一样。 - 家具不是"藏起来"就完事:
visibleProps(slot, props, mode)同时喂给渲染 和碰撞(propObstacles),否则会撞上看不见的桌子。中心球体永远留下—— 它就是再选回来的唯一入口,manifest 校验因此新增一条:dome 房间必须 有中心球体,且三种模式下入口点与球体都必须可达。 - 三张地板都在进房时一次性建好,切换只改 visible:选择必须是瞬时的, 重建房间会把地板照片重新解码一遍。玻璃地板用的是走廊地板同一份 material spec(同频同相呼吸、1% 不透明度下限),所以"和走廊一样"是 真的一样;房间卸载时会把它的呼吸条目从列表里摘掉(否则每帧写一个已 dispose 的 material)。
- 纯新增,E 的老含义没动(OCP):只有"在 dome 房间 + 站在球体旁 + 编辑器未打开"这一种情形下 E 才是控制台,其余场合 E 依旧是编辑器开关; 编辑器打开时 E 仍然是它自己的关闭键。iPad 没有 E,所以靠近球体时屏幕 底部出现一个可点的提示按钮(桌面版指针被锁,同一个元素只作文字提示)。
- 选择记在
localStorage(inception.floorMode.<roomId>,每人每房间), 不写进房间文档——房间文件仍然只由 owner 决定家具与画。进房前就应用, 所以不会闪一下旧地板。已知取舍:presence 只传坐标,别人下潜到你的地板 以下时,在你这边会贴着你的地板(要真看见需要扩协议,暂不做)。 - 新增
tests/inception-floor-console.test.js(8 项:三种模式的体积/碰撞/ 可达性、镜像曲线的解析解、控制台位置、manifest 守卫、已提交的 room-b 文档仍有中心球体)。npm run check= 235 项全绿。
2026-08-06(同一个坑的另一半:笔刷落点差很远 —— 射线拿的是第一套 UV) ← 请 QA
- Owner:脸不跑了,但"笔刷画的位置离手指很远"。同一个根因的另一半: 射线命中会同时给出
uv(第一套)和uv1(第二套),而画笔一直用第一套 换算成画布像素;头的贴图却是用第二套读的。 - 有多远,用数字说话:头网格 7,993 个顶点,两套 UV 平均相差 0.377 (最大 0.762)——在 4096 宽的图集上平均约 1,500 像素。所以这不是精度 问题,是画到别的地方去了。
- 修法:画层记住自己那件"外套"是用哪套 UV 读的(
channel),落笔时用strokeUv(intersection, channel)取对应的那一套;取不到就不画—— 宁可没反应,也不要画错地方。 - 两项新测试:纯函数
strokeUv(含"没有第二套就返回 null"),以及在 GLB 上 实测头的两套 UV 确实是两套不同的排布(平均差 > 0.05)——这条测试就是 在说明"为什么笔刷必须挑 UV",免得以后被"简化"回第一套。npm run check= 270 项全绿。请 owner 再涂一次确认落点对上了。
2026-08-06(BUG:一涂色脸就跑了 —— 换贴图时把"怎么采样"丢了) ← 请 QA
- Owner 报告:"给 space baby 涂色的时候,它的脸移位了"。不是 GLB 的问题, 是我们这边的:v1.1.2 的头用第二套 UV(
texCoord: 1)读那张整头图集 (合法的 glTF,渲染器本身处理得好好的)。但画笔会把材质的 map 换成自己的 canvas,而新建的 three 纹理channel默认是 0——于是一落笔,头就改用 另一套坐标去读同一张图集,脸自然滑走了。 - 两处都有这个毛病,而且第二处更糟:
avatarStudio的画层(本地涂色时);threeMuseum.paintMaterials——穿上已保存的涂装时,本地和每个远端 玩家都会中招。
- 修法是一个共用规则
matchSampling(base, texture)(放在paintTargets.js, studio 和 museum 本来就都 import 它):替换贴图必须继承原来的采样身份 ——uv 套、wrap、offset/repeat/rotation/center、flipY、色彩空间、各向异性、 过滤方式。原来只抄了一个flipY。 - 新增 3 项测试:
matchSampling逐字段(含"没有原始贴图时不要瞎编"),以及 在 GLB 上钉住 "每个材质用到的 uv 套,它的 mesh 必须真的有",并说明头 用的是第 1 套——下次谁再动这条路径就会被提醒。npm run check= 268 项全绿。 - 涂色本身没法在这里验收(pane 视口 0×0,studio 是要手画的),请 owner 真机再涂一次确认脸不再跑。
2026-08-06(Space Baby Avi v1.1.2:整头贴图版;GLB 重新打包 -64%)+ sky water v4 ← 请 QA
- Owner 给了
space-baby-avi-v1.1.2.glb(11.4 MB):"需要优化就直接做"。 这一版换了头:原来的球 + 卡通脸贴片 + 雕刻面片,现在是一整块带 贴图的头(4096×2048 whole-head atlas)。因此BabyAstronaut_CartoonFaceDecal/BabyAstronaut_SculptedFacePatch两个节点和SpaceBabyAvi_CartoonFace材质退休,manifest 的 skin 分区改指Baby_Head_Textured。头发壳、绿蝴蝶 结、宇航服、骨骼、五个动作、两只已批准的扫描手都没变。 - 优化:只换容器,不动像素——按 owner 自己 2026-08-05 那套配方(sharp、 JPEG q95、4:4:4、不重采样)把两张不透明贴图转成 JPEG,头发(几乎 全是 alpha)保持 PNG,然后把 GLB 重新打包(bufferView 重新对齐,215 个 accessor 逐个校验仍在范围内):
- 宇航服 1254² 2,155,643 → 553,690
- 整头图集 4096×2048 7,080,195 → 1,364,131
- 11,413,084 → 4,094,836 字节(-64.1%),回到测试里 6 MB 的预算内。 屏幕上不会有任何损失:
capAvatarTextures本来就把头像贴图在加载时压到 1024²,4096 的像素从来没到过 GPU。 - 三角面 44,015 → 53,592,超过原来 50k 的预算。这是 owner 给的模型 (一整块头要自己承担原来贴片假装的起伏),且几何体在所有克隆体之间共享 ——人多了是蒙皮开销,不是又一份内存。因此把上限提到 56k 并写清理由。
- sky water v4:又是一次"只改数字"的导入(object-1 右边界 41→60, object-2 的水带 11..23 → 17..68 —— 鱼可以游得更开),形状没变,registry 一行没加,房间改指 v4;鱼图随包移到
assets/rooms/sky-floor-water-v4/。 - PROVENANCE 记了:v1.1.2 源导出的 sha、每张贴图的母版 sha → 运行时派生 sha、新 GLB 的 sha,以及 v4 的 sha 与三个数字的差异。
npm run check= 265 项全绿。浏览器实测:控制台museum avatar space-baby-avi clips idle/walk/float/dive正常、outfit glb/third(没有退回 mannequin)、room-b 加载无告警。 观感(脸/贴图接缝/JPEG 质量)待 owner 真机验收。
2026-08-06(地板改成"全房间投票":WebSocket 一分钟计票,Space Travel 仍是个人体验) ← 请 QA
- Owner:走到球体按 E、弹出对话框——这些都不变;但选中一项不再立刻改, 而是投一票。第一票开始 60 秒倒计时,这一分钟里房间内其他人也能投; 倒计时结束取票数最高的,全房间一起换。Space Travel 不参与投票—— 它是一个人的旅程,谁想坐就坐。
- 服务端说了算(否则各人一套地板):hub 里每个 zone 记
floor和poll。客户端只发{type:"vote",mode}(约 30 字节);服务端在每次投票 广播一条{type:"poll",ends,tally}(约 80 字节),倒计时结束广播一条{type:"floor",mode}。一分钟三个人三次投票 = 4 条小消息,没人投票时 一条都没有——符合"简单、不频繁"。进房时服务端单独告诉新来的人当前 地板(和正在进行的投票),所以后到的人不会站在自己那一套地板上。 - 规则:同一分钟内改投 = 换掉自己那一票(不是加一票);倒计时不会 因为新票重新开始;平票时先被提出的那个赢(确定、且好向小孩解释)。 hub 自己不持有任何计时器——
settleDue(now)就是时钟,一个 1 秒的 tick 在 socket 层调用它,所以规则可以在毫秒内跑完一整分钟来测试。 - 降级保护(重要):没有 presence 服务器(单人/离线)时,球体还是 原来的样子——当场改地板。更隐蔽的一种:客户端比服务端新(线上先发 了前端),旧服务端会默默丢掉 vote 消息 —— 所以投票后 2.5 秒内没等到 tally,就当作"这台服务器不懂投票",自己改。球体永远不会变成死按钮。
- 安全边界没破:vote 是三个已知单词之一,不是自由文本;
parseClientMessage只放行{type,mode},多带的字段(zone/who/…)一律丢掉。协议头与SPACE-MUSEUM-DIRECTION.md §10的"transform only"表述已同步更新。 - 新增
tests/server-floor-vote.test.js(7 项:协议白名单、开票/改票/不重启 倒计时、平票、分房间隔离、后到者拿到当前地板与进行中的投票、幽灵票)。 既有 presence 测试里"nobody hears their own echo"改为只针对 peer 消息 (进房现在还会收到一条 floor)。npm run check= 253 项全绿。 - 真机联调:另起一个 API(:8799)+ 两个真 WebSocket 客户端跑完整一分钟: 两人进房各收到 floor:painting → ann 投 glass(两人都收到 tally 1)→ bob 投 open(1:1)→ bob 改投 glass(2:0,且 ends 59→58 没重置)→ 第三人中途 进来直接拿到 floor + 进行中的 poll → 61.8s 三个人同时收到
floor:glass。画面观感待 owner 真机验收(pane 视口 0×0)。
2026-08-06(v3:鱼小了一点,位置也挪了 —— 一次"什么都不用改"的导入) ← 请 QA
- Owner 看过 v2 的鱼之后给了
sky-floor-water-v3.lpp:"鱼变小了 更好看"。 除四个数字外与 v2 逐字节相同:同一张底图、同一份遮罩、同样两张图片、 同一个 Water Ripples 场、同样的代码形状。变的是custom-object-1——它现在 只在一条更窄的水带里出现(left 8→22、right 92→41、top 100→56),朝向 114°→122°;custom-object-2一动没动。地板越远越小,所以把它挪进更靠里的 一带水,看上去就更小、更远。 - 不需要任何审查:只有 ALL-CAPS 的数字变了,形状没变,所以 registry 一行都没加,编译直接通过——这正是"按形状认身份"的意义。
- 房间改指 v3(
room-b.room.json的 face.lpr),两张鱼图挪到assets/rooms/sky-floor-water-v3/(同样的字节),v2 留在语料里作为"引入 鱼的那一版"。PROVENANCE 与测试同步到 v3。npm run check= 246 项全绿。
2026-08-06(The Sky 的水里有鱼了:.lpr 学会"抠像活物",并且直接走 GPU) ← 请 QA
- Owner 更新了
sky-floor-water-v2.lpp(删掉 Fade Veil,加了两条鱼),要求 编译进.lpr。第一次编译带 1 个 gap:两条鱼共用一个没人审过的代码 形状,编译器按规矩拒绝——而 gap 会让整包 incomplete,房间地板直接退回 静态图。所以这件事不能只补一行注册表。 - 读了作者源码(art lab 的 "Fish Swim"):
spawn()每个 cycle 在一个水域 矩形里随机落点、move()朝 HEAD_ANGLE 游并横向摆动、deform()把身体 切成竖条弯曲(尾巴摆得最狠)、appear()/disappear()给出 alpha、 scale 和三圈涟漪。这比.lpr原来的 alive 契约(整张图平移+淡入 淡出)多得多,硬塞进旧契约就是"悄悄换了一幅画"。 - 于是按格式自己的规矩扩展:
- 新能力
aliveObject("带自己的遮罩、会分条弯曲、会缩放、会留下 涟漪的抠像物体")。只会aliveLayer的渲染器必须拒绝——否则它会 把整个矩形画上去(而且 object-1 是 JPEG,根本没有透明通道)。 - 遮罩进包:
[value, 高字节, 低字节]的行程编码(art lab 的decodeAliveMask原样转写),和 warp 的 mask 一样是数据不是代码。 fish-swim@1作为纯函数写进behaviors.js(规范本身),组合顺序 照抄 art lab 的render():spawn 位移 =(spawn − 抠像中心),scale 与 alpha 在 move/appear/disappear 之间相乘,涟漪取 appear/disappear 里更强的那个;rand()用的就是宿主同一个哈希,所以每台设备落点一样。- registry 登记
shape:49cf1025…→fish-swim@1(两条鱼同一个形状, 一次审查覆盖两条)。 - 渲染走 GPU(owner 选的,理由是别再把刚从过渡舱拿掉的"每帧重绘+上传" 搬到最常去的房间地板上):地板保持原来的 warp shader,每条鱼是盖在 同一张面上的一层皮——抠像(图 × 遮罩)在加载时烘焙成一张只有自己包围 盒大小的纹理,之后每帧只推几个 uniform。片元做的是参考渲染器的逆运算: 先反解摆放和缩放,再按落点算出它属于哪一条竖条、反解那条的偏移,最后 取那个像素——所以两条路径按构造一致(测试逐像素正反对照)。竖条的阶梯感 是故意保留的:参考渲染器就是 12~64 条,抹平就成了另一幅画。
- 只有两处是"接近"而不是"逐位相同",都在涟漪:canvas 是描椭圆 +
filter: blur(),GPU 这边是椭圆距离 + 同宽度的 smoothstep 软边;圈数、 半径、相位、颜色、alpha 都照抄。 - 边界:鱼目前只能画在已经是 warp 面的房间面上(那条路径才声明了
aliveObject);纯 alive 的面会被参考渲染器明确拒绝并退回静态图, 不会画错。图片按assets/rooms/<包名>/<文件名>随房间发布,逐字节校验, PROVENANCE 已登记(含 .lpp 新 sha)。 - 新增
tests/lpr-alive-object.test.js(6 项)。npm run check= 246 项全绿。 浏览器实测:?inspect=room-b房间加载、着色器编译、逐帧渲染均无报错 (draws 145)。观感待 owner 真机验收——pane 视口是 0×0,截不出画面。
2026-08-05(过渡舱终于走 GPU:alive 图层 = 一个纹理偏移,每帧 8 MiB 上传归零) ← 请 QA
- Owner 报告"房间过渡有点卡",并问过渡舱是不是走了
.lpr的优化路径。 没有:.lpr有三条播放路径——warp 走 GPU shader(The Sky 的水面 地板)、marks 走 instanced billboard(走廊天空)、alive 图层只有 Canvas2D 参考渲染器。天体雨 v5 是0 marks / 0 warps / 4 alive,所以 过渡舱每个调度回合都要重画一整张 2048×1024 画布(底图 + 4 张全画布 screen 合成)并重传 8 MiB;按已测的 5.3 ms/MiB,那是单帧 ~42 ms 的 上传,每秒 6~8 次。iPad 比桌面还糟:舱内纹理是min(2048, 档位×2), High 和 Ultra 都吃满 2048,而 High 刷得更勤(8Hz vs 6Hz)。 - 修法:alive 图层本来就只是"整张图在平移"(
resolveAliveLayer返回 dx/dy/alpha),而在已经穿好画作 uv 的曲面上,平移一张图就是一个纹理 偏移。于是底图和 4 张图各自只上传一次,每帧只改texture.offset.y和opacity——零重画、零上传。舱体几何复用同一份 barrel+cap, 底图 1 层 + 图层 4 层 = 10 个 draw call。 - 两处必须做对,否则就不是同一幅画:
- 裁剪。canvas 画到边界就截断,纹理却会 wrap 或拖边缘像素,而雨 最多要走 610/1024 行——差别很明显。所以每张图层画进上下各多一像素 透明边的纹理里,v 方向 clamp:参考渲染器截掉的地方,GPU 采到的正是 那圈透明边。u 方向不能这么做(画作在桶壁上本来就要 wrap),所以 这条路径按行为白名单开启:
ALIVE_GPU_BEHAVIORS = ["celestial-fall"], 测试逐帧钉住它的dx恒为 0。 - 混合。canvas 的 screen(背景不透明时)是
D + aS(1-D),等价于 预乘片元配ONE / ONE_MINUS_SRC_COLOR;"lighter" 就是ONE/ONE。 所以用内置MeshBasicMaterial+premultipliedAlpha+ CustomBlending, 不写自定义 shader——颜色空间照旧走 three 的内置管线,不会和以前 的画面有色差。
- 能力检查而不是猜:
aliveLayersRunOnGpu(pkg)要求"没有 marks + 图层 尺寸等于画布 + 行为在白名单里",否则整包退回 Canvas2D 参考渲染器(marks 本来就是 CPU 活)。LprMarksSurface 一行未改。 - 图片仍走
fetchVerifiedBytes逐字节校验(Polish 事故的纪律不能丢), 解码后画进 canvas 再做纹理——顺带解决 ImageBitmap 纹理不认 flipY 这个坑(舱体 uv 是按 flipY 写的)。 - 新增
src/inception/lpr/aliveTransform.js+ 4 项测试(含把 uv 偏移反解回 "参考渲染器会画哪一行",逐 dy 逐行比对,并验证被裁掉的行确实落在透明边 上)。npm run check= 240 项全绿。真机观感(朝向/混合/边缘)待 owner 验收:pane 的视口是 0×0,canvas 只有 2×2,截不出可看的帧。
2026-08-05(中心球体第 4 项:Space Travel —— 为了坐而坐的过渡舱) ← 请 QA
- Owner:在球体菜单里加 4. Space Travel——直接进过渡舱场景,停在圆 柱段(天体雨在四周流过,走路循环照播),想待多久待多久;桌面按 Space / 手机点屏幕才起飞,播的就是进房时那一秒的加速冲进半球。它不去 任何地方:结束后回到出发的那个位置(球体旁)。它不是地板模式,不写
localStorage,所以永远不带 ✓。 - 实现是把已有的一段共用出来,不是复制:
flyChamber(approachStart, now)就是原来stepTransit的身体部分(钉在轴上 + 一秒缓入加速),stepTransit与新的stepRide各自只决定什么时候放行——前者是房间 加载完,后者是访客按键。原有过渡行为一字未变。 - 顺手修掉一个旧 bug:过渡舱里的化身其实一直被钉在它离开的那个空间 的地板上(
setAvatar的floorY传的是走廊/房间的地板,而身体在 y=-340),所以第三人称过渡时舱里是空的。新增纯函数transitFloorY()= 舱底(轴心 - 半径):舱拥有身体时,舱就是地板。第三人称相机的体积 钳制原本也只避开transit,现在同样避开 ride。 - Presence:坐 Space Travel 时发布出发点的坐标而不是舱内坐标——否则别 人会看到你的化身瞬移到博物馆下方 340 米的口袋空间里。
- 边界:
Digit4原本是截图键,对话框打开时让位给第 4 项;ride 进行中 E 不再开编辑器(舱拥有身体);过渡舱没建成功(包或图集失败)时第 4 项 根本不出现。npm run check= 236 项全绿。
2026-08-06(Paint Studio:模型正面朝向画者 + 新增橡皮擦) ← 请 QA
- Owner:进 studio 看到的是模型背面。起始
orbitYaw原为Math.PI,即相机 落在 +Z(glTF 正前方)的反面——正好是后脑勺;代码注释写的却是"先朝向访客", 所以这从一开始就是个 bug。改为orbitYaw = avatar.rotation.y:相机沿模型 自身的正前轴(含 catalog 的 yaw 修正)落位,因此每个模型都正脸相迎, Angel 的 -30° 修正也照样成立。相机同时落在主光源一侧,正面是被照亮的。 - 新增 Eraser 工具:借用 brush 的完整笔画几何(同样按屏幕路径行走、同样 的 UV 接缝规则),只是每个 stamp 改为在圆盘内重画出厂贴图——等于把 Clear 一小块一小块地做。不用透明擦除:材质不透明,擦成透明就是在身上打洞; 模型本来就有皮肤,"擦掉颜料"应当露出皮肤。橡皮擦同样进 undo 栈。
- 顺带一个存储守卫:
PaintLayer.hasPaint。橡皮擦会碰到从没上过色的部件, 若因此给它建图层并存进 payload,就违反了"只保存真正画过的部件"。上色时置 位,读取旧作品时也置位(否则重存会丢掉今天之前画的部件)。 npm run check= 246 项全绿。未在浏览器面板自测:studio 是 rAF 驱动的 WebGL 场景,而 headless 面板从不触发 rAF,截图只会是黑的——请在真机上确认 正面朝向与橡皮擦手感。
2026-08-05(Van Gogh House 六面静画换成 JPEG 派生品:进房下载量 -66.5%) ← 请 QA
- room-a 进房要下载的六张静画原为 PNG 母版,合计 34,485,998 字节。按走廊 尽头天空全景的同一配方(sharp,JPEG quality 95,4:4:4 chroma,不重采样、 像素尺寸不变)重编码为运行时 JPEG 派生品:合计 11,551,829 字节 (-66.5%)。逐张:天花板 8,559,550 → 2,795,202;地板 8,359,754 → 2,600,822;墙 A 4,369,514 → 1,526,647;墙 B 4,649,452 → 1,840,904; 墙 C 4,422,882 → 1,487,828;墙 D 4,124,846 → 1,300,426。墙 A 的四张 房屋物件贴图本来就是 JPEG,未动。
- PNG 母版一字节未改,移入图集的
masters/子目录留作参考母版;roomImages.ts的 glob 排除masters/**,所以构建不再发出它们(vite build 核实:dist 恰好只有六张 JPEG,无一张画廊 PNG)。room-a.room.json 的六个 face 改指.jpg;van-gogh 房间测试改为解析 JPEG SOF 尺寸,并新增 守卫——每张派生品在masters/里必须有同尺寸 PNG 母版。目录 README 与 PROVENANCE.md 的派生品记录同步。npm run check= 246 项全绿。 presentationRevision同时升到student-village-objects-v2。改文件名属于 "committed art-set 换版",不升版号的话,本地旧草稿(仍写着 .png)继续压过 committed 文档,而那些名字已经解析不到,六个面会静默回退成空白面——首次 提交时正是这样,owner 进房看到"图全没了"。草稿不会被删除,编辑一次即重新 获得权威。全新访客无此问题(owner:不必再为此加机制)。
2026-08-05(生产事故:Cloudflare Polish 改写图片字节 → 过渡舱回退;已修) ← 请 QA
- 线上完全不显示过渡场景。控制台:
Transit chamber failed to build … expects a different image (shell.png)。根因(Codex 诊断正确):Cloudflare Polish 在边缘重压缩 PNG(cf-polished: ok, imgq:85,1,454,948 → 1,251,626 字节,像素相同、二进制不同)→ 严格 hash 校验抛错 → 舱构建失败 → 按设计 静默回退直切。本地不经 Cloudflare,所以永远复现不了。 - Codex 的第一版修复(0bbb883:请求侧 no-transform 头 + sha 查询参数 + no-store)上线后无效,且把事情弄糟——对生产实测证明:Polish 只认源站 响应的
Cache-Control: no-transform,请求头无用;带 sha 的新 URL 反而 获得自己的边缘缓存条目,cache fill 时被 Polish 处理后永久提供压缩版 (实测 HIT 返回 2948…);no-store还顺带废掉了浏览器对 1.4MB 文件的缓存。 "验证过一次返回原始文件"是 MISS 时的假象——MISS 会先回源字节,缓存后 才换成 polished 版。 - 真修复(本次,未部署——owner push 后 Render 同步 blueprint 生效):
- render.yaml 给
/assets/*加源站响应头Cache-Control: public, max-age=0, s-maxage=300, no-transform(沿用 Render 现默认值,只追加 no-transform——Polish 唯一认可的信号)。 - 客户端两个 surface 的取图收敛为共享
verifiedImage.js:正常一次 fetch(保留浏览器缓存);hash 不符时一次性?fresh=<唯一键>+cache:"reload"重试(MISS 必回源字节,可自愈中毒的边缘条目——旧条目 本身 300 秒后也会过期);再不符则拒绝,错误信息直接写明 want/got 两个 hash 和 "查 CDN no-transform"。严格校验一丝未松。 - 该 bug 同样会打坏 The Sky 的水地板(lprSurface 同样校验 photo)—— 共享 helper 一并覆盖。
- 5 项新单测钉住纪律(干净字节=1 次普通 fetch;被改写→1 次免缓存重试恢复; 持续改写→点名拒绝;无 hash 图直通;fresh URL 只加不改)。227 项全绿。 DEPLOY-RENDER.md "Things that will bite" 新增完整条目 + 部署后验证命令。 待 owner:push + Render blueprint 同步后,用
curl -sI .../assets/<img>.png确认no-transform在、cf-polished无; 或在 Cloudflare 控制台给 is 子域关 Polish(二选一即可,前者已就绪)。
2026-08-05(天体雨 v5:十六种手写弧线;v4 退役) ← 请 QA
- Owner 交来 v5:rim 笔刷与 v4 逐字节相同(revision 都没变)→ 完全不用 再审;四个下落图层也照旧。变的是弧笔刷,改名为
natural-sequential-polar-nebula-breath,并真的长出了新动作: 横向 sway(只在该相位亮着时)、微小的纵向 ripple、以及按 mark.index 的逐点微光local——所以"十六个相位"是十六种手写手势,而不是同一枚图章 复制十六次;Link 距离也按手势各不相同(72..138)。 - 判为新 opcode 而不是第三个版本:作者给了新名字 + 新的运动项, 于是
sequential-polar-nebula-breath@1/@2原封不动保留(v4 的语义不受 影响),新行为独立登记。渲染器新增供给mark.index(第一个读它的行为)。 - 同日再交一版 v5(同 id,重编),两处真改动:(1) 独立的呼吸 rim 图层 整层删除——冰蓝洞口现在画进静态 shell,不再有连线点光;(2) 时序重排:
sequenceLevel的相位偏移改用新的SEQUENCE_WINDOW = .68(原来是整个 CYCLE),CYCLE 2.8s、FADE_IN .18s ⇒ 十六个手势全部在 0.68 秒内起步、 且在这一秒过渡结束前全部亮完——秒开的房间也能看到完整手势而不是三分之一。 一行代码 + 一个新参数 ⇒ 登记为该行为的 version 2(v1 语义保留)。 - 第三版 v5(owner:"removed some effects")把 mark 全部拿掉:呼吸 rim 之外,十六相位的整层外圈气体也删了——没有曲线笔触、halo、伴随细丝、 Link 连线,连笔刷源都不在归档里。出口现在完全画在静态 shell 上 (含 12px 单侧余晖去黑环)。最终包 = 4 个下落图层 + 静态 shell: 0 marks / 0 strokes / 4 alives,behaviors 只剩
celestial-fall@1, 能力回到aliveLayer + marks。 - 被拿掉的行为(
polar-ring-breath、两个 sequential 系列及其版本、stroke links)保留在已审核表与测试里——形状一旦审过就不撤销,owner 随时可以 再放回来;相关测试改为直接钉行为算术,并注明"当前作品已不使用"。 v4 退役(.lpp/.lpr/shell 已删)。222 项全绿;headless 复现:碰门 → loading → approach,截图见极点一枚干净的冰蓝洞口,四周天体流过。
2026-08-05(天体雨 v4:会呼吸的极点出口 + 星座连线;v3 退役) ← 请 QA
- Owner 交来
space-museum-celestial-rain-v4.lpp:四个下落图层与其动作代码 未变,新增会呼吸的隧道口——rim 用 224 个 mark 两条相连带,外圈 16 个弧相位(+4 个同相位 UV 接缝副本)518 个 mark,Link 距离 320, 10.4 秒一圈按方位角依次点亮。没有任何东西在旋转:顺序点亮 + 曲线连线 就够骗过眼睛。v4 还删掉了出口外的静态气体(图集 21..106 行),107..147 行 羽化回深空——正是我上一轮标记的那圈淡方框。 - 两支新笔刷过审入册:
polar-ring-breath(rim 呼吸,方位相位取自 mark 画在 哪里)+sequential-polar-nebula-breath(弧序列,槽位取自stroke 自己的 seed)。behaviors.js 逐行转写;测试钉住"永不旋转/永不漂移出界/暗时仍有 底光/16 个相位在同一瞬间亮度各异"。 - 格式两处扩展:(1) stroke 级
links——LINKS 是画的时候的设定、不是 笔刷代码,所以挂在 stroke 上,并新增strokeLinks能力:画不了连线的 renderer 必须拒绝,而不是悄悄只画散点。语义照抄 Art Lab 自己的契约 (距离内连线、alpha 随距离衰减、MAX_STROKE_LINKS上限),连线走 additive 且画在 mark 下面。(2)mark.strokeSeed送进行为——这才有"依次点亮"。 - 包:449 marks / 20 strokes / 4 alive 图层,complete;能力
aliveLayer + marks + strokeLinks。v3 退役(.lpp/.lpr/shell 已删)。 - 同日 owner 重调("应该只是小改"):外圈调柔——glow 138→42、mark 更少更小 (518→225)、Link 320→130、极点可见度 30%→60%、rim 加 12px 单侧余晖去掉 黑环。rim 笔刷只动了数字 → 形状不变、无需再审(这正是"身份=代码形状" 要买的东西);但弧笔刷同时改了代码里的内联系数(漂移 5+6·seed → 2+2·seed,尺寸摆幅压平),内联数字就是代码 → 登记为
sequential-polar-nebula-breathversion 2,behaviors 按版本分支,resolveMark新增 version 参数(缺省 1,老包语义不变)。若把 v2 的 marks 当 v1 重放,就会悄悄把 owner 调柔的效果又调回去 —— 新测试正是钉 这一点(v2 漂移更小、尺寸更平、alpha 曲线一模一样)。213 项全绿。 - 顺带修掉一个真 bug(诊断路径):
loop()无条件requestAnimationFrame自续帧,于是?debug&manual=1下真实帧与合成帧交错,两个时钟互相拉扯 →dt可能为负 →worldSeconds倒流(实测 -9.3s)→ 过渡的最后 1 秒 卡死不动。这正是我上一轮误判为"harness 假象"的现象,更正如下: manual 模式不再自续帧,且dt夹到>= 0(真实浏览器时间戳虽然单调, 这条防线对标签页恢复/系统改时间同样有用)。修完 headless 复现: loading → z 3 → 2.57 → 落进房间,worldSeconds 单调递增。212 项全绿。
2026-08-05(天体雨 v3:极点出口 + screen 合成;v1 退役) ← 请 QA
- Owner 交来
space-museum-celestial-rain-v3.lpp,两处真实改动:
- 新 shell:白洞/黑洞出口被数学极坐标展开进图集顶部带(作者算的 "dome 带 = 0..257 行 = 25.18%" 与我发布的几何契约
transitBarrelShare()算出的 0.74817/0.25183 完全吻合,无需改任何 UV 代码)。平面图集里 它看着像横条纹,球面映射会把它收拢成正前方一个圆形出口。 - 四个下落图层改为
screen合成:抽取物自带近黑底,source-over 会在 新背景上抠出方块黑洞;screen 只加光、不会减。
- 格式相应扩展:schema 新增
BLEND_MODES白名单 (source-over / screen / lighter)——值最终会进 canvas 的globalCompositeOperation,而包只许携带数据,绝不能指定任意操作; compileLpp 只在非默认值时才写blendMode(旧包保持逐字节一致); surface 绘制 alive 图层时按层应用。 - v1 退役(.lpp / .lpr / 旧 shell 已删),语料保留 v3 与旧隧道包。
- 实机验证(
?debug&manual=1headless):碰门 → loading(舱内 y=-340) → approach(z 3 → 2.6 → 0.02)→ 落入房间;截图确认极点出口收拢成一个 圆形漩涡、行星/星系在四周流过、没有方块黑洞。诊断 probe 顺手加了 worldSeconds/approachStart/loaded 三个字段(只在 manual 模式下)。 203 项全绿,6 个包 verify。 - 我注意到的一点(留给 owner 判断):出口漩涡外围能看到一圈很淡的方形边界, 像是 shell 自身极坐标带与近黑背景的交界。若刺眼,我可以在 shell 的带边 加一圈羽化,或把 dome 带的 v 映射再收一点。
2026-08-05(过渡舱换上"天体雨";.lpr 学会 alive 图层) ← 请 QA
- Owner 交来
space-museum-celestial-rain-v1.lpp:没有笔刷——从真实 源图里抽取的天体像素,按连通区域分成 4 个"速度组"整层图片,每层一个move(t)整体下落(各自的距离与周期,首尾 smoothstep 渐隐藏住回位; t=0 时四层重新拼回源图构图)。这是 marks / warp 之外的第三种内容: alive 图层。 - 管线先学会"看见"它:此前编译这份 .lpp 会得到一个"complete 却什么都 不画"的包——正是格式要禁止的静默丢弃。现在 lppProject/lppArchive 解析 alive 图层与对象,
requiredShapes把 move() 当笔刷代码对待(去参数 hash),未审核的 alive shape = gap(第一次编译如实报 4 个 gap)。 4 个组的代码只差标题注释一行 → 4 个签名指向同一个已审核行为celestial-fall@1(与 liquid-warp 两个 shape 同理)。 - 包格式:
alives[](layer 索引 + 行为 + 参数 + 按内容 hash 命名的 图片,如同 photo 规则)、能力aliveLayer、stats.alives,校验器全部 把关。运行时resolveAliveLayer()是 behaviors.js 里的纯参考实现; canvas surface 按 shell → alive 图层 → marks 的顺序画,且 canvas 改为 按作者 2:1 宽高比(省一半绘制与上传)。5 张图(shell + 4 组)作为 资产入库,逐一 hash 校验。 - 舱体 UV 契约随之更换(旧隧道图集的 seam/椭圆规则退役,隧道包留在 corpus):整幅 2:1 画连续包住筒身+穹顶——画顶 = 穹顶极点,画底 = 尾部 开口,按实际表面路径长度分带(筒 0.748 / 穹顶 0.252),穹顶 u 用球 几何自带的防缝 u 再偏移 1/4 圈对齐筒身(纹理 u 方向 Repeat),下落的 天体跨环不横跳。"雨"从前方极点向后掠过 = 前进错觉。
- 202 项全绿,6 个包 verify(所有旧包 stats 多了
alives: 0,hash 已 重新生成)。 - Owner QA"进门没效果"→ 已修并实机验证:surface 自己的能力检查通过后, 内部构造的 mark 参考渲染器又用只有
["marks"]的清单拒绝了整个包 (missing aliveLayer)→ 过渡舱构建失败 → 按设计静默回退成旧的直切。 修法:渲染器构造函数接受served(组合它的 surface 自己负责画的能力, 这里是 aliveLayer);单独使用时它仍然拒绝任何会被截断的包。新增回归 测试钉住两个方向。诊断路径顺手补强:?debug&manual=1的__museum.step()增加 strafe 参数、probe()增加 x/z/transit 字段—— 借此在 headless 面板里全程复现:碰门 → transit "loading"(y=-340 舱内) → "approach" 前移 → 落进 room-b,截图可见天体雨包裹整个视野。203 项全绿。
2026-08-04(画质新增 Ultra 档:桌面默认 2048,iPad 默认 1024) ← 请 QA
- Owner 在 Mac 和 iPad 上实测
?arttex=2048都满意 → 新增 Ultra 画质档(2048px/面,facesPerFrame: 1,刷新率 6Hz——比 High 更慢是有意 的:一面 2048 是 16MB 上传,Ultra 买的是像素不是帧率)。菜单循环变为 Low → Medium → High → Ultra。 - 默认档整体上移一级:桌面开局 Ultra(2048),触屏开局 High(1024) ——2026-08-02 的默认是 1024/512。已存的选择永远优先(包括优先于新 默认),所以存过档位的设备要么菜单里切一下、要么清 localStorage 才会 看到新默认。过渡舱纹理 = 画质×2 上限 2048,两端现在都是满幅原图集。
2026-08-04(传送门过渡舱:加载时的"穿梭"体验) ← 请 QA
- 按 owner 的设计稿(
space-museum-transit-design/+ 随附.lpp)实现: 碰到传送门的瞬间,avatar 被放进一个预建的隐藏过渡舱(开口圆柱 + 前端半球,全部内表面),原地不动、只播走路动画,舱壁的 Live Painting (圆柱流光从穹顶接缝向后流 + 穹顶从消失点向外辐射 + 慢速面纱盖住 UV 接缝) 制造前进错觉。房间加载完成后,恰好 1.0 秒的 smooth ease-in 加速冲向 穹顶中心,然后切入房间——缓存过的房间也完整播这 1 秒,切换落在最快 的那一帧上。加载失败则退回走廊门口,不困在舱里。 .lpp三支新笔刷过审入册:portal-glass-current/dome-convergence/glass-veil-pulse,全部是 (mark, seed, t) 纯函数、wrap/正弦时钟—— 无累积,慢加载永远循环干净。它们会移动 mark(天幕的 GPU billboard 路 径以"不位移"为前提),所以走 canvas 路径:behaviors.js 三个新 opcode (参考算术)+LprMarksSurface(一块画布 = shell 图 + marks,按内容 hash 校验图)。包 208 marks,complete。- 共享调度器、单动态纹理、零新循环:过渡舱纹理挂在
updateLiveExtras的同一dueFaces节奏上,隐藏时不占任何绘制轮;几何和.lpr在 init 时与走廊资产一起预载(构建失败自动退回旧的直切流程)。 - UV 契约进 museum.js(纯数据+纯函数,Node 可测):圆柱前环精确采样 y=683 接缝行;穹顶极点 = 图集视觉中心 (1024, 286),径向椭圆映射 (squash 0.36)连续无缝;作者椭圆超出图集顶边 ~49px 属有意裁切 → UV 夹回 区域内,永不越过接缝采到圆柱带。舱体停在 y=-340 的独立口袋 (房间都向上挂在 +250 起)。5 项新测试;200 项全绿。
- 已知取舍:过渡期间 presence 仍以走廊 zone 广播舱内坐标(同伴看到你在 340 米下方一闪,肉眼几乎不可见);第三人称相机在舱内不做走廊裁剪。
- Owner QA 第一轮,两处修正:(1) avatar 悬在筒轴正中而不是贴底—— 隧道流光从四面均匀掠过(transitStartPoint y = 轴心)。(2) "特效像 256 低清",两个原因都修了:过渡舱包住整个视野,材质尺寸改为画质档 ×2、 上限 2048(桌面 High 即 1:1 原图集;上传预算成立,因为舱可见时房间 还在加载、没有别的 Live Painting 占绘制轮);更根本的是 canvas 参考 渲染器把作者的柔光笔触画成了硬边形状——现在
glow走 additive ("lighter",与天幕 billboard 同规则)、软 dot 按 softIdx 径向衰减、 streak 是旋转拉伸的柔光椭圆而非硬条,渐隐终点用同色零透明度 (穿透明黑会镶暗边)。
2026-08-04(Angel 站正:目录里的 yaw 修正) ← 请 QA
- Owner QA:Angel 在馆里是歪的,需要"以中心为轴向右转 20~30 度"。导出文件 本身没有正对自己的 +Z,所以修正做成目录数据而不是硬编码:
yawCorrectionDegrees(负 = 向模型自己的右转;要微调只改这一个数)。 先 -25,owner 二次 QA"差不多了,再往右 5°"→ -30 定稿。 - 装配点统一吃这个字段:博物馆
buildGlbAvatar(远端克隆自动继承——它们 克隆的就是这个对象)+ 涂色 Studio(画的时候也正对画家)。Baby 导出是正的, 不带该字段。测试钉住"修正在 -20 ~ -30 之间、baby 无修正"。195 项全绿。
2026-08-04(Esc 菜单:Leave museum 回首页 + 全屏;"4" 截图;诊断参数全部收进 ?debug) ← 请 QA
- Menu 里的
Leave museum(owner 澄清:离开 = 回首页):Esc/Menu 里 常驻一个按钮,点了回 landing page(带着 ?debug 回去,诊断会话不断)。 离开房间维持原样——飞进光之门;门膜的出口逻辑顺手抽成了唯一的leaveRoom()。 - Menu 里的
Full screen:整个文档进/出全屏,iPad Safari 走 webkit 前缀;两者都没有的浏览器不显示这个按钮。 - 按 "4" 截图:把当前画面存成 PNG(
museum-<房间|corridor>-<时间>.png)。 GL canvas 不保留帧缓冲,所以按键只立 flag,帧循环在 render 之后立即取图; 在输入框里打 "4" 不会触发。 - 菜单排版改成两列网格(owner QA):Resume 整行;Switch view 与 Full screen 同一行(不支持全屏的浏览器里 Switch view 独占整行); Graphics 与 Gravity 同一行;Live effects 滑条整行、左缘与 Graphics 对齐; Leave museum 整行收底。登录区分隔线取同一宽度,所有控件共享同一组网格边。
- URL 参数收口(owner:除 ?debug 外不要用 query param):现在 URL 分 两类。门参数(landing page 写的
world/room/avatar/gravity)保持原样 ——它们是入口链接,旧链接必须继续有效。其余全是诊断旋钮 (paint/bench/live/arttex/faces/hz/dpr/sky/hud/manual/inspect/presence), 一律只在?debug同时存在时生效:param()一处收口 +resolvePresenceUrl同规则(贴来的链接从此不可能把别人的博物馆指到别的 presence 服务器或悄悄关掉绘画)。诊断写法变为?world=museum&debug&bench=30。PERFORMANCE.md 的旋钮表已注明。
2026-08-05(Studio 三个 bug:登录卡挡模型 / 一笔变很多笔 / Clear 清不干净) ← 请 QA
- Owner 确认远端头像已正确。Studio 三处:
- 登录卡挡住模型:
gate.hidden = true对内联display:flex无效 ([hidden]只是 UA 样式表的display:none,内联样式优先级更高)。改为 直接驱动style.display。 - 一笔变很多笔(不是笔刷的问题,是模型的 UV):读 GLB 实测——宝宝的 7 个套装部件共用同一张 fabric 贴图,且 UV 全部占满同一个 0..1 方块 (双臂/双腿/躯干 0..1,双靴 0.13..0.88)。所以在那张贴图上画一笔,7 个 部件同时出现同一笔(截图完全对应)。天使是单网格独立 UV,所以之前没 暴露。"可涂表面"的定义从"每个材质"改为"每个网格" (
paintTargets.paintableSurfaces返回 {key, mesh, slot, material}), 并在首次落笔时才复制材质(isolateSurface,copy-on-write)→ 画左臂 只有左臂变;没人画的部件零开销;保存只带真正画过的部件(payload 反而 更小)。博物馆端(本机与远端 rig)走同一份规则与同一个 copy-on-write。 - Clear 清不干净:原实现只重绘"已有画布"的层。现在 Clear = 把出厂 贴图放回每个部件并丢掉画布,必然清干净;提示语也说清"Clear 不可撤销, 但没保存过——重载即可取回上次保存的画"。
- 7 项表面规则测试(共享材质=多个表面、isolate 不影响孪生部件、多材质槽、 空模型)。221 项全绿。
2026-08-05(每个人用自己的模型渲染 —— 远端头像终于是本人) ← 请 QA
- Owner:一边 avi、一边 angel(隐身窗口),互相看到的头像不对;他贴的 WS 报文完全正确(自己发
av: space-baby-avi,收到 peerav: my-angel)。 数据没问题,问题在渲染:远端 rig 一直是本机模型的克隆(我上一轮 记录的"下一步首项"),所以每个人看到的"别人"其实都是自己那副身体。 - 修复:
threeMuseum现在按头像 id 维护模型原型 (avatarTemplates+avatarTemplateLoads去重并发下载,init()多收 一个resolveAvatar解析器 = 目录查询)。buildRemoteRig(avatarId)用 对方自己的原型克隆:各自的高度归一缩放、各自的四个剪辑、各自的出厂 贴图(baseMaps 也随原型走);peer 换了身体则重建 rig;GLB 还在下载时 先不画(宁可晚半秒出现,也不要先穿错身体);未知/加载失败 → procedural mannequin("人在,模型不可用"是诚实答案)。 - 因此撤掉了上一轮的临时限制:
dressPeer不再"头像不同就不拉画作"—— 对方穿自己的身体,自己的画自然合身;applyRemotePainting的painting.avatarId ≠ rig.avatarId拒绝仍在(材质键对不上)。 - 诊断:
presence-avatar: loading <id> for another explorer/presence-avatar: peer <8位> is a <avatarId>(Console 过滤presence-avatar)。dispose()现在也清理头像原型的几何与贴图。 220 项全绿。注意:每多一个别人的头像 = 懒加载一次该 GLB(天使 8.9MB), 这是"看到真人"的代价,可接受。
2026-08-05(登录共用一份实现;dev 登录在局域网地址上失效;身份可观察) ← 请 QA
- dev 登录不管用 =
isLocalDev()只认 localhost。npm run dev是--host 0.0.0.0(为了 iPad),你从192.168.x.x打开时 dev 按钮根本 没渲染。规则抽成纯模块auth/localOrigin.js(loopback / 192.168.x / 10.x / 172.16-31.x /.local;公网地址永不命中,私有段本身不可路由), 真测试 14 个主机名。 - 登录 UI 现在只有一份实现(你说得对):
auth/signInPanel.ts—— Esc 菜单与 Studio 都挂它,dev 快捷登录属于这一份流程,所以两处必然都有。 菜单自己只保留"编辑器"相关按钮(谁能建房是我们自己的白名单)。 - 身份可观察(回答"存哪了"):访客 id 存
localStorage["inception.guestId"];账号 id 由邮箱现算、不存储。启动时 浏览器打印presence-identity: <kind> <id>——两个 tab 必须打印同一个 id;服务端在退场时打印presence: retired <socket> — identity <前缀> opened a newer tab。没有这行 = 规则没跑:npm run api是普通 node 进程,改了服务端必须重启(线上则需重新部署)。 - 顺手拆掉一颗地雷:局域网 http 不是 secure context,浏览器没有
crypto.subtle→ 原来账号哈希会抛异常并炸掉整个 bootMuseum。现在 账号路径失败时回退到本机 guest id(启动日志会说明),多 tab 归一仍然 成立,只有"跨设备同账号=一个人"要等 https;画作 id 也改为以服务器 返回的 id 为准(服务端用同样字节算同样哈希),本地哈希只是离线兜底。 220 项全绿。
2026-08-05(一个人 = 一个 explorer:身份、登录才能 painting、画作绑定头像) ← 请 QA
- Owner 定的三条:只允许登录用户 painting(保证 painting id 唯一、 多开 tab 也只有一个 avatar、只保留 latest);guest 也要有 id(多开 tab 同样只算一个 avatar;private 模式控制不了,无所谓);账号可以 map 到自己拥有的 avatars(先写文档,持久层稍后)。
- 身份层
src/inception/net/identity.js:登录 =sha256("account:"+ email)前 16 位十六进制;访客 = 本机 profile 里持久化的随机 16 位。 只在 join 里发送、只发哈希(永不发邮箱)、不转发给其他客户端。 服务端 hub 新增identityOf+retireOlder():同一身份的旧连接被 退场(收到replaced→ 客户端停止发布并关闭 socket、状态栏说明; 其他人收到 peer-left)→ 一个孩子开三个 tab 不再是三个 avatar。换房间 不会退场自己(exceptId 自排除);没有身份的访客照常入场,不做驱逐。 - Studio 登录门:未登录可以看/转/缩放,但工具、Save、Enter 全部禁用, 居中卡片走同一套邮件验证码(本地还有 dev 登录);未登录时在身体上 拖动 = 旋转模型(门必须在整个身体上生效,不只是面板底下)。
- 画作绑定头像:presence state 新增
av(头像 slug,正则校验);applyRemotePainting拒绝painting.avatarId ≠ rig.avatarId的画作 (材质键是"那个头像材质表里的位置",天使的画穿到宝宝身上就是乱涂), 拉取侧也提前跳过。这正是 owner 看到的"数据有些串了"。 - owner 报告的另一半必须说清:他把一个 tab 换成 space baby 后所有 多开 avatar 都变成 space baby —— 那不是数据串,而是已记录的既有 缺口:远端 explorer 目前一律用观看者自己的模型绘制(rig 是本机 模板的克隆)。身份层让重复 avatar 不再出现,但"用别人的身体显示别人" 这件事仍在待办:需要按 avatar id 缓存/懒加载多套 GLB 模板。已列为下一 步首项。
- 数据关系写进 AVATAR-STUDIO.md(谁能画 + 身份表 + 关系图:account → avatars(ownership) / account → paintings → 唯一 avatar_id;全局不分 world)与 ADR-0007。11 项新测试(身份大小写归一/不含邮箱、访客跨 tab 一致、锁定 profile 仍有身份、join 只认哈希形状、av 校验、同身份 退场+旧 tab 不能再移动+换房不自退、无身份不驱逐)。219 项全绿。
2026-08-05(line 工具删除:brush 直接拖就是直线)
- Owner:line 没必要(brush 完全能实现)而且有 bug,不保留。已整体移除:
PAINT_TOOLS变为 brush/spray/dot、lineStamps删除、工具栏按钮与 press-then-release 那套独立路径逻辑(lineStart / endPointer 分支 / 状态 提示)全部拆掉。保留的是有价值的那一半:笔画沿屏幕路径回投曲面的 走法留给 brush(快速拖动不再跨图集涂抹)。工具集由测试钉住 (["brush","spray","dot"]),文档 AVATAR-STUDIO.md 写明"故意没有 line 工具"及原因。212 项全绿。
2026-08-05(Studio 的 line 会断:笔画改为沿屏幕路径回投到曲面) ← 已按 owner 决定删除 line,见上条
- Owner:直线工具没有顺着 3D 曲面走,线是断的。根因:line 在贴图空间 两点间插值,而扫描体的 UV 是很多小岛,贴图上的直线在模型上会离开表面、 跨过图集缝隙与其他 UV 块 → 断成几截,还可能画到别的部位。
- 修复:新增纯函数
screenSamples()(paintTools.js),笔画沿屏幕线段每 4px 采一点,逐点射线回投到模型;相邻采样点只在"同一材质且 UV 距离 很近"时才在贴图空间连线,遇到 UV 缝就改为盖章——在模型上看是连续的, 这才是唯一需要连续的地方。画笔(brush)同样接入,顺手修掉快速拖动时 跨图集涂抹的同类隐患;喷罐/圆点仍是点工具。单笔采样上限 240(每点一次 35k 三角形射线检测),空白处不跨接。line 的笔宽与 brush 统一(原来是 0.8 倍,孩子的心智模型更简单:直线就是走直路的画笔)。 - 210 项全绿(新增采样规则测试:步长不超限、终点必达、单击=1 点、超长 拖动被截断但终点保留)。
2026-08-05(别人的涂装看不见 / 只看见一部分 —— 两个真 bug) ← 请 QA
- Owner 报告:远端玩家的 painting 贴图有时全看不见、有时只见一部分。 先答架构问题:画作字节从不走 WebSocket,presence 只传 16 位十六进制 的内容哈希
pt,画本身走 HTTP/api/paintings。 - Bug 1(只见一部分)= 材质共享被克隆打散。 宝宝的
Baby_Suit_Textured一个带贴图材质被 7 个网格共享(实测 GLB 确认); 远端形象克隆时我按"每网格 clone 一份材质",1 个可涂表面变成 7 个 → 材质编号全部后移 → 对方的画只落在第一块布料上,脸和头发保持出厂皮, 后面几块还错戴了本机玩家的画。修复:按源材质各 clone 一次 (Map 缓存),共享结构原样保留;dispose 也按材质去重。 - Bug 2(全看不见)= 一次 404 就被永久拉黑。 同伴一进场就广播
pt, 而它自己的上传还在飞(Render 免费实例还可能在唤醒);旧逻辑把这第一次 404 当成结论存进missingPaintings,整个会话再也不会重试。修复: 新增纯策略模块net/paintPull.js(指数退避 2s→20s、6 次后安静放弃、 单飞防抖),拉到后立刻上色并存内存——正确性不再依赖 localStorage 回读(配额报错现在被吞掉,缓存降级为纯优化)。 - 顺带消除漂移根源:材质编号规则原本在 studio 与 threeMuseum 各写一 份(且 studio 遇到贴图未就绪会跳号不加一),现在抽成唯一一份纯函数
studio/paintTargets.js,两端共用,可在 node 里用假场景图测试。 - 诊断:console 全部加
presence-paint前缀(谁穿上了哪张画/覆盖几个面、 某 id 还没进中继将重试);上传失败不再静默(studio 保存按钮会说"已存 本机,进馆时会重试分享")。文档 AVATAR-STUDIO.md 增"Watching it work" (console / Network / WS 三种过滤方式)+ 两条教训。6 项新测试 (共享材质=1 个面、编号规则、404 重试不拉黑、放弃阈值、配额不抛异常), 209 项全绿。
2026-08-04(ADR-0007 已接受并按 owner 修订;inception-db 改名完成)
- Owner 逐条裁决:artworks 要;三层权限太复杂 → 和房间一样只留 public/private(private ≈ 原 personal_draft、public ≈ 原 public_snapshot;class_shared 随 classes 一起推迟);classes 暂不 需要;avatars 要,但绝不做网页上传(内容审查考虑——资产仍走仓库 provenance + 预算审查),表只做所有权登记(owners[] + visibility, 私有头像只对 owner 显示在目录里);Phase 6 AI 审批保留为备注(将来 自动化内容审查的重要一步,需 LLM API,暂不考虑;落地时接同一个 visibility 开关:没有决策记录不得转 public)。
- 改名已完成:owner 在 Dashboard 把 museum-db → inception-db(正确 顺序第一步),render.yaml 两处与 DEPLOY-RENDER.md 已对齐;下次 blueprint sync 确认连的是原实例。ADR-0007 状态 → accepted with amendments。200 项全绿(含 owner 并行加入的 Angel yawCorrection 装配 微调 −30° 及其测试)。
2026-08-04(数据库设计升级为 ADR-0007:一个库、多世界、全局用户) ← 已接受,见上条
- Owner 审查 museum-db 后定调:整个 Inception Space 用一个数据库; Inception Space 1→多 worlds(太空博物馆是一个 world);world 1→多 rooms;user 全局(如 avatar 涂装)。写成 ADR-0007(proposed), 已发布到 /docs/。
- Schema v2:
worlds(id/name/kind/owners/visibility/config JSONB)→rooms主键改(world_id, id)(其余与现表一致)→users(email 主键 + display JSONB:公开署名用假名(R6 红线)+ 偏好同步位)→avatar_paintings(内容哈希 id,全局随人,UNIQUE(email) 起步、预留每 avatar 3 幅)。 - 计划盘点补齐的"没想到要存"清单:
artworks(.lpp/.lpr 元数据,字节进 R2);§2.3 三层权限落为 status 枚举(personal_draft / class_shared / public_snapshot + published_at/approved_by 审计,发现层 text twin 未来 从库生成);classes+memberships(Phase 7,含公开假名);avatars目录表(落地页已预告学生自制头像);Phase 6 AI 审批决策记录。有意 永不入库:presence、房间通行证(无状态 HMAC)、OTP/账号(D1)、画作 中继缓存(内存)、儿童行为分析(任何计划都不要)。 - 改名步骤(owner 亲自执行云操作):Render blueprint 按名字匹配资源 ——先在 Dashboard 把 museum-db 改名 inception-db,再同一变更里改 render.yaml 对齐(顺序反了会新建第二个库,且 free 档一个 workspace 仅 一个免费 Postgres)。今天表是空的 = 零成本窗口。连接串内部 databaseName=museum 属外观问题,付费迁移时再换。
- 接受 ADR 后的实现顺序:store.js 建 worlds + 房间双键 →
/api/worlds/:world/rooms/:id(博物馆世界保留兼容读)→ 从"启动自建表" 毕业到有序 migration 文件(第一个 ALTER 出现时)。194 项全绿。
2026-08-04(Angel 动画 v2 入库) ← 请 QA
- Owner 交来 multiclip-v2:四个剪辑名/时长、几何、贴图与上版完全一致, 改进在动画曲线本身 → 纯资产替换(目录映射零改动),PROVENANCE 哈希 更新(两个旧哈希留档 superseded)。194 项全绿。
2026-08-04(Studio 收尾:画完直接进馆)
- Owner:涂色后的下一步是进入所选世界("Enter Space Museum"),不是 回门口。落地页的 Paint 链接现在携带完整选择(
?studio=<avatar>&room= <room>&gravity=<g>),Studio 工具栏主按钮 = Enter <房间名>(薄荷色 主样式):点击先自动保存(本地 + 中继上传)再selectionHref进馆 ——不会有孩子把杰作丢在门外。"Entrance" 降级为小字链接。Save 按钮保留 (只存不走)。194 项全绿。 - Owner 预告:GLB 还会再更新一版。流程已就绪:替换文件 →
node scripts/inspect-glb.mjs自检 → PROVENANCE 哈希必改(测试强制)→ 剪辑 名若变,改目录clips映射即可。
2026-08-04(四剪辑 Angel 换装 + 头像涂色 Studio + 画作按 id 同步) ← 请 QA
- 多剪辑 GLB 到位:
Angel_Idle_Loop5.0s /Angel_Walk_Loop2.0s /Angel_Float_Loop3.0s /Angel_Dive_Loop2.5s,几何贴图不变。目录 四个角色各接各的剪辑;PROVENANCE 哈希与说明同步(旧单剪辑哈希留档为 superseded)。头像导入性能守则写入 docs/PERFORMANCE.md("Importing an avatar"):三角/骨骼几乎从不是问题,扫描级贴图才是;先跑scripts/inspect-glb.mjs。 - 涂色 Studio(owner 新功能):落地页选中头像 → Paint → 全屏
?studio=<id>(src/inception/studio/avatarStudio.ts)。在真 3D 身体 上作画:射线命中 → UV → 在该材质的 albedo 画布上盖章;工具 = 画笔 / 喷罐 / 圆点 / 直线(几何全在纯模块 paintTools.js,可测),调色板 + 自由取色 + 粗细 + Undo(每层快照,触到哪层撤哪层)+ Clear;单指/左键 画,双指/右键旋转缩放。没有 live painting——纯颜料。笔刷只在小 UV 跳变内连线,绝不横跨图集孤岛涂花。 - 画作同步(owner 要求的架构):WebSocket 只传 id。保存时每个 已画材质压成 512² 有损 JPEG(视觉有损被明确接受),本地按头像存 (paintStore),id = 内容哈希(客户端与服务端逐字节同算,测试钉住 一致)→ 幂等上传
POST /api/paintings(server/paintings.js 内存 LRU 中继缓存,shape 校验 + 384KB 上限 + immutable 下载头)。presence 状态 新增严格校验的pt字段(十六进制哈希,非自由文本红线不破)。同伴见pt→ 先查本地缓存(留最近 10 张)→ 未命中才 GET 一次 → 给该 rig 穿上。远端克隆现在穿自己的克隆材质(先重置出厂皮,再上对方的 画)——谁的颜料都不会串到别人身上;rig 卸载时其画作纹理一并 dispose。 全程软失败:无 API = 单机照常。 - 未来计划已入文档(docs/archive/AVATAR-STUDIO.md,/docs/ 已发布):登录后 才能保存到数据库(re-check server-side、拒 dev session,同房间保存 规则);一邮箱暂一幅,schema 预留每头像最多 3 幅;中继缓存留作 快路径。19 项新测试(工具几何 / 存储 / 服务端缓存 / 协议 pt / 目录)。 194 项全绿。
2026-08-04(Angel 跟进:来源更正、贴图瘦身、可扩展头像选择、GLB 检查脚本) ← 请 QA
- PROVENANCE 更正为 owner 的原话:owner 用 Polycam 亲自扫描自己的 实体天使模型,Codex 在 Blender 里由扫描件制作游戏资产(清理 + 5 骨骼 绑定 +
Angel_Gentle_Loop动画)。扫描件与 .blend 留在 owner 机器上。 - 性能答复(owner 问 Pilot 预算):三角形与骨骼都不是问题 (34.8k 三角对 GPU 微不足道,5 骨骼蒙皮比宝宝的 65 根还便宜); 8.5MB 下载只在选中天使时发生。真正的开销是扫描级贴图:三张 4096² JPEG 解码后 ≈190MB GPU 显存(iPad Safari 整页预算约 1–1.5GB)。 已加运行时兜底:
capAvatarTextures在加载时把头像贴图缩到 1024² (0.3m 高的角色在屏幕上根本用不到 4K),GPU 成本降约 16×。若以后 重导出,建议 Blender 里直接把贴图设为 1024²,文件也会小很多。 - 多动作:当前 GLB 只含一个 5.04s 剪辑(inspect 证实)。Codex 在 Blender 里若做了多个 Action,导出时需在 glTF 导出器把 Animation mode 设为 "Actions"(或把每个 Action 推进 NLA 轨)。新 GLB 到位后 = 纯数据 改动:目录
clips映射 + PROVENANCE 哈希。新增scripts/inspect-glb.mjs(打印剪辑/三角/骨骼/贴图尺寸/脚底原点), 导出后先跑它自检。 - 头像选择 UI 可扩展化:房间与头像改为纵向两个区块——房间保持大卡 (世界不会多),头像变成紧凑 auto-fill 网格(150px 卡,手机 130px, 10+ 个自动换行成行;说明文字转为悬停提示)。auto-fill 短行不放大 卡片,是既有的版式习惯。183 项全绿。
2026-08-04(第二个可选头像:My Angel) ← 请 QA
- Owner 交来 Blender 原创天使(
my-angel-v1.glb,34,794 三角、5 骨骼、 3 张内嵌贴图、8.5MB、脚底原点、高 0.85m;PROVENANCE 已登记双哈希—— 创作说明由 CC 代记,请 owner 像宝宝那样用自己的话修订)。预览图 一并入库,落地页头像卡片自动出现(目录驱动),数量文案改为随目录计数。 - 头像目录成为真数据:AVATAR_CATALOG 每项带
assetUrl(静态new URL字面量——Vite 只 emit 看得见的资产,动态路径曾让博物馆 丢过头像)、assetHeight、clips角色映射。?avatar=<id>现在真的 被博物馆读取(此前只有 bakeoff 认?avatar=mannequin);mannequin 逃生门保留(显式选择 → 直接建 procedural 人偶)。 - 高度归一规则:世界缩放 = AVATAR_SCALE·(1.8/H),居中偏移 = −H/2。 宝宝原来硬编码的 −0.9 · ×1 正是 H=1.8 的特例;该规则让 offset·scale ≡ −AVATAR_FOOT_DROP 对每个头像恒成立(足底钉地 常量无需分头像),且所有头像世界身高一致(~0.3m)。远端克隆同用 归一缩放。
- Angel 只有一个剪辑
Angel_Gentle_Loop→ 目录把 idle/walk/float/dive 四个角色都映射到它(天使走路也悬浮,成立)。加载器按映射解析剪辑, 角色共享同一剪辑时权重混合退化为该剪辑本身。 - 预算:超出默认头像的 Pilot 预算(20k 三角/4MB)——作为可选、 仅选中才加载的头像接受,单独上限 40k/10MB 由 tests/inception-avatar-angel.test.js 守卫(结构/尺寸/剪辑/哈希↔ PROVENANCE 同守)。dist 实测两个 GLB 均被 emit。
- 已知缺口(记录,未做):presence 远端形象目前显示为观看者自己 选的头像(协议只传 transform);头像身份字段(目录枚举下标,符合 无自由文本红线)= 下一步。183 项全绿。
2026-08-04(重力成为门口的选择:Zero-G / Micro / Moon) ← 请 QA
- Owner 报告偶发"微重力感"且觉得不错。解释:松开推进后速度按阻尼 (2.2/s)指数衰减,会滑行一小段——一直存在,只是黑洞变清晰后被注意到; 触屏上 touchend 偶尔丢失也会留下摇杆残留的缓慢漂移。既然感觉好 → 做成正式选项。
stepFlight纯规则新增可选params.gravity(m/s²,默认 0 = 原样 零重力,逐字节不变):仅在 vy 上加恒定下拉,阻尼自然给出终端下沉速度 gravity/damping(Micro 0.5 → ~0.23 m/s;Moon 1.6 → ~0.73 m/s);落地由 体积 clamp 吸收;r6"地板上 Space 无操作"不受影响;推进 7 m/s² 远大于 Moon,随时能飞起来。- 三个入口:落地页新增 Gravity 药丸行(Zero-G/Micro/Moon,选非默认时 enter 链接带
?gravity=——旧链接语义不变);Menu 与画质并排的循环 按钮;localStorageinception.gravity记忆,URL 覆盖记忆。 - 测试:零重力路径逐字节不变 / micro 下沉不超过终端速度、落地停稳 / Moon 被浮起推进克服 / resolver 优先级 / selectionHref 仅非默认携带。
2026-08-04(Van Gogh House:新文档入库;房子到顶;旋转碰撞按真墙;编辑残影修复) ← 请 QA
- Owner 的新 room-a 文档入库(sculptureOrb + 大小学生屋 + 灯),通过 validateRoomDoc;编辑器光标字段
selected按惯例不入库。 - "天花板缝隙"其实是屋身与屋顶之间的缝(owner 随后澄清):我第一版 理解错了,把房子垂直拉伸到天花板——已全部还原(房子恢复原尺寸,实体 高度回到 roofPeak)。真正的缝是建模问题:屋檐被抬在墙顶上方 0.02 处(
eaveY = wallHeight + 0.02),从很多角度能透过这条 2cm 的空隙看 到对面。修复 = 屋檐恰好落在墙顶(共享棱边、不重叠——共面的山墙 三角不与侧墙打架,无 z-fighting)。这些房子是代码建模 (studentVillageHouse.js 手写几何),不是 Blender 资产,所以直接改代码 即可。测试钉住:两栋房 scale=1 + 屋顶最低点 == wallHeight(Float32 容差)。 - 旋转碰撞:世界 AABB 本来就按角度取包围盒(保守),但 45° 的大屋 四角多出 ~1m 隐形墙。障碍现在携带
local(prop 自身坐标系的精确 脚印),resolveObstacles在该坐标系推挤——撞到的是真墙,角落不再 挡人。房子的实体同时升到天花板(上方没有"飞过去"的空间)。测试钉住 三件事:AABB 角落不推人 / 屋内推出正好落在真墙上 / 实体高度 = 8。 - 编辑残影:连续 nudge 触发并发异步 loadRoom,旧加载完成后其 group 被新注册覆盖、永远留在场景里 = 一堆重叠影子。loadRoom 现在按房间 记 generation,过期加载在提交点整体自弃(几何/材质/纹理/表面全部 dispose;回廊门核心材质改为提交时注册,杜绝过期覆盖)。顺带修掉 unloadRoom 一直漏清的 warpFaces 积累(每进出一次水地板房漏一条永久 更新的 uniforms;现按 owner/generation 清除并 dispose photo/mask)。
npm run check= 177 项全绿。
2026-08-04(mark 特效穿玻璃 = 透明通道顺序;marks 移到所有玻璃之下) ← 请 QA
- Owner:黑洞改普通合成后暴露了老问题——mark 特效画在玻璃上面(穿玻璃)。 根因是透明通道按 renderOrder 升序绘制:走廊玻璃地板/墙分别在 −3/−2, 而 marks 在 −0.5、换天渐变球在 −1,都排在玻璃之后。加法时代星光只是 往玻璃上多加一点光,看不出来;黑洞会"涂黑玻璃",一眼就穿帮。
- 修复:marks 是天空的一部分,必须先于世界里的任何玻璃落下 →
renderOrder = -5;换天渐变球同理 →-6(在 marks 之下、在玻璃之下淡入)。 现在玻璃像给全景上色一样给星星和黑洞上色。 - 同时把 marks 的
depthTest打开:天空球不写深度,星星对着天空永远可见, 但实心物体(穹顶房的地板、道具、宇航员)现在能正确遮挡星星——之前 depthTest:false 时 marks 理论上会叠在任何东西上面,只是没被注意到。 - 测试更新:marks 网格源码扫描现在钉住
renderOrder = -5+depthTest: true+ NormalBlending(切片锚点改为this.skyMarks.set)。171 项全绿。 - 备忘:将来 warp 类天空包落地时,若其材质是 transparent,天空球本体会跳进 透明通道(现在 renderOrder −1)——届时要一并压到玻璃之下。
2026-08-04(黑洞看不见 = 加法混色不会变暗;天幕 marks 改普通合成) ← 请 QA
- Owner:精心画的黑洞(纯黑 mark)一个都看不见。数据没丢:包里 215 个 纯黑 mark(rgb 0,0,0)全在。问题在合成:GPU 天幕用 AdditiveBlending, 加法只能加光,纯黑加零 = 隐形。
- 对照 art-lab 的现行语义(
assets/brush-draw.js):glow marks 先在隔离 的临时画布上互相 "lighter" 叠加,再把整张 sheet 以 source-over 盖回 画面——单个 mark 落到画上等价于src·a + dst·(1−a)= 普通混色,不是 纯加法。黑 mark 在 sheet 上积累的是 alpha,盖回时就是半透明的黑:这正是 黑洞能涂暗星云的原理。 - 修复:天幕 marks 材质 AdditiveBlending → NormalBlending(shader 原样,
vec4(vColour, alpha)对 SrcAlpha 因子同样正确)。亮星在近黑背景上两种 混色几乎一致;重叠的亮 mark 现在向自身颜色收敛而不是冲向白——这更接近 art-lab 参考,但请重看一眼星与星云的观感。已知一阶近似:art-lab 同层 glow 先求和再一次性合成,GPU 按 mark 顺序逐个合成;当前 alpha 量级下 看不出差别。走廊 trim 光环合法保留加法。 - 新测试钉两件事:v7 包恰有 215 个纯黑 mark;marks 材质必须
blending: NormalBlending(源码扫描,与资产测试同一手法)。171 项全绿。 - 备忘:继承的房间渲染器(curatedLivePainting.ts)仍是旧语义
mark.glow ? "lighter"、无隔离 sheet——房间画作若也画黑 glow mark 会同样 隐形;等它并入 .lpr 路径时一并对齐。
2026-08-04(v7 新修订导入:46 个笔刷修订、31 个代码形状,审核结论 = 同一个行为) ← 请 QA
- Owner 交来新的天幕
.lpp(2.3MB,含全新重写的 "Nebula Breath" 笔刷 + 689 个新星云 mark)。第一次编译 30 个 gap:owner 一边画一边改笔刷 源码,46 个修订散成 31 个代码形状,多数只被一笔引用——这正是 "身份 = 代码形状" 设计预告过的画法(改代码,不是拖滑块)。 - 审核走了一条诚实的捷径:把每个形状的运行时视图(去掉注释与只在 绘画时执行的
brush()——它的输出早已烘焙进 marks)归一后对比, 30 个 gap 形状逐字节收敛成一个,而它的move()/fadeCycle数学与 已审核的photo-nebula-breath@2完全一致(同两条正弦、同包络、同夹取; 差异只有注释、格式、move()不读的 TERRITORY/LINKS 常量与惰性返回字段)。 8 个 @2 参数每个形状都带全。结论:30 条 registry 映射 → @2, 不加版本、不改 shader、不改 schema。 - 包从 758 marks(带 30 gap)→ 1447 marks 完整编译(39 远星 + 17 亮星 + 1391 云;owner 顺手把星层也精简了,原 48+20)。photo hash 与馆内 4K 全景逐字节一致(测试钉住)。mark-shader 测试的钉值同步 770→1447、 702→1391。167 项全绿。旧修订备份在会话 scratchpad。
- Owner:走廊尽头换成 background1/background2 后,星光
.lpp的活效果应当 消失——它是画在 background.png 上的。修复:天幕 marks 网格按"为哪张全景 而画"登记(skyMarks: variant → mesh),setSkyVariant在渐变开始的那 一刻切换可见性:只有为当前全景注册的包亮着。 - Owner 说未来会给 background1/2 各上传独立
.lpp——通道已预留:museumMain 的SKY_PACKAGES = { default: v7, north: null, south: null },新包编译好 填 id 即可;启动时逐个setSkyPackage(id, null, variant),同一变体重复 应用会替换旧 marks(几何/材质释放、调度数组同步清理)。warp 类天空包暂时 只支持 default(渐变还不认识 shader 材质,代码里留了警告说明)。 167 项全绿。
2026-08-02(waternew.lpp 导入:零代码改动就通过了)
- Owner 确认效果已经很好,给了修订版
waternew.lpp→ 归档为sky-floor-water-v2.lpp,The Sky 地板改指向它。 - 两支笔的代码形状与已审核的完全相同,所以不需要任何审核、不需要改 registry、不需要改代码——变的只是数字与笔触:面纱 target opacity 2%→5%、 mask feather 2→0,两块蒙版都覆盖到几乎整个面(水流 4474→6139 / 6144 格)。 这正是"身份 = 代码形状"要换来的东西:重画一遍、拖几个滑块,直接编译通过。
- v1 保留归档以便对比(不需要的话可以删)。122 项通过,6 个包全部编译。
2026-08-02(面纱恢复为"淡出透明":两个场是独立的) ← 请重测
- Owner:现在"过几秒就停下来、安静了,而且没有任何东西淡出透明"。判断正确: 我把面纱耦合成"让水面平静下来",等于把作者写的那一刻换成了一段空白。 warp 场与面纱场是各自独立的:水流一直在跑,面纱只改变图片的不透明度。
- 淡出透明就是这个效果——面纱降下时,surface 背后的东西透出来;在 The Sky 的地板上那就是深空。GPU 上它只是片元的 alpha,因此是逐像素、带 羽化的,而不是一堆被擦掉的矩形(材质改为 transparent + depthWrite:false)。
- Canvas2D 参考实现同步恢复淡出。122 项通过。
2026-08-02(warp 改走 GPU:条纹和慢都是"技术选错") ← 请重测
- Owner 追问:"lpp 这种设计是不是不合理?为什么还是慢、而且全是条纹碎裂。" 结论:
.lpp的设计没问题,问题是我用 CPU 条带去做图像扭曲。 图像扭曲是逐像素操作:每个输出像素要从位移后的位置读原图。CPU 只能 用 drawImage 复制条带来近似——每条带一道缝(约 670 道)、条带移开处露出 未位移的原图,还要每次重绘 1024² 画布并上传 4MB。把条带调细救不了前三项。 warpShader.js:照片只上传一次,mask 变成一张 96×64 的 RGBA 贴图 (R=水流覆盖,G=面纱覆盖,B=面纱内部深度),片元着色器直接texture2D(photo, vUv - displacement),由采样器插值。CPU 每帧只写两个 数(时钟 + 面纱 alpha)。纯 warp 的包因此完全不占 paint scheduler。- 对照表:缝 670→0;每帧 CPU 重绘 1024²→两个 uniform;每帧上传 4MB→0; 动画速率 8Hz→60Hz。Canvas2D 版保留为参考实现,并有测试把着色器的算式在 JS 里重算、与
waterCurrent逐点比对,防止两条路走偏。122 项通过。
2026-08-02(第一版效果很差——是渲染器的问题,不是 lpp 的问题) ← 请重测
- Owner 反馈"效果太差"。核查结论:
.lpp没问题(它的 shell 与 layer-source 字节相同,没有缺失的图层),问题在我的参考渲染器,三处:
- 条带宽度绑到了 96×64 的 mask 网格 → 13px 的方块整体平移,看起来是 滑动的瓷砖而不是水。现在条带固定 4 个画布像素,与 mask 无关 (mask 只回答"画在哪里",位移是连续的、必须密采样)。
- 位移没有从作品自身的 1254 画布换算到实际渲染的面(1024/512)。
- 面纱不该"擦除"。它的笔刷说的是"rebuild the source picture",而本作 的 layer-source 与 shell 是同一张图 → 面纱降下来时应该是动效淡出、 回到静止原图(水起波纹→平静→再起)。之前用 destination-out 在地板上 挖了个洞,透出太空。现在帧计划里根本没有擦除步骤,测试锁定这一点。
- 118 项通过。
2026-08-02(.lpr 上屏:The Sky 的水面地板由编译包驱动) ← 需要 QA
warpFrame.js把 warp 场变成计划(源矩形+像素偏移、要擦除的矩形), 纯数据,可在 Node 里断言整帧;drawWarpFrame只是消费它,不做任何算术。 两个易错点:条带方向是声明的(water-current 只随 x 变→切成列,切成行 会画出"看起来合理但不是作者那张"),以及只重绘 mask 的连续段(否则每帧 等于整面重采样)。- 房间面用
faces[].lpr指定包,配合image作为它的照片。LprArtworkSurface对外与继承的 Live Painting 同形(canvas/update/耗时), 所以 paint scheduler、Live 滑块、画质档位无需知道区别就能管住它。 绘制前会校验照片的 sha256,不匹配就拒绝(回退静态图)。 - room-b 地板已接上
sky-floor-water-v1;测试锁定"包要的照片 == 房间给的 照片"。118 项通过。这一步可以 QA 了:进 The Sky 看地板是否在流动。
2026-08-02(warp 行为可以回放了:两个纯函数 + mask 解码)
warpField.js:把两支已审核的 warp 笔刷的数学原样转写为纯函数 (water-current@1用 sin(u·π) 把painted box 左右边缘钉住,两股水流 加横向漂移;source-veil@1是 hold/fade/hold/fade 的图片自身透明度循环, 从笔画未触边缘向内羽化)。纯函数是为了将来 GPU 版能逐点对拍。- 还有 mask 解码(96×64 网格,base64 的 (值,长度) 对——畸形就拒绝, 绝不画半个 warp 场)、painted box 求解、以及 BFS 边缘深度。113 项通过。
- 要让水真的动起来还差两步:Canvas2D 的绘制步骤(把位移与面纱应用到 shell 照片上),以及把 .lpr 包接到房间某个面上的博物馆接线。
2026-08-02(.lpr v2:photoSample 也进来了;发现继承 opcode 的参数是错的)
photoSample落地。ripple 的环每帧都要从源照片的移动外缘重新取色, 颜色无法烘焙进 mark,所以包按内容命名照片(photo:{source,sha256, width,height}),validateLpr会拒绝没写照片的取样包;照片字节不进包。 sunflower 现在编译出 8 个 marks + 4 个 warp,只剩 2 个 gap。- 发现一个坑:v1 继承下来的 6 个 marks opcode 的参数是按curated 中间 格式定的,与笔刷本身不一致——curated 的
lissajous-heartbeat有speed, 而画它的笔刷声明的是 BEAT_A / BEAT_B / LOOP / FLOW。把.lpp的形状映射到 这些 opcode 上会悄悄用错参数,正是"一个接缝"要防的事。所以它们只能像 ripple 一样逐个从笔刷源码重新推导,在此之前留在待审队列里。 lppArchive.js:构建脚本与测试用同一段读档代码(含 shell 照片的 内容哈希),避免两处实现走偏。106 项测试通过,npm run lpr:verify干净。
2026-08-02(.lpr v2:warp fields 落地,water.lpp 第一个完整编译)
registry.js:signature → {behavior, version, role},只有这三项。参数范围 不在这里重复——每支笔在@ui里已声明,编译器按作者的范围校验,schema 只留一层安全上限。role 也是审核的一部分:审为 warp 的形状永远不能当 marks 用。- 未审核形状的处理规则(open question 2 的后半):渲染器根本看不到它。 解析只发生在编译器;未审核形状变成
gaps,包complete: false并逐"形状" 写明漏了哪些笔、多少 revision、为什么。validateLpr会拒绝"自称完整却带 gaps"或"藏起 gaps"的包;渲染器默认拒绝不完整的包。behavior 带版本号 (water-current@1),旧包永远指名旧行为。 - v2 能装 warp fields(语料库要的第一项能力,也正是 The Sky 地板的全部): behavior + 参数 + 原样的 mask(
artlab-rle-v1base64),对本格式不透明, 由声明warpField的渲染器解码。npm run lpr/npm run lpr:verify。 - 结果:sky-floor-water-v1 完整编译(2 个 warp,0 gap),另外 4 个带 gap 编译(sunflower 4 个 warp、cypress 2 个)。marks 全部还是 gap——语料里每个 marks 形状都需要
photoSample,那是下一项能力。106 项测试通过。
2026-08-02(.lpr v2 开工:先解决"身份"问题)
- 语料库 = 仓库里 5 个
.lpp。新增src/inception/lpr/lppProject.js:把.lpp的两个 JSON 读成纯数据(stage / 有序图层 / 笔画 / marks / warp / 笔刷索引),不碰文件系统与 zip,所以每条规则都能在 Node 里测。这就是 open question 1 要的"只有一个接缝"。 - open question 2 由语料库给出了答案:身份 = 代码形状,而不是参数值。
.lpp用源码 sha256 做 key,所以拖动一次滑块就产生一个新 revision, 继承的 importer 因此手工维护了 56 条表项。把顶层const NAME = 值抹掉后再哈希(shape:<sha256>),46 个 revision 收敛成 18 个待审形状 (6 个 ripple → 1 个;14 个 starry-liquify-spin → 1 个)。此后调参数 不再需要改代码,改代码才需要重新审核。 node scripts/lpp-report.mjs= 待办清单本身。语料库要的能力按优先级: warp fields(18 个里 9 个是 warp)、photoSample、source-alpha(fade-veil)、 以及画布相对几何(继承实现写死 960×640,语料里有 1440×720 和 1254²)。 101 项测试通过。
2026-08-02(water.lpp 已归档,但还导入不了——这正是 .lpr 的清单)
- owner 的
water.lpp归档为content/live-projects/sky-floor-water-v1.lpp(5.1MB,stage 1254×1254,document v6)。它的 shell 与已提交的静态地板图 字节相同,所以现在地板已经是这张画,缺的只是动。 - 暂时无法导入,两个硬阻塞:(1) 两支笔都是 warp 能力,而继承来的运行时 warp 路径只认
liquid-warp;"Fade Veil"(把源图透明度从 100% 降到 2%, hold 2.25s / fade 3.2s)没有任何对应 adapter,直接会抛错。(2) warp 几何 写死在 960×640 的 ROOM_WIDTH/HEIGHT 上,而这张是 1254 见方。 - 也就是说:要么在继承渲染器里新增两种 adapter(owner 今天一直在改那个 文件),要么把这份工作一次性做进
.lpr的 behavior registry。第二个 例子恰好证实了 LPR-FORMAT.md 里"拒绝清单就是待办清单"的判断:当前node scripts/compile-lpr.mjs对 4 个项目全部拒绝,缺的能力是 photoSample、curvePath、warp fields、以及 owner 新写的 3 个星空 behavior。
2026-08-02(走廊资源全部预加载,只有房间才 lazy)
- 三张深空全景(默认 + 两头的彩蛋)现在在开场时全部加载并上传到 GPU, 不再按需加载:走廊飞行途中不允许卡顿。房间内容仍然 lazy——进房间本来 就有一次可见的停顿。
- 光预加载不够:解码 ≠ 上传。每张贴图还要过一次
renderer.initTexture, 否则第一次切换仍要在一帧里付约 32MiB 的纹理上传,也就是那一下卡顿。 - 代价:开场多下载约 5.7MB(0.7MB PNG + 两张约 2.5MB JPEG)。
- 新增测试:渲染器引用的每个
assets/space/*文件都必须真实存在——预加载 之后,改名或漏文件会变成开场失败,早发现好过飞到尽头才发现。95 项通过。
2026-08-02(BUG:碰撞体没跟着物件走)
- Owner 报的:编辑器里移动物件、保存后画面对了,碰撞还在原处。原因是 碰撞一直取自 manifest 里那份固定道具表,跟房间文档无关——所以编辑器 改动和已提交的房间文档从来没有影响过碰撞。
- 修法:新增
propObstacles(slot, i, props)接受显式道具表,运行时改用 当前房间文档的 props;ThreeMuseum.roomDocOf(id)暴露渲染器实际使用 的那份文档,两边不可能再不一致。空间缓存的 key 加上文档引用,因此每次 编辑(applyEdit 返回新对象)都会自动重建碰撞体。 - 顺带修好了已提交的两间房:room-a 有 3 件、room-b 有 6 件此前碰撞位置错误, room-b 还多出 2 个"隐形家具"(被删掉的画架仍在挡路)。94 项通过。
2026-08-02(房间的面可以是静态图:The Sky 的水面地板)
- 新增
faces[].image:一个面可以贴静态图片(content/inception/assets/ rooms/下的文件名)而不是 Live Painting。静态面不进 paint scheduler (surfaces/textures 两个数组就是调度器的下标空间),只在进房间时上传一次。 - The Sky 的地板换成 owner 的
sky-floor-water-plants-v1.png(1254×1254, 已登记 provenance;同批的 nine-fish / tech-glass-platform 未入库)。 文件缺失时打警告并退回纯色,不会让房间变黑。92 项通过。
2026-08-02(彩蛋:走廊两头是不同的深空)
- 走廊中段是默认全景
background.png;飞到 -Z 尽头换成background1.jpg, +Z 尽头换成background2.jpg。触发在 |z| ≥ 46(空气墙在 58,所以两头 都真的到得了);换过去就不再换回来——往回走天空保持不变,只有走到 另一头才会再变(owner 2026-08-02,顺带消除了临界点闪烁)。规则是纯函数skyVariantFor(z, current),可单测。 - 切换是 1.8 秒淡入淡出而不是硬切:新天空是叠在旧天空上的第二个球, 透明度 0→1,涨满后成为正式天空;淡入途中再次触发会先把当前这次落地, 避免三张图叠着溶。
- 只有走廊参与判定:口袋房间的世界坐标 z 有几百米,会误触发。
- 两张彩蛋图保持原生 4096×2048,以 JPEG quality 95 在启动时预加载并缓存; 合计下载量由 PNG 母版的 19.4 MB 降至 5.0 MB(-74.1%)。PNG 母版保留作 参考但不进入 Vite 运行包;默认稀疏星空仅 0.75 MB,继续使用 PNG 保护暗部 和小星。进入走廊前完成 GPU 上传,切换时不闪黑也不卡顿;URL 是静态字面量。 95 项通过。
2026-08-02(第一批提交的房间)
- Owner 编辑好的两间房已入库:
room-a.room.json(Van Gogh House,摆位微调) 与room-b.room.json(The Sky,删掉两个画架、长凳挪到 (0.5,-5.5))。 新增测试会遍历content/inception/rooms/*.room.json,逐一校验它们仍然 匹配所命名房间的形状——以后再改世界,不会让 owner 的摆位悄悄退回默认值。
2026-08-02(编辑器的 JSON 现在真的能"提交"了)
- Owner 的工作流(编辑器里摆好 → Download → 拷进项目 → 以后进数据库)之前 落不了地:项目里没有任何地方会读这个文件,运行时只会读 localStorage 草稿或内置默认值——按钮上写的"commit it as the room's document"是空头支票。
- 现在有了:
content/inception/rooms/<slot id>.room.json(附 README 说明 流程)。一条规则决定房间用哪份文档(chooseRoomDoc,世界与编辑器共用): 本地草稿 > 提交的文件 > 内置默认;id 或面数对不上、或校验失败的文档 会被跳过并打警告,绝不半套生效。编辑器的 Revert 现在回到"提交的版本" 而不是内置默认。90 项通过。
2026-08-02(The Sky 把家具摆回来)
- 撤回"穹顶房间不放道具"的决定(owner: 摆回来)。
roomPropsFor对穹顶返回 同一套家具,但把超出地板圆盘的件按自身方向拉回来(只有两盏角落的 lamp,r=8.77 → 6.8),否则它们会站在空处;碰撞体随之生成。 - 校验新增:穹顶房间每件家具都必须站在圆盘上、并且在该半径处的穹顶净高 之下(
sqrt(r² - d²))。89 项通过。 - 已知限制:编辑器仍按 16×16 的方形裁剪坐标,所以在穹顶房间里手动拖动可以 把物件拖出圆盘——等 owner 说的那次重构(物件/背景图 ownership)一起处理。
2026-08-02("The Sky":不贴图的半球)
- room-b 定名 "The Sky",半球面永不贴图——抬头就是博物馆自己的深空 星图。房间只画一个面(地板圆盘),
roomFaceNames对穹顶返回["floor"], 渲染端也不再生成半球网格;那个看不见的半球只决定"能飞到哪"。 - 修掉 owner 报的 bug:切换 256/512/1024 之后穹顶突然出现贴图。两个原因 都修了:(1) 半球不再有画;(2)
applyTextureSize过去用默认文档重建房间, 会丢掉编辑(并在房间换形状后偷偷重画),现在房间记住自己的 doc 并按它重建。 - 另加一道保险:旧形状的草稿会被拒绝(
loadDraftForSlot)。room-b 变穹顶 之前存下的六面草稿如果照旧回放,会画出六个重叠的地板圆盘。89 项通过。
2026-08-02(编辑器:物件名牌 + 编辑时冻结 Live)
- 编辑模式下,每个物件头顶浮出一个名称 + 坐标的标签(精灵,永远面向 镜头,
depthTest:false所以不会被家具挡住)。文字与面板下拉框完全 一致(archetype (x, z)),下拉里选中的那个标签会放大并变金色, 所以"在编辑哪一个"一眼可见。退出编辑即隐藏。 - 进入编辑器时 Live Painting 冻结(
liveScale = 0,停在最后一帧), 退出时恢复到滑块的设定值;编辑中拖动滑块只记设定、不解冻。
2026-08-02(房间有了形状:16×16×8 大厅 + 半球房间)
- 房间不再是 16 的立方体。
ROOM.height = 8,梵高房间 = 16×16×8 的展厅 (四面墙现在是 2:1,lpp 资源由 owner 稍后重做)。 - 另一间梵高房间改名 "The Magic Paintbrush",改为半球空间(半径 8): owner 要在里面画鱼、蝴蝶、马。它只画两个面——地板圆盘 + 半球穹顶 (墙和天花板是同一张画),而不是六面。
- 形状是每个 slot 的属性(
roomShape(slot)),走廊/传送门/入口/返回门 /碰撞体/校验全部改为从"地板 + 形状"推导,不再假设立方体。 - 零重力钳制学会了穹顶:
volumeContains/clampToVolumes支持dome体积,越高横向空间越小,撞壁时沿半径滑回球面而不是卡进包围盒的角。 - 穹顶房间不放道具(弯墙会吞掉家具,且碰撞体为空 = 有画面没碰撞),
validateRoomDoc直接拒绝带道具的穹顶文档。88 项测试通过。 - 下一步(owner 指定):3D 物件与背景图也要有和房间一样的 email ownership —— 单独一次重构,尚未开始。
2026-08-02(三档画质 + 分设备默认)
- 画质从两档改为三档:Low 256 / Medium 512 / High 1024(每面 Live Painting 的贴图边长,不是屏幕 DPI)。桌面默认 High(1024),iPad 默认 Medium(512),已保存的选择永远优先。High 每帧最多只上传 1 个面、刷新率 低于 Medium:1024 面 = 4MB ≈ 21ms 上传(实测 5.3ms/MB),一帧两个必卡。
- 天空 4K 静态图与画质档位无关;
maxPixelRatio才是屏幕 DPI 那一档。 已给天空贴图开启各向异性过滤(走廊尽头是掠射角,默认值会把 4K 糊掉)。
2026-08-02(走廊全长可达)
airWallZ24 → 58:之前只能走到画出来的走廊的 1/4 处,中途像撞上空气墙。 现在整条画出的走廊都能飞,最后一道接缝(56)之后仍留 2 米,尽头依旧不可达 (校验规则"尽头不可达"未变)。
2026-08-02(玻璃与名牌呼吸)
- 走廊玻璃墙+天花板、房间名牌改成与传送门同样的呼吸:从完全透明渐变到 玻璃/满不透明。玻璃 0.09 Hz(约 11 秒一轮,比传送门 0.22 Hz 慢,避免整条 走廊一起闪),名牌与传送门同频同相。
- 地板与墙面同频同相一起呼吸,但保留 1% 不透明度(
minOpacity): 零重力下它是唯一的地面参照,可以淡到只剩痕迹,但不能消失。 - 只有玻璃接缝(trim rings)不参与——玻璃淡出时它是唯一留下的骨架。 实现上"没有
pulseHz就是恒定",由pulseLevel(spec, seconds)纯函数 统一驱动传送门/玻璃/地板/名牌,测试锁定接缝恒为 1、地板与墙面曲线相同。
2. 已确认的产品方向
产品结构不是“2.5D 或 3D”二选一,而是:
真 3D 空间 + 保留原始笔迹的 2D / Live Painting 艺术表面。
2.1 真 3D 世界层
- 极简、非写实的未来科幻博物馆。
- 可行走的入口走廊和传送门。
- 正四面体、立方体及后续多面体房间。
- 桌子、椅子、画板、画框、灯、展台和少量雕塑等低多边形物品。
- 一个虚构、身份安全、可走路的 3D 角色;不使用学生脸部扫描或写实身份。
2.2 作品层
- 扫描画、抠图、普通 Painting。
- 从
.lpp创作源编译出的 Live Painting。 - 作品可以成为墙面画、房间动态背景、传送门表面、画架作品或角色服装纹理。
- 作品保持学生原始笔迹;AI 只能计划、批评和翻译获批行为。
2.3 三种权限层
personal_draft:学生和授权教师可见,可自由试验。class_shared:学生明确共享后班级可见;跨作品关系需双方同意。public_snapshot:策展后生成的不可变、只读、去身份化快照。
共享密码不是可靠的隐私边界。正式实现必须在服务器逐次读取时验证账户角色或高熵邀请 token。
3. 引擎策略
3.1 保留 TypeScript + Vite
TypeScript + Vite 可以承载这个架构。它是应用壳、编辑器、权限流程、AI 审批、文件编译器和测试环境,不等于必须自己写渲染引擎。3D 世界和 2D artwork renderer 都放在明确 adapter 后面。
3.2 Three.js 能否承载
能。Three.js 支持 glTF、骨骼动画、AnimationMixer、CanvasTexture、render target、raycasting、instancing 和自定义 shader。3D 空间与 2D 动态画面之间的交互在技术上没有结构性障碍。
真正的风险不是 TypeScript,而是:
- 如果把相机、碰撞、资源生命周期、LOD、编辑器 gizmo 和序列化都自己拼装,项目会逐渐形成隐形自制引擎;
- 多张 512px Canvas 每帧上传 GPU 会产生纹理上传成本;
- 旧
.lpp中的任意 Function Brush 源码不能直接进入学生或公开运行时。
3.3 为什么加入 Babylon.js 对照
Babylon.js 是更完整的 Web 原生引擎,内建更统一的相机、碰撞、动画、glTF、Inspector、DynamicTexture、RenderTargetTexture,并同时维护 WebGL 与 WebGPU 路径。它仍然使用 TypeScript/JavaScript,也能嵌入普通网页编辑器。
因此不立即把整个项目锁死在 Three.js。先用同一个 world/artwork interface 建两个很小的对照切片:
| 项目 | Three.js | Babylon.js |
|---|---|---|
| 定位 | 灵活的渲染库 | 更完整的 Web 游戏/渲染引擎 |
| 优势 | 控制细、生态大、CanvasTexture 直接 | 生命周期、相机、碰撞、动画和 render target 更标准 |
| 风险 | 需要更多工程胶水 | 更大抽象层,部分底层定制可能更绕 |
| 本项目角色 | 性能与扩展性基线 | 标准引擎首选候选 |
3.4 引擎决策规则
对照场景必须完全相同:
- 一条走廊、一个传送门、一个立方体房间;
- 20 个低多边形静态物品;
- 一个带 idle/walk 的 rigged GLB 角色;
- 键盘和触摸移动;
- 六个整面 512×512 Live Painting 动态表面:房间四面墙 + 地面 + 房顶全部为 Live Painting(门洞墙由三块 UV 分段共享一张画面,跨门洞连续;owner 2026-08-01 指示);
- 同一 world manifest、interaction signals 和性能采样方式。
选择规则:
- 两者都通过且 Babylon.js 的 p95 frame time 不比 Three.js 差超过 10%,默认选择 Babylon.js,以减少长期自制引擎工作。
- 如果 Three.js 的 p95 frame time、内存或纹理上传表现好 15% 以上,或 Babylon 阻碍
.lprGPU renderer,选择 Three.js。 - 如果两者都失败,不先转 Unity/Godot;先定位失败来自旧 artwork renderer、纹理上传还是世界渲染。
- Godot/Unity 只在 Web 原生两条路径都出现结构性失败时重新评估。
引擎选择结果必须写入 ADR,并包含测量设备、浏览器、commit、场景资产版本和原始数据。
4. 免费 3D 资产与角色计划
4.1 结论
免费资产足够,但 Three.js 本身不是模型商店。项目统一使用开放的 GLB/glTF 运行时格式,因此可以使用 Blender、Blockbench、Kenney、Quaternius 和其他合法来源的模型。glTF 是 Khronos 的 royalty-free、runtime-neutral 标准。
4.2 第一版角色
优先候选:Quaternius Universal Base Characters。
- CC0,可用于教育和商业项目;
- 提供 glTF、FBX 和 Blend;
- humanoid rig,适合动画 retarget;
- 包含不同体型和发型;
- 约 13k triangles,适合先做移动端技术切片;
- 已支持眼睛和肤色定制,可扩展分区材质。
必须同时做一个无外部资产的 procedural mannequin fallback:由 capsule/box/sphere 和层级关节组成,支持 idle/walk 和全材质调色。这样角色系统的正确性不依赖某个资产包。
4.3 角色上色分三层
- Pilot:材质分区调色——头发、上衣、下装、鞋、配件;稳定、便宜、适合儿童。
- 第二阶段:角色必须有稳定、非重叠 UV;用 Canvas 生成 clothing paint texture,通过 UV/raycast 在衣服上绘画。
- 后续实验:把
.lpr动态纹理映射到特定服装表面;默认不允许任意动态纹理覆盖脸部和身份敏感部位。
角色资产验收标准:
- GLB/glTF;Y-up、米制单位、pivot/ground 正确;
- idle 和 walk 可循环并平滑切换;
- 不超过 Pilot 约定的三角形、骨骼、材质和纹理预算;
- 非重叠 UV,至少提供明确的可上色 material slots;
- 碰撞 capsule 与视觉 mesh 分离;
- provenance 包含来源、作者、下载日期、原始 URL、license snapshot、修改和 reviewer。
4.4 资产来源政策
- 首选 CC0:Quaternius、Kenney。
- CC BY 可用,但必须保留 attribution 和许可证快照。
- Khronos Sample Assets 主要用于 importer/animation 测试;每个模型许可证不同,不能整库当作 CC0。
- Mixamo 可免费使用角色和动画,但需要 Adobe ID,且不作为可再分发原始资产库的默认来源;使用前单独做许可证和分发边界审核。
- 禁止使用无明确许可证、仅标注“free”、NC 条款与预期公开/商业使用冲突或无法保存 provenance 的资产。
参考来源:
- Quaternius Universal Base Characters: https://quaternius.com/packs/universalbasecharacters.html
- Quaternius Universal Animation Library: https://quaternius.com/packs/universalanimationlibrary.html
- Kenney assets/support: https://kenney.nl/assets · https://kenney.nl/support
- Khronos glTF: https://www.khronos.org/gltf/
- Khronos Sample Assets: https://github.com/KhronosGroup/glTF-Sample-Assets
- Adobe Mixamo FAQ: https://helpx.adobe.com/creative-cloud/faq/mixamo-faq.html
5. .lpp 到标准 2D runtime 的路线
5.1 文件职责
.lpp 继续是可编辑的创作源文件,不直接作为最终公开渲染格式。
新增编译产物:.lpr(Lucas Painting Runtime)。.lpr 是版本化、renderer-neutral、无任意代码的标准 2D runtime 包。
.lpp authoring source
├── 原始图片、mask、笔触和 Function Brush source
├── 编辑器参数、说明和 provenance
└── 允许保留复杂创作信息
│
▼ validate / compile / bake
.lpr runtime package
├── manifest.json
├── textures/*.webp|png|ktx2
├── buffers/marks.bin
├── timeline.json
├── interactions.json
├── provenance.json
└── preview.webp
5.2 .lpr 硬边界
- 不包含 JavaScript、Function Brush source、shader source 或 eval-able expressions。
- 每层、mark、atlas、texture、animation channel 和交互数量都有上限。
- 所有行为由白名单 opcode/behavior graph 表示,例如 translate、rotate、scale、opacity、paletteShift、warpPreset。
- 固定时钟与 seed,保证相同输入可确定性重放。
- 静态 marks、soft glow、mask 和可合并层在编译时烘焙。
- 动态 marks 编码为 typed binary buffers,避免逐帧解析大型 JSON。
- manifest 带 schema version、feature flags、hash、尺寸、预算和 provenance。
- 编译器拒绝越界或未知行为;不能静默降级成任意执行。
5.3 两个 renderer
Canvas2DReferenceRenderer
- 用于编辑器预览、正确性和 fallback;
- 必须与现有审定视觉建立截图基线;
- 优先完成,作为标准行为定义。
GpuArtworkRenderer
- 在最终选择的 3D 引擎同一 GPU context 中,用 instanced quads、shader 和 render target 输出 artwork texture;
- 避免每帧把整张 Canvas 从 CPU 上传到 GPU;
- 输出可直接作为墙面、背景、传送门或服装材质。
两者必须通过同一 conformance fixtures。GPU 版本允许有很小抗锯齿差异,但时间、位置、alpha、颜色和交互结果必须在定义容差内一致。
5.4 性能原则
- 一个中央 frame-budget scheduler 管理全部 Live Painting;每件作品不能自己创建无限 RAF。
- 近处且可见作品最高优先级;远处降频;视野外和其他房间暂停。
- Canvas fallback 只有 dirty 时上传 texture;同一帧限制上传数量。
- 静态内容只渲染一次;大批 marks 使用 atlas/instancing。
- WebGL 2 是 Pilot baseline;WebGPU 是可选加速,不能成为唯一运行路径。
- 所有性能结论必须在 Chromebook、iPad Safari 和普通桌面浏览器验证。
6. 3D 世界与 2D 动态作品交互协议
3D 与 2D 不直接互相调用内部对象。它们只通过版本化协议通信。
6.1 World → Artwork signals
viewer.distanceviewer.enter/viewer.leaveviewer.focusroom.lightportal.openAmountworld.timepeer.event.<approved-id>
信号是有限的 number/boolean/vector/id,按 scope 和频率限制发送;不逐帧序列化自由 JSON。
6.2 Artwork → World events
request.lightPulserequest.soundCuerequest.portalHintrequest.peerSignal.<approved-id>
作品只能发出白名单请求。World rule/teacher-approved interaction graph 决定是否执行,作品不能直接开门、改权限、加载 URL 或修改其他学生资产。
6.3 示例
- 观众靠近画作,
viewer.distance让笔触从静止逐渐呼吸。 - 画作完成一次潮汐循环后请求
lightPulse,房间灯光轻微回应。 - 两位学生批准关系后,A 的鸟只向 B 发出
peer.event.bird-arrived,B 自己的 behavior 决定如何响应。 - Live Painting 可成为房间动态背景,但仍受同一 scheduler、权限和
.lpr预算约束。
7. 分阶段执行
Phase 0 — 保留与隔离基线(已完成)
交付:独立项目身份、禁止旧生产部署、完整继承基线、文档与测试通过。
禁止:清理旧内容、创建线上资源。
Phase 1 — 角色与引擎对照切片
建议新增而不改旧入口:
index.html
src/inception/main.ts
src/inception/world/*
src/inception/artwork/*
src/inception/avatar/*
content/inception/assets/*
docs/benchmarks/ENGINE-BAKEOFF.md
任务:
- 定义最小
WorldEngine、ArtworkSurface、AvatarController接口。 - 建立 Three.js 和 Babylon.js 两个独立 spike entry,使用同一 manifest/fixtures。
- 加入 procedural mannequin 和一套经过 provenance 审核的 CC0 rigged character。
- 实现 idle/walk、键盘/触摸行走、材质分区调色。
- 立方体房间整体包裹 Live Painting:四面墙 + 地面 + 房顶共六个整面 Canvas 动态纹理(门洞墙三块 UV 分段),另有 20 件低模物品和一个传送门。
- 记录 FPS、p50/p95 frame time、CPU artwork time、texture upload time、draw calls、triangles、内存、bundle 和加载时间。
验收:
- 桌面目标 60fps;Pilot 移动设备稳定不低于 30fps。
- 两个 spike 的场景和采样方法一致。
- 写出有原始数据的引擎选择 ADR。
npm run check和新 spike tests 通过。
Phase 2 — .lpr v1 spec、compiler 与参考 renderer
任务:
- 写 JSON Schema、binary layout、budgets、feature flags 和版本策略。
- 实现
.lpp -> .lprdeterministic compiler。 - 把至少三套现有审定
.lppfixtures 编译为.lpr。 - 实现
Canvas2DReferenceRenderer。 - 添加未知 opcode、超量 mark、过大 texture、源码夹带和 zip bomb 防护测试。
- 添加截图/时间序列 conformance tests。
验收:
- 同一输入产生 byte-identical
.lpr。 .lpr中不存在可执行源码。- 三个 fixtures 在定义容差内匹配现有审定渲染。
- 编译失败有明确、儿童编辑器可翻译的错误信息。
Phase 3 — GPU artwork renderer 与最终世界底座
任务:
- 在获选引擎中实现
GpuArtworkRenderer和 render target 输出。 - 用
.lprbuffers 做 atlas/instancing/shader path。 - 实现中央 scheduler、visibility/distance priority、暂停与资源释放。
- 完成走廊、传送门、立方体和正四面体房间。
- 建立 GLB loader、资产预算、碰撞 proxy、材质/纹理缓存与 dispose 规则。
- 实现 3D↔2D signals/events 协议。
验收:
- Phase 1 同场景用
.lprGPU path 达到或超过 Canvas baseline。 - 六个整面动态表面无周期性长帧;隐藏房间停止不必要工作。
- portal/room transition 无明显资源泄漏。
- WebGL2 路径在目标设备通过。
Phase 4 — 儿童创作与空间编辑器
任务:
- 导入扫描画/透明图片,创建普通 Painting 或
.lpp。 - 提供房间模板、物品库、移动/旋转/缩放/吸附和参数化 primitives。
- 把 artwork 放到 wall/frame/easel/background/portal surface。
- 提供行为积木和 3D↔2D interaction graph,不暴露任意代码。
- 角色支持分区调色;UV 服装绘画作为可关闭实验功能。
- 每个操作有 undo/redo、autosave 和个人草稿状态。
验收:
- 学生可从原始画作完成“导入 → 动起来 → 放入房间 → 私有预览”。
- 未批准作品不会进入班级或公开层。
- 编辑器错误不会破坏原始资产或最后可用版本。
Phase 5 — 数据、权限与策展发布
任务:
- 为新产品设计独立 schema、对象存储和访问控制;不复用旧生产数据库。
- 实现
personal_draft、class_shared、public_snapshot服务端授权。 - 跨学生作品关系采用双方同意和稳定版本引用。
- 公开发布生成清理后的 immutable snapshot,不直接公开课堂数据库对象。
- 建立撤回未来使用、版本替换、provenance 和审计记录。
验收:
- 权限测试证明只改前端 URL/flag 不能越权。
- public snapshot 不含真实姓名、邮箱、班级 membership、私密反思、AI 对话或草稿历史。
- 无账户/高熵邀请授权时不能读取 private/class 内容。
Phase 6 — AI planner、critic 与审批
任务:
- AI 输出版本化
BehaviorPlanschema,不输出运行时代码。 - 保存
student_intent、ai_proposal、student_decision、revision。 - Critic 使用学生目标和渲染截图,给出可观察偏差和问题。
- 只有学生点击批准后,translator 才把计划编译为 behavior graph/
.lpr参数。 - 教师可查看变更来源,但 AI 不替学生写最终艺术家陈述。
验收:
- 未批准 proposal 对作品 hash 和世界 manifest 没有影响。
- 所有 AI 输出都经过 schema validation、budget 和内容安全边界。
- 学生可以拒绝、局部接受、撤销和比较修改前后。
Phase 7 — 课程 Pilot 与成果输出
课程建议比例:
- 50% 工作室艺术基础;
- 25% 新媒体、创意编程与简单 3D;
- 25% 艺术史、批评、修订、策展和艺术家陈述。
每位学生成果档案至少包含草图、形式练习、材料实验、原始作品、行为计划、批评记录、一次修订、艺术家陈述和公开选择。
输出:互动只读展览、策展截图和可用于视频编辑的稳定镜头/录制模式。公开或合作方使用另走授权流程。
Phase 8 — 第一版后清理继承内容
只有以下条件全部满足才开始:
- 新入口、
.lprcompiler、世界 runtime、编辑器和权限测试均通过; - 需要保留的 Live Painting fixtures、provenance 和迁移工具已列清单;
- owner 审核删除/归档清单;
- 已有可恢复 tag/branch。
先移动到 legacy/ 或归档分支并验证,再删除不再需要的 Hider/Seeker、Stripe、旧 Worker API、旧 migrations、旧房间和商业发行文档。不得一次性清空源项目。
8. 总体验收条件
第一版完成必须同时满足:
- 可在浏览器进入 3D 走廊、通过传送门、进入至少两种房间并行走。
- 有一个可 idle/walk、可分区调色的身份安全角色。
- 房间有简单桌椅、画板和展台等 3D 物品。
- 至少四幅
.lpr动态作品可成为画、背景或其他表面。 - 3D 与 2D 可通过受控信号互动。
- Chromebook/iPad/桌面达到既定性能闸门。
- 学生能完成私有创作、班级共享和公开快照的完整授权流程。
- AI 不经批准不能修改作品。
- 课程证据包含专业艺术训练、批评和修订,而不只是技术 demo。
- 未清理继承内容前,现有
npm run check继续通过。
English Plan
0. Execution instructions for CC
- Read
AGENTS.md, this plan, anddocs/decisions/0003-engine-bakeoff-and-lpr.mdcompletely before acting. - On the first run, audit the repository and record evidence in “Current state.” Do not assume future work described here already exists.
- Execute phases in order. A phase must satisfy its acceptance criteria before the next phase begins.
- Do not delete inherited Painterly Chameleon code, rooms, assets, tests, or documents. Cleanup begins only after the first release and an owner-approved inventory.
- Do not deploy, create cloud resources, publish a repository, connect real student data, or push to
painterly-source. - Engine selection is an explicit decision gate. Complete the identical Three.js/Babylon.js bake-off before choosing.
- All student- or LLM-authored published data must be declarative, bounded, and validated. Runtime JavaScript, shader source, and model scripts are prohibited.
- Run the inherited full checks after every implementation step and add dedicated tests and benchmarks for new modules. Preserve raw measurements instead of reporting only that something “feels smooth.”
Copyable task prompt for CC:
Follow
AGENTS.mdanddocs/INCEPTION-SPACE-EXECUTION-PLAN.zh-en.md. First audit the gap between the repository and the plan, then execute the next incomplete phase. Do not skip decision gates, delete inherited content, or deploy. Prove completion with files, tests, and benchmark results, and report remaining risks at the end of the phase.
1. Current state
Completed:
- A history-preserving project was forked from Painterly Chameleon.
- Project identity, README, repository rules, and Cloudflare configuration are isolated.
- Production D1, domains, email bindings, rate-limit namespaces, and deployment scripts were not inherited.
painterly-sourceis fetch-only and its push URL is disabled.- Inherited
.lppfiles, the Live Painting renderer, assets, and legacy application are intentionally preserved. - The inherited baseline passes the TypeScript/Vite build,
.lppchecks, and 105 tests;npm auditreports zero vulnerabilities.
Not completed:
- Three.js/Babylon.js is not yet part of the product runtime.
- There is no 3D corridor, room, avatar, or furniture slice.
- The formal
.lprruntime schema/compiler does not exist. - Live Painting has not been measured as a 3D dynamic texture on target devices.
- No visual direction, production backend, or AI provider has been selected.
Audit and progress log
2026-07-31 (first CC run: audit + Phase 1 slice)
- Audit evidence: inherited
npm run checkpassed (105 tests;npm audit0);wrangler.jsonccarries no D1/domain/email/rate-limit binding;git remote -vshowspainterly-sourcepush disabled; three/babylon were absent from dependencies; nosrc/inception/,index.html, ordocs/benchmarks/existed — matching this section's original claims. - Phase 1 slice implemented (purely additive; legacy entries untouched, nothing inherited was deleted):
index.html+src/inception/— shared manifest/interfaces, deterministic movement/collision, a 15-part procedural mannequin (six recolorable zones, idle/walk), Three.js and Babylon.js spikes, four 512×512 Live Painting dynamic textures (fixtures 1a/1b/1c plus a time-offset 1a instance), 20 low-poly props, one portal, keyboard/touch walking, and benchmark sampling with JSON report export.three@0.185.1and@babylonjs/core@9.19.0added; 25 new tests;npm run check= 130 passing. - Bundle evidence (vite build): threeWorld chunk 517.8 kB (gzip 131.0); babylonWorld chunk 1036.5 kB (gzip 246.9) plus ~230 kB lazy shader/loader chunks.
- Still blocking the engine decision gate: desktop/Chromebook/iPad Safari device benchmarks (owner captures per
docs/benchmarks/ENGINE-BAKEOFF.md; raw JSON underdocs/benchmarks/data/); the engine-selection ADR; the CC0 rigged-character download and provenance review (owner approval pending; the procedural mannequin already covers Phase 1 — seecontent/inception/assets/PROVENANCE.md).
2026-07-31 (same-day addendum: owner approved the Quaternius download + provenance registration)
- Quaternius Universal Animation Library [Standard] (CC0) downloaded and committed:
content/inception/assets/quaternius/UAL1_Standard.glb(13,744 triangles, 65 joints, Idle_Loop/Walk_Loop, meter-scale Y-up with feet at the origin, textureless two-material figure). License snapshot and hashes recorded incontent/inception/assets/PROVENANCE.md; structural acceptance automated intests/inception-avatar-glb.test.js(including a GLB-hash ↔ provenance consistency guard). Universal Base Characters [Standard] downloaded and hash-recorded, reserved for the later clothing-paint phase. - Both spikes now default to the GLB avatar: three uses GLTFLoader + AnimationMixer weight blending; babylon uses AssetContainer + AnimationGroup weights (slim glTF-loader registration saves ~166 kB gzip).
?avatar=mannequinforces the procedural mannequin; a GLB load failure falls back automatically and the report'sscene.avatarrecords the truth. Zone recoloring drives both avatar kinds through one API. npm run check= 135 passing (5 new GLB acceptance tests). Bundles with loaders: threeWorld 633.3 kB (gzip 162.4); babylonWorld 1266.3 kB (gzip 302.3). The engine gate now waits only on device benchmarks and the ADR.
2026-08-01 (owner direction: wrap the whole room in Live Painting)
- Scene-spec change (§3.4 updated accordingly): artworks are no longer 2×2 m framed quads hung on walls — every face of the cube room is itself a Live Painting: four walls plus floor and ceiling, six full-face 512×512 dynamic surfaces (the three inherited fixtures each used twice with distinct time offsets). The portal wall shares one texture across three UV-mapped segments so the painting flows around the doorway; all quads render double-sided to neutralize engine plane-winding differences. Six texture uploads per frame — deliberately heavier than the original four-painting scene as a worst-case stress.
- The manifest gained
artworkQuads(surface references, UV-rect validation, every-surface-placed check); both engines' artwork builders became per-surface textures + per-quad planes.npm run check= 136 passing; bundles essentially unchanged (threeWorld 633.6 kB gzip 162.5; babylonWorld 1267.6 kB gzip 302.7). - The ceiling is outside the current camera's view (owner confirmed this does not affect testing); camera changes are separate future work.
2026-08-01 (second batch: MacBook Safari benchmark data + product direction document)
- First device data archived:
docs/benchmarks/data/macbook-safari-{three,babylon}-30s.json. Steady p50/p95: three 51/62 ms, babylon 56/69 ms; p99 248 vs 2114 ms; both engines showed multi-second stalls (worst 8.8 s three, 24.3 s babylon). Consistent with the plan's thesis: the worst-case six-full-face repaint+upload path is the bottleneck and no engine choice removes it —.lpr+ the scheduler do; single-device evidence leans Three.js. Analysis indocs/benchmarks/ENGINE-BAKEOFF.md. - Found and fixed: both reports ran the mannequin fallback — the GLB URL was a dynamic template string, so Vite never emitted the asset and production builds 404'd it. Now a static literal (GLB verified in
dist/) plus a manifest-consistency assertion. - Owner decisions: no further benchmarking for now; the engine ADR waits for the product-shaped scene. Product direction (deep-space museum, endless glass corridor with side portals, per-room loading, zero-g free view, desktop-high/mobile-low DPI, WebSocket multiplayer, Codex on art tuning / CC on main program, future gameplay/poses/PropHunt) recorded in
docs/SPACE-MUSEUM-DIRECTION.md(English). - Core executed the same day (
index.html?world=museum, Three.js-first; the engine ADR stays open): deterministic procedural starfield skybox (NASA SVS swap pending download approval), endless glass corridor (air walls + portals on both side walls, 2 open rooms + 4 sealed slots), per-room streaming (only the occupied room's six faces repaint), zero-g flight with first/third-person toggle (V), floating avatar (GLB Swim_Idle_Loop / mannequin fallback), desktop-dpr2 / mobile-dpr1 policy. Multiplayer and all [FUTURE] items remain design-only.npm run check= 145 passing (9 new flight/museum tests). - Owner QA revision (same day): rooms became fully detached pocket spaces — portals teleport on membrane touch (fixing "can't enter": the inset corridor/throat/room passable spaces had a 0.3 m disconnect), the corridor glass is sealed (wall crossing impossible by construction), room content loads only on entry and unloads on return, each room holds a return portal, all portals sit at glass-segment midpoints (validation forbids seams), the third-person camera clamps inside the current space, and flight feel gained +20% accel / −20% damping.
npm run check= 146 passing. - Owner QA r2 (2026-08-01): fixed floor clipping (volumes inset top/bottom by BODY_CLEARANCE 1.05 m; the flight position is the body center); fixed the REAL portal-on-seam cause (rendered trim rings started at -60, off the segment grid — renderer and validation now share trimRingPositions()); desktop look switched to pointer lock (Esc frees the cursor); movement became horizontal-only WASD + Space rising/diving by gaze; iPad gained a virtual joystick plus Jump/Menu buttons; corridor portals carry nameplates (test rooms "Van Gogh House"); in-room return portals moved to the floor. GLB T-pose under investigation via a new HUD animT clock.
npm run check= 149 passing. - Owner QA r3 (2026-08-02): root cause confirmed as AVATAR SIZE — the figure shrank to 1/3 (AVATAR_SCALE, ~0.6 m) and every clearance/camera offset now derives from it (BODY_CLEARANCE ≈0.38 m): walking hugs the floor, flight reaches the ceiling, room entry lands feet-on-paint. Portal triggers now measure distance to the membrane RECTANGLE (a small avatar at the door's foot triggers it). Animation state machine: Walk_Loop for horizontal movement, Swim while Space is held, Idle at rest, weight-blended. The bake-off furniture set (14 pieces, monumental next to the tiny figure) returned to every room. Pixel answer: 512² per face (≈32 px/m on a 16 m wall); new ROOM.artworkTextureSize knob plus
?arttex=768|1024for live trials.npm run check= 150 passing.
2026-08-02 (owner QA r4 passed + Phase 8 cleanup)
- QA passed (1024 art looks and runs well on desktop and iPad). The default rose to 1024²/face; the Menu gained High/Low graphics presets (1024²+dpr2 / 512²+dpr1, remembered in localStorage) and Esc opens that same Menu on desktop. Vertical control now pushes AWAY from the gaze (look down + Space rises, look up + Space sinks); the touch button is "Push"; sinking is suppressed at the floor and the renderer pins the soles above the floor plane so no pose clips through.
- Phase 8 cleanup executed (owner approved): every inherited piece unrelated to the new product was deleted — the painterly game entries, UI, i18n, Worker, Stripe, store, security, migrations, room/prop assets, their tests, and the commercial docs. Kept: the Live Painting renderer (
src/game/curatedLivePainting.ts), its three shell photos and runtime JSON, the three.lppauthoring sources (Phase 2.lprinputs), and provenance. The entry becameindex.html(was inception.html);wrangler.jsoncdropped the deleted worker main; tests went 150 → 51 (the removed ones covered removed code). Everything deleted was git-tracked and restorable withgit restore; the new work is untracked and untouched. - Owner QA r5 (same day): height is now fully decoupled from the view — Space became a modifier on the forward axis (W/↑+Space rises, S/↓+Space sinks, Space alone does nothing, and the forward axis stops moving you horizontally while it is held; joystick + Push behaves identically), extracted as the pure
verticalFromForward(). Room props are SOLID now: footprint plus height become world AABBs and the flight step pushes the body out along the shallowest axis (land on low props, blocked by tall ones); validation forbids a prop from blocking the entry or return door.npm run check= 53 passing. - Owner QA r6 (same day, including one rework): flight feel reverted to accel 7 / damping 2.2 per owner (corridor and rooms share one tuning). My first attempt at the floor complaint went the wrong way (re-derived clearances plus a contact shadow); the owner called it "extremely weird", so all of it was reverted and the geometry is back to the state that passed QA. Real cause and final rule: holding Space while standing on the floor drove
floatActivestraight from "is the key down", so the avatar switched to the prone float clip while its center stayed at floor height — the prone model then intersected the floor. Implemented per the owner's rule instead: on the floor, Space does nothing at all — no velocity, no movement, no pose change (floating = vertical !== 0); the lowest point of a descent is the floor. The?manual=1diagnosis hook (window.__museum.step()/probe(): synthetic-clock stepping plus posed skeleton readback) was kept — it mounts only under that flag and does not affect normal play.npm run check= 53 passing. - Owner's editor question is answered in
docs/SPACE-MUSEUM-DIRECTION.md§6b: Blockbench for models plus the Three.js Editor for scene/collider boxes, imported into the manifest by a small converter. - Owner QA (2026-08-03): the original Lucas Academy Baby Astronaut is now the default museum GLB (18,280 triangles, 7 joints, about 3.36 MiB, three embedded textures for suit fabric, painted spherical face features, and alpha hair; no face photo or scan). Its state machine blends
Idle_Loop,Walk_Loop,Float_Loop, andDive_Loopfrom actual movement direction. The future student-authoring route is Nomad Sculpt or Blender Sculpt for form, then Blender for retopology, UV/material cleanup, rigging, four named loops and reviewed GLB export; §6b records the Pilot budgets and identity boundary. - Visual revision (2026-08-03): the owner's fine elongated hair reference was rebuilt as one overhead texture with short front strands, continuous side coverage, longer rear strands and one crown whorl. It maps from the top pole across one continuous variable-length spherical shell; all rear/top repair patches were removed. Its magenta background was removed and strand tones baked dark brown. The stylized eyes, nose and mouth are likewise painted on a concentric front spherical patch, so there are no floating cards or protruding plastic facial features. A separately recolorable modeled green bow was added. The oxygen bubble remains visibly pale pink and translucent. Runtime scale changed from 1/3 to 1/6, with collision and vertical clearances derived from that same setting.
9. Next-step roadmap and confirmed decisions (2026-08-02)
Phase 1 is essentially closed (Codex delivered the deep-space skybox with a provenance entry). The order below is by unlocked surface per unit of work; the decisions are the owner's, 2026-08-02.
| Order | Item | Status / ownership | Why here |
|---|---|---|---|
| 1 | Lucas Account, one shared login service | built in the separate playground/lucas-account repo (10 tests + wrangler dry-run green; never deployed, no cloud resource created) | Editor saving, multiplayer identity, and the Render move all wait on it, and it collapses the Painterly / art-lab / lucas-academy OTP duplicates. Design: docs/decisions/0004-lucas-account-service.md |
| 2 | Editor MVP | first cut done (sign-in, room documents, rearrange / swap paintings / export JSON; local drafts, no backend) | Start local-first (place, tune, export JSON) with no backend; hang save/publish off the login when it lands. Allowlist lives in a config file ([email removed]) and must be re-checked server-side on any write |
| 3 | Render deployment | live 2026-08-02 (is.lucasacademy.org + inception-space-api + museum-db; runbook docs/DEPLOY-RENDER.md) | The museum is static Vite output; follow snake-lab/render.yaml. Mail and login stay in the Cloudflare Worker |
| 4 | WebSocket multiplayer presence | first cut done 2026-08-03 (transform-only presence in the API's own process; see that day's audit entry; owner verifies on devices after deploying) | Recommend Node + ws on Render (the proven snake-lab pattern) over Cloudflare Durable Objects, now that the app lives on Render; depends on item 1 for identity |
| 5 | .lpr compiler + central scheduler (plan Phases 2–3, the "performance work") | not started | Not urgent: only the occupied room paints and 1024² measured smooth. But it is the storage format for student work, so it must land before a class produces content at scale |
| 6 | Public snapshots + search/AI discoverability | in progress (room text twins + llms.txt + sitemap built 2026-08-03; the /docs/ documentation site added the same day; per-artwork public snapshots still missing) | New audience requirement in docs/DISCOVERY-SEO-GEO.md: the 3D canvas is invisible to crawlers and assistants, so every public room/artwork needs a text twin, structured data, and llms.txt. Privacy first — anything that is not a curated snapshot stays noindex |
Small items alongside: close the engine-selection ADR (evidence leans Three.js and the museum is Three-only in practice); keep the Babylon spike frozen rather than evolving it.
Confirmed decisions (owner 2026-08-02):
- Account service: separate repo, separate domain, separate deploy; Inception Space is the only client for now.
- JWT: the owner asked for permanent tokens. Recorded as one-year expiry plus
kidkey rotation — the JWT is a browser credential (the Render secret holds the signing key, not the user's token), so a literally permanent token could never be withdrawn after a lost laptop; rotating the key revokes everything at once. - Search engines and AI engines are first-class audiences, not a post-launch patch (the owner nearly lost this requirement when the inherited marketing docs were deleted).
2026-08-02 (Phase 2 begins: .lpr v1 spec, compiler, reference renderer)
- New
src/inception/lpr/:schema.js(opcode whitelist, parameter ranges, budgets, capability negotiation),buffers.js(marks packed as eight Float32 columns plus six byte columns instead of per-mark JSON objects),behaviors.js(pure per-mark functions — this file IS the specification of motion, and the GPU renderer will be ported from it and judged against the same fixtures),compile.js(curated JSON →.lprwith a deterministic hash),canvasRenderer.js(the Canvas2D reference renderer, advertising only themarkscapability). Spec:docs/LPR-FORMAT.md. - The §5.2 boundaries are real: no executable content; budgets (20 000 marks / 400 strokes / 8 layers / 4096 px / 64 behaviors); a fixed clock with baked seeds; and refusal instead of silent degradation — in strict mode all three inherited paintings are rejected with the precise reason (ripple/growth need photo sampling, curve-current needs a path buffer, liquid-warp displaces the whole surface).
--partialbuilds a package anyway and records every omission in its ownrefusalslist. - Found a real bug:
JSON.stringify(obj, keysArray)filters those keys at every depth, so nested parameters never reached the digest and two different packages hashed identically. Replaced with a recursive canonical serialization, with tests pinning that changing a parameter — or moving a single mark — changes the hash. - Added
npm run lpr:compileandlpr:verify; the verify step is part ofnpm run check, so a source edited without recompiling, or a compiler change that quietly alters output, fails the build. Packages are committed undercontent/lpr/.npm run check= 81 passing. - Same day, the owner paused Phase 2:
.lprmust meet.lppseamlessly,.lppis the extensible side, and he is building real examples first. Solpr:verifycame out ofnpm run check(sources are about to change often and a stale-package error would only block him) and none of the.lprcode is wired into the runtime — the inherited renderer still draws every room, so the pause costs nothing. Two questions to settle before resuming, recorded indocs/LPR-FORMAT.md: (1) one seam — the compiler should read.lppdirectly rather than the intermediate curated JSON; (2) extensibility without breakage —.lppgrows brushes freely, so.lprneeds a behavior registry keyed by id and version plus a rule for unknown behaviors ("refuse" is safe but too blunt once authoring moves fast). While the examples are being made, the strict-mode refusal list is the backlog: every linenode scripts/compile-lpr.mjsprints is a capability real art needs and the format lacks.
2026-08-02 (owner QA: portals and the floor)
- Portals lost their frames (corridor doorways and the in-room return door alike) and the membrane now stands on the floor — its centre is simply half its own height instead of being lifted 0.35/0.4 m. The pulse can now vanish completely:
minLevel0, a slower 0.22 Hz breath, and opacity carries it (the material also goes invisible at the bottom of the cycle rather than merely dimming). - Fixed "the floor disappears from some angles": a tint plane sat 4 cm above the glass floor, close enough to be coplanar for the depth sort, so the two swapped order per frame and the floor blinked out. The two are now one surface (
glassFloor, slightly denser so it still reads as ground in zero-g) and the transparent shell has fixedrenderOrder(floor -3, shell -2, portals 2) instead of relying on distance sorting.npm run check= 81 passing.
2026-08-02 (Live switch, and the seam for a live sky and live portals)
- The Menu gained a Live effects slider (0 % freezes every painting on its last frame, 100 % is full rate, the middle simply lowers the refresh rate), remembered in localStorage, with
?live=0..1so a benchmark can pin it. It governs every Live Painting, not just the six room faces. - The owner subsequently chose not to use Live Painting for space. Runtime now loads a 4096×2048 static 2:1 panorama through the generic filename
background.png; it uploads once at startup, takes no paint-scheduler turn, and ignores the Live slider.LIVE_TARGETS.skyis null again. The v5.lpp, inert adapters, and exact shell remain only as historical authoring/audit material until there is a specific future motion concept. The decoded 4K texture is about 32 MiB before mipmaps; anything above 4K requires iPad memory and startup testing first.
2026-08-14 (the editor learns the pointer: click an object to select it, drag it to move it; only the selected object wears a tag) <- please QA
- One tag, not twelve (owner 2026-08-13: "too many tags on the screen"): three places each decided label visibility on their own — the group flag, the floor-mode sweep, the selection tint — and editing tagged every object at once. They now all funnel through one
applyLabelStateinthreeMuseum.ts: a tag shows only while editing, only on the SELECTED object, and only when that object is really in the room (the open floor's rule is kept). Verified in The Sky: 12 objects, one amber tag, and it follows the dropdown. - Click-to-select and drag-to-move are EXTRA INPUTS on the same editor (owner: not a replacement; a switchable mode can still be cut in front later). While editing, the canvas has no other job — look lives on its pad, pointer lock is released, touch steering stands down — so Pointer Events serve mouse, pen, and iPad finger through one path. A press inside 6 px stays a click (selection only); past it, the object slides live.
- A drag previews on the holder and commits ONCE on drop — the same relative
moveedit an arrow-key nudge makes, through the sameapplyEdit/validateRoomDoc/clamp path: one undo entry, one draft save, one rebuild (verified: one U returns the whole drag). A cancelled drag walks the preview home. A moving prop's motion base follows the preview, orsetWorldTimewould snap the held object back every frame. - Picking: rays test only the room's
floorPropssubtree, so walls, paintings, tags, doors, the console orb, and avatars can never steal a click; hits resolve up to the keyed holder (userData.propKey); an "open" floor hides its props from the ray exactly as from the eye. - The drag geometry that survived contact with the room: the first cut intersected a horizontal plane at the object's feet, then at the grab height — both die at this camera, because a third-scale avatar's eye sits BELOW most furniture and a plane above the eye is unreachable the moment the pointer crosses the horizon. The scheme that works everywhere: sample the pointer's ray at the grab's own camera depth and keep only x/z — continuous at every attitude zero-g allows, and the room kind's own
clampPropbounds every step, so a wild swing cannot fling the object. - The math lives in
src/inception/editor/dragMove.js, pure and node-tested (grab-offset keeps the grip; the clamp bounds every step; one drop = one delta; a dome drop lands where the preview showed, through the realapplyEdit). 533 passing. - Known limits, deliberate: the wall boards keep their own elevation editor (a wall is a different drag plane); the console orb stays dropdown-only; the tag's coordinates go stale during a drag and true up on drop (owner: fine).
2026-08-06 (the landing page's three sizes, full screen on entering, and a blinking prompt in Space Travel) <- please QA
- Landing page: with one official room, "Choose a room" asks nothing — the card section is
hiddenand the script only builds and reveals it whenROOM_CATALOG.length > 1(a second room is still a data change). Three sizes now: big "Lucas Academy Inception Space", medium "Deep-Space Museum" (a newsubtitlefield on the room, so with more rooms the subtitle simply names the selected one), small "Free admission — step inside art made by kids, for kids, and with kids." The count line dropped the room number. - The paint button: the studio has always required an account (the gate in
avatarStudio), so the door says so — signed out it reads "Sign in to paint 🔒", signed in it goes back to "Paint <name>". The test is the studio's owncurrentSession()?.email(it honours expiry and the local dev shortcut), re-read on every label update, so signing in inside the studio and pressing Back comes home to an unlocked button. - Full screen on entering: no browser grants it unprompted — it wants a gesture, and the landing page's click was spent navigating here. So the first touch, click or key inside the museum carries the request, once only (leaving via Esc or the Menu never drags you back in), and
?debug&fullscreen=0opts out. The Menu's Full screen button now shares the sameFULLSCREENhelper (webkit prefix, no button where there is no support), so the two can no longer disagree. On the desktop that first click is asking for the pointer lock as well, and a browser may drop the lock while the viewport rearranges — which the museum reads as "Esc", and would have opened the Menu in the visitor's face. So a 1.2 s settle window after the request makes a lost cursor mean "click again" instead of "pause" (fullscreenSettleUntil); nothing else about Esc changed. - Space Travel's prompt finally reaches the desktop — on the status line, in the words it always used. A blinking centre-screen prompt was built for it twice and withdrawn both times on the owner's call (2026-08-07): the wording stays
Space Travel — tap to fly onon touch andSpace Travel — press Space to fly onon the desktop, on the status line where the iPad has always shown it. No new element, no blink. - The desktop had never shown that sentence at all, and not by design: choosing the ride runs
closeFloorDialog()first, which hands the desktop back its pointer lock, and thepointerlockchangehandler blanks the status line as the lock engages — a beat AFTERstartSpaceTravel()wrote it. Touch has no pointer lock, which is why only the iPad ever displayed it. So the status line can now be held:holdStatusLine()records what the world insists on, and the lock restores that instead of blanking (heldStatus ?? ""). The ride holds its line while it waits and releases it the moment it flies on; every other use of the status line, including "Click to capture the mouse", is untouched. npm run checkgreen (286 tests after the owner's parallel work). Not verified on a real device: clicks in the pane confirmed the arming (exactly one fullscreen request on the first gesture, none on the second), but the pane is a nested document where the browser refuses both fullscreen AND pointer lock — and the pointer lock IS the mechanism behind the missing desktop line, so that fix is reasoned from the code path, not observed. Please confirm on hardware: the desktop line during a ride, the iPad full screen, and that entering no longer opens the Menu.
2026-08-07 (a guest saw the room but not the easel or the horse: the registry was answering the wrong question) <- please QA
- First, the deployed site was verified properly, because my previous diagnosis was wrong. I had reported that the static site never rebuilt. It had:
museumMain-BhCb3DGI.jsonis.lucasacademy.orgis byte-identical (308,266 B) to a local build of64556f5and contains the read path. My grep had been reading the 46 KB entry file — the museum is a separate lazy chunk. Two more false alarms of my own making, recorded so the next session does not repeat them:/api/worlds/**museum**/...404s because the world id isspace-museum, and the headless pane's network panel does not record cross-origin fetches at all (instrumentwindow.fetchinstead). - Verified end to end as a guest, driving the deployed museum headlessly with
?debug&manual=1: walking into room-a issuesGET /api/worlds/space-museum/rooms/room-a-> 200, and both production rooms differ from the repository files (room-a carries propsp4/p5the repo does not; room-b'sp5is rotated-4.977against the repo's-1.5708). The read path is live and correct. - The real bug, and it is the ownership model's:
GET /api/objectsreturned only the objects the viewer may USE, and the renderer learns where a GLB object's bytes are from exactly that list.easelArtandpaintedHorseare private to the owner, so a guest was never told their addresses and the room came up without them — while the two student houses beside them appeared, because a code-built object needs no bytes. The owner saw a complete room; everybody else saw a half-furnished one. - Seeing and using are now two answers. The registry names EVERY object and carries
usable; ownership sets the flag.offeredObjectsrefusesusable: false(and an older API that omits the field still means usable, so a mid-deploy browser does not get an empty editor). The boundary is unchanged and is still the write path:server/rooms.jsrefuses a document naming an archetype its writer may not use, computed fromlistObjectsFor, never from what was listed. This does publish the bucket URL of a private object to anyone who asks — the bucket is public-read by design (docs/OBJECT-LIBRARY.md), and the alternative, resolving bytes per room, buys obscurity rather than protection. - The owner then named the rule properly, and it is a wording fix, not a schema one: "the 'private' I meant is actually usability (only owner can use it), and visibility should be: all objects are visible by all." So
visibilityis no longer published by either asset registry — the column stays, no migration, and the word is simply wrong for an asset. It still means what it says for a ROOM, where private really does hide the document. Recorded as an ADR-0007 amendment. - And the same for avatars, which needed it more.
GET /api/avatarsnow lists every avatar withusable, because presence draws OTHER people: an avatar a visitor cannot list is a peer a visitor cannot see. That exposed a real hole — for avatars the filtered picker WAS the only rule, nothing server-side enforced it.POST /api/paintingsnow refusesavatar_not_yours, since painting an avatar is the write that wearing one amounts to. An avatar the registry has never heard of still passes: a build/database mismatch is not a verdict. - A second cause of the same symptom, removed: which FILE a GLB object is belongs to the build, but the row was only ever written on insert — so the painted horse's re-export (the one WITH the animation) left production pointing at the previous upload, repairable only by an
UPDATEsomebody had to remember. Boot now re-points asource: 'r2'row at the ref the build names, and says so in the log. Content-addressed names make it safe: a new export is a new file beside the old one. Ownership is still never touched. - All three GLBs verified live in the bucket at the refs the build names (
paintedhorse-9d0a735819f9318 KB,easel-c7fb1f1f7edf595 KB,nativity-c22216b50f9a877 KB). - Still owed by the owner, and now the only one left: the museum's parametric easel is marked private in production from my 2026-08-07 id collision —
UPDATE objects SET visibility='public', owners=ARRAY[]::TEXT[] WHERE id='easel';. Boot repairs itssource/refbut deliberately never its ownership. - 4 new tests, 3 rewritten (everyone sees every object while ownership decides who may build with one; a look-but-not-touch object is listed with its bytes and kept out of the editor; a re-exported GLB follows the build without SQL; an avatar somebody else owns is listed and still not on offer; you cannot paint an avatar that is not yours to wear). 304 passing, typecheck and build clean. Not deployed.
- "Local still not showing it" (owner, later the same day) — two causes on the laptop, neither in the tree. Probed the RUNNING local API on :8788 without touching it: (1) it is the old build — anonymous
/api/objectsstill answered 8 public ids, nousable; (2) it has noOBJECT_CDN_BASE, so even a listed GLB resolvedurl: null. The env default is now the live public bucket (||not??, so the blueprint's oldvalue: ""accident class also lands on the default) — a laptop needs zero setup. "Unset means inert" was right only while the bucket did not exist. - Verified end to end on throwaway twins (API :8799 with the owner's EXACT saved room-a, Vite :5174) so the owner's processes stayed up: the museum asked
/api/objects+ the room doc and fetched BOTH GLB urls — blocked only by bucket CORS, which allowslocalhost:5173(confirmed with a curl per origin: 5173 getsaccess-control-allow-origin, 5174 does not). So on the real port the loop closes. The one remaining step is the owner's: restart the local API process so it runs today's code. The Vite dev server needs nothing — it serves the current source live. 304 passing.
2026-08-07 (a saved room is finally the room everyone sees: the read path reaches the database) <- please QA
- The owner's "others can't see my saved setting": the write half has worked since 08-06 — the PUT stores a document and production really does hold two of them — and nothing ever read it back. A room was assembled from this browser's draft, then from the repository file inlined into the JS bundle at build time, then from the code default. So an owner saw their own draft and everyone else saw the last build.
- ADR-0007's new "How a room is read" is now built: Postgres is the source of truth; the repository file is a seed (boot inserts only rooms with no row, never overwriting a saved one); the server keeps documents in memory (write-through on save, read-through on a miss, 30 s TTL — an optimisation, with Redis as the answer if there is ever more than one instance); the client reads a room as it enters (the transit ride covers the request, so it costs nothing anyone can feel); an unanswerable question falls back to the bundled copy, because the museum must still open with no API; and a successful save clears the local draft, without which an owner keeps seeing only their own copy — precisely what hid this for a day.
- The floor is a room setting now (owner: "in general floor should be in room setting as well"): the document carries
floor, absent meaning the default so no existing document changes, and the editor cycles it. The orb's live switching and the room-wide vote stay ephemeral — The Sky's console is an affordance, not stored state. Order on entry: this visitor's own choice for this room → the room's own floor → the museum default. - Private rooms are unchanged (owner: "private just means private view, not private data"), so every committed file still ships in the bundle; the API's own read still applies
authorizeRead. - Verified through the real client with no draft and no personal choice: a local API serving a room-b that differs from the repository (glass floor, one prop) makes the museum open on the glass floor — it is reading the database's copy, not its bundle.
- Dev parity fixed on the way: memory mode seeds rooms too. Without it a laptop's API answered "no such room" for every room and the client quietly fell back to its bundle — the exact shape of the bug being fixed.
- 6 new tests (the repository seeds are readable and valid; what one person saves the next person reads; four kinds of unanswerable question fall back quietly; a private room still needs its owner or pass; the room floor's read, write and absent-by-default). 300 passing.
- One pre-existing mismatch noticed, not touched:
npm run rooms:exportwrites tocontent/rooms/while the seed readscontent/inception/rooms/. The backup script and the seed directory should agree.
2026-08-07 (the horse gallops; a placement can be resized; and the editor's jumping selection) <- please QA
- The jumping selection (owner: "select easel to move, it selects the horse automatically") — found properly on the second pass. My first guess was the focused
<select>eating arrow keys; blurring it did not fix anything. The real cause is inapplyEdit:addwroteselectedinto the DOCUMENT, and every other action spread it forward with{...doc}, whilecommit()ranif (next.selected) this.select(...)on every edit — so moving anything re-selected the last object added. The owner added the horse last, so nudging the easel jumped to the horse. Fixed by rebuilding every action from a document withoutselected(onlyaddsets a fresh one) and having the editor CONSUME it — it never reaches a draft, a committed JSON, or the API, because the cursor is the editor's state and not the room's. The two blurs stay; they are correct on their own terms. - Resize range widened to 0.1–10, as asked.
- The horse's animation: my export spec deleted it. The spec said "no animation clips (props do not move)", so the script exported with
export_animations=Falseand removed the armature — the bucket's copy has 0 clips and 0 joints while the owner's Blender file always hadmetarigAction(3.08 s, 147 tracks, 49 joints). The spec now allows animation, with a joint budget. - I did the re-export myself. (I said earlier that Blender not being on PATH stopped me — wrong:
/Applications/Blender.app/Contents/MacOS/Blender -b -Pruns it.) It keeps the rig and clip, scales to a real-world 1.70 m from the tallest moment of the cycle, turns the nose to glTF +Z to match the still version, and grounds the horse on the lowest hoof of the whole cycle — a gallop lifts each hoof in turn, so grounding one frame sinks the others. The transform rides on the root object; baking it into vertices would break the skinning. Measured as the renderer sees a rigged model: 0.51 m wide, 2.11 m long, 1.70 m tall, no frame below the floor. File at~/Desktop/objects-export/paintedHorse-animated.glb. - The renderer keeps each template's clips and gives every PLACEMENT its own
AnimationMixer, phased from the prop's key so two horses never move in lockstep; skinned models clone withSkeletonUtils.clone, because a plain clone shares the template's skeleton and every copy moves as one. Verified on the real file: two placements rotated 6.96 and 10.24 radians in the same 0.4 s and differ by 4.83. - Size is per placement:
props[].scale, absent at natural size so older documents are untouched, bounded 0.1–10, - / = in the editor with×1.5shown in the list. Collision scales with it, including the exact rotated footprint the flight resolver uses — a shrunken object must not keep an invisible full-size wall. - Also:
objects:plannow reports clips and joints and the budget gainedjoints ≤ 64; and re-exporting an object that IS a GLB object no longer trips the id guard (only a collision with parametric furniture does). - 294 passing.
2026-08-07 (visible locally, missing on the deployed site: the blueprint blanked the variable) <- please QA
- Root cause, in the render.yaml I wrote:
OBJECT_CDN_BASEwas declared asvalue: "". Render MANAGES every env var a blueprint gives a value to and re-applies it on each push — so the bucket address the deploy task set by hand in the dashboard was silently blanked by the next push. On the deployed API everysource:'r2'row then resolved tourl: null, the new rule correctly declined to offer an object it could not draw, and nothing appeared. A laptop, running its own API, was unaffected. - Fixed to
sync: false— the key is declared, the value belongs to the dashboard — withtests/deploy-blueprint.test.jsholding two rules: this key must besync: falsewith no value, andvalue: ""must not appear anywhere in the blueprint. DEPLOY-RENDER.md gains "a declared value is a MANAGED value". - How to tell which half is missing, run in the deployed museum's console while signed in:
fetch(API + "/api/objects", { headers: { authorization: "Bearer " + localStorage.getItem("inception.authToken") } })— no such rows means the API has not redeployed with the new seeds; rows withurl: nullmeans the variable is still unset; rows with a url means it should draw. - 292 passing.
2026-08-07 (the owner saw the option on localhost but no object — two real problems) <- please QA
- Problem 1, my design error: when the registry could not be asked I offered the WHOLE catalog. That is right for parametric furniture, which always draws, and wrong for a GLB object: no URL means no bytes, so the option appeared, the room stayed empty, and nothing explained why.
offeredObjectsnow takes aneedsBytespredicate — a GLB object is offered only when the registry both names it and gives it a url — while the parametric fallback is unchanged. - Problem 2: the bucket refs existed only in the production database, so a local API's in-memory store could never resolve these objects. The refs now live in the manifest beside their archetypes (
blobRef). They are content-addressed and the bucket is public-read, so naming them in the build gives nothing away, and any fresh database — or a laptop — resolves them with no hand-written SQL. Good side effect: the next production deploy now creates the correcteaselArtrow by itself, leaving you just one statement (restoringeaselto public/code/NULL). - To see them on a laptop:
OBJECT_CDN_BASE=https://objects.lucasacademy.org npm run api, and sign in with an emailed code rather than the dev shortcut — a dev session carries no signed token, so the API answers as a guest. Verified: as the owner both URLs resolve, as a guest only the eight public pieces are offered. - 2 new tests (a GLB object offered only with a url; the seeded r2 row's shape). 290 passing.
2026-08-07 (GLB objects reach a room; and a live permissions bug fixed on the way) <- please QA
- The live bug: an
easelid collision, and it was mine. The operator steps I wrote registered the GLB undereasel, which is already a parametric archetype the build ships, soON CONFLICT DO UPDATErewrote the museum's own easel into a private R2 object: prod's public list went from eight objects to seven, nobody but the owner was offered an easel, and a co-owner could no longer save a room containing one. Fixed in three places: (1)scripts/objects.mjsrefuses an id the manifest already uses; (2) boot callsreconcileCodeObject, which resets the contradiction "a code-built object pointing at a bucket" back to code/NULL — and deliberately does not touchowners/visibility, because granting somebody an object is a database decision a deploy must never silently revoke; (3) the GLB easel is renamedeaselArt. One SQL statement is still yours to run — I have no connection string for the production database. - The GLB prop path is built.
PROP_ARCHETYPESgainseaselArtandnativitywithcustomRenderer: "glb", declaring label, collision footprint and height in code — collision, document validation and the editor's list all run before a byte is fetched — while the URL comes from the object registry at runtime.threeMuseumloads one template per archetype and clones it per placement, caps its textures to 1024 px exactly as it caps an avatar's, and marks every cloned nodeuserData.sharedAssetso BOTH room teardown paths leave the shared geometry and materials alone (the lesson remote avatars taught on 2026-08-05: the first room to unload would have emptied every later one). The registry is read once before any room loads, raced against a 2.5 s timer so a sleeping free instance cannot stall the opening. - Verified against the live bucket: both objects fetched cross-origin by the museum's own GLTFLoader in ~360 ms — easelArt 210 triangles, 1024 px textures, y 0.00…2.00, 1.02 × 1.05 m; nativity 20,000 triangles, 2048 px, y 0.00…0.306, 0.47 × 0.50 m — byte counts matching the reviewed exports. In the room, an A/B: an empty room draws 119,170 triangles, and with both objects placed 159,950 (+40,420). This scene's counter reflects two passes per frame — one parametric orb, estimated at 180 triangles by the manifest, measured +360 — so +40,420 is exactly one easel and one nativity, once each.
- 6 new tests (the collision's self-repair and its refusal to touch ownership; a GLB archetype declaring everything a room needs without its bytes;
archetypeHeightreading the declared height; a room document placing them with working collision; only GLB objects markedexpectsBlob). 289 passing.
2026-08-07 (the R2 layer is written, and deliberately inert; the two GLBs stay out until the bucket exists) <- the owner creates the bucket
- The owner's call: wait for R2 before putting the two objects in. So this round is only the code R2 needs, and it touches no cloud resource (AGENTS.md forbids creating hosted resources without the owner; this session also has no Cloudflare authorization).
- One thing worth stating plainly: R2 had no code in this repository at all — only the two columns and comments added the day before. The API runs on Render, so R2 also needs a bucket, a domain, CORS (three.js fetches a GLB by XHR; cross-origin without CORS is an empty room), and one server-side
OBJECT_CDN_BASE. - What landed:
world/objectAssets.js—OBJECT_BUDGET(25,000 triangles / 4 MB / 2048 px),objectRefIsSafe(onlyobjects/*.glb, nothing that climbs out of the prefix),objectRefFor(id, sha256)= content naming (objects/<id>-<hash12>.glb, so a corrected export is a new ref and the bytes a room trusts can never be swapped), andobjectAssetUrlresolving 'code' | 'r2'.GET /api/objectsnow answers{ id, visibility, source, url }— the URL is resolved server-side, so moving the bucket never needs a client rebuild, and still never an email. - Inert is the design: with
OBJECT_CDN_BASEunset everysource:'r2'row resolves to null and is not offered; the museum runs on the objects the build ships. The render.yaml entry is deliberately empty, and neither service holds R2 credentials — nothing in this repository writes to a bucket. npm run objects:plan <id> <file.glb>reads a GLB, measures triangles / textures / bytes, computes the sha256 and the bucket ref, gives a budget verdict, and then prints the four steps an operator runs (provenance, the wrangler upload, the row, the curl check). It writes nothing.- Still no upload endpoint (content safety, the avatars' reason), and the GLB prop renderer is still not built — deliberately left until there is a real file to draw and verify against.
- Both files measured, both over budget: easel — 210 triangles, four PNGs (largest 1600 px), 6.18 MB, spans −1.00…1.00 m so its origin is at its middle and it would sit waist-deep in the floor; nativity — 82,587 triangles, one 4096 px JPEG, 3.54 MB, 0.00…0.31 m with feet already at 0. The export spec and both licences are recorded in docs/OBJECT-LIBRARY.md: the easel is CC BY 4.0 by Olivercharlton11 (attribution is a condition, so it goes in provenance and in the published credits), and the nativity is the owner's own Kiri Engine scan plus their child's painting, on the same footing as my-angel and the student houses.
- 5 new tests (content naming and its refusals, ten refused ref shapes, no bucket = not offered and no doubled slash, a code row needing no bucket, the budget verdict against both files' real numbers) plus the API projection pinned to
source/url. 286 passing.
2026-08-07 (the object library has owners too: migration 3 objects, with the GLB-in-R2 seam declared) <- please QA
- The owner's rule: "objects belong to the room and its owner — not everyone can use the objects." Two questions that had been one: placement was always owned (
props[]lives in the room document, guarded by that room'sowners— nobody could move your furniture), while the library was not: the editor offered every room all ten archetypes. - The
objectsregistry (migration 3, appended — no applied migration touched) is the exact shapeavatarshas:id / owners[] / visibility('public','private'). The default policy lives insrc/inception/world/props.js(and the emails do not): the museum's eight parametric pieces are public; the two student houses — art the owner modelled and textured — are private toWORLD_OWNERS. - Seeing is not using: a public room shows everything it contains (room-a's houses are visible to every visitor). The registry only decides who may BUILD with an object.
- All three layers:
GET /api/objectsreturns{id, visibility}only (R6 — a child-facing page never carries an email); the editor's "add an object…" list is filtered by it, and falls back to the whole catalog when the question cannot be asked (an empty editor is worse than an unfiltered one); and the boundary is the server —authorizeWritechecks every archetype in the document and answers 403object_not_yours. Injected as a predicate, so tests and a bare memory store behave exactly as before. - A dev-experience trap fixed on the way: without
DATABASE_URLthe memory store was never seeded, so/api/objectsand/api/avatarsboth answered empty — which every client correctly reads as "no opinion", making the whole feature invisible on a laptop.main()now seeds the same registries in memory mode, so local behaves like production. - For the GLB-in-R2 future the owner named the same day: the table already carries
source('code' | 'r2') andref, withCHECK (source <> 'r2' OR ref IS NOT NULL). That day adds rows, not a migration; bytes still never enter Postgres (ADR-0005) and there is still no upload endpoint — an object arrives through the repository's provenance and budget review, exactly like an avatar. - Verified against a live local API: a guest is offered 8 objects (the two houses withheld), and the client module filters 10 down to the same 8. ADR-0007 gains the amendment and the
objectsentry. 7 new tests (the seed policy, migration 3's shape including the R2 constraint, the registry's projection and email casing, the write refusal and the no-registry no-opinion case, a room still serving somebody else's art; the client filter and its three fallbacks). 281 passing.
2026-08-06 (a stale draft no longer breaks a room; the door's dissolve was built and then withdrawn on the owner's call) <- please QA
- The owner's "no v2 package": one browser held an editor draft at
localStorage["inception.roomDoc.room-b"]still namingsky-floor-water-v2. Drafts outrank the committed room, survive hard reloads, and do not exist in incognito — hence "incognito works". Two fixes: (1)docArtIsAvailable(doc, hasPackage), checked by bothchooseRoomDocandloadDraftForSlot, refuses a document naming a package this build does not ship (the predicate is INJECTED — the server validates room documents with no bundle at all); (2) threeMuseum draws an orphaned face (a package that is gone, a photograph that is still there) as a still instead of throwing —loadLprSurface's throw used to fail the whole room load and put the visitor back in the corridor. Reproduced by planting a v2 draft: the console says "ignoring a draft…" and the room opens on v5. A stuck browser is one line:localStorage.removeItem("inception.roomDoc.room-b"). - The centre-out dissolving door was built and then reverted at the owner's word the same day. The owner's
.lpp(center-out-breath-door-512x640, 5,160 dot marks) had been transcribed into a shader — per pixel, one uniform a frame — and verified by compiling the GLSL offscreen and reading its pixels back. The owner did not want the design, soworld/portalDoor.js, its tests, the imported.lppand its package, and every document paragraph about it were removed; the door is the breathing sheet of light it was. The lesson worth keeping: a canvas can only dissolve a picture by being made of pieces, but the same effect on a flat door is a per-pixel function of x — next time, ask whether an effect really needs marks before compiling thousands of them. - 2 new tests (a draft naming a deleted package is refused; a server with no predicate has no opinion). 274 passing.
2026-08-06 (sky-floor-water-v5 imported; the previous four versions deleted on the owner's word) <- please QA
- v5 is live:
content/live-projects/sky-floor-water-v5.lpp(7,098,481 B) compiles complete — one warp field, two fish, no gaps — and room-b's floor now namessky-floor-water-v5. The first fish is a NEW picture (a PNG where there was a JPEG, 1,313,696 B) and both were re-tuned:custom-object-1's objectScale 0.85 → 0.1, tail and body wave up, and the arrival and leaving ripples turned off on both. The pictures were extracted beside the room atassets/rooms/sky-floor-water-v5/and the runtime verifies them byte for byte. Measured in the room:aliveScale [0.1, 0.3], no console warnings. - v1–v4 deleted: their
.lpp, their.lpr.jsonandassets/rooms/sky-floor-water-v4/. All of it is in git history and restorable withgit restore. What remains is v5 plus the shell stillsky-floor-water-plants-v1.png(v5's photo carries the same sha256). - One thing that needed care: v1 was the only project carrying a Fade Veil (source-veil), and three test files used it as their end-to-end fixture. The behavior is still registered and still rendered, so it must not lose its tests with its fixture:
tests/fixtures/water-with-veil.jshangs a veil field on the v5 package with v1's authored parameters, reusing the current's real mask. The warp-frame and warp-shader veil assertions run unchanged. The only coverage genuinely lost is "a veil compiles out of a real.lpp"; every pure rule (sourceVeilAlpha, veilCoverage, feathering) is untouched. - Two assertions that had pinned an ART CHOICE now test the RULE instead: the ripple strengths are checked against a params copy that asks for rings, with a separate assertion that v5's fish deliberately have none; and the canvas renderer's refusal now names everything it lacks (aliveLayer, aliveObject, warpField). PROVENANCE.md's four water entries became one v5 entry with the v1→v5 lineage written out — the files may go, the provenance may not.
- 273 passing (
npm run check; all five packages compile and verify).
2026-08-06 (QA follow-ups: the fish belong to the water, a cut-out is finally the size the studio drew it, and the vote is 27 seconds) <- please QA
- The fish no longer swim over the space glass. A cut-out object is its OWN mesh laid over the floor face, and three.js visibility is not inherited from a sibling, so
applyFloorMode— which only ever touched the painted floor, the glass and the floor props — left both fish swimming through Space glass and Deep Dive. The room now remembers the meshes that belong to the PAINTING (floorAlive) and they come and go with it. Measured through the real path (orb → vote → settle): painting 2 visible → glass 0 → open 0 → back to painting 2 again. objectScalewas being dropped by the compiler (owner: "the movement changed, the size is still the old one"). In the.lppthat control sits beside the mask — it is NOT one of the brush's ALL-CAPS constants — soreadParamsnever saw it, and v3's 0.55 and v4's 0.3 were both drawn at
- Now
lppProjectreads it,compileLppwritesalives[].scale(clamped toALIVE_SCALE0.05–4, rounded), the schema validates it, and the renderer multiplies it into every pose. The field travels only when the studio recorded one, so v1/v2/celestial-rain keep their exact bytes and only water v3/v4 recompiled. Measured in room-b: the two fish are nowaliveScale [0.85, 0.3], where they were[1, 1]. The lesson is in LPR-FORMAT.md: whatever the studio lets an author change, the compiler has to read — a control the format ignores looks like a broken save.
- The vote is 27 seconds (was 60): a minute of nothing happening makes the orb read as broken. The constant lives in
presenceProtocol.js, the dialog's sentence now quotes it rather than promising "a minute", and the vote tests derive every moment from it, so the next change is one number. - A new diagnosis hook beside
probe()under?manual=1:__museum.probe().floorreports the floor mode, which of the three layers is showing, how many cut-outs the room has, how many are visible, and each one's alpha and scale. A screenshot cannot tell "mid-fade" from "absent". - A pre-existing limit, now written down: with the Live slider at 0 (or the editor holding the world still)
advanceAliveObjectsdoes not run and a fish's alpha starts at 0 — so a frozen room shows no fish in any floor mode. Each object is now posed once when it is built, so entering a room later in a frozen session shows them; booting straight into a frozen world still shows none, because their cycle genuinely starts transparent. Say if a frozen room should hold them mid-cycle instead. - 13 tests where there were 12 (the new one: the authored size reaches the package — studio value, compiled value and committed file agree — plus the out-of-range refusals and older packages staying legal without the field). 273 passing.
2026-08-06 (the persistence layer lands: paintings reach the database, rooms can be saved back to the museum, and a room owns its door and its transit chamber) <- please QA
- Ownership (owner's ruling): a room owner owns more than the space behind the wall — the corridor door that leads into the room (with its nameplate) and the transit chamber ridden on the way in (today the shared celestial-rain chamber) belong to the room's owner too. Both ride in the ROOM document, so they inherit its owners and
authorizeWritealready refuses a stranger; an empty slot and the corridor itself stay with the world owners. ADR-0007 gains a "How far ownership reaches" section. - The door must become an
.lpr: the newdocs/archive/ROOM-DOOR-AND-TRANSIT.mdcarries every number an author needs — the door is a 2.4 m × 3.0 m plane (a 4:5 portrait) standing on the floor (centre y = 1.5), no frame, drawn double-sided, at x = ±2.92 in the corridor; the in-room return door is the same rectangle. Colour: open =#59d7ff, driven every frame by a 0.22 Hz breath (opacity0.9 × level, colour scaled 0.35 → 1.0, gone entirely at the trough); a sealed slot =#17435aat 0.5, constant; the nameplate is 3.0 × 0.75 m at y = 4.35. Suggested canvas 512 × 640 (4:5), 256 × 320 for the cheap tier — do not author a square:LIVE_TARGETS.portalstill says 256 × 256 and that entry changes with the first real door package. The chamber's own numbers (radius 3, length 14, canvas 2048 × 1024, barrel share 0.75) are recorded alongside, and so are thedoor/transitdocument seams (declared now, built later). - Paintings persist (ADR-0007
avatar_paintings):POST /api/paintingsnow requires an account token (verifyBearer — the same lock as a room write) and refuses dev sessions with 403; it touches the globalusersrow the painting's foreign key points at, then stores the record. The relay cache stays in front as the fast path, andGET /api/paintings/:idfalls back to the database on a miss — a restart no longer loses a peer's paint. - The bytes still hash to the id: JSONB keeps no key order, so the server re-serializes a row with exactly the studio's canonical rule (v → avatarId → materials in sorted order); what a peer receives from the database hashes to the id it asked for (pinned by a test).
- A database hiccup is not a failed Save: a failed write returns
{ id, persisted: false }and logs, while the child's Save succeeds — the cache already has the painting, so peers see it this session. - The studio and the museum boot both send the token; with no token (a guest, or a LAN dev sign-in) nothing is uploaded and Save says "Saved on this device — sign in with an emailed code to keep it in the museum".
- The avatar picker asks the ownership registry: the landing page reads
GET /api/avatarsand offers only public avatars plus the private ones the account owns. No answer (no API, a sleeping instance, an error) means the whole shipped catalog stays on offer — an empty picker is worse than an unfiltered one. There is still no upload endpoint. - Editor → API: the editor panel gains "Save to museum", a PUT to
/api/worlds/space-museum/rooms/<id>(net/roomApi.js, one plain sentence per refusal: signed out / dev session / not an owner / invalid document / unreachable). Copy JSON and Download are untouched and drafts stay local-first.MUSEUM_WORLD_IDnow lives once, inworld/museum.js, andserver/store.jsre-exports it. - Still missing (next): getting your own painting back on a NEW device. The row is keyed by email and
store.paintingFor(email)answers it, but no endpoint hands it back, so a fresh iPad still shows the factory coat. - 12 new tests (the login gate, dev sessions, the write plus its user row, one-per-email replacement, byte-exact recovery after a restart, a failed write that does not fail the Save, malformed paintings, the canonical round trip; the room-save URL, token and all five refusals; the registry filter and its fallbacks). 265 passing (
npm run check: live:check + build +node --test, Node 22).
2026-08-05 (The Sky's floor becomes a choice: the centre orb is a floor console) <- please QA
- Owner: fly to the orb at (0,0) in the middle of The Sky, press E, and a dialog offers three floors: 1 Water painting (the current
.lprfloor, all the furniture on it), 2 Space glass (the corridor's own glass, all the furniture on it), 3 Deep Dive! — no floor at all: dive to the bottom, with nothing left on the floor line except the centre orb — and 4 Space Travel (see the next entry). The dialog lists the names and nothing else — owner: the names already say it. - "Deep Dive!" completes the hemisphere into a whole sphere: the dome volume grows a
depth, andflight.js'sdomeReachmirrors the same curve below the floor line — it narrows as you descend until it closes to a point, and that point is the bottom (floorOfmoves down with it, so feet and gravity settle there).depthdefaults to 0, so a room with a floor is byte-for-byte the hemisphere it always was. - Hiding the furniture is not enough on its own:
visibleProps(slot, props, mode)feeds BOTH the renderer and collision (propObstacles), or you would fly into invisible tables. The centre orb always stays — it is the only way to choose back — so manifest validation gained a rule: a dome room must carry a centre orb, and its entry point and orb must be reachable under all three modes. - All three floors are built once, at room load; switching only flips
visible. Choosing has to be instant, and a rebuild would re-decode the floor photograph every time. The glass floor uses the corridor floor's OWN material spec (same breath, same phase, same 1% opacity floor), so "the same as the corridor" is literally true; unloading a room removes its breathing entry from the list, or every frame would write opacity into a disposed material. - Purely additive; E's old meaning is untouched (OCP): E is the console only in a dome room, only at the orb, and only while the editor is closed — everywhere else it still toggles the editor, and an open editor still closes on its own key. There is no E on an iPad, so approaching the orb raises a tappable prompt at the bottom of the screen (on desktop, where the pointer is captured, the same element is a label).
- The choice lives in
localStorage(inception.floorMode.<roomId>, per visitor per room) and is never written into the room document — the room file still owns its furniture and its painting. It is applied before the room is built, so entering never flashes the floor you turned off. Known trade-off: presence is transform-only, so a peer who dives below your floor is drawn resting ON it; showing that truly would need a protocol field, which this change does not add. - New
tests/inception-floor-console.test.js(8 tests: volumes, collision and reachability in all three modes, the mirrored curve against its closed form, where the console is, the manifest guard, and that the committed room-b document still stands an orb on its centre).npm run check= 235 green.
2026-08-06 (the other half of the same hole: the brush landed far from the finger — the raycast reads uv set 0) <- please QA
- Owner: the face stays put now, but "the brush is not painting the right spot, really far off". Same root cause, other direction: a raycast hit carries every uv set the geometry has (
uv,uv1), and the brush was always converting the FIRST into canvas pixels — while the head's coat is read through the second. - How far, in numbers: across the head mesh's 7,993 vertices the two sets differ by 0.377 uv on average (max 0.762) — about 1,500 pixels of its 4096-wide atlas. Not a rounding error; a different place.
- Fix: a paint layer remembers which uv set its coat is read through (
channel), and a stroke resolves its position withstrokeUv(intersection, channel). If that set is missing from the hit it paints nothing — doing nothing beats doing it somewhere wrong. - Two new tests: the pure
strokeUvrule (including "no second set, no stroke"), and a measurement on the shipped GLB proving the head's two uv sets really are different layouts (mean > 0.05) — the test exists to say why the brush must choose, so nobody simplifies it back to uv 0.npm run check= 270 green. Please paint once more and confirm the brush now lands under the finger.
2026-08-06 (bug: painting the space baby moved its face — a replaced map lost HOW it is sampled) <- please QA
- Owner: "when paint the space baby, its face moved". Not the GLB — ours. v1.1.2's head reads its whole-head atlas through the second uv set (
texCoord: 1), which is legitimate glTF and which the renderer honours perfectly. But painting swaps the material's map for a canvas of its own, and a freshly built three texture defaults tochannel = 0— so the first stroke made the head read the same atlas through the wrong coordinates, and the face slid across it. - It was in two places, and the second is worse:
avatarStudio's paint layer (painting your own avatar);threeMuseum.paintMaterials— wearing a saved painting, which hits the local avatar and every remote explorer wearing one.
- The fix is one shared rule,
matchSampling(base, texture), inpaintTargets.js(the module both already import): a replacement map inherits the original's sampling identity — uv set, wrap, offset/repeat/rotation/center, flip, colour space, anisotropy, filtering. OnlyflipYwas being carried before. - Three new tests:
matchSamplingfield by field (including "invent nothing when there is no original"), and a GLB rule that every material's uv set is actually carried by its meshes, naming the head's second set — so the next person down this path is told the second uv set is load-bearing.npm run check= 268 green. - The painting itself cannot be checked here (0x0 viewport, and the studio wants a hand): please paint the baby once on a real screen and confirm the face stays put.
2026-08-06 (Space Baby Avi v1.1.2: the one-piece textured head; the GLB repacked 64% smaller) + sky water v4 <- please QA
- The owner supplied
space-baby-avi-v1.1.2.glb(11.4 MB): "if optimization is needed, go ahead". This revision changes the head: the sphere plus cartoon decal plus sculpted patch became one textured surface wearing a 4096x2048 whole-head atlas. SoBabyAstronaut_CartoonFaceDecal/BabyAstronaut_SculptedFacePatchand the materialSpaceBabyAvi_CartoonFaceare retired, and the manifest's skin zone now namesBaby_Head_Textured. Hair shell, green bow, suit, rig, the five movement actions and both approved scan hands are unchanged. - The optimization changes the container, not the pixels. By the owner's own 2026-08-05 recipe — sharp, JPEG quality 95, 4:4:4, no resampling — the two OPAQUE textures became JPEG while the hair (almost entirely alpha) stayed PNG, and the GLB was repacked around them (bufferViews re-aligned; all 215 accessors re-checked in range):
- suit 1254²: 2,155,643 -> 553,690 bytes
- whole-head atlas 4096x2048: 7,080,195 -> 1,364,131 bytes
- 11,413,084 -> 4,094,836 bytes (-64.1%), back inside the 6 MB budget the avatar test enforces. Nothing is lost on screen:
capAvatarTexturesalready caps avatar maps at 1024 on load, so those 4096 pixels never reached the GPU. - Triangles went 44,015 -> 53,592, past the old 50k budget. This is the owner's model (one surface now carries the relief the decal used to fake), and geometry is shared by every clone — a crowd costs skinning, not another copy — so the cap moved to 56k with that reasoning written down.
- sky water v4: another numbers-only import (object-1's right edge 41 -> 60; object-2's band 11..23 -> 17..68, giving both fish more water to roam). Same shapes, no registry change, room re-pointed, and the fish pictures followed the package to
assets/rooms/sky-floor-water-v4/. - PROVENANCE records the v1.1.2 source hash, each texture's master -> runtime derivative hashes, the new GLB hash, and v4's hash with its three numbers.
npm run check= 265 green. Checked in a real browser: the console reportsmuseum avatar space-baby-avi clips idle/walk/float/diveandoutfit glb/third(no mannequin fallback), and room-b loads with no warning. The look — face, atlas seams, JPEG quality — still needs the owner's eyes.
2026-08-06 (the floor becomes a room-wide vote over WebSocket; Space Travel stays personal) <- please QA
- Owner: fly to the orb, press E, get the dialog — all unchanged; but choosing an option no longer changes anything by itself. It casts a vote. The first vote starts a 60-second countdown, anyone else in the room can answer inside that minute, and when it runs out the room moves to whichever floor got the most votes, together. Space Travel is not voted on — it is one person's journey, and anyone can take it.
- The server owns it (otherwise everyone gets their own floor): the hub keeps a
floorand apollper zone. A client sends only{type:"vote",mode}(~30 bytes); the server broadcasts one{type:"poll",ends,tally}(~80 bytes) per vote, and one{type:"floor",mode}when the clock runs out. Three people voting three times in a minute is four small messages, and none at all when nobody is voting — "simple and not often". On arrival the server tells the newcomer what the room stands on (and hands them any vote in progress), so nobody walks in onto a floor of their own. - Rules: voting again inside the minute replaces your own vote rather than adding one; a new vote never restarts the clock; a tie goes to whichever floor was asked for first (deterministic, and the only tie-break a child needs explaining). The hub holds no timer —
settleDue(now)is the clock, called by a one-second tick in the socket layer, which is what lets a test run the whole minute in a millisecond. - Failing soft, twice (this matters): with no presence server — solo or offline — the orb behaves exactly as it always did and changes the floor on the spot. The subtler case is a client newer than its server (a deployed museum can outrun its API): an older server drops the vote silently, so if no tally comes back within 2.5s the client decides alone. The orb never becomes a dead button.
- The safety line holds: a vote is one of three known words, not free text;
parseClientMessagepasses{type,mode}and drops everything else a caller tries to attach. The protocol header andSPACE-MUSEUM-DIRECTION.md §10were updated to say so rather than leaving "transform only" quietly untrue. - New
tests/server-floor-vote.test.js(7 tests: the wire whitelist, opening/replacing/not-restarting, ties, room isolation, a latecomer receiving both the floor and the running poll, ghost votes). The existing "nobody hears their own echo" now counts peer messages only, since joining legitimately delivers a floor.npm run check= 253 green. - Driven end to end against a real server on :8799 with two real sockets and a real minute: both joiners got
floor:painting; ann voted glass (both saw the tally); bob voted open (1–1); bob changed to glass (2–0, andendswent 59→58 rather than restarting); a third explorer arriving mid-vote got the floor and the poll; at 61.8s all three receivedfloor:glassat once. The look itself still needs the owner's eyes (the pane's viewport is 0x0).
2026-08-06 (v3: the fish are smaller and better placed — the import where nothing had to change) <- please QA
- Having seen v2's fish in the room, the owner supplied
sky-floor-water-v3.lpp: "the fish got smaller, it looks better". It is byte-identical to v2 apart from four numbers — same shell, same mask, same two pictures, same Water Ripples field, same code shapes. What moved iscustom-object-1: it now appears only in a narrower band of water (left 8→22, right 92→41, top 100→56) and swims at 122° instead of 114°.custom-object-2is untouched. The floor recedes, so a fish placed further into it simply reads as smaller. - No review at all was needed: only the ALL-CAPS numbers changed, the shape did not, so not one registry line was added and the package compiled straight through. That is exactly what shape-based identity buys.
- The room now names v3 (
room-b.room.json's face.lpr), the two fish pictures moved toassets/rooms/sky-floor-water-v3/(same bytes), and v2 stays in the corpus as the revision that introduced the fish. PROVENANCE and the tests follow v3.npm run check= 246 green.
2026-08-06 (there are fish in The Sky's water: .lpr learns the cut-out object, and draws it on the GPU) <- please QA
- The owner updated
sky-floor-water-v2.lpp(Fade Veil removed, two fish added) and asked for it to be compiled into.lpr. The first compile came back with one gap: both fish share a code shape nobody had reviewed, so the compiler refused it — and a gap makes the whole package incomplete, which drops the room's floor to a still picture. So this could never be "one line in the registry". - Reading the authoring source (the art lab's "Fish Swim"):
spawn()picks a fresh place inside a water rectangle each cycle,move()swims toward HEAD_ANGLE with a bob across that line,deform()cuts the body into upright strips and bends them (the tail hardest), andappear()/disappear()give alpha, a scale pop and three ripple rings. That is far more than the old alive contract (a whole image moved and faded); forcing it in would have been a silently different painting. - So the format grew, by its own rules:
- a new capability
aliveObject— "a cut-out object with its own mask, bending slices, scale and ripples". A renderer that only knowsaliveLayerMUST refuse it, or it would paint the whole rectangle (and object-1 is a JPEG, with no transparency at all). - the mask travels in the package:
[value, high, low]run-length, the art lab'sdecodeAliveMasktranscribed — data, not code, exactly like a warp field's mask. fish-swim@1as pure functions inbehaviors.js(the spec itself), composed in the art lab's own order: the spawn offset is (spawn − cut-out centre), scale and alpha multiply across move/appear/disappear, and the ripple is whichever of appear/disappear asks louder.rand()is the host's same hash, so every device picks the same places.- registry:
shape:49cf1025…→fish-swim@1. One shape, one review, both fish. - Drawn on the GPU (the owner's call — rather than move the repaint-and- upload we had just taken out of the transit chamber onto the floor of the most-visited room): the floor keeps its warp shader, and each fish is a skin over that same face. The cut-out (picture × mask) is baked at load into a texture only as big as its own bounding box; a frame is then a few uniforms. The fragment runs the reference renderer's transform backwards — undo the placement and scale, work out which strip the pixel belongs to, undo that strip's offset, read the pixel — so the two agree by construction (a test checks it pixel by pixel, both ways). The strip staircase is kept on purpose: the reference uses 12–64 strips, and smoothing it would be a different painting.
- Two things are close rather than exact, both in the ripple: canvas strokes an ellipse and blurs it with
filter: blur(); the GPU reads an elliptical distance and softens the edge with a smoothstep of the same width. Ring count, radii, phases, colours and alphas are the reference's. - Edges: fish can currently only be drawn on a face that is already a warp face (that is the path which declares
aliveObject); an alive-only face is explicitly refused by the reference renderer and falls back to its still picture rather than drawing something wrong. The pictures ship asassets/rooms/<package>/<file>, verified byte for byte, and PROVENANCE records them along with the.lpp's new sha. - New
tests/lpr-alive-object.test.js(6 tests).npm run check= 246 green. Checked in a real browser via?inspect=room-b: the room loads, the shader compiles, frames render, nothing logged (145 draws). The look itself still needs the owner's eyes — the pane's viewport is 0×0 there, so there is no frame worth capturing.
2026-08-05 (the transit chamber finally runs on the GPU: an alive layer is a texture offset, and 8 MiB a frame becomes nothing) <- please QA
- The owner reported that room transitions stutter, and asked whether the chamber was on an optimized
.lprpath. It was not..lprhas three playback paths — warp fields as a GPU shader (The Sky's water floor), marks as instanced billboards (the corridor sky), and alive layers on the Canvas2D reference renderer only. Celestial Rain v5 is0 marks / 0 warps / 4 alive, so every scheduler turn redrew a whole 2048x1024 canvas (the shell plus four full-canvasscreenblits) and re-uploaded 8 MiB — about 42 ms of upload inside one frame, six to eight times a second at the measured 5.3 ms/MiB. The iPad had it worse than the desktop: the chamber takesmin(2048, tier x 2), so High and Ultra both land on the full 2048 while High refreshes more often (8 Hz vs 6 Hz). - The fix is that an alive layer is only ever a whole picture being translated (
resolveAliveLayerreturns dx, dy, alpha), and on a surface already wearing the painting's uv, translating a picture IS a texture offset. The shell and all four pictures now go up ONCE, and a frame costs each layer atexture.offset.yand anopacity: no repaint, no upload. One barrel+cap geometry, worn five times — 10 draw calls. - Two things had to be exact, or it would not be the same painting:
- Clipping. Canvas cuts at the edge; a texture wraps or smears one instead, and the rain travels up to 610 of 1024 rows, so the difference is not subtle. Each layer's picture is drawn into a texture ONE TRANSPARENT PIXEL taller at each end and clamped in v: where the reference clips, the GPU samples that border. In u it must NOT do this (the painting genuinely wraps around the barrel), so the path is opt-in per behavior —
ALIVE_GPU_BEHAVIORS = ["celestial-fall"], with a test holding itsdxto zero across whole cycles. - Blending. Canvas
screenover an opaque backdrop isD + aS(1-D), which is a PREMULTIPLIED fragment withONE / ONE_MINUS_SRC_COLOR; "lighter" isONE / ONE. So it is a built-inMeshBasicMaterialwithpremultipliedAlphaand CustomBlending — no custom shader, which keeps colour management on three's own path and rules out a colour shift against the old chamber.
- A capability check, not a guess:
aliveLayersRunOnGpu(pkg)demands no marks, layer pictures the size of the canvas, and a whitelisted behavior; anything else keeps the whole package on the reference renderer (marks are CPU work by nature).LprMarksSurfaceis untouched. - Pictures still go through
fetchVerifiedBytesbyte-for-byte (the Polish incident's discipline stands), decoded and drawn into a canvas — which also sidesteps ImageBitmap textures ignoringflipY, and the chamber's uv is authored for flipY. - New
src/inception/lpr/aliveTransform.jsand 4 tests, including inverting the uv offset back into "which row the reference would draw" and comparing row by row for severaldy, plus proving the clipped rows land on the transparent border.npm run check= 240 green. For the owner: the look itself (orientation, blending, no smear at the edges) still needs your eyes — the pane's viewport is 0x0 there, which leaves a 2x2 canvas and no frame worth looking at.
2026-08-05 (the orb's fourth entry: Space Travel — the transit chamber ridden for its own sake) <- please QA
- Owner: add 4. Space Travel to the orb's menu — it drops you straight into the transit scene and holds in the barrel (the rain streaming past, the walk cycle playing) for as long as you like; Space on the desktop, a tap on mobile flies the same one-second accelerating approach into the dome that entering a room plays. It goes nowhere: you come back to the spot you left, beside the orb. It is not a floor mode — nothing is stored, so it never wears the ✓.
- Built by extracting the shared half, not copying it:
flyChamber (approachStart, now)is exactlystepTransit's body (pin on the axis, then the one-second ease-in), andstepTransitand the newstepRideeach decide only what releases it — a loaded room, or the visitor's key. The existing crossing behaves identically. - An old bug fell out of this: the avatar inside the chamber was pinned to the floor of the space it had LEFT (
setAvatar'sfloorYis the corridor's or the room's, while the body rides at y=-340), so a third-person crossing showed an empty tunnel. New puretransitFloorY()= the barrel's bottom (axis - radius): while the chamber owns the body, the chamber is the floor. The third-person camera clamp already skippedtransit; it now skips the ride too. - Presence publishes the departure point, not the chamber's, while riding — otherwise peers watch your avatar teleport into a pocket 340 m under the museum.
- Edges:
Digit4is the screenshot key, so it yields to the fourth entry while the dialog is open; E does not open the editor mid-ride (the chamber owns the body); and if the chamber failed to build, the entry is not offered at all.npm run check= 236 green.
2026-08-06 (Paint Studio: the avatar faces the painter, and an eraser) <- please QA
- Owner: the studio opened on the model's back. The starting
orbitYawwasMath.PI, which puts the camera on the far side of +Z — glTF forward, so exactly the back of the head — while the comment beside it claimed "face the visitor first". It was wrong from the start. It is noworbitYaw = avatar.rotation.y: the camera starts along the body's own forward axis including the catalog's yaw trim, so every avatar greets the painter face-on and the Angel's -30° correction still holds. That side is also the key light's side, so the face arrives lit. - New Eraser tool. It borrows the brush's entire stroke geometry — same walk across the screen, same UV-seam rule — and only changes what a stamp does: it redraws the factory coat inside the disc, which is Clear done one small piece at a time. Not transparency: the material is opaque, so erasing to transparent punches holes in the body; the model already wears a skin, and rubbing the paint off should show it. Eraser strokes undo like any other.
- One storage guard came with it:
PaintLayer.hasPaint. The eraser can touch a part nobody painted, and giving that part a layer in the save payload would break "a painting carries only what was actually painted". It is set when pigment lands and also when an earlier painting is loaded — without the second, a re-save would drop every part painted before today. npm run check= 246 green. Not self-verified in the browser pane: the studio is a rAF-driven WebGL scene and the headless pane never fires rAF, so a screenshot would only be black. Please confirm the facing and the eraser's feel on a real device.
2026-08-05 (Van Gogh House's six still faces become JPEG derivatives: room-entry payload -66.5%) <- please QA
- The six stills room-a downloads on entry were PNG masters totalling 34,485,998 bytes. They are re-encoded as runtime JPEG derivatives with the corridor-end sky panoramas' exact recipe (sharp, JPEG quality 95, 4:4:4 chroma, no resampling, identical pixel dimensions): 11,551,829 bytes combined (-66.5%). Per file: ceiling 8,559,550 → 2,795,202; floor 8,359,754 → 2,600,822; wall A 4,369,514 → 1,526,647; wall B 4,649,452 → 1,840,904; wall C 4,422,882 → 1,487,828; wall D 4,124,846 → 1,300,426. Wall A's four house-object textures were already JPEG and are untouched.
- The PNG masters moved byte-identical into the art set's
masters/subfolder as reference masters; theroomImages.tsglob now excludesmasters/**, so the build stops emitting them (verified with vite build: dist carries exactly the six JPEGs and no gallery PNG). The six faces inroom-a.room.jsonpoint at.jpg; the van-gogh room test now parses JPEG SOF dimensions and adds a guard that every derivative has a same-dimension PNG master inmasters/. The directory README and PROVENANCE.md's derivative record are updated to match.npm run check= 246 green. presentationRevisionmoved tostudent-village-objects-v2in the same change. Renaming the faces IS a committed art-set revision: without the bump an older local draft (still naming the.pngfiles) keeps authority over the committed document, those names no longer resolve, and all six faces silently fall back to blank surfaces — which is what the owner saw on entering the room right after the first commit. The draft is not deleted and reclaims authority the next time the room is edited. A first-time visitor cannot hit this (owner: no further mechanism needed).
2026-08-05 (production outage: Cloudflare Polish rewrites image bytes → transit falls back; fixed) <- please QA
- Production showed no transit scene at all. Console:
Transit chamber failed to build … expects a different image (shell.png). Root cause (Codex's diagnosis was right): Cloudflare Polish recompresses PNGs at the edge (cf-polished: ok, imgq:85, 1,454,948 → 1,251,626 bytes — same pixels, different binary) → the strict hash check threw → the chamber never built → the by-design silent fallback to the direct cut. Localhost has no Cloudflare in the path, so it can never reproduce this. - Codex's first fix (0bbb883: request-side no-transform header + sha query param + no-store) shipped and did not work — and made things worse. Probed against production: Polish honors ONLY the origin response
Cache-Control: no-transform; request headers do nothing. The novel sha-keyed URL earned its own edge cache entry, was polished at cache fill, and then served the transformed copy forever (measured: HIT returns 2948…).no-storealso disabled browser caching of a 1.4 MB file. The "verified it returns the original" observation was a MISS artifact — a miss is served origin bytes; the cached copy is what gets polished. - The real fix (this change; NOT deployed — takes effect when the owner pushes and Render syncs the blueprint):
- render.yaml sends the origin response header for
/assets/*:Cache-Control: public, max-age=0, s-maxage=300, no-transform(Render's current default value, plus the one directive Polish respects). - Both surfaces' image fetches collapse into a shared
verifiedImage.js: ONE ordinary fetch (browser caching restored); on hash mismatch, ONE retry with a unique?fresh=key andcache:"reload"— a miss must serve origin bytes, so a poisoned edge entry self-heals (and ages out anyway at s-maxage=300); still wrong → refuse, with an error naming want/got hashes and pointing straight at CDN no-transform. Strict verification did not loosen. - The same bug would break The Sky's water floor (lprSurface verifies its photo identically) — the shared helper covers it too.
- Five new unit tests pin the discipline (clean bytes = one plain fetch; transformed → recovered via one uncached retry; persistent transform → a named refusal; unnamed images pass through; the fresh URL only adds). 227 tests green. DEPLOY-RENDER.md "Things that will bite" gained the full entry plus the post-deploy verification curl. For the owner: after push + blueprint sync,
curl -sI .../assets/<img>.pngmust showno-transformand nocf-polished; the dashboard alternative is a Cloudflare Configuration Rule disabling Polish for theissubdomain (either works; the former is ready in this change).
2026-08-05 (Celestial Rain v5: sixteen hand-authored gestures; v4 retires) <- please QA
- The owner supplied v5. The rim brush is byte-identical to v4's (same revision hash), so it needed no review at all, and the four falling layers are unchanged again. What changed is the arc brush: renamed to
natural-sequential-polar-nebula-breath, and genuinely given life — a horizontal sway while its phase is lit, a small vertical ripple, and a per-mark shimmer keyed tomark.index, so "sixteen phases" is sixteen authored gestures rather than one stamp reused sixteen times. Link reach now varies per gesture too (72..138). - Judged a new opcode, not a third version: the author gave it a new name AND new motion terms, so
sequential-polar-nebula-breath@1/@2stay exactly as reviewed (v4's meaning is untouched) and the new behaviour is registered on its own. The renderer now suppliesmark.index— the first behaviour to read one. - A second v5 arrived the same day (same id, recompiled), with two real changes: (1) the separate breathing-rim layer is removed entirely — the ice-blue opening is painted into the static shell now, with no linked point-light at all; and (2) the timing is rebuilt:
sequenceLeveltakes its phase offset from a newSEQUENCE_WINDOW = .68instead of the whole CYCLE, with CYCLE 2.8 s and FADE_IN .18 s, so all sixteen gestures start inside 0.68 s and finish lighting before the one-second approach ends — an instantly-cached room now shows the complete gesture rather than a third of it. One line of code plus one new authored parameter, so it registers as version 2 of that behaviour (v1 keeps its meaning). - A third v5 (owner: "removed some effects") takes the marks out entirely: beyond the breathing rim, the whole sixteen-phase outer gas layer is gone — no curve strokes, halo marks, companion wisps, link connections, not even the gas brush source in the archive. The exit is now painted wholly into the static shell (including the 12 px one-sided afterglow that kills the black annulus). The final package is the four falling layers over that shell: 0 marks / 0 strokes / 4 alives, behaviors just
celestial-fall@1, capabilities back toaliveLayer + marks. - The behaviours that dropped out (
polar-ring-breath, both sequential families and their versions, stroke links) stay in the reviewed table and in the tests. A shape once reviewed is not un-reviewed, the owner can put any of them back in the next.lpp, and the arithmetic must not rot in the meantime; those tests now pin the behaviours directly and say plainly that the current painting no longer uses them. v4 retires (.lpp, .lpr and shell gone). 222 tests green; reproduced headlessly (portal touch → loading → approach) with a screenshot showing one clean ice-blue opening at the pole and the celestial objects streaming past.
2026-08-05 (Celestial Rain v4: a breathing polar exit and constellation links; v3 retires) <- please QA
- The owner supplied
space-museum-celestial-rain-v4.lpp: the four falling groups and their motion code are unchanged, and the tunnel mouth now breathes — a rim of 224 marks in two linked bands, plus sixteen outer arc phases (with 4 same-phase UV seam copies) of 518 marks at link distance 320, lighting in azimuth order over a 10.4 s loop. Nothing rotates: sequential lighting plus curved links is enough to fool the eye. v4 also removes the static gas outside the exit (atlas rows 21..106) and feathers rows 107..147 back into deep space — exactly the faint square boundary I flagged last round. - Two new brush shapes reviewed in:
polar-ring-breath(the rim, its azimuth phase taken from where each mark was painted) andsequential-polar-nebula-breath(the arc sequence, each stroke's slot chosen by its own stroke seed). Transcribed line by line into behaviors.js; tests pin "never rotates / never drifts out of bounds / keeps a floor of light when dark / sixteen phases differ at one instant". - Two format additions: (1) stroke-level
links— LINKS is a painting decision, not brush code, so it rides on the stroke, and the newstrokeLinkscapability makes a renderer that cannot draw lines refuse rather than quietly shipping loose dots. The semantics are Art Lab's own contract, transcribed: lines between marks closer than LINKS px, alpha falling with distance, bounded byMAX_STROKE_LINKS; lines draw under the marks and add light. (2)mark.strokeSeedreaches the behaviour, which is what "in sequence" is made of. - The package: 449 marks / 20 strokes / 4 alive layers, complete; capabilities
aliveLayer + marks + strokeLinks. v3 retires (.lpp, .lpr and shell gone). - Owner retune the same day ("should be just minor adjustment"): the outer ring calms down — glow 138 → 42, fewer and smaller marks (518 → 225), link distance 320 → 130, pole visibility 30% → 60%, and a 12 px one-sided rim afterglow that removes the black annulus. The RIM brush moved only numbers, so its shape is unchanged and it needed no review at all — exactly what shape-based identity is for. The ARC brush also changed inline coefficients (drift 5+6·seed → 2+2·seed, a flatter size swing), and inline numbers are code, so it is registered as
sequential-polar-nebula-breathversion 2; behaviors.js branches on the version andresolveMarktakes one (defaulting to 1, so older packages keep their meaning). Replaying v2's marks as v1 would have silently undone the owner's calmer exit — which is precisely what the new test pins: v2 drifts less, swells less, and its alpha curve is bit-identical to v1's. 213 tests green. - A real bug fell out of the diagnosis path:
loop()calledrequestAnimationFrameunconditionally, so under?debug&manual=1real frames and synthetic frames interleaved, each clock dragging the other —dtcould go negative andworldSecondsran BACKWARDS (measured -9.3 s), which froze the transit's final second. This is the effect I wrongly attributed to "harness promise interleaving" last round; the correction: manual mode no longer self-schedules, anddtis clamped to>= 0(real timestamps are monotonic, but the floor also protects against tab restore and clock changes). Re-verified headlessly afterwards: loading → z 3 → 2.57 → landed in the room, worldSeconds strictly increasing. 212 tests green.
2026-08-05 (Celestial Rain v3: the polar exit and screen compositing; v1 retires) <- please QA
- The owner supplied
space-museum-celestial-rain-v3.lppwith two real changes:
- A new shell: the white-hole / black-hole exit is mathematically polar-unwrapped into the atlas's TOP band. The author's figure ("dome band = rows 0..257 = 25.18%") matches the geometry contract I published yesterday —
transitBarrelShare()computes 0.74817 / 0.25183 — exactly, so not one line of UV code had to change. Flat, it reads as horizontal bands; on the sphere it collapses into ONE circular exit dead ahead. - The four falling layers now composite with
screen: the extracted objects keep a near-black matte, and source-over lets that matte cut square dark holes into the new background. Screen adds light only.
- The format grew to match:
BLEND_MODESin schema.js is a WHITELIST (source-over / screen / lighter) — the value reaches a canvasglobalCompositeOperation, and a package carries data, so it must never name an arbitrary operation.compileLppwritesblendModeonly when it is not the default (older packages stay byte-identical), and the surface applies it per layer. - v1 retires (
.lpp,.lprand its old shell deleted); the corpus keeps v3 and the earlier tunnel package. - Verified on the machine (
?debug&manual=1, headless): portal touch → loading (in the chamber at y = -340) → approach (z 3 → 2.6 → 0.02) → landed in the room. Screenshots confirm the polar bands collapsing into a single circular vortex straight ahead, planets and galaxies streaming past, and no square dark holes. The diagnosis probe gained worldSeconds/approachStart/loaded (manual mode only) — that is what showed the earlier "stuck approach" reading was the harness's real-time promise interleaving, not a product bug. 203 tests green, six packages verify. - One thing I noticed, for the owner to judge: a very faint square boundary is visible around the exit vortex, where the shell's own polar band meets the near-black background. If it reads badly I can feather the band edge in the shell, or pull the dome's v mapping in slightly.
2026-08-05 (the chamber wears the Celestial Rain; .lpr learns alive layers) <- please QA
- The owner supplied
space-museum-celestial-rain-v1.lpp: no brushes at all — real pixels extracted from a source photograph, grouped by connected region into four whole-image "speed groups", each with amove(t)that drops the ENTIRE layer its own distance per cycle (smoothstep fades at both ends hide the reset; at t = 0 the four layers reassemble the source composition). A third kind of content beside marks and warps: alive layers. - First the pipeline had to SEE them: compiling this
.lppyesterday produced a package that reported complete while drawing nothing — the exact silent drop the format forbids. lppProject/lppArchive now parse alive layers and objects,requiredShapestreats a move() as brush code (blanked-consts hash), and an unreviewed alive shape is a gap (the first compile honestly reported four). The groups' code differs only in its title comment, so four signatures point at one reviewed behavior,celestial-fall@1— the liquid-warp precedent. - Package format:
alives[](layer index + behavior + params + an image named by content hash, the photo rule again), capabilityaliveLayer,stats.alives, all held by the validator. At runtimeresolveAliveLayer()in behaviors.js is the pure reference; the canvas surface draws shell → alive layers → marks, and the canvas now keeps the author's 2:1 aspect (half the paint and upload of a square). Five image assets shipped, each verified by hash before a frame is drawn. - The chamber's UV contract changed with the painting (the old tunnel atlas's seam/ellipse rules retire; the tunnel package stays in the corpus): the one 2:1 canvas wraps barrel + dome continuously — canvas top = the dome's pole, canvas bottom = the rear opening — split by REAL surface path length (barrel 0.748 / cap 0.252), with the dome using the sphere geometry's own seam-safe u re-aligned a quarter turn to the barrel's (texture repeats in u), so a falling object crosses the ring without a sideways jump. The rain streams from the pole ahead toward the rear: the forward-travel illusion.
- 202 tests green; all six packages verify (every older package's stats gained
alives: 0, hashes regenerated). - Owner QA "enter in door no effect" → fixed and machine-verified: the surface's own capability check passed, then the mark reference renderer it composes refused the whole package against its
["marks"]-only list (missing aliveLayer) → the chamber failed to build → portals silently fell back to the old direct cut, exactly as designed for a broken loading screen. Fix: the renderer's constructor acceptsserved— capabilities the COMPOSING surface draws itself (here aliveLayer); standing alone it still refuses anything it would truncate. A regression test pins both directions. The diagnosis path got stronger along the way:?debug&manual=1's__museum.step()takes a strafe argument andprobe()reports x/z/transit — which is how the whole ride was reproduced headlessly: portal touch → transit "loading" (y = -340, in the chamber) → "approach" moving forward → landed in room-b, with a screenshot showing the celestial rain wrapping the view. 203 green.
2026-08-04 (a new Ultra graphics tier: desktop defaults to 2048, iPad to 1024) <- please QA
- The owner A/B'd
?arttex=2048on both the Mac and the iPad and took it → a new Ultra tier (2048px per face,facesPerFrame: 1, refresh 6 Hz — slower than High on purpose: a 2048 face is a 16 MB upload, and Ultra buys pixels, not rate). The Menu now cycles Low → Medium → High → Ultra. - Defaults move up one tier: desktops open at Ultra (2048), touch at High (1024) — the 2026-08-02 defaults were 1024/512. A stored choice still always wins, including over a new default, so a device that ever saved a tier keeps it until the Menu changes it (or localStorage is cleared). The transit chamber's quality×2-capped-2048 texture now lands on the full authored atlas on both device classes.
2026-08-04 (the portal transit chamber: loading as travel) <- please QA
- Implemented from the owner's design kit (
space-museum-transit-design/plus its.lpp): the instant a portal is touched the avatar is placed inside a preloaded, hidden transit chamber — an open cylinder joined to a front hemisphere, interior faces only — stationary in world space with its walk cycle playing, while the chamber's Live Painting (streams flowing rearward from the dome seam, radial convergence out of the vanishing point, a slow veil over the UV transition) supplies the travel. When the room reports ready, a smooth ease-in acceleration of exactly 1.0 second flies the avatar to the dome center, then the room takes over — a cached room still shows the full second, and the cut lands on the approach's fastest frame. A failed load returns the player to the corridor door instead of stranding them in the chamber. - Three new brush shapes reviewed into the registry:
portal-glass-current/dome-convergence/glass-veil-pulse— all pure functions of (mark, seed, t) on wrapped or sinusoidal clocks, so nothing accumulates and a slow load loops forever. They MOVE marks (the sky's GPU billboards assume none do), so they replay on the canvas path: three new behaviors.js opcodes (the reference arithmetic) plusLprMarksSurface(one canvas = shell picture + marks, shell verified by content hash). The package is 208 marks, complete. - Shared scheduler, one dynamic texture, no second loop: the chamber rides
updateLiveExtras' owndueFacescadence and takes no paint turns while hidden; geometry and.lprpreload at init alongside the corridor assets (a failed build falls back to the old direct cut). - The UV contract lives in museum.js (pure data + pure functions, so Node holds it): the cylinder's front ring samples exactly the seam row y = 683; the dome's pole is the atlas's visual center (1024, 286) under a continuous radial ellipse map (squash 0.36); the authored ellipse deliberately clips ~49px past the atlas top, so UVs clamp inside the region and can never sample across the seam. The chamber parks at y = -340 in its own pocket (rooms all detach upward from +250). Five new tests; 200 green.
- Known trade-offs: during transit, presence still broadcasts the chamber coordinates under the corridor zone (peers see you 340 m below — in practice invisible), and the third-person camera skips the corridor clamp while inside.
- Owner QA round one, two fixes: (1) the avatar floats at the BARREL'S AXIS, not on its floor — the tunnel streams past on every side equally (
transitStartPointy = axis). (2) "effects look like 256": two causes, both fixed. The chamber wraps the entire view, so its texture is now quality tier × 2, capped at the authored 2048 (desktop High paints the atlas 1:1; the upload budget holds because the chamber only takes paint turns while rooms are still loading). More fundamentally the canvas reference renderer drew the author's soft glowing marks as HARD shapes —glownow composites additively ("lighter", the same rule as the sky's billboards), soft dots dissolve by softIdx, streaks are rotated soft ellipses instead of hard bars, and every fade ends at the mark's own colour at zero alpha (fading through transparent black rings a glow with a dark fringe).
2026-08-04 (the Angel stands straight: a yaw trim in the catalog) <- please QA
- Owner QA: the Angel stood crooked in the museum — "rotate about 20 to 30 degrees to the right around its center axis". The export itself does not face straight down its own +Z, so the correction is CATALOG DATA, not a hard-coded number:
yawCorrectionDegrees(negative = to the model's own right; tuning it is a one-number edit). First −25; the owner's second QA ("almost — 5° more to the right") settled it at −30. - Every mount point reads the same field: the museum's
buildGlbAvatar(remote clones inherit it automatically — they clone this very object) and the paint Studio (the model faces the painter as squarely as it faces the corridor). The baby's export is true and carries no trim. A test pins the trim inside −20…−30 and the baby's absence of one. 195 tests green.
2026-08-04 (Esc menu: Leave museum + full screen; "4" screenshots; every diagnosis knob moves behind ?debug) <- please QA
Leave museumin the Menu (owner clarified: leaving = back to the home page): a permanent button that returns to the landing page, keeping?debugif it was on so a diagnosis session survives the round trip. Leaving a ROOM stays what it always was — fly into the doorway of light; the membrane's exit code was extracted into the singleleaveRoom()along the way.Full screenin the Menu: toggles document fullscreen, with the webkit-prefixed call for iPad Safari; a browser with neither is simply not offered the button.- "4" takes a screenshot: saves the current view as
museum-<room|corridor>-<timestamp>.png. The GL canvas does not keep its buffer between frames, so the key only sets a flag and the frame loop snapshots right after it renders; typing a 4 into the sign-in or editor fields does not trigger it. - Menu re-laid as a two-column grid (owner QA): Resume full-width; Switch view beside Full screen (a browser with no fullscreen support gives Switch view the whole row); Graphics beside Gravity; the Live-effects slider full-width with its left edge on Graphics; Leave museum full-width at the bottom. The sign-in divider takes the same width, so every control shares one set of grid edges.
- URL parameters consolidated (owner: no query params except ?debug): the URL now has two kinds of parameter. Door parameters — what the landing page writes (
world/room/avatar/gravity) — stay as they are: they are the entrance link, and an old link must keep meaning what it meant. Everything else is a diagnosis knob (paint/bench/live/arttex/faces/hz/dpr/sky/hud/manual/inspect/presence) and is honored only when?debugis also present — one gate inparam(), the same rule inresolvePresenceUrl(a pasted link can no longer point someone's museum at another presence server or quietly freeze the paintings). A diagnosis run now reads?world=museum&debug&bench=30; the knob table in PERFORMANCE.md says so.
2026-08-05 (three studio bugs: the sign-in card covered the model, one stroke came out as many, Clear left paint behind) <- please QA
- The owner confirmed remote avatars are correct now. Three studio fixes:
- The sign-in card covered the model:
gate.hidden = trueis powerless against an inlinedisplay:flex([hidden]is only the UA stylesheet'sdisplay:none, and inline styles win). It now drivesstyle.display. - One stroke came out as many — the brush was innocent, the model's UVs were not: reading the GLB measured it. The baby astronaut's seven suit parts share ONE fabric texture AND the same full 0..1 UV square (arms, legs and torso 0..1; boots 0.13..0.88), so a stroke in that texture landed on all seven at once, exactly as the screenshot showed. The angel is one mesh with its own UVs, which is why it never surfaced before. A paintable surface is now one MESH, not one material (
paintableSurfacesreturns {key, mesh, slot, material}) and a part takes a copy of the shared material at its FIRST stroke (isolateSurface, copy-on-write): painting the left arm changes the left arm, parts nobody paints cost nothing, and a saved painting carries only the parts actually painted — a smaller payload, not a bigger one. The museum (local avatar and remote rigs alike) reads the same rule and does the same copy-on-write. - Clear left paint behind: it used to repaint only the layers that already had a canvas. Clear now puts the factory textures back and drops the canvases, so every part is cleared; the status line is honest that Clear cannot be undone but that nothing was saved — reloading brings the last SAVED painting back.
- 7 surface-rule tests (a shared material is several surfaces, isolating one leaves its twins alone, multi-slot meshes, an unpaintable model). 221 tests green.
2026-08-05 (everyone is drawn in their OWN avatar at last) <- please QA
- Owner: one window in avi, one in angel (incognito) — each saw the wrong avatar for the other, and the WS frames he pasted were perfectly correct (his own state
av: space-baby-avi, the peer'sav: my-angel). The data was right; the RENDERER was wrong: every remote rig was a clone of the LOCAL player's model (the "top follow-up" recorded last round), so the person you saw was always your own body. - Fix:
threeMuseumnow keeps a prototype per avatar id (avatarTemplatesplusavatarTemplateLoadsso ten peers in one avatar fetch the GLB once;init()takes aresolveAvatarcatalog lookup).buildRemoteRig(avatarId)clones THEIR prototype: their own height-normalized scale, their own four clips, their own factory maps (baseMaps moved onto the template). A peer who changes body gets a rebuilt rig; while their GLB is still downloading they are not drawn at all (appearing half a second later beats briefly wearing someone else's body); an unknown or failed avatar falls back to the procedural mannequin — "someone is there, model unavailable" is the honest answer. - The stopgap from last round is therefore gone:
dressPeerno longer skips a painting because the avatars differ — peers wear their own bodies, so their own paintings fit.applyRemotePaintingstill refuses a painting whoseavatarIdis not the rig's (the material keys would not line up). - Diagnostics:
presence-avatar: loading <id> for another explorerandpresence-avatar: peer <8 chars> is a <avatarId>(console filterpresence-avatar).dispose()now also frees avatar prototypes. 220 tests green. Note the cost: each additional avatar among your peers lazily downloads that GLB (the angel is 8.9MB) — the price of seeing the real person, and worth it.
2026-08-05 (one sign-in implementation; the dev shortcut was invisible on LAN; identity made observable) <- please QA
- "Dev sign-in doesn't work" =
isLocalDev()only accepted localhost.npm run devserves--host 0.0.0.0for iPads, so opening192.168.x.xnever rendered the dev button at all. The rule is now a pure module,auth/localOrigin.js(loopback / 192.168.x / 10.x / 172.16-31.x /.local; a public origin can never match, since private ranges are unroutable), with a real test over 14 hostnames. - The sign-in UI is now ONE implementation (the owner was right):
auth/signInPanel.ts, mounted by the Esc menu and by the studio, dev shortcut included — so it can never be half-there again. The menu keeps only its editor buttons (who may build is our allowlist, not the account service's business). - Identity is observable (answering "where is it stored?"): the guest id lives at
localStorage["inception.guestId"]; an account's id is derived from the address each time and never stored. The browser printspresence-identity: <kind> <id>at boot — two tabs must print the same id — and the API printspresence: retired <socket> — identity <prefix> opened a newer tabwhen the rule fires. No such line means the rule never ran: the API is a plain Node process, so restartnpm run api(or deploy) after a server change. - A landmine removed on the way: plain http on a LAN address is not a secure context, so the browser has no
crypto.subtlethere — the account hash used to throw and take the whole of bootMuseum down with it. It now falls back to this profile's guest id (the boot line says so): multi-tab still collapses to one explorer, and only cross-device account unification waits for https. Painting ids now also prefer the id the server returns (it hashes the same bytes), with local hashing as the offline fallback. 220 tests green.
2026-08-05 (one person = one explorer: identity, login-only painting, paintings bound to an avatar) <- please QA
- The owner set three rules: only signed-in users may paint (so a painting id is unique, several tabs still hold ONE avatar, and the latest wins); guests get an identity too (so their tabs also resolve to one avatar — a private window is beyond our control and that is fine); and an account maps to the avatars it owns (documented now, persisted later).
- Identity layer
src/inception/net/identity.js: signed in =sha256("account:" + email), first 16 hex; guest = a random 16-hex id persisted in this browser profile. It is sent only on join, only as the hash (never an email), and never relayed to other clients. The hub gainsidentityOf+retireOlder(): the same identity's OLDER connection is retired (it receivesreplaced, stops publishing, closes its socket and says so in the status line; everyone else gets peer-left). Three tabs of one child are no longer three avatars. Changing rooms never retires your own connection, and a guest without an identity is still admitted. - Studio sign-in gate: without an account you may look, turn and zoom, but the tools, Save and Enter are disabled behind a centered card carrying the same email-code flow (plus dev sign-in locally); a guest's drag on the body turns the model — the gate has to hold over the whole body, not just under the panel.
- Paintings are bound to an avatar: presence state gains
av(the avatar slug, pattern-checked), andapplyRemotePaintingrefuses a painting whoseavatarIdis not the rig's (material keys are positions in THAT avatar's material list, so an angel's painting on a baby astronaut is just smearing). The puller skips it early too. This is the "数据有些串了" the owner saw. - The other half of his report, stated plainly: after switching one tab to Space Baby, all the duplicated avatars became Space Baby. That is not data crossing over — it is the already-recorded gap that remote explorers are drawn with the VIEWER's model (a rig is a clone of the local template). Identity removes the duplicates; drawing each peer as their own avatar still needs per-avatar GLB templates (cached and lazily loaded) and is now the top follow-up.
- The data relations are written into AVATAR-STUDIO.md (who may paint, the identity table, and the relation sketch: account → avatars ownership / account → paintings → exactly one avatar_id; nothing per-world) and into ADR-0007. 11 new tests (identity case-folding and no-email, one guest across tabs, a locked profile still gets an identity, join accepts only the hash shape,
avvalidation, retirement plus a retired tab cannot move, room changes never self-retire, no identity means no eviction). 219 tests green.
2026-08-05 (the line tool is gone: dragging the brush straight IS a line)
- Owner: the line tool is unnecessary (the brush already does it) and it had bugs, so it is not kept. Removed whole:
PAINT_TOOLSis now brush/spray/dot,lineStampsis deleted, and the toolbar button plus the press-then-release path logic (lineStart, the endPointer branch, its status text) are gone. The valuable half stayed: the brush keeps the surface-following screen walk, so fast drags no longer smear across the atlas. A test pins the toolset (["brush","spray","dot"]) and AVATAR-STUDIO.md records that there is deliberately no line tool, and why. 212 tests green.
2026-08-05 (the studio's line broke: strokes now walk the screen and trace back onto the surface) <- the line was then removed on the owner's call; see the entry above
- Owner: the line tool did not follow the 3D surface, so the line came out broken. Cause: the line interpolated between two positions in TEXTURE space, and a scanned body's UVs are many small islands — a straight path across the texture leaves the surface, crosses atlas gutters and other charts, so it lands in pieces (and sometimes on the wrong body part).
- Fix: a pure
screenSamples()(paintTools.js) walks the stroke's path every 4 screen pixels and traces each sample back onto the model by raycast. Consecutive samples are joined in texture space only when they land on the same material close together; at a UV seam the stroke stamps instead of smearing. It reads as one continuous line ON THE BODY, the only place continuity matters. The brush uses the same walk, which also fixes the same hazard on fast drags; spray and dot stay point tools. Samples are capped at 240 per segment (each is a raycast against 35k triangles) and empty space is never bridged. The line's weight now matches the brush (it was 0.8×; "a line is a brush that walks straight" is the simpler model for a child). - 210 tests green (new sampling tests: step never exceeded, the end point is always reached, a tap paints once, a very long drag is capped but still ends where the finger lifted).
2026-08-05 (peers' paint invisible / only partly visible — two real bugs) <- please QA
- Owner reported that remote explorers' paintings sometimes did not show at all, sometimes only in part. First, the architecture answer: painting bytes never travel over the WebSocket — presence carries only the 16-hex-character content hash
pt, and the painting itself moves over HTTP/api/paintings. - Bug 1 (partly visible) = cloning broke material sharing. The baby astronaut's textured
Baby_Suit_Texturedis shared by seven meshes (confirmed by reading the GLB). Remote rigs cloned materials PER MESH, so one paintable surface became seven, every material key after it shifted, and a peer's paint landed on one patch of suit while the face and hair stayed bare — with the leftover patches still wearing the LOCAL player's paint. Fix: clone once per SOURCE material (a Map), preserving exactly the sharing the studio saw; disposal dedupes the same way. - Bug 2 (invisible) = one 404 blacklisted the painting forever. A peer announces
ptthe instant it joins, while its own upload is still in flight (and a free Render API may be waking up). The old code took that first 404 as the answer and never asked again for the whole session. Fix: a pure policy modulenet/paintPull.js— exponential backoff (2s → 20s), six tries, then quiet; single-flight per id. A pulled painting is applied immediately and held in memory, so correctness no longer depends on localStorage having room (quota errors are swallowed; the device cache is a pure optimization now). - Removed the drift itself: the material-numbering rule existed twice (and the studio's copy skipped a number when an image was not ready). It is now ONE pure function,
studio/paintTargets.js, used by both sides and testable in node with a fake scene graph. - Diagnostics: every painting-sync event logs with a
presence-paintprefix (who wears which painting over how many surfaces; which id is not in the relay yet and will be retried), and upload failures are no longer silent (the studio's Save says "saved on this device — sharing will retry when you enter the museum"). AVATAR-STUDIO.md gains "Watching it work" (console / Network / WS filters) and the two lessons. 6 new tests (shared material = one surface, the numbering rule, a 404 retries rather than blacklists, the give-up threshold, quota never throws). 209 tests green.
2026-08-04 (ADR-0007 accepted with the owner's amendments; inception-db renamed)
- The owner ruled item by item: artworks yes; the three-layer permission machinery is over-engineering → plain public/private like rooms (private ≈ the old personal_draft, public ≈ public_snapshot; class_shared waits with classes); classes deferred; avatars yes, but NEVER via web upload (a content-safety choice — assets keep going through the repository's provenance + budget review), the table is an ownership registry (owners[] + visibility; a private avatar shows in the catalog only for its owners); the Phase 6 AI approval idea stays a note (likely an important step toward AUTOMATED content review later, needs an LLM API, not now; when it lands it plugs into the same visibility switch — nothing turns public without a decision row).
- Rename done: the owner renamed museum-db → inception-db in the dashboard (step one of the safe order); render.yaml (both references) and DEPLOY-RENDER.md are aligned; on the next blueprint sync, confirm it adopts the EXISTING instance. ADR-0007 status → accepted with amendments. 200 tests green (including the owner's parallel Angel yawCorrection mount trim of −30° and its test).
2026-08-04 (database design graduates to ADR-0007: one database, many worlds, global users) <- accepted, see the entry above
- The owner reviewed museum-db and set the shape: one database for all of Inception Space; Inception Space has many worlds (the Space Museum is one), a world has many rooms, and a user is global (e.g. avatar paintings). Written up as ADR-0007 (proposed), published at /docs/.
- Schema v2:
worlds(id/name/kind/owners/visibility/config JSONB) →roomskeyed(world_id, id)(otherwise today's table) →users(email PK + display JSONB: public pseudonym per the R6 line, plus synced preferences) →avatar_paintings(content-hash id, global per user; UNIQUE(email) now, shaped for 3 per avatar later). - The audit of "what else needs storing":
artworks(.lpp/.lpr metadata, bytes in R2); §2.3's three authorization layers become a status enum (personal_draft / class_shared / public_snapshot with published_at/approved_by audit — the discovery text twins can then be generated from the database);classes+memberships(Phase 7, with public pseudonyms); anavatarscatalog table (the landing page already promises student-made avatars); Phase 6 AI approval decisions. Deliberately never stored: presence, room passes (stateless HMAC), OTP/accounts (the account service's D1), the painting relay cache (memory), and any analytics of children (no plan wants it — keep it so). - Rename steps (owner executes all cloud changes): Render blueprints match resources BY NAME — rename museum-db → inception-db in the dashboard FIRST, then align render.yaml in the same change (the reverse order provisions a second database, and free workspaces allow only one free Postgres). The table is empty today = the zero-cost window. The connection string's internal databaseName=museum is cosmetic; change it at the paid-tier migration.
- Implementation order once accepted: worlds in store.js + the two-part room key →
/api/worlds/:world/rooms/:id(museum world keeps a compatibility read) → graduate from self-applied CREATE to ordered migration files the moment the first ALTER lands. 194 tests green.
2026-08-04 (Angel animation v2 landed) <- please QA
- The owner's multiclip-v2 export: identical clip names/durations, geometry and textures — the improvement is in the animation curves themselves. A pure asset swap (zero catalog changes); PROVENANCE hash updated with both older hashes kept as superseded. 194 tests green.
2026-08-04 (studio finish: painting flows straight into the museum)
- Owner: the step after painting is ENTERING the selected world ("Enter Space Museum"), not going back to the entrance. The landing page's Paint link now carries the whole selection (
?studio=<avatar>&room=<room>& gravity=<g>), and the studio toolbar's primary button is Enter <room name> (the mint primary style): it saves first (local + relay upload), then walks throughselectionHref— no child ever leaves a masterpiece at the door. "Entrance" demoted to a small text link; the Save button stays for saving without leaving. 194 tests green. - Owner heads-up: another GLB revision is coming. The path is ready: replace the file →
node scripts/inspect-glb.mjs→ PROVENANCE hash must change (a test forces it) → if clip names change, remap the catalog'sclipsand nothing else.
2026-08-04 (four-clip Angel + the avatar paint studio + paintings synced by id) <- please QA
- The multi-clip GLB landed:
Angel_Idle_Loop5.0s /Angel_Walk_Loop2.0s /Angel_Float_Loop3.0s /Angel_Dive_Loop2.5s, geometry and textures unchanged. The catalog maps each movement role to its own clip; PROVENANCE hash and story updated (the same-day single-clip hash stays on record as superseded). The avatar-import performance rules are now IN the docs (docs/PERFORMANCE.md, "Importing an avatar"): triangles and joints are almost never the problem, scan- grade textures are; runscripts/inspect-glb.mjson every export first. - The paint studio (owner's new feature): select an avatar on the landing page → Paint → full-screen
?studio=<id>(src/inception/studio/avatarStudio.ts). You paint the actual 3D body: a pointer ray hits the mesh, the hit's UV stamps onto that material's albedo canvas. Tools = brush / spray / dot / line (all geometry in the pure, tested paintTools.js), palette + free color + size + undo (per-layer snapshots; undo pops the layers the stroke touched) + clear; one finger/left button paints, two fingers/right button orbit and zoom. No live painting — plain pigment. Brush strokes connect only across small UV jumps so they never smear across atlas islands. - Painting sync (the architecture the owner asked for): the WebSocket carries only an id. Saving flattens each painted material to a 512² lossy JPEG (visual loss explicitly accepted), stores it locally per avatar (paintStore), and the id is the content hash (client and server hash the same bytes — a test pins them equal) → idempotent upload to
POST /api/paintings(server/paintings.js: in-memory LRU relay cache, shape-validated, 384KB cap, immutable download headers). Presence gains a strictly-validatedptfield (hex hash — the no-free-text line holds). A peer seeingptchecks its local cache (last 10 kept), pulls once on miss, and dresses that rig. Remote clones now wear their OWN cloned materials (reset to the factory coat, then that peer's painting) — nobody's pigment can bleed onto anyone else; a rig's painting textures are disposed with it. Soft failure everywhere: no API = the same solo museum. - The future plan is in the docs (docs/archive/AVATAR-STUDIO.md, published at /docs/): signing in becomes required to SAVE — paintings then persist to the database (server-side re-check, dev sessions rejected, same rule as room saves); one painting per email for now, schema already shaped for up to 3 per avatar later; the relay cache stays as the fast path. 19 new tests (tool geometry / stores / server cache / protocol
pt/ catalog). 194 tests green.
2026-08-04 (Angel follow-ups: provenance corrected, texture diet, scalable avatar picker, GLB inspector) <- please QA
- PROVENANCE corrected to the owner's own words: the owner scanned his own physical angel model himself with Polycam; Codex built the game asset from that scan in Blender (cleanup + a 5-joint rig +
Angel_Gentle_Loop). The scan and the .blend stay on the owner's machine. - Performance answer (owner asked about the Pilot budget): triangles and joints are non-issues (34.8k triangles are trivial for the GPU; a 5-joint skin is cheaper than the baby's 65); the 8.5 MB download happens only when the angel is chosen. The real cost was scan-grade textures: three 4096² JPEGs ≈ 190 MB of decoded GPU memory (iPad Safari's whole- page budget is roughly 1–1.5 GB). Runtime backstop added:
capAvatarTexturesdownsizes avatar textures to 1024² at load (a 0.3 m tall character never needs 4K on screen), cutting the GPU cost ~16×. On a future re-export, setting the textures to 1024² in Blender also shrinks the file itself. - Multiple actions: today's GLB carries ONE 5.04 s clip (verified by inspection). If Codex authored several Blender Actions, the glTF exporter must have Animation mode set to "Actions" (or each action stashed onto an NLA track). Once a new GLB lands it is a data-only change: the catalog's
clipsmap plus the PROVENANCE hash. Addedscripts/inspect-glb.mjs(prints clips / triangles / joints / texture sizes / feet-origin bounds) — run it on every export before committing. - Scalable avatar picker: rooms and avatars are now two stacked sections — rooms keep big cards (there will only ever be a few worlds), avatars become a compact auto-fill grid (150 px cards, 130 px on phones; ten or more wrap into tidy rows; descriptions become hover tooltips). auto-fill keeps card width steady on short rows — the house style. 183 tests green.
2026-08-04 (a second selectable avatar: My Angel) <- please QA
- The owner delivered an original Blender angel (
my-angel-v1.glb: 34,794 triangles, 5 joints, 3 embedded textures, 8.5 MB, feet-origin, 0.85 m tall; both hashes recorded in PROVENANCE — the creation story was written down by CC on import, please restate it in your own words as you did for the Baby Astronaut). The preview card ships too; the landing page's avatar card appears automatically (catalog-driven) and the count line now follows the catalogs. - The avatar catalog is real data now: each AVATAR_CATALOG entry carries
assetUrl(a staticnew URLliteral — Vite only emits assets it can see, and a computed path once shipped a museum with no avatar),assetHeight, and aclipsrole map.?avatar=<id>is now actually read by the museum (previously only the bake-off honoured?avatar=mannequin); the mannequin escape hatch survives (an explicit choice builds the procedural fallback directly). - Height-normalization rule: world scale = AVATAR_SCALE · (1.8 / H), centering offset = −H/2. The baby's old hardcoded −0.9 at ×1 is exactly the H=1.8 case; under the rule, offset·scale ≡ −AVATAR_FOOT_DROP holds for EVERY avatar (one foot-pinning constant for all) and every avatar stands the same world height (~0.3 m). Remote clones wear the same normalized scale.
- The angel has a single clip,
Angel_Gentle_Loop→ the catalog maps all four movement roles onto it (an angel hovers the same way walking or diving). The loader resolves clips through the map; when roles share one clip the weight blend degenerates to the clip itself. - Budget: over the default avatar's Pilot budget (20k tris / 4 MB) — accepted for an OPTIONAL, loaded-only-when-chosen avatar, with its own 40k / 10 MB cap guarded by tests/inception-avatar-angel.test.js (structure, size, clips, hash↔PROVENANCE all pinned). dist verified: both GLBs are emitted.
- Known gap (recorded, not built): remote presence explorers still appear as the VIEWER's chosen avatar (the protocol carries transforms only); an avatar-identity field (a catalog enum index — within the no-free-text line) is the next step. 183 tests green.
2026-08-04 (gravity becomes a choice at the door: Zero-G / Micro / Moon) <- please QA
- The owner reported an occasional "micro-gravity feel" — and liked it. Explanation: releasing the thrust leaves an exponential damping glide (velocity decays at 2.2/s), always there but noticed once the black holes sharpened the view; on touch, a lost touchend can also leave a slow joystick-residue drift. Since it feels good → make it a real, chosen thing.
stepFlightgains an optionalparams.gravity(m/s²; absent/0 = the classic zero-g, byte-for-byte unchanged): a constant pull on vy only, and damping yields a terminal sink of gravity/damping (Micro 0.5 → ~0.23 m/s; Moon 1.6 → ~0.73 m/s). The floor clamp absorbs the landing; the r6 "Space does nothing on the floor" rule is untouched; thrust at 7 m/s² beats the strongest preset, so you can always fly.- Three doors in: a Gravity pill row on the landing page (writes
?gravity=into the enter link only when not the default, so old links keep their exact meaning); a Menu cycle button beside Graphics; localStorageinception.gravityremembers, the URL overrides. - Tests: the zero-g path is unchanged / micro sinks below terminal speed and settles on the floor / float thrust outclimbs Moon / resolver precedence / selectionHref carries gravity only when chosen.
2026-08-04 (Van Gogh House: new document committed; houses reach the ceiling; rotated collision uses the true walls; editor ghosts fixed) <- please QA
- The owner's new room-a document is committed (sculptureOrb + the two student houses + a lamp) and passes validateRoomDoc; the editor's cursor field
selectedstays out of the repository, as before. - "The ceiling gap" was really a slit between body and roof (owner clarified): my first reading stretched the houses to the room ceiling — fully reverted (houses back to authored size, solid height back to roofPeak). The real gap was a MODELLING bug: the eaves floated 0.02 above the wall top (
eaveY = wallHeight + 0.02), a see-through 2 cm slit from many angles. Fix = the eaves rest exactly on the wall top (sharing the edge, not overlapping — the coplanar gable triangles stay clear of the side walls, no z-fighting). These houses are code-built geometry (studentVillageHouse.js), not Blender assets, so the model is edited directly in code. Tests pin both: scale stays 1 and the roof's lowest vertex equals wallHeight (within Float32 epsilon). - Rotated collision: the world AABB always followed the angle (a conservative bounding box), but at 45° the large house grew ~1 m of invisible wall at each corner. Obstacles now carry
local— the exact footprint in the prop's own frame — andresolveObstaclesresolves there: you collide with the REAL walls, and the empty corners no longer block. The house's solid also rises to the ceiling (there is no "over the house" any more). Tests pin all three: corner points untouched / an inside push exits exactly on a true wall / solid height = 8. - Editor ghosts: rapid nudges launch overlapping async loadRoom calls; an older load finishing late had its group overwritten in the registry but left in the scene forever — the pile of overlapping shadow copies. loadRoom now stamps a per-room generation and a stale load disposes everything at its commit point (geometry, materials, textures, surfaces; the return-portal core registers at commit so a stale load can never overwrite the live one). Also fixed the long- standing unloadRoom leak of warpFaces (every visit to the water-floor room left one forever-updating uniforms entry; entries now carry an owner/generation and are purged with their photo/mask textures).
npm run check= 177 green.
2026-08-04 (mark effects pierced the glass = transparent-pass ordering; marks now render beneath every pane) <- please QA
- Owner: with black holes compositing normally, an old problem surfaced — mark effects painted ON TOP of the corridor glass. The transparent pass draws in ASCENDING renderOrder: the glass floor/walls sit at −3/−2 while the marks sat at −0.5 and the sky cross-fade sphere at −1 — both AFTER the glass. In the additive era a star merely added a little light to a pane, unnoticeable; a black hole visibly paints the glass dark.
- Fix: marks are part of the SKY and must be laid down before any of the world's glass →
renderOrder = -5; the cross-fade sphere likewise →-6(fades in under the marks, under the glass). A pane now tints the stars and black holes exactly as it tints the panorama. - Also enabled
depthTeston the marks: the sky sphere writes no depth, so stars remain visible against the sky — but real solids (a dome room's floor, props, the astronaut) now occlude them correctly, which depthTest:false silently never did. - Tests updated: the marks-mesh source scan now pins
renderOrder = -5+depthTest: true+ NormalBlending (slice anchor moved tothis.skyMarks.set). 171 tests green. - Note for later: when a warp-type sky package lands, a transparent sky material would move the sky sphere itself into the transparent pass (renderOrder −1 today) — push it below the glass at the same time.
2026-08-04 (invisible black holes = additive blending cannot darken; sky marks now composite normally) <- please QA
- Owner: the carefully painted black holes (pure-black marks) were nowhere to be seen. The data was fine — 215 pure-black marks (rgb 0,0,0) are in the package. The compositing was not: the GPU sky used AdditiveBlending, and addition can only add light — black adds zero and is invisible.
- The art-lab's current semantics (
assets/brush-draw.js): glow marks blend "lighter" with EACH OTHER on an isolated scratch sheet, and the sheet is then laid over the painting with source-over — per mark the painting receivessrc·a + dst·(1−a), which is normal blending, not pure addition. A black mark accumulates ALPHA on the sheet and lands as translucent black: that is precisely how a black hole darkens a nebula. - Fix: the sky marks material moves AdditiveBlending → NormalBlending (shader untouched —
vec4(vColour, alpha)is equally correct against a SrcAlpha blend factor). Bright stars over the near-black sky look almost identical under both; overlapping bright marks now build toward their own colour instead of blowing toward white — closer to the art-lab reference, but please re-eyeball the stars and nebulae. Known first-order approximation: the art-lab sums a layer's glow marks before compositing once, the GPU composites mark by mark in authored order; at these alphas the difference is invisible. The corridor trim rings keep their legitimate additive glow. - A new test pins both facts: the v7 package carries exactly 215 pure-black marks, and the marks material must say
blending: NormalBlending(source scan, same technique as the asset tests). 171 tests green. - Note for later: the inherited room renderer (curatedLivePainting.ts) still has the OLD semantics —
mark.glow ? "lighter"with no isolation sheet — so a black glow mark in a ROOM painting would vanish the same way; align it when that path moves onto.lpr.
2026-08-04 (new v7 revision imported: 46 brush revisions, 31 code shapes, reviewed as ONE behavior) <- please QA
- The owner delivered a new sky
.lpp(2.3MB, with a fully REWRITTEN "Nebula Breath" brush and 689 new nebula marks). First compile: 30 gaps — the owner edited the brush source while painting, so 46 revisions scatter into 31 code shapes, most cited by a single stroke. Exactly the painting style the shape-identity design predicted (editing code, not dragging sliders). - The review took an honest shortcut: comparing each shape's runtime view (comments stripped, and the paint-time
brush()removed — its output is already baked into the marks), the 30 gap shapes collapse byte-identically into one, and itsmove()/fadeCyclemath is exactly the reviewedphoto-nebula-breath@2(same two sines, same envelope, same clamps; the differences are comments, formatting, the TERRITORY/LINKS consts move() never reads, and inert returned fields). Every shape carries all eight @2 parameters. Verdict: 30 registry mappings → @2 — no new version, no shader change, no schema change. - The package went from 758 marks (with 30 gaps) to 1447 marks, complete (39 distant stars + 17 bright-star marks + 1391 clouds; the owner also thinned the star layers from 48+20). The photo hash is byte-identical to the museum's 4K panorama (test-pinned). The mark-shader test's pins moved 770→1447 and 702→1391. 167 tests green. The previous revision is backed up in the session scratchpad.
2026-08-03 (a live effect belongs to its own sky)
- Owner: after a corridor end swaps the sky to background1/background2, the starlight
.lpp's live effects must be gone — they were painted on background.png. Fix: the sky mark mesh is registered under the panorama it was painted FOR (skyMarks: variant → mesh), andsetSkyVarianttoggles visibility the moment the fade begins, so only a package registered for the current panorama stays lit. - The owner plans separate
.lppuploads for background1/background2; the seam exists now:SKY_PACKAGES = { default: v7, north: null, south: null }in museumMain — compile a package, fill in its id, and boot applies each viasetSkyPackage(id, null, variant). Re-applying a variant replaces its old marks (geometry and material disposed, the scheduler entry removed with them). Warp-type sky packages stay default-only for now — the cross-fade does not know shader materials yet, and a warning in the code says exactly that. 167 tests green.
2026-08-03 (two follow-ups: desktop Shift dives; /docs/ works on the dev server)
- Owner: add Shift = dive on desktop, Space unchanged. A pure input mapping: Shift (either side) feeds the
diveHeldchannel the touch Dive button already uses —verticalFromControlsitself is untouched, so a dive still stops at the floor (the r6 rule) and combines with WASD, and a held Space keeps its exact priority (float, ceiling-latch dive). Landing-page Controls text updated. - Owner:
localhost:5173/docs/served nothing — the doc pages only existed as build-time files in dist. The page rendering moved whole intosrc/inception/discovery/docsPages.js(one set of functions shared by the prerender script and the dev server), and vite.config.ts gained a dev-only middleware:/docs/,/docs/<slug>.htmland/docs/<slug>.mdnow render live from the working tree (edit a doc, refresh, see it), with the same redaction; production still serves the prerendered dist files. Verified with curl against :5173 — all three path shapes answer 200. 167 tests green.
2026-08-03 (WebSocket presence + the /docs/ documentation site) <- verify from two devices after deploying
- Roadmap item 4 lands: presence runs in the museum API's own process (the exact seat ADR-0005 reserved).
server/presence.jsis a pure hub (zone → members, no sockets, unit-testable as data) plus a thinwsskin on the same node:http server at/presence. Sharding matches per-room loading one to one: one corridor channel, one channel per room, and a client subscribes only to the space it occupies. - The protocol is transform-only: six clamped, rounded numbers
{x,y,z,yaw,hs,vm}through a whitelist sanitizer; a join carries only zone/token/pass. There is no field a person could type into (the AGENTS.md safety line); identity is a server-issued random id and no email is ever broadcast. Flood control: per-socket rate limit, 4KB message cap, 24 per zone, 200 connections, a heartbeat that reaps dead sockets, and an Origin check. - A private room's channel obeys exactly the read rule of
GET /api/rooms/:id(it reusesauthorizeRead: owner token or room pass) — personal boundaries hold in realtime too. A room the store has never seen counts as public: the static build already hands every visitor its document, so the channel holds nothing left to protect; the moment an owner saves it private, the gate closes. - Client:
net/presenceClient.ts(VITE_MUSEUM_API→ wss; backoff reconnect;?presence=offdiagnosis switch; in dev with no API running it tries once, quietly) plus pure smoothing innet/remotePresence.js(exponential approach, shortest-arc yaw, jumps over 8 m land instantly as teleports, 5 s of silence reads as idle). A remote explorer is a SkeletonUtils clone of the local GLB (geometry/materials shared, its own mixer, the same walk/float/dive/idle weight rules as the local avatar) with the same mannequin fallback. OnesyncRemoteAvatarsreconciliation per frame; with no API configured the museum stays the solo museum it was (additive only). - The
/docs/documentation site (owner: "render md like lucasacademy.org/doc", for people, search engines, and AI assistants with no repository context, so any of them can build a solid plan): 12 real documents (README, vision, direction, the bilingual execution plan, the LPR format, performance, discovery, ADR-0002..0006) render at build time viascripts/prerender-discovery.mjsthrough a small no-dependency escape-first Markdown renderer (src/inception/discovery/markdown.js) into/docs/<slug>.html, each with a raw.mdmirror beside it. Email addresses are redacted at render time to[email removed], and the privacy test now covers everything under /docs. sitemap / robots /llms.txtlist it all (llms.txt links the.mdmirrors directly). The landing page gains an expandable "Project docs" section.DEPLOY-RENDER(an ops runbook) and AGENTS/CLAUDE (tool instructions) are deliberately not published. ws@8joins the dependencies; 24 new tests (protocol sanitizing / hub rules / a real end-to-end socket / smoothing / the Markdown renderer / catalog and privacy).npm run check= 166 green. Presence needs no new cloud resource; after deploying, the owner can verify with two browsers looking at each other.
2026-08-03 (v7 sky: the nebula keeps breathing) <- please QA
- The owner supplied
space-museum-corridor-starlight-v7.lpp("added a lot of nebula brush effect"). Archived into the corpus and the sky now wears it: 770 marks — 48 distant stars, 20 bright-star marks and 702 of nebula cloud — against v5's 85. v5 stays in the corpus as the previous revision. - The first compile came back with one gap, which is shape-based identity working exactly as designed: this time the brush's code changed. The two-sine breath is still there, now multiplied by an endless envelope (dark rest → fade in → bright hold → fade out → repeat) with six new
@uicontrols, and its 11 revisions collapsed into one shape to review. Read, then registered asphoto-nebula-breathversion 2; v5 keeps naming@1and keeps its look. - Two format conclusions: parameters belong to a version, not to a behaviour (
paramsByVersionin the schema,paramsFor(kind, version)in the compiler and the validator — otherwise every v1 package is suddenly "missing" six values), and the shader dispatches onbehavior@version, because drawing it differently is what a version IS. Replaying v2's data as v1 is now a validation error instead of a loop that silently stops. nebulaLoopLevel()is the JS twin of the shader'snebulaFadeCycle(the plan §5.3 rule), and the test pins the property the owner actually asked for: across four seeds and six cycles every cloud reaches full brightness and returns to rest — no setting can switch a cloud off for good. A stagger of 0 makes them breathe together; "light kept when dark" lifts the floor without touching the peak and clamps at 60% where the brush does.- v7's shell is byte-identical to the 4K panorama the museum shows (a test checks the hash), so the clouds' colours are sampled from that exact picture. The package is 73 KB of JSON inlined in the bundle. 141 tests green.
- Owner QA: "I can see the effects, but they're misplaced" → fixed. A mark's canvas y counts DOWN from the top of the painting; the sphere's texture v counts UP from the bottom. Feeding y straight through mirrored the whole sky vertically — every cloud sat under the wrong nebula (v5's 85 stars were flipped too; tiny dots against a starfield just never showed it).
buildMarkInstancesnow passes1 - y/height, and a new test pins "a mark lands under the pixel it was painted on": a synthetic package's top maps to the zenith and its bottom to the nadir, then every mark in the real v7 package is checked against the height formula. 142 tests green.
2026-08-03 (a sealed room stops drawing the deep space) <- please QA
- Owner: "Van Gogh House has nothing transparent — then it doesn't need to load and render the space background; The Sky is totally correct." Confirmed by reading the geometry: a six-face room is opaque (
MeshBasicMaterial, no transparency) and its return membrane stands against a wall, so the sky sphere is behind all of it. Every frame in that room was paying a full-screen fill — 4K panorama, the warp shader now the sky runs a package, and every additive star instance — and then covering it with a painting. The Sky is a dome with no ceiling, so there the sky is the ceiling and must stay. - Implemented as the owner asked, as a room attribute:
spaceBackgroundin the room document, with an on/off button in the editor next to the face pickers.showsSpaceBackground(doc)is the one rule; a document written before the field falls back to what its layout implies (dome on, six faces off), so no existing draft or committed file changes behaviour. Entering a room,?inspect=, and every editor rebuild callThreeMuseum.setSkyVisible(); leaving restores it because the corridor is glass. The stars ride along — they are children of the sphere. Both committed rooms now say so explicitly (room-a false, room-b true). - Rendering only, not loading. The sky sphere is built once at startup for the corridor, and every visit begins in the corridor, so there is nothing per-room left to fetch or upload; making it lazy would only move the cost into the first corridor frame.
docs/PERFORMANCE.mdrecords this. - 135 of 137 tests pass; the two red ones are the same
inception-live-sky.test.jshashes noted below (the owner's regenerated.lpp), untouched by this change.
2026-08-03 (marks on the GPU: the 4K sky is alive) <- please QA
- Reading the three sky brushes showed why this is cheap: their
move()produces no displacement and samples no photograph — only size, alpha and colour from (mark.index, seed, t). So nothing is repainted. The 85 stars upload once as instance attributes and animate entirely in a vertex shader, one uniform per frame. The same painting on the canvas path would re-upload about 32 MB per refresh. markShader.jstranscribes it line by line: eight Messier colour families, eight bright-star families with eight breathing curves, the nebula's two-sine breath. A bright star is several marks — core, diffraction rays, sometimes a halo — so the fragment shader drawsdot,streak(stretched and turned by its authored angle) andring.- Two real bugs fell out:
ringwas not in the shape table at all, andcompileLpppacked every shape as 0 (a mark names"streak",packMarkswants an index,Number("streak")is NaN), so every halo and ray had been flattened to a plain dot. - The sky package is now complete (85 marks, 3 behaviours, needs only the
markscapability) and is applied at boot;?sky=offrestores the plain panorama for comparison. 134 of 136 tests pass. - The two red ones are the owner's own
inception-live-sky.test.js: they pin the legacy curated record, and the new 4K.lppcontains brush revisions the old importer's approval table does not know. That pipeline has been superseded by.lpr(live:checknow verifies the packages; the old one is kept aslive:legacy-check). Regenerate the record or retire it — the owner's call.
2026-08-03 (the HUD goes behind ?debug; catching up with parallel edits)
- Clarification: by "?debug" the owner meant the HUD panel (fps, draws, tris…), not the bake-off links. The HUD is now off by default and appears with
?debug,?hud=1or?bench=N(a benchmark needs it to show its report);?hud=0still works. - Caught up with the owner's parallel work: the avatar is now
avatar/baby-astronaut-v1.glbwith three new clips (Float, Dive, Swim_Idle) — hash and contents recorded, but its source and licence still need one line from the owner;AVATAR_SCALE1/3 -> 1/6, so that test now pins the clearances the scale DERIVES rather than the value itself; room-a wears six student paintings fromvan-gogh-student-gallery-v1/; and the text twins no longer call a still picture a living painting. 127 tests green.
2026-08-03 (engine ADR closed, landing page trimmed, made findable)
- ADR-0006: Three.js is the engine. The Babylon slice stays but appears only under
?debug. The reason is not the benchmark — ADR-0003 was right to refuse a synthetic verdict — but that a month of product-shaped work grew on Three, and the real bottleneck turned out to be canvas-to-GPU upload, which is not an engine property at all. - The landing page now shows only what the owner asked for: the door and the controls. The description, the room list and the bake-off moved behind
?debug. - favicon:
public/favicon.svgplus a 64x64 PNG fallback — a deep-space tile with a glowing portal. - Discovery surface built (DISCOVERY-SEO-GEO R1-R4/R6):
scripts/prerender-discovery.mjsruns after Vite and generatesdist/rooms/<id>.htmlfor PUBLIC rooms only,sitemap.xml,robots.txtandllms.txtfrom the SAME manifest and room documents the 3D world reads, so descriptions cannot drift. JSON-LD on the root and every room page, containing noPerson. Two privacy tests: no owner email or the word "password" may appear in anything published, and a room is in the sitemap if and only if it is public. 124 tests green.
2026-08-02 (waternew.lpp imported with zero code changes)
- The owner confirmed the effect is good and supplied a revision,
waternew.lpp-> archived assky-floor-water-v2.lpp, and The Sky's floor now names it. - Both brushes have exactly the reviewed code shapes, so it needed no review, no registry entry and no code change — only the numbers and the strokes moved: the veil's target opacity 2% -> 5%, mask feather 2 -> 0, and both masks now cover nearly the whole surface (the current went from 4,474 to 6,139 of 6,144 cells). This is precisely what shape-based identity was built for: repaint and re-tune, compile straight through.
- v1 stays archived for comparison. 122 tests green, all six packages compile.
2026-08-02 (the veil fades to transparent again: the two fields are independent) <- please re-QA
- Owner: "it stops and goes quiet after a few seconds, and nothing fades to transparent." Correct on both counts — I had coupled the veil to the current so it calmed the water, which replaced the authored moment with a dead pause. A warp field and a veil field are independent: the current runs continuously, and the veil only changes the picture's opacity.
- Fading out is the effect: where the veil is down, what lies behind the surface shows through — on The Sky's floor, open space. On the GPU it is the fragment's alpha, so it is per-pixel and feathered rather than a stack of erased rectangles (the material is now transparent, depthWrite off).
- The Canvas2D reference fades again too. 122 tests green.
2026-08-02 (warps move to the GPU: the stripes AND the slowness were the technique) <- please re-QA
- The owner asked whether the
.lppdesign itself was unreasonable, given it was still slow and looked fractured. It is not: the.lppis sound and the mistake was mine — warping an image with CPU strips. Warping is a per-pixel operation;drawImagestrips can only approximate it, which leaves a seam at every strip edge (~670), shows un-displaced photograph where a strip moved away, and still repaints a 1024² canvas and uploads 4 MB per refresh. Narrower strips fix none of the first three. warpShader.js: the photograph uploads once, the mask becomes one 96 x 64 RGBA texture (R = the current's coverage, G = the veil's, B = depth inside the veil's stroke), and the fragment shader samplesphotoatvUv - displacementwith the sampler interpolating. The CPU writes two numbers per frame. A warp-only package takes no paint scheduler turn at all.- Seams 670 -> 0; CPU per frame a full repaint -> two uniforms; upload per frame 4 MB -> none; animation 8 Hz -> 60 Hz. The Canvas2D path stays as the reference, and a test recomputes the shader's arithmetic in JS and compares it with
waterCurrentpoint by point so the two cannot drift. 122 tests green.
2026-08-02 (the first result looked bad — the renderer, not the .lpp) <- please re-QA
- The owner's verdict on the first frame was blunt and correct. Checked: the
.lppis fine — its shell and layer source are byte-identical, so no layer is missing. Three defects in my reference renderer:
- Strip width was tied to the 96 x 64 mask grid, so 13px blocks each slid by a constant offset — sliding tiles, not water. Strips are now 4 canvas pixels, independent of the mask (the mask says WHERE a stroke is; displacement is smooth and must be sampled densely).
- Displacement was not scaled from the painting's authored 1254 canvas to the face actually being rendered.
- The veil must not erase. Its brush "rebuilds the source picture", and here the layer source IS the shell, so lowering the veil should fade the MOVEMENT out to the pristine still — ripple, calm, ripple.
destination-outpunched a hole in the floor showing open space. A frame plan can no longer remove the picture; a test asserts it.
- 118 tests green.
2026-08-02 (.lpr on screen: The Sky's floor is driven by the package) <- needs QA
warpFrame.jsturns a field into a plan (source rectangles with pixel offsets, rectangles to erase) — pure data, so a whole frame can be asserted in Node;drawWarpFrameonly consumes it. Two easy mistakes avoided: strip direction is DECLARED (water-current varies with x, so columns — rows would draw a plausible painting that is not the authored one), and only the mask's painted runs are redrawn, or a warp would cost a full-surface resample every frame.- A face wears a package via
faces[].lprbeside theimagethat is its photograph.LprArtworkSurfacepresents the same shape as an inherited Live Painting, so the paint scheduler, the Live slider and the quality presets govern it without knowing the difference. It hashes the photograph before drawing and refuses a mismatch. - room-b's floor now names
sky-floor-water-v1, and a test pins that the photograph the room supplies is the one the package names. 118 tests. This is the first piece you can QA: enter The Sky and see whether the floor moves.
2026-08-02 (warp behaviors can be replayed: two pure functions and a mask)
warpField.jstranscribes the two reviewed warp brushes into pure functions (water-current@1pins the painted box's left and right edges with sin(u·pi) and moves everything between;source-veil@1is the picture's own opacity on a hold/fade/hold/fade cycle, feathered in from the stroke's untouched edge). Purity is deliberate: a GPU version can be compared against these point for point.- Plus mask decoding (96 x 64 grid of base64 run-length pairs — a malformed mask is refused, never half-drawn), the painted box, and BFS edge depth. 113 tests green.
- Two steps remain before the water actually moves: the Canvas2D draw step that applies displacement and veil to the shell photograph, and the museum wiring that puts an
.lprpackage on a room face.
2026-08-02 (.lpr v2: photoSample lands, and the legacy opcodes are wrong)
photoSampleis carried. A ripple ring re-reads its colour from the source photograph at its moving outer edge every frame, so the colour cannot be baked into the mark: the package names the photograph by content (photo: { source, sha256, width, height }) andvalidateLprrefuses a sampling package that does not. No image bytes travel inside. Sunflower now compiles 8 marks and 4 warp fields with only 2 gaps left.- A trap found in passing: the six mark opcodes inherited from v1 were parameterised by the CURATED intermediate, not by the brushes, and the two disagree — the curated
lissajous-heartbeatcarriesspeedwhile the brush that drew it declares BEAT_A, BEAT_B, LOOP and FLOW. Mapping a.lppshape onto one of them would silently mis-parameterise the art, exactly what the one-seam rule exists to prevent. Each will be re-derived from its brush source at review time, asripplewas. lppArchive.jsgives the build script and the tests ONE archive reader (shell photo hashing included), so they cannot drift. 106 tests green andnpm run lpr:verifyis clean.
2026-08-02 (.lpr v2: warp fields land, water.lpp compiles first)
registry.jsissignature -> { behavior, version, role }and nothing else. Parameter ranges are not duplicated there: each brush declares its own in its@uiline, so the compiler validates against the ARTIST's bounds while the schema keeps a safety envelope.roleis part of the review — a shape reviewed as a warp can never be used as a mark source.- The rule for an unreviewed shape (open question 2's other half): a renderer never sees one. Resolution happens once, in the compiler; an unreviewed shape becomes a declared gap, and the package sets
complete: falseand lists per code shape which brushes and how many revisions were left out and why.validateLprrefuses a package that claims completeness while listing gaps, or hides gaps while claiming incompleteness. Behaviors are versioned (water-current@1), so an old package keeps naming the behavior it was built against. - v2 carries warp fields — the corpus's first-priority capability, and the whole of The Sky's floor: behavior + parameters + the authored mask verbatim (
artlab-rle-v1base64), opaque here and decoded by a renderer that claimswarpField.npm run lpr/npm run lpr:verify. - Result: sky-floor-water-v1 compiles complete (2 warp fields, no gaps); the other four compile with gaps declared (sunflower carries 4 warp fields, cypress 2). Every mark shape is still a gap — they all need
photoSample, the next capability. 106 tests green.
2026-08-02 (.lpr v2 begins: identity first)
- The corpus is the five
.lppfiles in the repo. Newsrc/inception/lpr/lppProject.jsreads an archive's two JSON files into plain data (stage, ordered layers, strokes, marks, warp fields, brush index) touching no filesystem and no zip library, so every rule in it is a Node test. That is open question 1's "one seam". - The corpus answered open question 2: identity is the code shape, not the parameter values. A
.lppkeys brushes by source sha256, so one slider move mints a new revision — which is why the inherited importer hand-maintains 56 entries. Hashing the source with every top-levelconst NAME = valueblanked (shape:<sha256>) collapses 46 revisions into 18 shapes to review (sixripplerevisions become one; fourteenstarry-liquify-spinbecome one). Turning a slider can no longer require a code change; changing code still faces review. node scripts/lpp-report.mjsIS the backlog. In priority order the corpus needs: warp fields (9 of the 18 shapes),photoSample, a source-alpha behavior (fade-veil), and canvas-relative geometry (the inherited path hard-codes 960 x 640; the corpus has 1440 x 720 and 1254 square). 101 tests green.
2026-08-02 (water.lpp archived, but not importable — and that IS the .lpr backlog)
- The owner's
water.lppis archived ascontent/live-projects/sky-floor-water-v1.lpp(5.1 MB, stage 1254 x 1254, document v6). Its shell is byte-identical to the committed still floor, so the floor already shows this picture; only the motion is missing. - It cannot be imported yet, for two hard reasons: (1) both brushes are warp-capability, and the inherited runtime warp path accepts only
liquid-warp; "Fade Veil" (source alpha 100% -> 2%, hold 2.25 s, fade 3.2 s) has no adapter kind at all and would throw on load. (2) Warp geometry is hard-coded to ROOM_WIDTH 960 x ROOM_HEIGHT 640, while this project is 1254 square. - So the choice is: add two adapter kinds to the inherited renderer (the file the owner has been editing today), or do that work ONCE inside the
.lprbehavior registry. This second real example confirms LPR-FORMAT.md's "the refusal list is the backlog": todaynode scripts/compile-lpr.mjsrefuses all four projects, needing photoSample, curvePath, warp fields, and the owner's three new sky behaviors.
2026-08-02 (every corridor asset preloads; only rooms stream)
- All three deep-space panoramas (the default and both easter eggs) are now loaded and uploaded to the GPU before the museum opens, not on demand: flying down the corridor must never hitch. Room contents still stream, because entering a room already has a visible pause.
- Preloading alone is not enough: decoding is not uploading. Each texture also goes through
renderer.initTexture, without which the first swap still pays ~32 MiB of texture upload inside a single frame — which was the hitch. - The cost is ~5.7 MB more at startup (0.7 MB PNG + two ~2.5 MB JPEGs).
- New test: every
assets/space/*file the renderer names must exist. Now that these preload, a rename or a missing file is a boot failure, and catching it at startup beats discovering it at the end of the hall. 95 tests green.
2026-08-02 (bug: collision did not follow the object)
- Owner's report: move an object in the editor, save, the picture is right but the collision stays behind. The cause was that collision always came from the manifest's fixed prop table, never from the room document — so neither editor edits nor committed room files had ever affected it.
- Fix:
propObstacles(slot, i, props)takes an explicit prop list, and the runtime feeds it the current room document's props.ThreeMuseum.roomDocOf(id)exposes the very document the renderer built from, so the two can no longer disagree. The space cache is keyed on that document, so every edit (eachapplyEditreturns a new object) rebuilds the boxes automatically. - This also repaired the committed rooms: 3 objects in room-a and 6 in room-b had their collision in the wrong place, and room-b carried two "invisible furniture" boxes for easels that had been deleted. 94 tests.
2026-08-02 (a face can be a still image: The Sky's water floor)
- New
faces[].image: a face may wear a still picture (a file name undercontent/inception/assets/rooms/) instead of a Live Painting. A still face is kept OUT of the paint scheduler — the surfaces/textures arrays are that scheduler's index space — so it costs one upload when the room loads and nothing per frame. - The Sky's floor is now the owner's
sky-floor-water-plants-v1.png(1254 x 1254, provenance registered; the sibling nine-fish and tech-glass-platform candidates are not committed). A missing file warns and falls back to a flat colour rather than blacking the room out. 92 tests green.
2026-08-02 (easter egg: a different deep space at each end)
- The middle of the corridor keeps
background.png; reaching the -Z end swaps the sky tobackground1.jpgand the +Z end tobackground2.jpg. A variant takes over at |z| >= 46 (the air walls are at 58, so both ends are genuinely reachable) and then stays: walking back toward the middle keeps what you found, and only the other end changes it again (owner 2026-08-02 — which also removes any flicker at the threshold). The rule is a pureskyVariantFor(z, current)and is unit-tested. - The change is a 1.8 s cross-fade, not a cut: the incoming sky is a second sphere over the current one, its opacity rising from zero until it becomes the sky. A trigger during a fade lands the one in flight first, so three images never dissolve through each other.
- Only the corridor decides this: a pocket room's world z is hundreds of metres out and would trigger the egg by accident.
- Both egg panoramas remain native 4096 x 2048 and preload and cache at startup as quality-95 JPEGs. That reduces their combined transfer from 19.4 MB for the PNG masters to 5.0 MB (-74.1%). The PNG masters remain as unimported references; the sparse 0.75 MB default stays PNG to protect dark gradients and tiny stars. GPU upload finishes before corridor flight, so switching neither flashes nor hitches; static URL literals keep Vite packaging reliable. 95 tests green.
2026-08-02 (the first committed rooms)
- The owner's two arranged rooms are checked in:
room-a.room.json(Van Gogh House, placement tweaks) androom-b.room.json(The Sky, both easels removed and the bench moved to (0.5, -5.5)). A new test walks everycontent/inception/rooms/*.room.jsonand re-validates it against the room it names, so a later change to the world cannot quietly drop the owner's arrangement back to defaults.
2026-08-02 (the editor's JSON can actually be committed now)
- The owner's workflow — arrange in the editor, Download, copy into the project, database later — had nowhere to land: nothing in the project ever read such a file, so the runtime only ever saw a localStorage draft or the built-in default. The button's "commit it as the room's document" was a promise the code did not keep.
- It does now:
content/inception/rooms/<slot id>.room.json, with a README describing the loop. One rule decides which document a room uses (chooseRoomDoc, shared by the world and the editor): draft > committed file > default. A document whose id or face layout does not match, or that fails validation, is skipped with a warning — never half-applied. The editor's Revert now returns to the committed version instead of the built-in default. 90 tests green.
2026-08-02 (the furniture is back in The Sky)
- Reversed the "a dome holds no props" decision (owner: put them back).
roomPropsForgives a dome the same furniture, but pulls anything past the edge of the floor disc back along its own direction — only the two corner lamps qualify (r = 8.77 → 6.8) — because otherwise they stand over nothing. Their obstacles come back with them. - New validation: every piece in a dome room must stand on the disc and fit under the headroom at its radius (
sqrt(r² - d²)). 89 tests green. - Known limit: the editor still clamps coordinates to a 16 x 16 square, so dragging inside a dome room can push a piece off the disc. It belongs with the object/background ownership refactor the owner has queued.
2026-08-02 ("The Sky": an unpainted hemisphere)
- room-b is named "The Sky" and its hemisphere is never painted — look up and you see the museum's own deep-space starfield. The room paints one surface (its floor disc):
roomFaceNamesreturns["floor"]for a dome and the renderer builds no hemisphere mesh at all. The invisible dome now only decides where you may fly. - Fixed the bug the owner reported — switching 256/512/1024 made a texture appear on the dome. Both causes are gone: (1) nothing is painted overhead any more, and (2)
applyTextureSizeused to rebuild rooms from DEFAULT documents, discarding edits (and silently repainting a room whose shape had changed); each room now remembers its own document and rebuilds from that. - Added a guard for the same class of bug: a draft from the room's older shape is refused (
loadDraftForSlot). A six-face draft saved before room-b became a dome would otherwise replay as six overlapping floor discs. 89 tests green.
2026-08-02 (editor: object tags, and Live frozen while editing)
- In edit mode every prop floats a name + coordinate tag above it (a sprite, always camera-facing,
depthTest: falseso furniture never hides it). The text is identical to the panel's dropdown entry (archetype (x, z)), and the selected one turns gold and grows, so which object you are editing is obvious from inside the room. Hidden on exit. - Entering the editor freezes Live Painting (
liveScale = 0, holding the last frame) and leaving restores the slider's value. Moving the slider while editing records the setting without unfreezing.
2026-08-02 (rooms have shapes: a 16x16x8 hall and a dome)
- Rooms are no longer 16 m cubes.
ROOM.height = 8, so the Van Gogh room is a 16 x 16 x 8 hall — its walls are now 2:1, and the owner will redo the.lppart for them. - The second Van Gogh room is renamed "The Magic Paintbrush" and is now a hemisphere (radius 8), where the owner will paint fish, butterflies and horses. It paints two surfaces, not six: a floor disc under a dome, so its wall and ceiling are one continuous painting.
- Shape is a per-slot property (
roomShape(slot)). Entry points, return doors, volumes, obstacles and validation all derive from floor + shape instead of assuming a cube. - Zero-g clamping understands domes:
volumeContains/clampToVolumesaccept adomevolume whose sideways room shrinks as you rise, and a player pushed at the wall slides back along the radius onto the sphere rather than into the bounding box's corner. - The dome holds no props — a curved wall swallows furniture, and the physics builds no obstacle for it, so validation refuses a dome document that carries any. 88 tests green.
- Next (owner's call): 3D objects and background images need the same email ownership rooms have — a separate refactor, not started.
2026-08-02 (three quality tiers, per-device defaults)
- Quality went from two tiers to three: Low 256 / Medium 512 / High 1024 — the pixel edge of each Live Painting face, not screen DPI. Desktop opens at High (1024), touch at Medium (512); a saved choice always wins. High uploads at most one face per frame and refreshes slower than Medium, because a 1024 face is 4 MB ≈ 21 ms of upload at the measured 5.3 ms/MB.
- The static 4K sky is independent of the tiers;
maxPixelRatiois the actual DPI knob. The sky texture now uses max anisotropic filtering — most of it is seen at a grazing angle, where the default throws the 4K away.
2026-08-02 (the whole corridor is walkable)
airWallZ24 → 58. Flight used to stop a quarter of the way along the drawn hall, which read as an invisible wall in the middle. The entire drawn corridor is now walkable, with 2 m left past the final seam at 56 so the open mouth stays unreachable (the validation rule is unchanged).
2026-08-02 (breathing glass and nameplates)
- The corridor's glass walls and ceiling, and the room nameplates, now breathe the way the portals do: fully transparent at the trough, back to glass (or full opacity) at the peak. Glass runs at 0.09 Hz — an ~11 s cycle, slower than the portals' 0.22 Hz so the corridor never strobes as one block — and each nameplate shares the portal rate and phase.
- The floor breathes on the walls' exact beat but keeps 1 % opacity (
minOpacity) at the trough: in zero-g it is the only ground reference, so it may thin to a hint and never vanish. - Only the trim rings stay constant — they are the skeleton left when the panes fade out. The rule is "no
pulseHzmeans constant", driven by one purepulseLevel(spec, seconds)shared by portals, glass, floor and plates; tests pin the trim at 1 and the floor on the walls' curve.
2. Confirmed product direction
The product is not choosing between 2.5D and 3D. It is:
A true 3D space with 2D / Live Painting art surfaces that preserve the student’s original hand.
2.1 True 3D world layer
- A minimal, non-photoreal future science-fiction museum.
- A walkable entry corridor and portals.
- Tetrahedron, cube, and later polyhedron rooms.
- Low-poly tables, chairs, easels, frames, lights, plinths, and a small number of sculptures.
- A fictional, identity-safe, walkable 3D avatar; no student face scanning or realistic identity.
2.2 Artwork layer
- Scanned drawings, cutouts, and ordinary paintings.
- Live Paintings compiled from
.lppauthoring sources. - Artworks may be wall paintings, animated room backdrops, portal surfaces, easel works, or avatar clothing textures.
- The student’s original marks remain primary. AI may only plan, critique, and translate approved behavior.
2.3 Three authorization layers
personal_draft: visible to the student and authorized educators for experimentation.class_shared: visible after explicit student sharing; cross-artwork relationships require mutual consent.public_snapshot: a curated, immutable, read-only, identity-safe export.
A shared password is not a sufficient privacy boundary. The server must authorize every read through scoped roles or high-entropy invitation tokens.
3. Engine strategy
3.1 Keep TypeScript + Vite
TypeScript + Vite can support this architecture. It is the application shell, editor, permission flow, AI approval layer, file compiler, and test environment; it does not mean the team must build a rendering engine. Both the 3D world and 2D artwork renderer sit behind explicit adapters.
3.2 Can Three.js handle it?
Yes. Three.js supports glTF, skeletal animation, AnimationMixer, CanvasTexture, render targets, raycasting, instancing, and custom shaders. There is no structural blocker to 3D-space/2D-dynamic-surface interaction.
The real risks are:
- assembling cameras, collision, asset lifecycle, LOD, editor gizmos, and serialization may gradually become a hidden custom engine;
- uploading several 512px canvases to the GPU every frame can become expensive;
- arbitrary Function Brush source from legacy
.lppprojects cannot enter a student or public runtime.
3.3 Why benchmark Babylon.js
Babylon.js is a more complete web-native engine with standardized cameras, collision, animation, glTF, Inspector, DynamicTexture, and RenderTargetTexture, while maintaining WebGL and WebGPU paths. It still runs in TypeScript/JavaScript and embeds in an ordinary web editor.
Do not permanently lock the product to Three.js yet. Build two small slices against the same world/artwork interface:
| Item | Three.js | Babylon.js |
|---|---|---|
| Positioning | Flexible rendering library | More complete web game/rendering engine |
| Strength | Fine control, large ecosystem, direct CanvasTexture | Standard lifecycle, camera, collision, animation, render targets |
| Risk | More engineering glue | Larger abstraction; some low-level customization may be less direct |
| Role here | Performance/extensibility baseline | Preferred standard-engine candidate |
3.4 Engine decision rule
The comparison scene must be identical:
- one corridor, one portal, one cube room;
- 20 low-poly static props;
- one rigged GLB avatar with idle/walk;
- keyboard and touch movement;
- six full-face 512×512 Live Painting dynamic surfaces: all four room walls plus the floor and ceiling are Live Painting (the portal wall wraps one painting around the doorway in three UV segments; owner direction 2026-08-01);
- the same world manifest, interaction signals, and instrumentation.
Decision rules:
- If both pass and Babylon.js p95 frame time is within 10% of Three.js, choose Babylon.js by default to reduce long-term custom-engine work.
- Choose Three.js if its p95 frame time, memory, or texture-upload result is more than 15% better, or if Babylon blocks the
.lprGPU-renderer path. - If both fail, do not immediately move to Unity/Godot. Isolate whether the failure is the legacy artwork renderer, texture uploads, or world rendering.
- Reconsider Godot/Unity only if both web-native paths have a structural failure.
The engine ADR must include devices, browsers, commit, scene asset version, and raw measurements.
4. Free 3D assets and avatar plan
4.1 Conclusion
There are enough free assets, but Three.js is not an asset store. The project uses open GLB/glTF runtime assets, so models may come from Blender, Blockbench, Kenney, Quaternius, and other properly licensed sources. glTF is Khronos’s royalty-free, runtime-neutral standard.
4.2 First-release avatar
Preferred candidate: Quaternius Universal Base Characters.
- CC0 for educational and commercial use;
- glTF, FBX, and Blend formats;
- humanoid rig suitable for retargeting;
- multiple proportions and hairstyles;
- about 13k triangles, appropriate for the mobile technical slice;
- existing eye/skin customization that can extend to material zones.
Also build an asset-independent procedural mannequin fallback from capsules/boxes/spheres and hierarchical joints. It must support idle/walk and full material recoloring so avatar-system correctness never depends on a specific pack.
4.3 Three recoloring levels
- Pilot: material-zone recoloring for hair, top, bottom, shoes, and accessories. Stable, inexpensive, and child-friendly.
- Phase two: require stable, non-overlapping UVs; generate a clothing paint texture from Canvas and paint through UV/raycast mapping.
- Later experiment: map
.lpranimated textures onto approved clothing surfaces. Dynamic content must not cover faces or identity-sensitive regions by default.
Avatar acceptance criteria:
- GLB/glTF, Y-up, meter scale, correct pivot and ground;
- loopable idle/walk with smooth blending;
- within the Pilot triangle, bone, material, and texture budgets;
- non-overlapping UVs and explicit paintable material slots;
- collision capsule separated from visual mesh;
- provenance records source, author, download date, original URL, license snapshot, modifications, and reviewer.
4.4 Asset-source policy
- Prefer CC0: Quaternius and Kenney.
- CC BY is acceptable only with preserved attribution and a license snapshot.
- Khronos Sample Assets are mainly importer/animation fixtures; licenses vary per model and the collection is not blanket CC0.
- Mixamo characters and animations may be used royalty-free with an Adobe ID, but Mixamo is not the default redistributable source library; review license and redistribution boundaries before use.
- Reject assets with no explicit license, “free” without terms, NC terms conflicting with expected public/commercial use, or missing provenance.
References:
- Quaternius Universal Base Characters: https://quaternius.com/packs/universalbasecharacters.html
- Quaternius Universal Animation Library: https://quaternius.com/packs/universalanimationlibrary.html
- Kenney assets/support: https://kenney.nl/assets · https://kenney.nl/support
- Khronos glTF: https://www.khronos.org/gltf/
- Khronos Sample Assets: https://github.com/KhronosGroup/glTF-Sample-Assets
- Adobe Mixamo FAQ: https://helpx.adobe.com/creative-cloud/faq/mixamo-faq.html
5. From .lpp to a standardized 2D runtime
5.1 File responsibilities
.lpp remains the editable authoring source. It is not the final public runtime format.
Add .lpr (Lucas Painting Runtime): a versioned, renderer-neutral standard 2D runtime package with no arbitrary executable code.
.lpp authoring source
├── source images, masks, marks, and Function Brush source
├── editor parameters, notes, and provenance
└── rich authoring information is allowed
│
▼ validate / compile / bake
.lpr runtime package
├── manifest.json
├── textures/*.webp|png|ktx2
├── buffers/marks.bin
├── timeline.json
├── interactions.json
├── provenance.json
└── preview.webp
5.2 .lpr hard boundaries
- No JavaScript, Function Brush source, shader source, or eval-able expressions.
- Every layer, mark, atlas, texture, animation channel, and interaction count is bounded.
- Behaviors use whitelisted opcodes/graphs such as translate, rotate, scale, opacity, paletteShift, and warpPreset.
- Fixed clocks and seeds allow deterministic replay.
- Static marks, soft glow, masks, and mergeable layers are baked during compilation.
- Dynamic marks use typed binary buffers instead of large per-frame JSON parsing.
- The manifest records schema version, feature flags, hashes, dimensions, budgets, and provenance.
- The compiler rejects unknown or over-budget behavior; it never silently falls back to arbitrary execution.
5.3 Two renderers
Canvas2DReferenceRenderer
- editor preview, correctness definition, and fallback;
- screenshot baselines against currently approved visuals;
- implemented first as the normative behavior.
GpuArtworkRenderer
- uses instanced quads, shaders, and render targets in the selected 3D engine’s GPU context;
- avoids uploading a full CPU Canvas every frame;
- outputs a texture directly usable by walls, backdrops, portals, and clothing.
Both renderers use the same conformance fixtures. Small anti-aliasing differences are allowed, but timing, position, alpha, color, and interaction results must remain within specified tolerances.
5.4 Performance principles
- One central frame-budget scheduler owns all Live Painting work; individual artworks do not create unbounded RAF loops.
- Nearby visible works receive priority; distant work updates more slowly; out-of-view and other-room work pauses.
- Canvas fallback uploads only when dirty and limits texture uploads per frame.
- Static content renders once; high mark counts use atlases and instancing.
- WebGL 2 is the Pilot baseline. WebGPU is optional acceleration, not the only runtime path.
- Validate every performance claim on Chromebook, iPad Safari, and an ordinary desktop browser.
6. 3D-world/2D-artwork interaction contract
3D and 2D modules do not call each other’s internal objects. They communicate through a versioned protocol.
6.1 World → Artwork signals
viewer.distanceviewer.enter/viewer.leaveviewer.focusroom.lightportal.openAmountworld.timepeer.event.<approved-id>
Signals are bounded numbers, booleans, vectors, or IDs with scoped frequency limits. Do not serialize arbitrary JSON every frame.
6.2 Artwork → World events
request.lightPulserequest.soundCuerequest.portalHintrequest.peerSignal.<approved-id>
An artwork may only emit whitelisted requests. The world rule/teacher-approved interaction graph decides whether to act. An artwork cannot directly open doors, change permissions, load URLs, or mutate another student’s asset.
6.3 Examples
- As the viewer approaches,
viewer.distancechanges marks from stillness to breathing. - After a tide cycle, the artwork requests
lightPulse, and the room responds gently. - After mutual consent, student A’s bird emits only
peer.event.bird-arrived; student B’s behavior decides how to respond. - A Live Painting may be a room backdrop while remaining under the same scheduler, permissions, and
.lprbudgets.
7. Phased execution
Phase 0 — Preserve and isolate the baseline (complete)
Deliverables: independent identity, no inherited production deployment, preserved baseline, documentation, and passing tests.
Prohibited: legacy cleanup and online-resource creation.
Phase 1 — Avatar and engine bake-off slice
Add new work without replacing the legacy entry:
index.html
src/inception/main.ts
src/inception/world/*
src/inception/artwork/*
src/inception/avatar/*
content/inception/assets/*
docs/benchmarks/ENGINE-BAKEOFF.md
Tasks:
- Define minimal
WorldEngine,ArtworkSurface, andAvatarControllerinterfaces. - Build independent Three.js and Babylon.js spike entries using identical manifests/fixtures.
- Add the procedural mannequin and one provenance-approved CC0 rigged character.
- Implement idle/walk, keyboard/touch movement, and material-zone recoloring.
- Wrap the cube room in Live Painting: four walls plus floor and ceiling as six full-face Canvas dynamic textures (three UV segments on the portal wall), plus 20 low-poly props and one portal.
- Record FPS, p50/p95 frame time, CPU artwork time, texture-upload time, draw calls, triangles, memory, bundle size, and load time.
Acceptance:
- Target 60fps on desktop and stable 30fps or better on Pilot mobile devices.
- Both spikes use identical scenes and instrumentation.
- Produce an engine ADR with raw measurements.
npm run checkand all new spike tests pass.
Phase 2 — .lpr v1 spec, compiler, and reference renderer
Tasks:
- Define JSON Schema, binary layouts, budgets, feature flags, and versioning.
- Implement a deterministic
.lpp -> .lprcompiler. - Compile at least three approved legacy
.lppfixtures. - Implement
Canvas2DReferenceRenderer. - Test unknown opcodes, excessive marks, oversized textures, source smuggling, and zip bombs.
- Add screenshot/time-series conformance tests.
Acceptance:
- Identical input creates byte-identical
.lproutput. .lprcontains no executable source.- Three fixtures match approved rendering within defined tolerances.
- Compiler failures are explicit and translatable for the child-facing editor.
Phase 3 — GPU artwork renderer and final world foundation
Tasks:
- Implement
GpuArtworkRendererand render-target output in the selected engine. - Render
.lprbuffers through atlases, instancing, and shader paths. - Implement a central scheduler, visibility/distance priority, pausing, and disposal.
- Build the corridor, portal, cube room, and tetrahedron room.
- Add the GLB loader, asset budgets, collision proxies, material/texture caches, and disposal rules.
- Implement the 3D↔2D signal/event contract.
Acceptance:
- The Phase 1 scene running through the
.lprGPU path matches or outperforms the Canvas baseline. - Six full-face dynamic surfaces do not create periodic long frames; hidden rooms stop unnecessary work.
- Portal/room transitions have no material resource leak.
- The WebGL2 path passes on target devices.
Phase 4 — Child-facing art and space editor
Tasks:
- Import scanned/transparent art and create ordinary Paintings or
.lppsources. - Provide room templates, prop library, transform/snap tools, and parametric primitives.
- Place art on wall/frame/easel/background/portal surfaces.
- Provide behavior blocks and a 3D↔2D interaction graph without arbitrary code.
- Support avatar material-zone recoloring; keep UV clothing painting behind an experiment flag.
- Provide undo/redo, autosave, and private-draft state.
Acceptance:
- A student completes import → animate → place in room → private preview.
- Unapproved work never enters class/public layers.
- Editor errors cannot damage original assets or the last good version.
Phase 5 — Data, authorization, and curated publishing
Tasks:
- Design dedicated schema, object storage, and access control; do not reuse a production database.
- Enforce
personal_draft,class_shared, andpublic_snapshotserver-side. - Cross-student relationships require mutual consent and stable version references.
- Public publishing creates a sanitized immutable snapshot rather than exposing classroom records.
- Add withdrawal of future use, version replacement, provenance, and audit records.
Acceptance:
- Permission tests prove that changing a front-end URL/flag cannot bypass access control.
- Public snapshots exclude real names, email, class membership, private reflection, AI dialogue, and draft history.
- Private/class content is unreadable without scoped account or high-entropy invitation authorization.
Phase 6 — AI planner, critic, and approval
Tasks:
- AI emits a versioned
BehaviorPlanschema, never runtime code. - Store
student_intent,ai_proposal,student_decision, andrevision. - Critic compares the student goal with rendered screenshots and identifies observable differences/questions.
- Translator creates behavior graph/
.lprparameters only after explicit student approval. - Educators can inspect change provenance; AI does not write the final artist statement for the student.
Acceptance:
- An unapproved proposal cannot change artwork hashes or the world manifest.
- All AI output passes schema validation, budgets, and content-safety boundaries.
- Students can reject, partially accept, undo, and compare before/after.
Phase 7 — Curriculum Pilot and outputs
Recommended allocation:
- 50% studio-art foundation;
- 25% new media, creative coding, and simple 3D;
- 25% art history, critique, revision, curation, and artist statements.
Each student archive includes sketches, a formal exercise, material experiments, original work, behavior plan, critique notes, one revision, an artist statement, and publication choice.
Outputs: an interactive read-only exhibition, curated screenshots, and a deterministic camera/recording mode for video editing. Public or partner use requires a separate authorization workflow.
Phase 8 — Clean inherited content after v1
Start only when all are true:
- the new entry,
.lprcompiler, world runtime, editor, and permission tests pass; - required Live Painting fixtures, provenance, and migration tools are inventoried;
- the owner approves the delete/archive list;
- a recoverable tag/branch exists.
Move content to legacy/ or an archive branch and verify first; then remove obsolete Hider/Seeker, Stripe, legacy Worker API, migrations, rooms, and commercial-release documents. Never empty the inherited project in one operation.
8. Overall v1 acceptance criteria
V1 is complete only when all are true:
- A browser user can enter a 3D corridor, cross a portal, enter at least two room types, and walk.
- There is an identity-safe avatar with idle/walk and material-zone recoloring.
- Rooms include simple 3D tables, chairs, easels, and display plinths.
- At least four
.lprdynamic works can serve as paintings, backdrops, or other surfaces. - 3D and 2D interact through controlled signals.
- Chromebook, iPad, and desktop pass the defined performance gates.
- A student can complete private creation, class sharing, and public-snapshot authorization.
- AI cannot modify work without approval.
- Curriculum evidence demonstrates serious art practice, critique, and revision rather than only a technology demo.
- Until legacy cleanup begins, the inherited
npm run checkcontinues to pass.