Skip to content

Commit 21272df

Browse files
author
DavidQ
committed
Normalize shared world-to-screen scale between Asteroids runtime and Collision Inspector V2 - PR_26139_011-shared-world-scale-normalization
1 parent 3e63b40 commit 21272df

9 files changed

Lines changed: 292 additions & 38 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# PR_26139_011-shared-world-scale-normalization
2+
3+
## Scope
4+
- Added a shared canonical world/screen transform helper in `src/engine/rendering/WorldScreenTransform.js`.
5+
- Kept Asteroids runtime as the source of truth: object world coordinates map 1:1 to canvas pixels at `CANONICAL_WORLD_TO_SCREEN_SCALE = 1`.
6+
- Moved runtime object render translate/rotate/scale composition in `ObjectVectorRuntimeAssetService` through the shared helper while preserving prior instance scale semantics.
7+
- Updated Collision Inspector V2 to use the shared helper for viewport sizing, canvas CSS sizing, pointer-to-world conversion, and zoom transform composition.
8+
- Kept Collision Inspector zoom as additional user zoom only.
9+
- Kept Object Vector Studio V2 as an editor surface with editor viewport zoom only; added status text that explicitly separates editor zoom from runtime/world scale.
10+
- Preserved intentional asteroid instance scale tuning, ship flame flicker behavior, manifest geometry SSoT, and manifest-only object rendering.
11+
12+
## Prior Reference
13+
- Read `docs/dev/PROJECT_INSTRUCTIONS.md`.
14+
- Used current PR_26139_010 implementation and `docs/dev/reports/PR_26139_010-collision-inspector-usability-polish_report.md` as the available prior reference.
15+
- Requested `docs/dev/reports/PR_26139_010-size-scale-differences_report.md` was not present in this workspace.
16+
17+
## Scale Normalization Rule
18+
- Asteroids runtime: canonical world scale is `1 world unit = 1 canvas pixel`; per-object gameplay/art tuning remains `options.scale`.
19+
- Collision Inspector V2: uses the same canonical world-to-screen transform and manifest screen dimensions; `collisionZoomInput` applies user zoom after canonical world scaling.
20+
- Object Vector Studio V2: uses editor-only viewport/grid zoom for authoring; it does not apply runtime/world scale to the editor work surface.
21+
22+
## Validation
23+
- PASS: `node --check src/engine/rendering/WorldScreenTransform.js`
24+
- PASS: `node --check src/engine/rendering/ObjectVectorRuntimeAssetService.js`
25+
- PASS: `node --check tools/collision-inspector-v2/js/CollisionInspectorV2App.js`
26+
- PASS: `node --check tools/collision-inspector-v2/js/CollisionInspectorV2Controls.js`
27+
- PASS: `node --check tools/collision-inspector-v2/js/CollisionInspectorV2Renderer.js`
28+
- PASS: `node --check tools/object-vector-studio-v2/js/ToolStarterApp.js`
29+
- PASS: `node --check tests/playwright/tools/CollisionInspectorV2.spec.mjs`
30+
- PASS: `npx playwright test tests/playwright/tools/CollisionInspectorV2.spec.mjs --project=playwright --workers=1 --reporter=list`
31+
- `4 passed`
32+
- Validates Asteroids runtime canvas scale and Collision Inspector canvas scale.
33+
- Validates same manifest ship physical size between Asteroids runtime object rendering and Collision Inspector at zoom `1.0`, allowing stroke/antialias bounds tolerance.
34+
- Validates Collision Inspector zoom remains additional user zoom up to `5x`.
35+
- Validates Object Vector Studio V2 editor zoom changes without changing shared runtime scale.
36+
- Validates Workspace Manager V2 launch path still loads Asteroids objects.
37+
- PASS: `npm run build:manifest`
38+
- This repo does not define a plain `npm run build`; `build:manifest` is the available build script.
39+
- Removed generated `docs/build` output after validation.
40+
- PASS: `git diff --check`
41+
- Only existing line-ending conversion warnings were reported.
42+
- PASS: Playwright V8 coverage report generated at `docs/dev/reports/playwright_v8_coverage_report.txt`.
43+
44+
## Full Samples Smoke Test
45+
- Skipped. This PR is limited to shared scale transform wiring plus targeted Collision Inspector/Object Vector Studio validation.
46+
47+
## Manual Test Notes
48+
- Open `tools/collision-inspector-v2/index.html?manifestPath=/games/Asteroids/game.manifest.json`.
49+
- Verify the ship appears the same physical size as Asteroids runtime at Collision Inspector zoom `1.0`.
50+
- Increase Collision Inspector zoom to `5x`; expected result is zoomed inspection only, with unchanged collision/world coordinates.
51+
- Open Object Vector Studio V2 and use its zoom controls; expected result is editor viewport zoom only, not runtime/world scale changes.

