Skip to content

Commit bc208d8

Browse files
author
DavidQ
committed
Normalize shared orientation transforms between Asteroids runtime and Collision Inspector V2 - PR_26139_012-collision-orientation-runtime-alignment
1 parent 21272df commit bc208d8

15 files changed

Lines changed: 513 additions & 138 deletions
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# PR_26139_012-collision-orientation-runtime-alignment
2+
3+
## Scope
4+
- Added `src/engine/rendering/OrientationTransform.js` beside the shared world/screen scale helper.
5+
- Routed Object Vector runtime shape transforms, SVG transform output, shared collision geometry, Collision Inspector V2 heading guides, and Object Vector Studio V2 preview point transforms through the shared orientation helper.
6+
- Kept Asteroids runtime heading as radians and made Asteroids object render calls explicit with `rotationUnit: 'radians'`.
7+
- Kept Collision Inspector V2 rotate controls in degrees and made inspector instances explicit with `rotationUnit: "degrees"`.
8+
- Preserved manifest geometry as the SSoT, intentional asteroid scale tuning, and ship flame flicker behavior.
9+
10+
## Orientation Contract
11+
- Runtime object instance rotation uses radians unless a caller explicitly provides `rotationUnit`.
12+
- Manifest shape transforms remain degrees.
13+
- Collision Inspector V2 UI rotation remains degrees, then converts through the shared helper before collision/heading rendering.
14+
- Object Vector Studio V2 remains an editor surface; its preview shape transforms call the same helper for SVG transforms, point transforms, inverse point transforms, origin normalization, and rotation normalization.
15+
- Object instance `x/y` remains the Asteroids runtime source-of-truth world position for the object's local coordinate origin. Manifest `objectOrigin` is transformed through the same runtime orientation path for diagnostics and inspector origin markers.
16+
17+
## Validation
18+
- PASS: `node --check src/engine/rendering/OrientationTransform.js`
19+
- PASS: `node --check src/engine/rendering/WorldScreenTransform.js`
20+
- PASS: `node --check src/engine/rendering/ObjectVectorRuntimeAssetService.js`
21+
- PASS: `node --check src/engine/collision/objectVector.js`
22+
- PASS: `node --check tools/collision-inspector-v2/js/CollisionInspectorV2App.js`
23+
- PASS: `node --check tools/collision-inspector-v2/js/CollisionInspectorV2Controls.js`
24+
- PASS: `node --check tools/collision-inspector-v2/js/CollisionInspectorV2Renderer.js`
25+
- PASS: `node --check tools/object-vector-studio-v2/js/ToolStarterApp.js`
26+
- PASS: `node --check games/Asteroids/game/AsteroidsGameScene.js`
27+
- PASS: `node --check games/Asteroids/game/AsteroidsAttractAdapter.js`
28+
- PASS: `node --check games/Asteroids/entities/Ufo.js`
29+
- PASS: `node --check tests/games/AsteroidsPresentation.test.mjs`
30+
- PASS: `node --check tests/playwright/tools/CollisionInspectorV2.spec.mjs`
31+
- PASS: `node --input-type=module -e "import('./tests/games/AsteroidsPresentation.test.mjs').then((module) => module.run())"`
32+
- Validates Asteroids gameplay, attract, UFO, ship, asteroid, and bullet render calls use manifest object IDs and the runtime radians convention.
33+
- PASS: `node --input-type=module -e "import('./tests/final/PrecisionCollisionSystems.test.mjs').then((module) => module.run())"`
34+
- Validates shared collision transform behavior still matches existing radians expectations.
35+
- PASS: `npx playwright test tests/playwright/tools/CollisionInspectorV2.spec.mjs --project=playwright --workers=1 --reporter=list`
36+
- `4 passed`
37+
- Validates Collision Inspector V2 layout and shared collision path.
38+
- Validates runtime render bounds and shared collision bounds align for ship, asteroid, UFO, and bullet orientation samples.
39+
- Validates bullet transformed points differ at `0`, `90`, `180`, and `270` degree headings while resolving through the same runtime/collision helper.
40+
- Validates inspector heading guides use explicit rotation units.
41+
- Validates Object Vector Studio V2 preview transforms match the shared orientation helper.
42+
- PASS: `npm run build:manifest`
43+
- Available repo build script wrote `docs/build/sample-manifest.json`; generated output was removed after validation.
44+
- PASS: `git diff --check`
45+
- Only existing line-ending conversion warnings were reported.
46+
- PASS: Playwright V8 coverage report generated at `docs/dev/reports/playwright_v8_coverage_report.txt`.
47+
48+
## Full Samples Smoke Test
49+
- Skipped. This PR is limited to shared orientation/runtime alignment and targeted Collision Inspector V2, Object Vector Studio V2, and Asteroids orientation validation.
50+
51+
## Manual Test Notes
52+
- Open `tools/collision-inspector-v2/index.html?manifestPath=/games/Asteroids/game.manifest.json`.
53+
- Select ship, bullet, asteroid, and UFO objects and rotate A/B; expected result is heading guides, origins, transformed points, and collision results updating consistently.
54+
- Launch `games/Asteroids/index.html`, fire bullets at several ship headings, and verify bullet render direction and collision behavior track the ship heading.
55+
- Open Object Vector Studio V2 and rotate/edit object geometry; expected result is unchanged editor zoom behavior with preview transforms matching runtime/collision orientation math.

