From 7ad412f0ec473f23c653d2ff36910d6d692d324f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 17:08:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E8=BF=90=E8=A1=8C=E7=94=BB=E9=9D=A2?= =?UTF-8?q?=E7=82=B9=E9=80=89=E5=90=8E=E9=A2=84=E8=A7=88=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E7=BC=A9=E6=94=BE=EF=BC=9A=E9=AB=98=E4=BA=AE=E6=A1=86=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8F=82=E4=B8=8E=E5=B0=BA=E5=AF=B8=E4=B8=8A=E6=8A=A5?= =?UTF-8?q?=EF=BC=8C=E5=91=BD=E4=B8=AD=E7=9F=A9=E5=BD=A2=E8=A3=81=E5=89=AA?= =?UTF-8?q?=E5=88=B0=E7=94=BB=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因:高亮框(body 下 data-genarrative-preview-inspect,position:fixed)被尺寸上报的 measureContentBounds 遍历计入内容尺寸;three 投影盒超出画布时把内容尺寸撑到 3756×2643(视口 900×560),宿主改 iframe 尺寸触发游戏 resize 重算相机,形成自激振荡 - 桥:measureContentBounds 的 TreeWalker 对 data-genarrative-preview-fit / data-genarrative-preview-inspect 返回 FILTER_REJECT(桥自己的节点不参与内容测量) - 桥:新增 clipInspectRect,引擎档(three 投影盒、Phaser 世界包围盒)命中矩形统一裁剪到画布可见范围;与画布无交集时退化为指针点矩形(不出现 0/负尺寸) - 测试:新增 localPreviewInspectSizeStability.test.ts(5 例:桥节点不参与测量、同尺寸普通节点仍计入的对照组、进入检查模式与 hover 后尺寸与宿主适配布局不变、投影盒超出画布时载荷被裁剪、与画布无交集时退化为 1×1);runtimeInspectEngines 的字段换算用例改到画布内的等价参数(原矩形落在画布外,裁剪后按新规则退化为指针点矩形) - 文档:pitfalls 记一条「高亮框被尺寸上报当成页面内容」的根因、口径与回归 --- .../resources/preview/local-preview-fit.js | 36 +- .../localPreviewInspectSizeStability.test.ts | 523 ++++++++++++++++++ .../tests/runtimeInspectEngines.test.ts | 9 +- docs/project-memory/shared-memory/pitfalls.md | 8 + 4 files changed, 569 insertions(+), 7 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js b/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js index 5ff19cd48..d5a34392e 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js +++ b/apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js @@ -170,9 +170,24 @@ heightBaseline: height.baseline, }; }; + // 桥自己的节点(尺寸上报的 style、点选高亮框)不属于页面内容:高亮框还是 `position: fixed` + // 且跟随命中对象,一旦被 `measureContentBounds` 当成固有尺寸,宿主就会据此改 iframe 尺寸、 + // 游戏 resize 重算相机——实测表现为「点选后画面自己缩放」。 + const isPreviewBridgeNode = (element) => { + const dataset = element && element.dataset; + if (!dataset) return false; + return ( + dataset.genarrativePreviewFit !== undefined + || dataset.genarrativePreviewInspect !== undefined + ); + }; const measureContentBounds = (body, viewportWidth, viewportHeight) => { if (!body) return { width: 0, height: 0, truncated: false }; - const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT); + const walker = document.createTreeWalker(body, NodeFilter.SHOW_ELEMENT, { + acceptNode: (node) => ( + isPreviewBridgeNode(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT + ), + }); let width = 0; let height = 0; let widthBaseline = 0; @@ -499,6 +514,19 @@ width: 1, height: 1, }); + // 命中矩形裁剪到画布可见范围:引擎侧的世界 / 投影包围盒可能远超画布,高亮框与载荷都不该出现 + // 比画面还大的矩形(同时它也是「点选后画面自己缩放」的输入之一,见 measureContentBounds 注释)。 + // 与画布无交集时返回 null,由调用方退化成指针点矩形。 + const clipInspectRect = (rect, canvasRect) => { + if (!rect || !canvasRect) return null; + const left = Math.max(rect.left, canvasRect.left); + const top = Math.max(rect.top, canvasRect.top); + const right = Math.min(rect.left + rect.width, canvasRect.left + canvasRect.width); + const bottom = Math.min(rect.top + rect.height, canvasRect.top + canvasRect.height); + if (![left, top, right, bottom].every(Number.isFinite)) return null; + if (right <= left || bottom <= top) return null; + return { left, top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) }; + }; const engineInspectSelection = (identity, rect) => ({ label: identity.label || 'canvas', elementTag: 'canvas', @@ -835,7 +863,8 @@ } catch { boundsRect = null; } - const rect = boundsRect || inspectPointerRect(clientX, clientY); + const canvasRect = canvasElement.getBoundingClientRect(); + const rect = clipInspectRect(boundsRect, canvasRect) || inspectPointerRect(clientX, clientY); return { selection: engineInspectSelection(phaserResourceIdentity(gameObject), rect), rect }; }; const phaserInspectTarget = (handle, canvasElement, clientX, clientY) => { @@ -1016,7 +1045,8 @@ const hit = Array.isArray(hits) && hits.length > 0 ? hits[0] : null; const object = hit && hit.object; if (!object) return null; - const rect = threeBoxScreenRect(three, object, camera, domRect) || inspectPointerRect(clientX, clientY); + const rect = clipInspectRect(threeBoxScreenRect(three, object, camera, domRect), domRect) + || inspectPointerRect(clientX, clientY); return { selection: engineInspectSelection(threeResourceIdentity(object), rect), rect }; }; const resolveInspectEngineSelection = (canvasElement, clientX, clientY) => { diff --git a/apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts b/apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts new file mode 100644 index 000000000..1848d578b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts @@ -0,0 +1,523 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolveLocalGamePreviewFitLayout } from '../src/features/project-workspace/LocalGamePreviewFrame'; +import { repoPath } from './repoPath'; + +/** + * 「点选不改变预览尺寸」的回归用例。 + * + * 用户实测 BUG:three 项目里点选 3D 对象后预览画面自己缩放/视角异常。根因是桥自己的高亮框 + * (`data-genarrative-preview-inspect`,`position: fixed`,尺寸随命中对象)被尺寸上报的 + * `measureContentBounds` 当成页面固有内容 → 宿主据此改 iframe 尺寸 → 游戏 `resize` 重算相机。 + * 这里在 jsdom 里加载**真实桥脚本**、用假 `window.parent` 收尺寸消息,断言: + * 高亮框(以及任何桥自己的节点)出现、移动、消失都不改变尺寸上报与宿主算出的适配布局。 + */ +const BRIDGE_SOURCE = readFileSync( + repoPath( + 'apps/ai-game-creator-shell/src-tauri/resources/preview/local-preview-fit.js', + ), + 'utf8', +); +const SIZE_MESSAGE = 'genarrative.local-preview-size.v1'; +const INSPECT_MESSAGE = 'genarrative.local-preview-inspect.v1'; +const VIEWPORT = { width: 900, height: 560 }; + +type SizePayload = { + type?: string; + contentWidth?: number; + contentHeight?: number; + viewportWidth?: number; + viewportHeight?: number; +}; + +type Posted = SizePayload & { + action?: string; + selection?: Record; +}; + +type StubRect = { left: number; top: number; width: number; height: number }; + +const posted: Posted[] = []; +const rectOverrides = new WeakMap(); + +function fakeRect(rect: StubRect) { + return { + ...rect, + x: rect.left, + y: rect.top, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + toJSON: () => ({}), + }; +} + +function stubRect(element: Element, rect: StubRect): void { + rectOverrides.set(element, rect); +} + +function sizeMessages(): SizePayload[] { + return posted.filter((message) => message.type === SIZE_MESSAGE); +} + +function lastSize(): SizePayload { + const messages = sizeMessages(); + const message = messages[messages.length - 1]; + if (!message) throw new Error('未收到尺寸上报'); + return message; +} + +function lastSelection(): Record { + const message = [...posted] + .reverse() + .find( + (entry) => entry.type === INSPECT_MESSAGE && entry.action === 'selected', + ); + if (!message?.selection) throw new Error('未收到点选载荷'); + return message.selection; +} + +/** 触发一次尺寸上报:桥按 sizeKey 去重,先制造一次不同的内容尺寸再回到当前尺寸。 */ +function publishSize(): SizePayload { + const scratch = document.createElement('div'); + stubRect(scratch, { left: 0, top: 0, width: 12345, height: 12345 }); + document.body.appendChild(scratch); + window.dispatchEvent(new Event('resize')); + scratch.remove(); + window.dispatchEvent(new Event('resize')); + const messages = sizeMessages(); + if (messages.length === 0) throw new Error('未收到尺寸上报'); + return messages[messages.length - 1] as SizePayload; +} + +function overlayElement(): HTMLElement | null { + return document.querySelector('[data-genarrative-preview-inspect="true"]'); +} + +function overlayStyleRect() { + const overlay = overlayElement(); + if (!overlay) return null; + return [ + overlay.style.left, + overlay.style.top, + overlay.style.width, + overlay.style.height, + ]; +} + +function sendInspectAction(action: 'enable' | 'disable'): void { + window.dispatchEvent( + new MessageEvent('message', { + source: window.parent as unknown as MessageEventSource, + data: { type: INSPECT_MESSAGE, action }, + }), + ); +} + +function createCanvas(): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + stubRect(canvas, { left: 0, top: 0, ...VIEWPORT }); + document.body.appendChild(canvas); + return canvas; +} + +function clickAt(target: Element, clientX: number, clientY: number): void { + target.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + clientX, + clientY, + }), + ); +} + +function moveAt(target: Element, clientX: number, clientY: number): void { + target.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX, clientY }), + ); +} + +/** 假 three:够桥走完射线 + 八角投影。 */ +function createThreeFake(options: { + hits: { object: unknown; distance: number }[]; + /** 世界坐标 → 屏幕坐标(与页面同尺度),用来造出「投影盒超出画布」。 */ + projection: (x: number, y: number) => { x: number; y: number }; +}) { + const meshBox = new WeakMap< + object, + { min: { x: number; y: number }; max: { x: number; y: number } } + >(); + class Vector2 { + constructor( + public x: number, + public y: number, + ) {} + } + class Vector3 { + constructor( + public x: number, + public y: number, + public z: number, + ) {} + project(): this { + const point = options.projection(this.x, this.y); + this.x = point.x; + this.y = point.y; + return this; + } + } + class Box3 { + min = { x: 0, y: 0, z: 0 }; + max = { x: 0, y: 0, z: 0 }; + setFromObject(object: unknown): this { + if (object && typeof object === 'object') { + const box = meshBox.get(object); + if (box) { + this.min = { ...box.min, z: 0 }; + this.max = { ...box.max, z: 0 }; + } + } + return this; + } + } + class Raycaster { + setFromCamera(): void {} + intersectObjects(): { object: unknown; distance: number }[] { + return options.hits; + } + } + return { + namespace: { Vector2, Vector3, Box3, Raycaster }, + createMesh( + object: object, + box: { min: { x: number; y: number }; max: { x: number; y: number } }, + ) { + meshBox.set(object, box); + return object; + }, + }; +} + +function stubDocumentMetrics(): void { + Object.defineProperty(window, 'innerWidth', { + value: VIEWPORT.width, + configurable: true, + }); + Object.defineProperty(window, 'innerHeight', { + value: VIEWPORT.height, + configurable: true, + }); + // 真实 AGC 游戏页面不留 body 外边距;否则桥把 margin 计入固有尺寸(900 → 916)。 + document.body.style.margin = '0'; + document.documentElement.style.margin = '0'; + for (const element of [document.documentElement, document.body]) { + Object.defineProperty(element, 'offsetWidth', { + value: VIEWPORT.width, + configurable: true, + }); + Object.defineProperty(element, 'offsetHeight', { + value: VIEWPORT.height, + configurable: true, + }); + Object.defineProperty(element, 'scrollWidth', { + value: VIEWPORT.width, + configurable: true, + }); + Object.defineProperty(element, 'scrollHeight', { + value: VIEWPORT.height, + configurable: true, + }); + stubRect(element, { left: 0, top: 0, ...VIEWPORT }); + } +} + +beforeEach(() => { + posted.length = 0; + document.body.innerHTML = ''; + stubDocumentMetrics(); + // 桥只在 window.parent !== window 时上报尺寸:这里放一个假的父窗口记录 postMessage。 + Object.defineProperty(window, 'parent', { + value: { postMessage: (message: Posted) => posted.push(message) }, + configurable: true, + }); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((( + callback: FrameRequestCallback, + ) => { + callback(0); + return 0; + }) as unknown as typeof window.requestAnimationFrame); + vi.spyOn(window, 'setInterval').mockImplementation( + (() => 0) as unknown as typeof window.setInterval, + ); + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + function (this: Element): DOMRect { + const override = rectOverrides.get(this); + if (override) return fakeRect(override) as unknown as DOMRect; + if ( + this instanceof HTMLElement && + this.dataset?.genarrativePreviewInspect !== undefined + ) { + // 真实浏览器里高亮框的 border-box = inline style 的 left/top/size + 2px 边框。 + const px = (value: string) => Number.parseFloat(value) || 0; + return fakeRect({ + left: px(this.style.left), + top: px(this.style.top), + width: px(this.style.width) + 4, + height: px(this.style.height) + 4, + }) as unknown as DOMRect; + } + return fakeRect({ + left: 0, + top: 0, + width: 0, + height: 0, + }) as unknown as DOMRect; + }, + ); + if ( + document.querySelector('[data-genarrative-preview-fit="true"]') === null + ) { + // eslint-disable-next-line no-new-func + new Function(BRIDGE_SOURCE)(); + } +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('运行画面点选不改变预览尺寸', () => { + it('尺寸上报把桥自己的节点排除在内容测量之外(同样大小的普通节点仍会被计入)', () => { + const baseline = publishSize(); + expect(baseline.contentWidth).toBe(VIEWPORT.width); + expect(baseline.contentHeight).toBe(VIEWPORT.height); + + // 对照组:普通节点撑开内容 → 上报必须变化(证明这条用例能抓住「内容尺寸被撑大」)。 + const plain = document.createElement('div'); + stubRect(plain, { left: 0, top: 0, width: 4000, height: 3000 }); + document.body.appendChild(plain); + const widened = publishSize(); + expect(widened.contentWidth).toBe(4000); + expect(widened.contentHeight).toBe(3000); + plain.remove(); + + // 实验组:同样的尺寸,但带桥自己的标记 → 不得参与测量。 + const bridgeOwned = document.createElement('div'); + bridgeOwned.dataset.genarrativePreviewInspect = 'true'; + stubRect(bridgeOwned, { left: 0, top: 0, width: 4000, height: 3000 }); + document.body.appendChild(bridgeOwned); + const afterBridgeNode = publishSize(); + expect(afterBridgeNode.contentWidth).toBe(VIEWPORT.width); + expect(afterBridgeNode.contentHeight).toBe(VIEWPORT.height); + bridgeOwned.remove(); + }); + + it('进入检查模式与 hover 出现高亮框后:尺寸上报与宿主适配布局都不变', () => { + const canvas = createCanvas(); + const mesh = { + name: 'Tower', + type: 'Mesh', + geometry: { type: 'CylinderGeometry' }, + userData: {}, + material: {}, + }; + const three = createThreeFake({ + hits: [{ object: mesh, distance: 1 }], + projection: (x, y) => ({ + x: (x / VIEWPORT.width) * 2 - 1, + y: 1 - (y / VIEWPORT.height) * 2, + }), + }); + // 投影盒远超画布:真实项目里贴近相机的大物体就是这样。 + three.createMesh(mesh, { + min: { x: -400, y: -300 }, + max: { x: 1300, y: 860 }, + }); + ( + window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown } + ).__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three: three.namespace, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + const baseline = publishSize(); + expect(sizeMessages().length).toBeGreaterThan(0); + expect( + resolveLocalGamePreviewFitLayout(VIEWPORT, { + contentWidth: baseline.contentWidth as number, + contentHeight: baseline.contentHeight as number, + viewportWidth: baseline.viewportWidth as number, + viewportHeight: baseline.viewportHeight as number, + }), + ).toEqual({ width: VIEWPORT.width, height: VIEWPORT.height, scale: 1 }); + + sendInspectAction('enable'); + moveAt(canvas, 450, 280); + // 高亮框被裁剪到画布内:不再是超出画面的投影盒。 + expect(overlayStyleRect()).toEqual(['0px', '0px', '900px', '560px']); + + const afterHover = publishSize(); + expect(afterHover.contentWidth).toBe(baseline.contentWidth); + expect(afterHover.contentHeight).toBe(baseline.contentHeight); + expect( + resolveLocalGamePreviewFitLayout(VIEWPORT, { + contentWidth: afterHover.contentWidth as number, + contentHeight: afterHover.contentHeight as number, + viewportWidth: afterHover.viewportWidth as number, + viewportHeight: afterHover.viewportHeight as number, + }), + ).toEqual({ width: VIEWPORT.width, height: VIEWPORT.height, scale: 1 }); + + clickAt(canvas, 450, 280); + const afterClick = publishSize(); + expect(afterClick.contentWidth).toBe(baseline.contentWidth); + expect(afterClick.contentHeight).toBe(baseline.contentHeight); + expect(overlayElement()).toBeNull(); + }); + + it('three 投影盒超出画布时:载荷矩形被裁剪到画布内,width/height 不为 0', () => { + const canvas = createCanvas(); + const mesh = { + name: 'Tower', + type: 'Mesh', + geometry: { type: 'CylinderGeometry' }, + userData: {}, + material: {}, + }; + const three = createThreeFake({ + hits: [{ object: mesh, distance: 1 }], + projection: (x, y) => ({ + x: (x / VIEWPORT.width) * 2 - 1, + y: 1 - (y / VIEWPORT.height) * 2, + }), + }); + three.createMesh(mesh, { + min: { x: -400, y: -300 }, + max: { x: 1300, y: 860 }, + }); + ( + window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown } + ).__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three: three.namespace, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 450, 280); + + expect(lastSelection()).toMatchObject({ + label: 'Tower', + elementTag: 'canvas', + width: VIEWPORT.width, + height: VIEWPORT.height, + resourceIds: [], + sourcePath: '', + }); + }); + + it('投影盒与画布完全无交集时退化为指针点矩形,不产生 0 或负尺寸', () => { + const canvas = createCanvas(); + const mesh = { + name: 'Far', + type: 'Mesh', + geometry: { type: 'BoxGeometry' }, + userData: {}, + material: {}, + }; + const three = createThreeFake({ + hits: [{ object: mesh, distance: 1 }], + projection: (x, y) => ({ + x: (x / VIEWPORT.width) * 2 - 1, + y: 1 - (y / VIEWPORT.height) * 2, + }), + }); + three.createMesh(mesh, { + min: { x: 2000, y: 1500 }, + max: { x: 2600, y: 1900 }, + }); + ( + window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown } + ).__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'three', + three: three.namespace, + scene: { children: [mesh] }, + camera: {}, + renderer: { domElement: canvas }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 120, 90); + + expect(lastSelection()).toMatchObject({ width: 1, height: 1 }); + }); + + it('Phaser 世界包围盒超出画布时同样裁剪到画布内', () => { + const canvas = createCanvas(); + const sprite = { + name: 'Big', + type: 'Sprite', + visible: true, + depth: 0, + texture: { key: 'big' }, + getBounds: () => ({ x: -500, y: -400, width: 2000, height: 1600 }), + getData: () => undefined, + }; + const scene = { + sys: { isActive: () => true }, + cameras: { + main: { + scrollX: 0, + scrollY: 0, + zoom: 1, + x: 0, + y: 0, + width: VIEWPORT.width, + height: VIEWPORT.height, + originX: 0.5, + originY: 0.5, + rotation: 0, + getWorldPoint: (x: number, y: number) => ({ x, y }), + }, + }, + input: { + hitTestPointer: (pointer: { camera?: unknown }) => { + pointer.camera = scene.cameras.main; + return [sprite]; + }, + }, + children: { list: [sprite] }, + }; + ( + window as unknown as { __GENARRATIVE_PREVIEW_GAME__?: unknown } + ).__GENARRATIVE_PREVIEW_GAME__ = { + engine: 'phaser', + game: { + input: { activePointer: { x: 450, y: 280 } }, + scene: { scenes: [scene] }, + scale: { + gameSize: { width: VIEWPORT.width, height: VIEWPORT.height }, + transformX: (pageX: number) => pageX, + transformY: (pageY: number) => pageY, + }, + }, + }; + + sendInspectAction('enable'); + clickAt(canvas, 450, 280); + + expect(lastSelection()).toMatchObject({ + label: 'big', + width: VIEWPORT.width, + height: VIEWPORT.height, + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts b/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts index 1457b2ec1..5da32aade 100644 --- a/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts +++ b/apps/ai-game-creator-shell/tests/runtimeInspectEngines.test.ts @@ -710,8 +710,8 @@ describe('运行画面点选:Phaser 4 档', () => { data: { genarrativeResourceId: 'local-asset:plain' }, }); const camera = createPhaserCamera({ - scrollX: 10, - scrollY: 20, + scrollX: -400, + scrollY: -300, zoom: 2, x: 3, y: 4, @@ -733,8 +733,9 @@ describe('运行画面点选:Phaser 4 档', () => { sendInspectAction('enable'); clickAt(canvas, 50, 40); - // 世界 (10,20)-(110,70) 经 origin=0.5 / zoom=2 / scroll=(10,20) / camera=(3,4) - // → 屏幕 (-397,-296)-( -197,-196):宽 200、高 100。 + // 相机字段换算(origin=0.5 / zoom=2 / scroll=(-400,-300) / camera=(3,4)): + // 世界 (10,20)-(110,70) → 屏幕 (423,344)-(623,444),宽 200、高 100(世界尺寸的 2 倍)。 + // 矩形落在画布内,画布裁剪不改变它。 expect(selectedSelection()).toMatchObject({ width: 200, height: 100, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c92b4ceee..b9f45cae2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,14 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-05 运行画面点选的高亮框被尺寸上报当成页面内容:点选后预览自己缩放 + +- **现象**:three 项目里点选 3D 对象后预览画面自己缩放、视角异常(相机 aspect 与重新适配的距离都变了),点选结束后视口仍停在放大后的尺寸。实测夹具里是**自激振荡**:10 秒内 1459 条尺寸消息、1457 次 iframe 尺寸/缩放变更、1454 次 `resize`,相机 aspect 在 1.607143 ↔ 1.421112 之间来回变。 +- **原因**:高亮框是 `document.body` 下的 `div[data-genarrative-preview-inspect]`(`position: fixed`、尺寸随命中对象),而尺寸上报的 `measureContentBounds` 用 `createTreeWalker(body, SHOW_ELEMENT)` 遍历所有元素并把 `rect.right/bottom` 计入内容尺寸。three 档的高亮矩形来自 `Box3` 八角投影,物体贴近相机时投影盒远超画布(实测 6607×4721、left/top 为负)→ 上报内容被撑到 3756×2643(视口只有 900×560)→ 宿主 `resolveLocalGamePreviewFitLayout` 把 iframe 改成 3756×2643 + scale 0.2119 → 游戏 `resize` 重算相机 → 视口变大又让投影盒更大,如此循环。 +- **结论(现行口径)**:① 桥自己的节点一律不进内容尺寸测量——`measureContentBounds` 的 TreeWalker 用 `acceptNode` 对 `data-genarrative-preview-fit` / `data-genarrative-preview-inspect` 返回 `FILTER_REJECT`;以后新增任何桥注入的 DOM 节点都要带上这两个标记之一,否则会重新引入这条反馈。② 引擎档命中矩形统一裁剪到画布可见范围(`clipInspectRect`;与画布无交集时退化为指针点矩形),不再出现比画面还大的高亮框。 +- **回归**:`apps/ai-game-creator-shell/tests/localPreviewInspectSizeStability.test.ts`(5 例:桥节点不参与测量、同尺寸普通节点仍计入的对照组、进入检查模式与 hover 后尺寸与宿主适配布局不变、投影盒超出画布时载荷被裁剪、与画布无交集时退化为 1×1)。 +- **关联**:`resources/preview/local-preview-fit.js`(`measureContentBounds` / `clipInspectRect` / `threeInspectTarget` / `phaserSelection`)、`features/project-workspace/LocalGamePreviewFrame.tsx`(`resolveLocalGamePreviewFitLayout`)。 + ## 2026-10-05 把整个 `THREE` 命名空间塞进预览句柄会让 tree-shaking 失效 - **现象**:three 项目的运行画面点选句柄写成 `{ engine: 'three', THREE, scene, camera, renderer }` 时,同一个 Vite 构建的产物从 517,682 B 涨到 730,831 B(+213,149 B ≈ +41%)。