From 7dc93e8cc43de4b963c18e1ba8f6a057cbaa162d Mon Sep 17 00:00:00 2001 From: Malte Hilpert Date: Fri, 23 Oct 2026 06:54:16 +0200 Subject: [PATCH] Paste ghost: keep odd-sized copies on the hand-placement grid The ghost's anchor is the group's centre, which sits at x.5 for a lone belt or assembler. Rounding it to a whole tile slid the stamp half a tile off the grid. Snap by whole-tile steps from the copied position instead. --- packages/renderer/package.json | 2 +- packages/renderer/src/pasteSnap.ts | 13 ++++++ packages/renderer/src/render.ts | 4 +- packages/renderer/test/pasteSnap.test.ts | 54 ++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 packages/renderer/src/pasteSnap.ts create mode 100644 packages/renderer/test/pasteSnap.test.ts diff --git a/packages/renderer/package.json b/packages/renderer/package.json index 676d68f..646b22f 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -6,7 +6,7 @@ "main": "src/index.ts", "scripts": { "check": "tsc --noEmit", - "test": "tsx test/wires.test.ts && tsx test/wireEdit.test.ts && tsx test/beltAnimation.test.ts && tsx test/animProfile.test.ts && tsx test/pipe.test.ts && tsx test/beltGraph.test.ts && tsx test/spatial-camera.test.ts && tsx test/redraw.test.ts && tsx test/recordingSummary.test.ts && tsx test/entityPreview.test.ts && tsx test/heatNetwork.test.ts && tsx test/inserterLayering.test.ts && tsx test/bake.test.ts && tsx test/undergroundPairing.test.ts && tsx test/buildOver.test.ts && tsx test/fusionPorts.test.ts && tsx test/fluidArt.test.ts && tsx test/railGeometry.test.ts && tsx test/railArt.test.ts && tsx test/railPlanner.test.ts && tsx test/collision.test.ts && tsx test/railBlocks.test.ts && tsx test/railSignals.test.ts && tsx test/tiles.test.ts && tsx test/cargoBay.test.ts && tsx test/sceneCache.test.ts" + "test": "tsx test/wires.test.ts && tsx test/wireEdit.test.ts && tsx test/beltAnimation.test.ts && tsx test/animProfile.test.ts && tsx test/pipe.test.ts && tsx test/beltGraph.test.ts && tsx test/spatial-camera.test.ts && tsx test/redraw.test.ts && tsx test/recordingSummary.test.ts && tsx test/entityPreview.test.ts && tsx test/heatNetwork.test.ts && tsx test/inserterLayering.test.ts && tsx test/bake.test.ts && tsx test/undergroundPairing.test.ts && tsx test/buildOver.test.ts && tsx test/fusionPorts.test.ts && tsx test/fluidArt.test.ts && tsx test/railGeometry.test.ts && tsx test/railArt.test.ts && tsx test/railPlanner.test.ts && tsx test/collision.test.ts && tsx test/railBlocks.test.ts && tsx test/railSignals.test.ts && tsx test/tiles.test.ts && tsx test/cargoBay.test.ts && tsx test/sceneCache.test.ts && tsx test/pasteSnap.test.ts" }, "dependencies": { "@factoriotools/engine": "*" diff --git a/packages/renderer/src/pasteSnap.ts b/packages/renderer/src/pasteSnap.ts new file mode 100644 index 0000000..d98a145 --- /dev/null +++ b/packages/renderer/src/pasteSnap.ts @@ -0,0 +1,13 @@ +/** Where a paste ghost's anchor lands for a cursor at `cursor`, when the + * ghost snaps to whole tiles: the copied position shifted by a whole number + * of tiles. The anchor is the group's centre, which is NOT on a tile corner + * for a group of odd width (one belt, one assembler: x.5), and rounding it + * to a whole tile would slide the stamp half a tile off the grid hand + * placement uses. Keeping the anchor's own fraction keeps every stamped + * entity exactly as far from the grid as the original was. */ +export function snapAnchorToTiles(cursor: { x: number; y: number }, anchor: { x: number; y: number }): { x: number; y: number } { + return { + x: anchor.x + Math.round(cursor.x - anchor.x), + y: anchor.y + Math.round(cursor.y - anchor.y), + }; +} diff --git a/packages/renderer/src/render.ts b/packages/renderer/src/render.ts index 6e2a60d..33d28f7 100644 --- a/packages/renderer/src/render.ts +++ b/packages/renderer/src/render.ts @@ -22,6 +22,7 @@ import { drawInserter } from "./sprites/inserter.js"; import { SpatialIndex, type IndexedBox } from "./spatialIndex.js"; import { boxHitsEntity, entitiesCollide } from "./collision.js"; import { RAIL_BLOCK_COLORS } from "./railBlocks.js"; +import { snapAnchorToTiles } from "./pasteSnap.js"; import { isElevatedRail, isRail, isRollingStock, onRailGrid, openEnds, RAIL_DECK_HEIGHT, RAIL_END_CAP, railCentreline, railHighlightBox, railJoints, snapToRailGrid, type RailEnd, type RailPiece, type RailSlot } from "./railGeometry.js"; import { buildRailIndex, @@ -853,7 +854,8 @@ export function mountRenderer(container: HTMLElement, data: GameData, catalog: R function snapPasteAnchorToTiles(cursor: { x: number; y: number }): { x: number; y: number } { const cell = pasteCell(); - if (!cell || mode.kind !== "paste" || !mode.snap) return { x: Math.round(cursor.x), y: Math.round(cursor.y) }; + if (mode.kind !== "paste") return { x: Math.round(cursor.x), y: Math.round(cursor.y) }; + if (!cell || !mode.snap) return snapAnchorToTiles(cursor, mode.anchor); const { offset, size } = cell; const origin = mode.snap.absolute ? mode.snap.offset : relativeSnapOrigin; // The cell's top-left if the ghost were centred on the cursor. diff --git a/packages/renderer/test/pasteSnap.test.ts b/packages/renderer/test/pasteSnap.test.ts new file mode 100644 index 0000000..7f70817 --- /dev/null +++ b/packages/renderer/test/pasteSnap.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { snapAnchorToTiles } from "../src/pasteSnap.js"; + +let passed = 0; +function test(name: string, fn: () => void) { + try { + fn(); + passed++; + console.log(` ok ${name}`); + } catch (error) { + console.error(`FAIL ${name}`); + console.error(error); + process.exitCode = 1; + } +} + +/** Where hand placement puts a footprint's centre for a cursor (render.ts's + * snapAxis): odd widths on a tile centre, even widths on a tile corner. */ +const handPlaced = (cursor: number, width: number) => (width % 2 === 1 ? Math.floor(cursor) + 0.5 : Math.round(cursor)); + +// Issue #61: copy one building, stamp the ghost anywhere, and it must sit +// where the same building would if placed by hand. +for (const width of [1, 2, 3, 4, 5]) { + test(`${width}x${width} copy stamps where hand placement would`, () => { + const copiedAt = handPlaced(10.3, width); + for (let i = -70; i <= 70; i++) { + const cursor = i / 10 + 0.003; + const snapped = snapAnchorToTiles({ x: cursor, y: cursor }, { x: copiedAt, y: copiedAt }); + assert.ok(Math.abs(snapped.x - handPlaced(cursor, width)) < 1e-9, `cursor ${cursor}: ${snapped.x} vs ${handPlaced(cursor, width)}`); + assert.ok(Math.abs(snapped.y - handPlaced(cursor, width)) < 1e-9); + } + }); +} + +test("a group moves by whole tiles from where it was copied", () => { + const anchor = { x: 1.75, y: -3.5 }; + for (let i = -13; i <= 13; i++) { + const cx = i * 0.37; + const snapped = snapAnchorToTiles({ x: cx, y: cx * 1.3 }, anchor); + assert.ok(Math.abs(snapped.x - anchor.x - Math.round(snapped.x - anchor.x)) < 1e-9); + assert.ok(Math.abs(snapped.y - anchor.y - Math.round(snapped.y - anchor.y)) < 1e-9); + } +}); + +test("the ghost stays within half a tile of the cursor", () => { + const anchor = { x: 0.5, y: 0.5 }; + for (let i = -38; i <= 38; i++) { + const c = i * 0.13; + const snapped = snapAnchorToTiles({ x: c, y: c }, anchor); + assert.ok(Math.abs(snapped.x - c) <= 0.5 + 1e-9); + } +}); + +console.log(`${passed} passed`);