games/Asteroids/entities/Ufo.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ export default class Ufo {
111111

112112
getCollisionPolygon() {
113113
return transformCollisionPoints(this.collisionPoints, {
114+
rotation: 0,
115+
rotationUnit: 'radians',
114116
x: this.x,
115117
y: this.y,
116118
});

games/Asteroids/game/AsteroidsAttractAdapter.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export default class AsteroidsAttractAdapter {
9999
fps: 12,
100100
objectId,
101101
requireManifestBinding: true,
102+
rotationUnit: 'radians',
102103
stateId: 'active',
103104
...options,
104105
});
@@ -141,6 +142,7 @@ export default class AsteroidsAttractAdapter {
141142
objectId: ASTEROIDS_OBJECT_GEOMETRY_IDS.ship,
142143
requireManifestBinding: true,
143144
rotation: -0.28,
145+
rotationUnit: 'radians',
144146
scale: 1.1,
145147
stateId: 'idle',
146148
x: 328,
@@ -234,6 +236,7 @@ export default class AsteroidsAttractAdapter {
234236
objectId: ASTEROIDS_OBJECT_GEOMETRY_IDS.ship,
235237
requireManifestBinding: true,
236238
rotation: Math.sin(this.demoTime * 0.9) * 1.2,
239+
rotationUnit: 'radians',
237240
stateId: 'idle',
238241
x,
239242
y,
@@ -259,6 +262,8 @@ export default class AsteroidsAttractAdapter {
259262
fps: 12,
260263
objectId: ASTEROIDS_OBJECT_GEOMETRY_IDS.ufoLarge,
261264
requireManifestBinding: true,
265+
rotation: 0,
266+
rotationUnit: 'radians',
262267
stateId: 'active',
263268
x: 480 + Math.cos(this.demoTime * 0.43) * 280,
264269
y: 284,

games/Asteroids/game/AsteroidsGameScene.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,7 @@ export default class AsteroidsGameScene extends Scene {
760760
objectId,
761761
requireManifestBinding: true,
762762
rotation: asteroid.angle,
763+
rotationUnit: 'radians',
763764
stateId: "active",
764765
x: asteroid.x,
765766
y: asteroid.y,
@@ -773,6 +774,8 @@ export default class AsteroidsGameScene extends Scene {
773774
fps: 12,
774775
objectId,
775776
requireManifestBinding: true,
777+
rotation: 0,
778+
rotationUnit: 'radians',
776779
stateId: "active",
777780
x: this.world.ufo.x,
778781
y: this.world.ufo.y,
@@ -784,6 +787,7 @@ export default class AsteroidsGameScene extends Scene {
784787
objectId: ASTEROIDS_OBJECT_GEOMETRY_IDS.bullet,
785788
requireManifestBinding: true,
786789
rotation: bullet.angle,
790+
rotationUnit: 'radians',
787791
stateId: "active",
788792
x: bullet.x,
789793
y: bullet.y,
@@ -795,6 +799,7 @@ export default class AsteroidsGameScene extends Scene {
795799
objectId: ASTEROIDS_OBJECT_GEOMETRY_IDS.bullet,
796800
requireManifestBinding: true,
797801
rotation: bullet.angle,
802+
rotationUnit: 'radians',
798803
stateId: "active",
799804
x: bullet.x,
800805
y: bullet.y,
@@ -810,6 +815,7 @@ export default class AsteroidsGameScene extends Scene {
810815
objectId: ASTEROIDS_OBJECT_GEOMETRY_IDS.ship,
811816
requireManifestBinding: true,
812817
rotation: this.world.ship.angle,
818+
rotationUnit: 'radians',
813819
stateId: this.world.ship.thrusting && this.session.mode === 'playing' ? "move" : "idle",
814820
x: this.world.ship.x,
815821
y: this.world.ship.y,
@@ -896,6 +902,7 @@ export default class AsteroidsGameScene extends Scene {
896902
objectId: ASTEROIDS_OBJECT_GEOMETRY_IDS.ship,
897903
requireManifestBinding: true,
898904
rotation: -Math.PI / 2,
905+
rotationUnit: 'radians',
899906
scale: 1.05,
900907
stateId: "idle",
901908
x: startX + index * LIFE_SPACING,

src/engine/collision/objectVector.js

Lines changed: 23 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ objectVector.js
77
import { isColliding } from './aabb.js';
88
import { arePolygonsColliding, getPolygonBounds, isPointInPolygon } from './polygon.js';
99
import { areMasksColliding, createRasterMask } from './raster.js';
10+
import {
11+
normalizeObjectVectorOrigin,
12+
normalizeObjectVectorTransform,
13+
transformObjectVectorInstancePoint,
14+
transformObjectVectorShapePoint,
15+
transformRuntimeOrientedPoints,
16+
} from '../rendering/OrientationTransform.js';
1017

1118
export const OBJECT_VECTOR_COLLISION_ENGINE_PATH = 'src/engine/collision/objectVector.js';
1219
export const OBJECT_VECTOR_COLLISION_MODES = Object.freeze(['bounds', 'vector', 'pixel-sprite', 'hybrid']);
@@ -80,21 +87,11 @@ function firstObjectFrame(object, preferredStateIds = ['active', 'idle']) {
8087
}
8188

8289
function shapeTransform(shape) {
83-
const transform = isRecord(shape?.transform) ? shape.transform : {};
84-
return {
85-
rotation: numberValue(transform.rotation),
86-
scaleX: numberValue(transform.scaleX, 1),
87-
scaleY: numberValue(transform.scaleY, 1),
88-
x: numberValue(transform.x),
89-
y: numberValue(transform.y),
90-
};
90+
return normalizeObjectVectorTransform(shape?.transform);
9191
}
9292

9393
export function getObjectVectorOrigin(object) {
94-
return {
95-
x: numberValue(object?.objectOrigin?.x),
96-
y: numberValue(object?.objectOrigin?.y),
97-
};
94+
return normalizeObjectVectorOrigin(object?.objectOrigin);
9895
}
9996

10097
function effectiveShapeForFrame(shape, frame, shapeIndex) {
@@ -193,31 +190,8 @@ function shapeLocalPolygons(shape) {
193190
return [];
194191
}
195192

196-
function transformShapePoint(point, transform, origin) {
197-
const radians = (transform.rotation * Math.PI) / 180;
198-
const scaledX = (point.x - origin.x) * transform.scaleX;
199-
const scaledY = (point.y - origin.y) * transform.scaleY;
200-
const rotatedX = scaledX * Math.cos(radians) - scaledY * Math.sin(radians);
201-
const rotatedY = scaledX * Math.sin(radians) + scaledY * Math.cos(radians);
202-
return {
203-
x: rotatedX + origin.x + transform.x,
204-
y: rotatedY + origin.y + transform.y,
205-
};
206-
}
207-
208-
function rotationRadians(value, unit = 'degrees') {
209-
const rotation = numberValue(value);
210-
return unit === 'radians' ? rotation : (rotation * Math.PI) / 180;
211-
}
212-
213-
function transformInstancePoint(point, origin, instance) {
214-
const radians = rotationRadians(instance?.rotation, instance?.rotationUnit || 'degrees');
215-
const dx = point.x - origin.x;
216-
const dy = point.y - origin.y;
217-
return {
218-
x: (dx * Math.cos(radians) - dy * Math.sin(radians)) + origin.x + numberValue(instance?.x),
219-
y: (dx * Math.sin(radians) + dy * Math.cos(radians)) + origin.y + numberValue(instance?.y),
220-
};
193+
function transformInstancePoint(point, instance) {
194+
return transformObjectVectorInstancePoint(point, instance, { rotationUnit: 'degrees' });
221195
}
222196

223197
export function transformCollisionPoints(points, {
@@ -229,13 +203,15 @@ export function transformCollisionPoints(points, {
229203
scaleX = scale,
230204
scaleY = scale,
231205
} = {}) {
232-
const radians = rotationRadians(rotation, rotationUnit);
233-
const safeScaleX = numberValue(scaleX, 1);
234-
const safeScaleY = numberValue(scaleY, 1);
235-
return normalizePoints(points).map((point) => ({
236-
x: numberValue(x) + ((point.x * safeScaleX) * Math.cos(radians)) - ((point.y * safeScaleY) * Math.sin(radians)),
237-
y: numberValue(y) + ((point.x * safeScaleX) * Math.sin(radians)) + ((point.y * safeScaleY) * Math.cos(radians)),
238-
}));
206+
return transformRuntimeOrientedPoints(normalizePoints(points), {
207+
rotation,
208+
rotationUnit,
209+
scale,
210+
scaleX,
211+
scaleY,
212+
x,
213+
y,
214+
});
239215
}
240216

241217
function boundsFromPoints(points) {
@@ -301,7 +277,7 @@ export function getObjectVectorCollisionOutlinePoints(object, options = {}) {
301277
return tool === 'polygon' || tool === 'polyline';
302278
}) || visibleEffectiveShapes(object, options).find((candidate) => shapeTool(candidate) === 'line');
303279
const transform = shapeTransform(shape);
304-
return shapeLocalOutlinePoints(shape).map((point) => transformShapePoint(point, transform, origin));
280+
return shapeLocalOutlinePoints(shape).map((point) => transformObjectVectorShapePoint(point, transform, origin));
305281
}
306282

307283
export function createObjectVectorCollisionGeometry(object, instance = {}, options = {}) {
@@ -310,7 +286,7 @@ export function createObjectVectorCollisionGeometry(object, instance = {}, optio
310286
.flatMap((shape) => {
311287
const transform = shapeTransform(shape);
312288
return shapeLocalPolygons(shape)
313-
.map((polygon) => polygon.map((point) => transformInstancePoint(transformShapePoint(point, transform, origin), origin, instance)))
289+
.map((polygon) => polygon.map((point) => transformInstancePoint(transformObjectVectorShapePoint(point, transform, origin), instance)))
314290
.filter((polygon) => polygon.length >= 3);
315291
});
316292
const bounds = boundsFromPolygons(polygons);
@@ -322,7 +298,7 @@ export function createObjectVectorCollisionGeometry(object, instance = {}, optio
322298
mask: maskFromPolygons(polygons, bounds, maskCellSize),
323299
object,
324300
origin,
325-
originWorld: object ? transformInstancePoint(origin, origin, instance) : { x: 0, y: 0 },
301+
originWorld: object ? transformInstancePoint(origin, instance) : { x: 0, y: 0 },
326302
polygons,
327303
shapeRotations: [...new Set(visibleEffectiveShapes(object, options).map((shape) => shapeTransform(shape).rotation))],
328304
transformedPoints: polygons.flat(),

src/engine/rendering/ObjectVectorRuntimeAssetService.js

Lines changed: 10 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import {
2+
applyObjectVectorCanvasTransform,
3+
normalizeObjectVectorOrigin,
4+
normalizeObjectVectorTransform,
5+
objectVectorSvgTransformAttribute
6+
} from "./OrientationTransform.js";
17
import { createWorldScreenTransform } from "./WorldScreenTransform.js";
28

39
const DEFAULT_SCHEMA_URL = new URL("../../../tools/schemas/tools/object-vector-studio-v2.schema.json", import.meta.url);
@@ -119,30 +125,11 @@ function shapeGeometryTool(shape) {
119125
}
120126

121127
function shapeTransform(shape) {
122-
const transform = shape.transform || {
123-
rotation: 0,
124-
scaleX: 1,
125-
scaleY: 1,
126-
x: 0,
127-
y: 0
128-
};
129-
return {
130-
rotation: Number(transform.rotation ?? 0),
131-
scaleX: Number(transform.scaleX ?? 1),
132-
scaleY: Number(transform.scaleY ?? 1),
133-
x: Number(transform.x ?? 0),
134-
y: Number(transform.y ?? 0)
135-
};
128+
return normalizeObjectVectorTransform(shape?.transform);
136129
}
137130

138131
function objectTransformOrigin(object) {
139-
if (isPlainObject(object?.objectOrigin) && Number.isFinite(Number(object.objectOrigin.x)) && Number.isFinite(Number(object.objectOrigin.y))) {
140-
return {
141-
x: Number(object.objectOrigin.x),
142-
y: Number(object.objectOrigin.y)
143-
};
144-
}
145-
return { x: 0, y: 0 };
132+
return normalizeObjectVectorOrigin(object?.objectOrigin);
146133
}
147134

148135
function pointStyleValue(value) {
@@ -423,16 +410,6 @@ function effectiveShapeForFrame(shape, frame, shapeIndex) {
423410
return effective;
424411
}
425412

426-
function svgTransformAttribute(transform, origin = { x: 0, y: 0 }) {
427-
return [
428-
`translate(${transform.x} ${transform.y})`,
429-
`translate(${origin.x} ${origin.y})`,
430-
`rotate(${transform.rotation})`,
431-
`scale(${transform.scaleX} ${transform.scaleY})`,
432-
`translate(${-origin.x} ${-origin.y})`
433-
].join(" ");
434-
}
435-
436413
export class ObjectVectorRuntimeAssetService {
437414
constructor({ fetchRef = (...args) => fetch(...args), logger = console, schemaUrl = DEFAULT_SCHEMA_URL } = {}) {
438415
this.assetCache = new Map();
@@ -897,11 +874,7 @@ export class ObjectVectorRuntimeAssetService {
897874
const transform = shapeTransform(shape);
898875
context.save();
899876
try {
900-
context.translate(transform.x, transform.y);
901-
context.translate(transformOrigin.x, transformOrigin.y);
902-
context.rotate((transform.rotation * Math.PI) / 180);
903-
context.scale(transform.scaleX, transform.scaleY);
904-
context.translate(-transformOrigin.x, -transformOrigin.y);
877+
applyObjectVectorCanvasTransform(context, transform, transformOrigin);
905878
context.lineWidth = shape.style.strokeWidth;
906879
context.lineCap = pointStyleValue(shape.style.strokeLinecap ?? shape.style.startPointStyle ?? shape.style.pointStyle);
907880
context.lineJoin = strokeLineJoinValue(shape.style.pointStyle ?? shape.style.strokeLinecap);
@@ -1014,7 +987,7 @@ export class ObjectVectorRuntimeAssetService {
1014987
shapeToSvg(shape, transformOrigin = { x: 0, y: 0 }) {
1015988
const lineCap = pointStyleValue(shape.style.strokeLinecap ?? shape.style.startPointStyle ?? shape.style.pointStyle);
1016989
const lineJoin = strokeLineJoinValue(shape.style.pointStyle ?? shape.style.strokeLinecap);
1017-
const style = ` fill="${escapeXml(shape.style.fill)}" fill-opacity="${shape.style.fillOpacity}" stroke="${escapeXml(shape.style.stroke)}" stroke-opacity="${shape.style.strokeOpacity}" stroke-width="${shape.style.strokeWidth}" stroke-linecap="${lineCap}" stroke-linejoin="${lineJoin}" transform="${svgTransformAttribute(shapeTransform(shape), transformOrigin)}"`;
990+
const style = ` fill="${escapeXml(shape.style.fill)}" fill-opacity="${shape.style.fillOpacity}" stroke="${escapeXml(shape.style.stroke)}" stroke-opacity="${shape.style.strokeOpacity}" stroke-width="${shape.style.strokeWidth}" stroke-linecap="${lineCap}" stroke-linejoin="${lineJoin}" transform="${objectVectorSvgTransformAttribute(shapeTransform(shape), transformOrigin)}"`;
1018991
const geometryTool = shapeGeometryTool(shape);
1019992
if (geometryTool === "rectangle") {
1020993
const roundedPath = roundedPointPathCommands(shape, { closed: true });

0 commit comments

Comments
 (0)