src/engine/rendering/ObjectVectorRuntimeAssetService.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createWorldScreenTransform } from "./WorldScreenTransform.js";
2+
13
const DEFAULT_SCHEMA_URL = new URL("../../../tools/schemas/tools/object-vector-studio-v2.schema.json", import.meta.url);
24
const OBJECT_VECTOR_TOOL_ID = "object-vector-studio-v2";
35

@@ -865,11 +867,13 @@ export class ObjectVectorRuntimeAssetService {
865867

866868
drawObjectToCanvas(context, object, frame, options = {}) {
867869
try {
870+
const renderTransform = createWorldScreenTransform({
871+
worldScale: options.worldScale
872+
}).objectRenderOptions(options);
868873
context.save();
869-
context.translate(options.x || 0, options.y || 0);
870-
context.rotate(options.rotation || 0);
871-
const scale = Number.isFinite(options.scale) ? options.scale : 1;
872-
context.scale(scale, scale);
874+
context.translate(renderTransform.x, renderTransform.y);
875+
context.rotate(renderTransform.rotation);
876+
context.scale(renderTransform.scale, renderTransform.scale);
873877
let renderedShapes = 0;
874878
const transformOrigin = objectTransformOrigin(object);
875879
sortedShapes(object).forEach((shape, shapeIndex) => {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
export const CANONICAL_WORLD_TO_SCREEN_SCALE = 1;
2+
3+
function finiteNumber(value, fallback = 0) {
4+
const number = Number(value);
5+
return Number.isFinite(number) ? number : fallback;
6+
}
7+
8+
function positiveInteger(value, fallback = 1) {
9+
const number = Math.floor(Number(value));
10+
return Number.isFinite(number) && number > 0 ? number : fallback;
11+
}
12+
13+
function positiveScale(value, fallback = 1) {
14+
const number = Number(value);
15+
return Number.isFinite(number) && number > 0 ? number : fallback;
16+
}
17+
18+
export function createWorldScreenTransform({
19+
screenHeight = 1,
20+
screenWidth = 1,
21+
userZoom = 1,
22+
worldScale = CANONICAL_WORLD_TO_SCREEN_SCALE
23+
} = {}) {
24+
const resolvedScreenHeight = positiveInteger(screenHeight);
25+
const resolvedScreenWidth = positiveInteger(screenWidth);
26+
const resolvedUserZoom = positiveScale(userZoom);
27+
const resolvedWorldScale = positiveScale(worldScale, CANONICAL_WORLD_TO_SCREEN_SCALE);
28+
const center = Object.freeze({
29+
x: resolvedScreenWidth / 2,
30+
y: resolvedScreenHeight / 2
31+
});
32+
33+
return Object.freeze({
34+
center,
35+
cssAspectRatio: `${resolvedScreenWidth} / ${resolvedScreenHeight}`,
36+
cssHeight: resolvedScreenHeight * resolvedWorldScale,
37+
cssWidth: resolvedScreenWidth * resolvedWorldScale,
38+
screenHeight: resolvedScreenHeight,
39+
screenWidth: resolvedScreenWidth,
40+
userZoom: resolvedUserZoom,
41+
worldScale: resolvedWorldScale,
42+
applyUserZoom(context) {
43+
if (resolvedUserZoom === 1) {
44+
return;
45+
}
46+
context.translate(center.x, center.y);
47+
context.scale(resolvedUserZoom, resolvedUserZoom);
48+
context.translate(-center.x, -center.y);
49+
},
50+
applyWorldToScreen(context) {
51+
if (resolvedWorldScale === 1) {
52+
return;
53+
}
54+
context.scale(resolvedWorldScale, resolvedWorldScale);
55+
},
56+
clientPointToScreenPoint(event, rect) {
57+
const rectWidth = positiveScale(rect?.width);
58+
const rectHeight = positiveScale(rect?.height);
59+
return {
60+
x: ((finiteNumber(event?.clientX) - finiteNumber(rect?.left)) / rectWidth) * resolvedScreenWidth,
61+
y: ((finiteNumber(event?.clientY) - finiteNumber(rect?.top)) / rectHeight) * resolvedScreenHeight
62+
};
63+
},
64+
objectRenderOptions(options = {}) {
65+
return {
66+
rotation: options.rotation || 0,
67+
scale: (Number.isFinite(options.scale) ? options.scale : 1) * resolvedWorldScale,
68+
x: (options.x || 0) * resolvedWorldScale,
69+
y: (options.y || 0) * resolvedWorldScale
70+
};
71+
},
72+
screenPointToWorldWithUserZoom(point) {
73+
return {
74+
x: ((finiteNumber(point?.x) - center.x) / resolvedUserZoom + center.x) / resolvedWorldScale,
75+
y: ((finiteNumber(point?.y) - center.y) / resolvedUserZoom + center.y) / resolvedWorldScale
76+
};
77+
}
78+
});
79+
}

src/engine/rendering/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export { default as ResolutionScaler } from './ResolutionScaler.js';
1010
export { renderSpriteReadyEntities } from './SpriteRenderSystem.js';
1111
export { renderByLayers } from './LayeredRenderSystem.js';
1212
export { transformPoints, drawVectorShape } from './VectorDrawing.js';
13+
export { CANONICAL_WORLD_TO_SCREEN_SCALE, createWorldScreenTransform } from './WorldScreenTransform.js';

tests/playwright/tools/CollisionInspectorV2.spec.mjs

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,67 @@ async function canvasSignature(page) {
4747
});
4848
}
4949

50+
async function runtimeObjectPixelBounds(page, objectId, instance) {
51+
return page.evaluate(async ({ instance, objectId }) => {
52+
const { ObjectVectorRuntimeAssetService } = await import("/src/engine/rendering/index.js");
53+
const runtime = new ObjectVectorRuntimeAssetService({
54+
logger: {
55+
error() {},
56+
info() {},
57+
warn() {}
58+
}
59+
});
60+
const assetSet = await runtime.loadFromManifest("/games/Asteroids/game.manifest.json", {
61+
sourceLabel: "scale validation"
62+
});
63+
if (!assetSet) {
64+
throw new Error("Asteroids object-vector manifest failed to load for scale validation.");
65+
}
66+
67+
const canvas = document.createElement("canvas");
68+
canvas.width = 960;
69+
canvas.height = 720;
70+
const context = canvas.getContext("2d");
71+
const result = runtime.renderObject({ ctx: context }, assetSet, {
72+
elapsedMs: 0,
73+
objectId,
74+
rotation: instance.rotation,
75+
scale: instance.scale,
76+
x: instance.x,
77+
y: instance.y
78+
});
79+
if (!result.ok) {
80+
throw new Error(`Runtime object render failed for ${objectId}.`);
81+
}
82+
83+
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
84+
let maxX = -1;
85+
let maxY = -1;
86+
let minX = canvas.width;
87+
let minY = canvas.height;
88+
for (let y = 0; y < canvas.height; y += 1) {
89+
for (let x = 0; x < canvas.width; x += 1) {
90+
if (data[(y * canvas.width + x) * 4 + 3] <= 0) {
91+
continue;
92+
}
93+
minX = Math.min(minX, x);
94+
minY = Math.min(minY, y);
95+
maxX = Math.max(maxX, x);
96+
maxY = Math.max(maxY, y);
97+
}
98+
}
99+
if (maxX < minX || maxY < minY) {
100+
throw new Error(`Runtime object render produced no visible pixels for ${objectId}.`);
101+
}
102+
return {
103+
height: maxY - minY + 1,
104+
width: maxX - minX + 1,
105+
x: minX,
106+
y: minY
107+
};
108+
}, { instance, objectId });
109+
}
110+
50111
test.describe("Collision Inspector V2", () => {
51112
test("loads a game manifest and reports live vector, pixel, bounds, and hybrid collisions", async ({ page }) => {
52113
const server = await startRepoServer();
@@ -116,10 +177,19 @@ test.describe("Collision Inspector V2", () => {
116177
expect(collisionInspectorScale.scaleX).toBeCloseTo(1, 2);
117178
expect(collisionInspectorScale.scaleY).toBeCloseTo(1, 2);
118179
const runtimeRenderSource = await readFile(join(server.repoRoot, "src", "engine", "rendering", "ObjectVectorRuntimeAssetService.js"), "utf8");
180+
const worldScreenSource = await readFile(join(server.repoRoot, "src", "engine", "rendering", "WorldScreenTransform.js"), "utf8");
181+
const collisionControlsSource = await readFile(join(server.repoRoot, "tools", "collision-inspector-v2", "js", "CollisionInspectorV2Controls.js"), "utf8");
182+
const collisionRendererSource = await readFile(join(server.repoRoot, "tools", "collision-inspector-v2", "js", "CollisionInspectorV2Renderer.js"), "utf8");
119183
const objectVectorStudioSource = await readFile(join(server.repoRoot, "tools", "object-vector-studio-v2", "js", "ToolStarterApp.js"), "utf8");
120-
expect(runtimeRenderSource).toContain("const scale = Number.isFinite(options.scale) ? options.scale : 1;");
121-
expect(runtimeRenderSource).toContain("context.scale(scale, scale);");
184+
expect(worldScreenSource).toContain("export const CANONICAL_WORLD_TO_SCREEN_SCALE = 1;");
185+
expect(worldScreenSource).toContain("objectRenderOptions(options = {})");
186+
expect(runtimeRenderSource).toContain("createWorldScreenTransform");
187+
expect(runtimeRenderSource).toContain(".objectRenderOptions(options)");
188+
expect(collisionControlsSource).toContain("createWorldScreenTransform");
189+
expect(collisionRendererSource).toContain("createWorldScreenTransform");
190+
expect(collisionRendererSource).toContain("screenPointToWorldWithUserZoom");
122191
expect(objectVectorStudioSource).toContain("const OBJECT_PREVIEW_DRAWING_SCALE = GRID_STEP;");
192+
expect(objectVectorStudioSource).toContain("CANONICAL_WORLD_TO_SCREEN_SCALE");
123193
expect(objectVectorStudioSource).toContain("point.x / OBJECT_PREVIEW_DRAWING_SCALE");
124194
const asteroidsPage = await page.context().newPage();
125195
try {
@@ -147,6 +217,15 @@ test.describe("Collision Inspector V2", () => {
147217
await expect(page.locator("#overlapState")).toHaveText("false");
148218
await expect(page.locator("#originState")).toHaveText("Origins:\nA 360.000,320.000\nB 500.000,320.000");
149219
await expect(page.locator("#rotationState")).toHaveText("A 0 / B 0");
220+
const scaleMatchSummary = await readCollisionSummary(page);
221+
const runtimeShipBounds = await runtimeObjectPixelBounds(page, "object.asteroids.ship", {
222+
rotation: 0,
223+
scale: 1,
224+
x: 360,
225+
y: 320
226+
});
227+
expect(Math.abs(runtimeShipBounds.width - scaleMatchSummary.bounds.objectA.width)).toBeLessThanOrEqual(10);
228+
expect(Math.abs(runtimeShipBounds.height - scaleMatchSummary.bounds.objectA.height)).toBeLessThanOrEqual(10);
150229
await expect(page.locator("#collisionSummary")).toContainText('"enginePath": "src/engine/collision/objectVector.js"');
151230
await expect(page.locator("#collisionSummary")).toContainText('"objectOrigins"');
152231
await expect(page.locator("#collisionSummary")).toContainText('"recommendedMode": "vector"');
@@ -324,6 +403,39 @@ test.describe("Collision Inspector V2", () => {
324403
}
325404
});
326405

406+
test("keeps Object Vector Studio V2 zoom editor-only against shared world scale", async ({ page }) => {
407+
const server = await startRepoServer();
408+
const pageErrors = [];
409+
page.on("pageerror", (error) => {
410+
pageErrors.push(error.message);
411+
});
412+
413+
await coverageReporter.start(page);
414+
try {
415+
await page.goto(`${server.baseUrl}/tools/object-vector-studio-v2/index.html`, { waitUntil: "networkidle" });
416+
await expect(page.locator("#statusLog")).toHaveValue(/Object Vector Studio V2 editor zoom is viewport-only; runtime\/world scale remains 1:1/);
417+
const initialZoom = await page.evaluate(() => window.__objectVectorStudioV2App.viewport.zoom);
418+
const sharedScale = await page.evaluate(async () => {
419+
const { CANONICAL_WORLD_TO_SCREEN_SCALE } = await import("/src/engine/rendering/index.js");
420+
return CANONICAL_WORLD_TO_SCREEN_SCALE;
421+
});
422+
expect(sharedScale).toBe(1);
423+
await page.locator("#objectVectorStudioV2ZoomInButton").click();
424+
const editorZoom = await page.evaluate(() => window.__objectVectorStudioV2App.viewport.zoom);
425+
expect(editorZoom).toBeGreaterThan(initialZoom);
426+
const sharedScaleAfterZoom = await page.evaluate(async () => {
427+
const { CANONICAL_WORLD_TO_SCREEN_SCALE } = await import("/src/engine/rendering/index.js");
428+
return CANONICAL_WORLD_TO_SCREEN_SCALE;
429+
});
430+
expect(sharedScaleAfterZoom).toBe(1);
431+
await expect(page.locator("#statusLog")).toHaveValue(/Viewport zoom set/);
432+
expect(pageErrors).toEqual([]);
433+
} finally {
434+
await coverageReporter.stop(page);
435+
await server.close();
436+
}
437+
});
438+
327439
test("loads Asteroids Object Vector objects from a Workspace Manager V2 manifest context", async ({ page }) => {
328440
const server = await startRepoServer();
329441
const pageErrors = [];

tools/collision-inspector-v2/js/CollisionInspectorV2App.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
OBJECT_VECTOR_COLLISION_MODE_LABELS,
55
recommendObjectVectorCollisionMode
66
} from "../../../src/engine/collision/index.js";
7+
import { CANONICAL_WORLD_TO_SCREEN_SCALE } from "../../../src/engine/rendering/index.js";
78
import { resolveManifestScreenDimensions } from "../../../src/tools/common/GameManifestLoader.js";
89
import {
910
clone,
@@ -60,6 +61,7 @@ export class CollisionInspectorV2App {
6061
this.controls.setLaunchMode(this.isWorkspaceLaunch());
6162
this.controls.setZoom(this.zoom);
6263
this.logger.write("INFO Collision Inspector V2 ready.");
64+
this.logger.write(`INFO Shared world-to-screen scale is ${CANONICAL_WORLD_TO_SCREEN_SCALE}:1; Collision Inspector zoom is additional user zoom only.`);
6365
await this.loadInitialManifest();
6466
if (this.hasRenderableManifest()) {
6567
this.evaluateAndRender();

tools/collision-inspector-v2/js/CollisionInspectorV2Controls.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createWorldScreenTransform } from "../../../src/engine/rendering/index.js";
12
import {
23
labelForObject,
34
numberValue,
@@ -131,11 +132,15 @@ export class CollisionInspectorV2Controls {
131132
}
132133

133134
setViewportSize(width, height) {
134-
this.elements.canvas.setAttribute("width", String(Math.max(1, Math.floor(numberValue(width, 1)))));
135-
this.elements.canvas.setAttribute("height", String(Math.max(1, Math.floor(numberValue(height, 1)))));
136-
this.elements.canvas.style.setProperty("--collision-inspector-aspect-ratio", `${this.elements.canvas.width} / ${this.elements.canvas.height}`);
137-
this.elements.canvas.style.setProperty("--collision-inspector-screen-width", `${this.elements.canvas.width}px`);
138-
this.elements.canvas.style.setProperty("--collision-inspector-screen-height", `${this.elements.canvas.height}px`);
135+
const transform = createWorldScreenTransform({
136+
screenHeight: height,
137+
screenWidth: width
138+
});
139+
this.elements.canvas.setAttribute("width", String(transform.screenWidth));
140+
this.elements.canvas.setAttribute("height", String(transform.screenHeight));
141+
this.elements.canvas.style.setProperty("--collision-inspector-aspect-ratio", transform.cssAspectRatio);
142+
this.elements.canvas.style.setProperty("--collision-inspector-screen-width", `${transform.cssWidth}px`);
143+
this.elements.canvas.style.setProperty("--collision-inspector-screen-height", `${transform.cssHeight}px`);
139144
}
140145

141146
syncResult(result) {

0 commit comments

Comments
 (0)