Sync checkup flags with image harmonize; tighten gallery collision and exit-to-timeline.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
41d2d5d844
commit
67594ea4d3
@@ -143,7 +143,7 @@ Run `npm run dev:migrate` against prod DB after first deploy with auth vars set
|
|||||||
| `npm run devtoprod:db:restore -- --file <path>` | Restore into **prod** (requires confirmation) |
|
| `npm run devtoprod:db:restore -- --file <path>` | Restore into **prod** (requires confirmation) |
|
||||||
| `npm run harmonize` | Bidirectional catalog DB + image merge by `updated_at` / file mtime — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
| `npm run harmonize` | Bidirectional catalog DB + image merge by `updated_at` / file mtime — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
||||||
| `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) |
|
| `npm run harmonize:schema` | Apply dev migrations to prod schema only (dev → prod) |
|
||||||
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only (`harmonize:images` also regenerates painting + portrait thumbs on both sides) |
|
| `npm run harmonize:db` / `harmonize:images` | DB or image merge only (`harmonize:images` also merges artists/paintings checkup flags + image paths, then regenerates thumbs on both sides) |
|
||||||
| `npm run dev:backfill-updated-at` | Backfill catalog `updated_at` from image mtimes (dev) |
|
| `npm run dev:backfill-updated-at` | Backfill catalog `updated_at` from image mtimes (dev) |
|
||||||
|
|
||||||
**One-time split (recommended):** pgAdmin on dev PC → open [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) → run each STEP on database `postgres`, then verify on `gallery_dev`.
|
**One-time split (recommended):** pgAdmin on dev PC → open [`db/split-dev-prod-pgadmin.sql`](../db/split-dev-prod-pgadmin.sql) → run each STEP on database `postgres`, then verify on `gallery_dev`.
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
|
|||||||
| **Development** | `./data/images/` in repo | Working copy on dev PC |
|
| **Development** | `./data/images/` in repo | Working copy on dev PC |
|
||||||
| **Production** | `/mnt/BasePool/Applications/Gallery/data/images` on TrueNAS | SMB `\\192.168.10.122\Gallery\data\images` |
|
| **Production** | `/mnt/BasePool/Applications/Gallery/data/images` on TrueNAS | SMB `\\192.168.10.122\Gallery\data\images` |
|
||||||
|
|
||||||
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh all images on dev from prod:** `npm run prodto:dev:images`. **One artist only:** map the SMB share, then `robocopy` that artist’s `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins, then rebuild painting + portrait thumbs on both sides): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
|
**One-direction promote:** `npm run devtoprod:images` (dev → prod, skip older). **Refresh all images on dev from prod:** `npm run prodto:dev:images`. **One artist only:** map the SMB share, then `robocopy` that artist’s `ArtistName*` files under `paintings/` and `paintings/thumbs/` (see [FAC.md](FAC.md#dev--prod-image-sync-smb)). **Bidirectional merge** (newer file wins, then merge artists/paintings checkup flags + image paths, then rebuild painting + portrait thumbs on both sides): `npm run harmonize:images` or full `npm run harmonize` — see [harmonize-dev-prod.md](harmonize-dev-prod.md). General sync reference: [environments.md](environments.md).
|
||||||
|
|
||||||
## Scripts overview
|
## Scripts overview
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ File names are sanitised `{Artist}_{Title}.{ext}`. The image service can redisco
|
|||||||
| `fetch-missing-images.js` | `npm run dev:fetch-images` | Downloads files for paintings missing on disk |
|
| `fetch-missing-images.js` | `npm run dev:fetch-images` | Downloads files for paintings missing on disk |
|
||||||
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
|
| `image-fetcher.js` | *(library)* | Wikimedia / museum resolution used by fetch scripts and API |
|
||||||
| `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run devtoprod:images` / `npm run prodto:dev:images` | Robocopy via SMB `\\192.168.10.122\Gallery` |
|
| `sync-images-to-prod.ps1` / `sync-images-from-prod.ps1` | `npm run devtoprod:images` / `npm run prodto:dev:images` | Robocopy via SMB `\\192.168.10.122\Gallery` |
|
||||||
| `harmonize-db.js` / `harmonize-images.js` | `npm run harmonize:db` / `harmonize:images` | Bidirectional merge by `updated_at` / file mtime; images step then rebuilds thumbs on both sides |
|
| `harmonize-db.js` / `harmonize-images.js` | `npm run harmonize:db` / `harmonize:images` | Bidirectional merge by `updated_at` / file mtime; images step also merges artists/paintings checkup flags + paths, then rebuilds thumbs on both sides |
|
||||||
| `harmonize.ps1` | `npm run harmonize` | Orchestrator: backups, optional schema, DB + image merge |
|
| `harmonize.ps1` | `npm run harmonize` | Orchestrator: backups, optional schema, DB + image merge |
|
||||||
| `regenerate-thumbnails.js` | `npm run dev:regenerate-thumbnails` | Rebuild painting thumbs from full images via `sharp` |
|
| `regenerate-thumbnails.js` | `npm run dev:regenerate-thumbnails` | Rebuild painting thumbs from full images via `sharp` |
|
||||||
| `regenerate-portrait-thumbs.js` | `npm run dev:regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) and set `portrait_thumb_path` |
|
| `regenerate-portrait-thumbs.js` | `npm run dev:regenerate-portrait-thumbs` | Rebuild timeline portrait thumbs (~256px) and set `portrait_thumb_path` |
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ For **incremental** dev ↔ prod merge (both sides edited), use [harmonize-dev-p
|
|||||||
| `npm run devtoprod:db:restore` | Dev PC PowerShell | Restore into prod |
|
| `npm run devtoprod:db:restore` | Dev PC PowerShell | Restore into prod |
|
||||||
| `npm run harmonize` | Dev PC PowerShell | Bidirectional catalog + image merge — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
| `npm run harmonize` | Dev PC PowerShell | Bidirectional catalog + image merge — [harmonize-dev-prod.md](harmonize-dev-prod.md) |
|
||||||
| `npm run harmonize:db` | Dev PC PowerShell | DB merge only |
|
| `npm run harmonize:db` | Dev PC PowerShell | DB merge only |
|
||||||
| `npm run harmonize:images` | Dev PC PowerShell | Image merge + regenerate thumbs on both sides |
|
| `npm run harmonize:images` | Dev PC PowerShell | Image merge + artists/paintings checkup/path sync + regenerate thumbs on both sides |
|
||||||
|
|
||||||
## Image sync
|
## Image sync
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ npm run harmonize:dry-run
|
|||||||
| `backupProd` | `prod:db:backup` | Safety snapshot |
|
| `backupProd` | `prod:db:backup` | Safety snapshot |
|
||||||
| `schema` | `harmonize:schema` | Apply dev migrations to prod (when `schemaChanged: true`) |
|
| `schema` | `harmonize:schema` | Apply dev migrations to prod (when `schemaChanged: true`) |
|
||||||
| `db` | `harmonize:db` | Row-level catalog merge |
|
| `db` | `harmonize:db` | Row-level catalog merge |
|
||||||
| `images` | `harmonize:images` | Bidirectional file merge, then regenerate painting + portrait thumbs on **dev and prod** |
|
| `images` | `harmonize:images` | Bidirectional file merge, then merge **artists/paintings** (checkup flags + image paths), then regenerate thumbs on **dev and prod** |
|
||||||
| `verify` | curl `/api/bounds` | Optional smoke check |
|
| `verify` | curl `/api/bounds` | Optional smoke check |
|
||||||
|
|
||||||
Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_images_*.json` (gitignored).
|
Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_images_*.json` (gitignored).
|
||||||
@@ -98,9 +98,10 @@ Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_image
|
|||||||
| `npm run harmonize:db` | Merge catalog rows by `updated_at` |
|
| `npm run harmonize:db` | Merge catalog rows by `updated_at` |
|
||||||
| `npm run harmonize:db -- --dry-run` | Preview DB changes |
|
| `npm run harmonize:db -- --dry-run` | Preview DB changes |
|
||||||
| `npm run harmonize:db -- --prefer=dev` | On equal `updated_at`, dev wins |
|
| `npm run harmonize:db -- --prefer=dev` | On equal `updated_at`, dev wins |
|
||||||
| `npm run harmonize:images` | Merge image files by mtime, then regenerate painting + portrait thumbnails on both sides |
|
| `npm run harmonize:images` | Merge image files by mtime, merge artists/paintings checkup flags + image paths, then regenerate thumbs on both sides |
|
||||||
| `npm run harmonize:images -- --skip-thumbnails` | File merge only (no thumb rebuild) |
|
| `npm run harmonize:images -- --skip-thumbnails` | File + checkup/path DB merge only (no thumb rebuild) |
|
||||||
| `npm run harmonize:images -- --dry-run` | Preview file copies only (skips thumbnails) |
|
| `npm run harmonize:images -- --skip-db` | File merge (+ thumbs) without artists/paintings checkup sync |
|
||||||
|
| `npm run harmonize:images -- --dry-run` | Preview file copies and catalog merge (skips thumbnails) |
|
||||||
| `npm run dev:migrate:sync-timestamps` | Apply `updated_at` migration on dev only |
|
| `npm run dev:migrate:sync-timestamps` | Apply `updated_at` migration on dev only |
|
||||||
| `npm run dev:backfill-updated-at` | Backfill dev `updated_at` from image mtimes |
|
| `npm run dev:backfill-updated-at` | Backfill dev `updated_at` from image mtimes |
|
||||||
| `npm run harmonize:backfill-updated-at` | Same backfill on prod |
|
| `npm run harmonize:backfill-updated-at` | Same backfill on prod |
|
||||||
|
|||||||
@@ -419,3 +419,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exit-nav-footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(201, 169, 110, 0.25);
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ const WALL_PADDING = 1.4;
|
|||||||
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
|
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
|
||||||
const MAX_WALL_ROWS = 1;
|
const MAX_WALL_ROWS = 1;
|
||||||
const DOOR_WIDTH = 2.4;
|
const DOOR_WIDTH = 2.4;
|
||||||
|
/** Minimum horizontal distance from walls, corners, door planes, and painting faces. */
|
||||||
|
const PLAYER_CLEARANCE = 0.5;
|
||||||
const DOOR_HEIGHT = 2.5;
|
const DOOR_HEIGHT = 2.5;
|
||||||
/** Museum exit — dimensions derived from door opening. */
|
/** Museum exit — dimensions derived from door opening. */
|
||||||
const EXIT_JAMB = 0.13;
|
const EXIT_JAMB = 0.13;
|
||||||
@@ -176,6 +178,73 @@ interface HallLayout {
|
|||||||
segments: WallSegment[];
|
segments: WallSegment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface XZBounds {
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minZ: number;
|
||||||
|
maxZ: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inner wall face half-extents (room center → inside of wall box). */
|
||||||
|
function innerWallHalfExtents(width: number, depth: number): { halfW: number; halfD: number } {
|
||||||
|
return {
|
||||||
|
halfW: width / 2 - WALL_THICKNESS / 2,
|
||||||
|
halfD: depth / 2 - WALL_THICKNESS / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep-out box in front of a hanging painting (XZ), including player clearance. */
|
||||||
|
function paintingKeepOutBounds(slot: FrameSlot): XZBounds {
|
||||||
|
const [px, , pz] = slot.position;
|
||||||
|
const halfAlong = slot.maxW / 2 + FRAME_MAT_BORDER + FRAME_RAIL + 0.04;
|
||||||
|
const intoRoom = FRAME_DEPTH + FRAME_FACE_Z + PLAYER_CLEARANCE;
|
||||||
|
if (slot.side === 'left') {
|
||||||
|
return { minX: px - 0.02, maxX: px + intoRoom, minZ: pz - halfAlong, maxZ: pz + halfAlong };
|
||||||
|
}
|
||||||
|
if (slot.side === 'right') {
|
||||||
|
return { minX: px - intoRoom, maxX: px + 0.02, minZ: pz - halfAlong, maxZ: pz + halfAlong };
|
||||||
|
}
|
||||||
|
// back wall — faces into the room (+Z)
|
||||||
|
return { minX: px - halfAlong, maxX: px + halfAlong, minZ: pz - 0.02, maxZ: pz + intoRoom };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Door jamb keep-outs at an opening in the front (+Z) or back (−Z) wall. */
|
||||||
|
function doorJambKeepOuts(halfD: number, atFront: boolean): XZBounds[] {
|
||||||
|
const jambZ = atFront ? halfD : -halfD;
|
||||||
|
const along = PLAYER_CLEARANCE;
|
||||||
|
const intoRoom = PLAYER_CLEARANCE;
|
||||||
|
const leftX = -DOOR_WIDTH / 2;
|
||||||
|
const rightX = DOOR_WIDTH / 2;
|
||||||
|
if (atFront) {
|
||||||
|
return [
|
||||||
|
{ minX: leftX - along, maxX: leftX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 },
|
||||||
|
{ minX: rightX - along, maxX: rightX + along, minZ: jambZ - intoRoom, maxZ: jambZ + 0.02 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ minX: leftX - along, maxX: leftX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom },
|
||||||
|
{ minX: rightX - along, maxX: rightX + along, minZ: jambZ - 0.02, maxZ: jambZ + intoRoom },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointInBounds(x: number, z: number, b: XZBounds): boolean {
|
||||||
|
return x > b.minX && x < b.maxX && z > b.minZ && z < b.maxZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Push a point out of an AABB via the shortest axis (XZ). */
|
||||||
|
function pushOutOfBounds(pos: { x: number; z: number }, b: XZBounds): void {
|
||||||
|
if (!pointInBounds(pos.x, pos.z, b)) return;
|
||||||
|
const dxMin = pos.x - b.minX;
|
||||||
|
const dxMax = b.maxX - pos.x;
|
||||||
|
const dzMin = pos.z - b.minZ;
|
||||||
|
const dzMax = b.maxZ - pos.z;
|
||||||
|
const m = Math.min(dxMin, dxMax, dzMin, dzMax);
|
||||||
|
if (m === dxMin) pos.x = b.minX;
|
||||||
|
else if (m === dxMax) pos.x = b.maxX;
|
||||||
|
else if (m === dzMin) pos.z = b.minZ;
|
||||||
|
else pos.z = b.maxZ;
|
||||||
|
}
|
||||||
|
|
||||||
function paintingIsReviewed(painting: Painting): boolean {
|
function paintingIsReviewed(painting: Painting): boolean {
|
||||||
return !!painting.checkup_checked;
|
return !!painting.checkup_checked;
|
||||||
}
|
}
|
||||||
@@ -1749,7 +1818,7 @@ function MovementHallNavPanel({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
|
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
|
||||||
Exit to Timeline
|
Back to Timeline
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1762,11 +1831,13 @@ function NavigationPanel({
|
|||||||
navigation,
|
navigation,
|
||||||
loading,
|
loading,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onExitTimeline,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
navigation: ArtistNavigation | null;
|
navigation: ArtistNavigation | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onSelect: (artistId: number) => void;
|
onSelect: (artistId: number) => void;
|
||||||
|
onExitTimeline: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (
|
const renderColumn = (title: string, groups: MovementArtistGroup[], emptyHint: string) => (
|
||||||
@@ -1822,6 +1893,11 @@ function NavigationPanel({
|
|||||||
'No documented successors via painting influences.'
|
'No documented successors via painting influences.'
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="exit-nav-footer">
|
||||||
|
<button type="button" className="gallery-exit-btn movement-hall-exit-timeline" onClick={onExitTimeline}>
|
||||||
|
Back to Timeline
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2017,11 +2093,62 @@ export default function VirtualGallery(props: Props) {
|
|||||||
|
|
||||||
const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
|
||||||
|
|
||||||
const halfW = layout.width / 2 - 0.55;
|
const { halfW: wallInnerHalfW, halfD: wallInnerHalfD } = useMemo(
|
||||||
const halfD = layout.depth / 2 - 0.35;
|
() => innerWallHalfExtents(layout.width, layout.depth),
|
||||||
const exitZ = layout.depth / 2 - 0.55;
|
[layout.width, layout.depth]
|
||||||
|
);
|
||||||
|
/** Playable half-extents: 0.5 m clear of inner wall faces (corners included). */
|
||||||
|
const playHalfW = wallInnerHalfW - PLAYER_CLEARANCE;
|
||||||
|
const playHalfD = wallInnerHalfD - PLAYER_CLEARANCE;
|
||||||
|
const exitZ = playHalfD;
|
||||||
const fogFar = Math.max(55, layout.depth + 42);
|
const fogFar = Math.max(55, layout.depth + 42);
|
||||||
|
|
||||||
|
const collisionObstacles = useMemo(() => {
|
||||||
|
const boxes: XZBounds[] = [];
|
||||||
|
for (const seg of layout.segments) {
|
||||||
|
for (const slot of seg.slots) boxes.push(paintingKeepOutBounds(slot));
|
||||||
|
}
|
||||||
|
// Door jambs — keep 0.5 m from the opening posts; opening corridor stays walkable.
|
||||||
|
boxes.push(...doorJambKeepOuts(wallInnerHalfD, true));
|
||||||
|
if (isWingedHall) boxes.push(...doorJambKeepOuts(wallInnerHalfD, false));
|
||||||
|
return boxes;
|
||||||
|
}, [layout.segments, wallInnerHalfD, isWingedHall]);
|
||||||
|
|
||||||
|
const resolvePlayerPosition = useCallback(
|
||||||
|
(pos: { x: number; z: number }, allowFrontDoorApproach: boolean) => {
|
||||||
|
const clampToWalls = () => {
|
||||||
|
pos.x = Math.max(-playHalfW, Math.min(playHalfW, pos.x));
|
||||||
|
const inDoorBand = Math.abs(pos.x) < DOOR_WIDTH / 2 - PLAYER_CLEARANCE * 0.35;
|
||||||
|
if (isWingedHall) {
|
||||||
|
let minZ = -playHalfD;
|
||||||
|
let maxZ = playHalfD;
|
||||||
|
if (inDoorBand) {
|
||||||
|
minZ = -wallInnerHalfD + 0.08;
|
||||||
|
if (hasNextHall || allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08;
|
||||||
|
}
|
||||||
|
pos.z = Math.max(minZ, Math.min(maxZ, pos.z));
|
||||||
|
} else {
|
||||||
|
let maxZ = exitZ;
|
||||||
|
if (inDoorBand && allowFrontDoorApproach) maxZ = wallInnerHalfD - 0.08;
|
||||||
|
pos.z = Math.max(-playHalfD, Math.min(maxZ, pos.z));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
clampToWalls();
|
||||||
|
for (const box of collisionObstacles) pushOutOfBounds(pos, box);
|
||||||
|
clampToWalls();
|
||||||
|
},
|
||||||
|
[
|
||||||
|
playHalfW,
|
||||||
|
playHalfD,
|
||||||
|
exitZ,
|
||||||
|
isWingedHall,
|
||||||
|
hasNextHall,
|
||||||
|
wallInnerHalfD,
|
||||||
|
collisionObstacles,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const initialPos = useMemo(
|
const initialPos = useMemo(
|
||||||
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
|
() => new THREE.Vector3(0, EYE_HEIGHT, layout.depth / 2 - 2.2),
|
||||||
[layout.depth]
|
[layout.depth]
|
||||||
@@ -2091,8 +2218,24 @@ export default function VirtualGallery(props: Props) {
|
|||||||
}
|
}
|
||||||
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
|
||||||
|
|
||||||
const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
|
const backExitZ = isWingedHall ? -playHalfD : exitZ;
|
||||||
const frontPassageZ = layout.depth / 2 - 0.55;
|
const frontPassageZ = playHalfD;
|
||||||
|
|
||||||
|
const updateProximityFlags = useCallback(
|
||||||
|
(pos: { x: number; z: number }) => {
|
||||||
|
if (isWingedHall) {
|
||||||
|
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||||
|
const atFrontPassage =
|
||||||
|
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||||
|
setNearExit(atBackExit);
|
||||||
|
setNearPassage(!!atFrontPassage);
|
||||||
|
} else {
|
||||||
|
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
||||||
|
setNearExit(atExit);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isWingedHall, backExitZ, frontPassageZ, hasNextHall, exitZ]
|
||||||
|
);
|
||||||
|
|
||||||
const moveCamera = useCallback(
|
const moveCamera = useCallback(
|
||||||
(forward: number, strafe: number, rotY: number) => {
|
(forward: number, strafe: number, rotY: number) => {
|
||||||
@@ -2100,43 +2243,49 @@ export default function VirtualGallery(props: Props) {
|
|||||||
const target = camTargetRef.current.clone();
|
const target = camTargetRef.current.clone();
|
||||||
const angle = Math.atan2(target.x - pos.x, target.z - pos.z);
|
const angle = Math.atan2(target.x - pos.x, target.z - pos.z);
|
||||||
|
|
||||||
|
// Turn in place: never move; only change look direction.
|
||||||
if (rotY !== 0) {
|
if (rotY !== 0) {
|
||||||
const newAngle = angle + rotY;
|
const newAngle = angle + rotY;
|
||||||
const dist = pos.distanceTo(target);
|
const dist = Math.max(0.5, pos.distanceTo(target));
|
||||||
target.x = pos.x + Math.sin(newAngle) * dist;
|
target.x = pos.x + Math.sin(newAngle) * dist;
|
||||||
target.z = pos.z + Math.cos(newAngle) * dist;
|
target.z = pos.z + Math.cos(newAngle) * dist;
|
||||||
} else {
|
|
||||||
const newAngle = angle;
|
|
||||||
pos.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
|
|
||||||
pos.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
|
|
||||||
target.x += Math.sin(newAngle) * forward + Math.sin(newAngle + Math.PI / 2) * strafe;
|
|
||||||
target.z += Math.cos(newAngle) * forward + Math.cos(newAngle + Math.PI / 2) * strafe;
|
|
||||||
}
|
|
||||||
|
|
||||||
pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
|
|
||||||
target.x = Math.max(-halfW, Math.min(halfW, target.x));
|
|
||||||
|
|
||||||
if (isWingedHall) {
|
|
||||||
pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
|
|
||||||
target.z = Math.max(-halfD, Math.min(halfD, target.z));
|
|
||||||
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
|
||||||
const atFrontPassage =
|
|
||||||
hasNextHall && pos.z > frontPassageZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
|
||||||
setNearExit(atBackExit);
|
|
||||||
setNearPassage(!!atFrontPassage);
|
|
||||||
} else {
|
|
||||||
pos.z = Math.max(-halfD, Math.min(exitZ, pos.z));
|
|
||||||
target.z = Math.max(-halfD, Math.min(exitZ, target.z));
|
|
||||||
const atExit = pos.z > exitZ - 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
|
|
||||||
setNearExit(atExit);
|
|
||||||
}
|
|
||||||
|
|
||||||
levelHorizontalView(pos, target);
|
levelHorizontalView(pos, target);
|
||||||
|
updateProximityFlags(pos);
|
||||||
|
setCamPos(pos);
|
||||||
|
setCamTarget(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = Math.sin(angle) * forward + Math.sin(angle + Math.PI / 2) * strafe;
|
||||||
|
const dz = Math.cos(angle) * forward + Math.cos(angle + Math.PI / 2) * strafe;
|
||||||
|
if (dx === 0 && dz === 0) {
|
||||||
|
updateProximityFlags(pos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proposed = { x: pos.x + dx, z: pos.z + dz };
|
||||||
|
const resolved = { x: proposed.x, z: proposed.z };
|
||||||
|
resolvePlayerPosition(resolved, true);
|
||||||
|
|
||||||
|
// Hit wall/painting/door jamb: cancel the whole step — no slide, no view change.
|
||||||
|
if (
|
||||||
|
Math.abs(resolved.x - proposed.x) > 1e-4 ||
|
||||||
|
Math.abs(resolved.z - proposed.z) > 1e-4
|
||||||
|
) {
|
||||||
|
updateProximityFlags(pos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos.x = proposed.x;
|
||||||
|
pos.z = proposed.z;
|
||||||
|
target.x += dx;
|
||||||
|
target.z += dz;
|
||||||
|
levelHorizontalView(pos, target);
|
||||||
|
updateProximityFlags(pos);
|
||||||
setCamPos(pos);
|
setCamPos(pos);
|
||||||
setCamTarget(target);
|
setCamTarget(target);
|
||||||
},
|
},
|
||||||
[halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
|
[resolvePlayerPosition, updateProximityFlags]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2362,6 +2511,7 @@ export default function VirtualGallery(props: Props) {
|
|||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
loading={navLoading}
|
loading={navLoading}
|
||||||
onSelect={handleNavigate}
|
onSelect={handleNavigate}
|
||||||
|
onExitTimeline={onBack}
|
||||||
onClose={() => setShowExitNav(false)}
|
onClose={() => setShowExitNav(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -2407,7 +2557,7 @@ export default function VirtualGallery(props: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<li>Golden lamps mark works linked in the influence graph</li>
|
<li>Golden lamps mark works linked in the influence graph</li>
|
||||||
<li>Click the exit door or <kbd>E</kbd> to visit related artists</li>
|
<li>Click the exit door or <kbd>E</kbd> for related artists or back to the timeline</li>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Bidirectional image harmonize (dev <-> prod) by file mtime,
|
* Bidirectional image harmonize (dev <-> prod) by file mtime,
|
||||||
|
* then merge artists/paintings catalog rows (checkup flags + image paths),
|
||||||
* then regenerate painting + portrait thumbnails on both image roots.
|
* then regenerate painting + portrait thumbnails on both image roots.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* npm run harmonize:images
|
* npm run harmonize:images
|
||||||
* npm run harmonize:images -- --dry-run
|
* npm run harmonize:images -- --dry-run
|
||||||
* npm run harmonize:images -- --skip-thumbnails
|
* npm run harmonize:images -- --skip-thumbnails
|
||||||
|
* npm run harmonize:images -- --skip-db
|
||||||
*/
|
*/
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@@ -17,6 +19,9 @@ const { printCliResult } = require('./lib/cli-result');
|
|||||||
const IMAGE_SUBDIRS = ['portraits', 'paintings'];
|
const IMAGE_SUBDIRS = ['portraits', 'paintings'];
|
||||||
const MTIME_TOLERANCE_MS = 1000;
|
const MTIME_TOLERANCE_MS = 1000;
|
||||||
|
|
||||||
|
/** Tables that carry checkup flags and image/portrait path links. */
|
||||||
|
const IMAGE_CATALOG_TABLES = ['artists', 'paintings'];
|
||||||
|
|
||||||
const THUMB_SCRIPTS = [
|
const THUMB_SCRIPTS = [
|
||||||
'regenerate-thumbnails.js',
|
'regenerate-thumbnails.js',
|
||||||
'regenerate-portrait-thumbs.js',
|
'regenerate-portrait-thumbs.js',
|
||||||
@@ -24,10 +29,13 @@ const THUMB_SCRIPTS = [
|
|||||||
|
|
||||||
function parseArgs(argv) {
|
function parseArgs(argv) {
|
||||||
const configArg = argv.find((a) => a.startsWith('--config='));
|
const configArg = argv.find((a) => a.startsWith('--config='));
|
||||||
|
const preferArg = argv.find((a) => a.startsWith('--prefer='));
|
||||||
return {
|
return {
|
||||||
dryRun: argv.includes('--dry-run'),
|
dryRun: argv.includes('--dry-run'),
|
||||||
verbose: argv.includes('--verbose'),
|
verbose: argv.includes('--verbose'),
|
||||||
skipThumbnails: argv.includes('--skip-thumbnails'),
|
skipThumbnails: argv.includes('--skip-thumbnails'),
|
||||||
|
skipDb: argv.includes('--skip-db'),
|
||||||
|
prefer: preferArg ? preferArg.slice('--prefer='.length).trim().toLowerCase() : null,
|
||||||
configPath: configArg ? configArg.slice('--config='.length) : null,
|
configPath: configArg ? configArg.slice('--config='.length) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -117,6 +125,35 @@ function regenerateThumbnails({ label, imageDir, database }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge artists + paintings so checkup flags and image/portrait path links
|
||||||
|
* travel with the file sync (harmonize:images alone used to leave DB stale).
|
||||||
|
*/
|
||||||
|
function syncImageCatalogRows({ dryRun, prefer, verbose }) {
|
||||||
|
console.log('\n--- Catalog (artists + paintings checkup / image paths) ---');
|
||||||
|
const args = [
|
||||||
|
path.join(__dirname, 'harmonize-db.js'),
|
||||||
|
`--tables=${IMAGE_CATALOG_TABLES.join(',')}`,
|
||||||
|
];
|
||||||
|
if (dryRun) args.push('--dry-run');
|
||||||
|
if (verbose) args.push('--verbose');
|
||||||
|
if (prefer === 'dev' || prefer === 'prod') args.push(`--prefer=${prefer}`);
|
||||||
|
|
||||||
|
const result = spawnSync(process.execPath, args, {
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
// Parent already confirmed prod writes for this harmonize:images run.
|
||||||
|
CONFIRM_PROD: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const code = result.status ?? 1;
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(`Image catalog sync failed: harmonize-db.js exited ${code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function timestampSlug(date = new Date()) {
|
function timestampSlug(date = new Date()) {
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
return [
|
return [
|
||||||
@@ -146,7 +183,7 @@ async function main() {
|
|||||||
|
|
||||||
if (!dryRun && process.env.CONFIRM_PROD !== '1') {
|
if (!dryRun && process.env.CONFIRM_PROD !== '1') {
|
||||||
await confirmProdAction(
|
await confirmProdAction(
|
||||||
'Harmonize will copy image files between dev and prod, then regenerate painting and portrait thumbnails on both sides (prod files and gallery_prod paths may be updated).',
|
'Harmonize will copy image files between dev and prod, merge artists/paintings checkup flags and image paths, then regenerate thumbnails on both sides (prod files and gallery_prod may be updated).',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +242,23 @@ async function main() {
|
|||||||
fs.mkdirSync(reportDir, { recursive: true });
|
fs.mkdirSync(reportDir, { recursive: true });
|
||||||
const reportPath = path.join(reportDir, `harmonize_images_${timestampSlug()}.json`);
|
const reportPath = path.join(reportDir, `harmonize_images_${timestampSlug()}.json`);
|
||||||
|
|
||||||
const thumbDetails = [];
|
const extraDetails = [];
|
||||||
|
|
||||||
|
if (!options.skipDb) {
|
||||||
|
syncImageCatalogRows({
|
||||||
|
dryRun,
|
||||||
|
prefer: options.prefer,
|
||||||
|
verbose: options.verbose,
|
||||||
|
});
|
||||||
|
extraDetails.push(
|
||||||
|
dryRun
|
||||||
|
? 'Catalog checkup/path sync previewed (artists, paintings; dry-run)'
|
||||||
|
: 'Catalog checkup flags + image paths merged (artists, paintings)',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
extraDetails.push('Catalog checkup/path sync skipped (--skip-db)');
|
||||||
|
}
|
||||||
|
|
||||||
if (!dryRun && !options.skipThumbnails) {
|
if (!dryRun && !options.skipThumbnails) {
|
||||||
require('dotenv').config({ path: path.join(rootDir, '.env') });
|
require('dotenv').config({ path: path.join(rootDir, '.env') });
|
||||||
const devDb = process.env.DB_NAME || 'gallery_dev';
|
const devDb = process.env.DB_NAME || 'gallery_dev';
|
||||||
@@ -215,24 +268,26 @@ async function main() {
|
|||||||
imageDir: devDir,
|
imageDir: devDir,
|
||||||
database: devDb,
|
database: devDb,
|
||||||
});
|
});
|
||||||
thumbDetails.push(`Dev thumbs regenerated (${devDb} @ ${devDir})`);
|
extraDetails.push(`Dev thumbs regenerated (${devDb} @ ${devDir})`);
|
||||||
|
|
||||||
regenerateThumbnails({
|
regenerateThumbnails({
|
||||||
label: 'prod',
|
label: 'prod',
|
||||||
imageDir: prodDir,
|
imageDir: prodDir,
|
||||||
database: PROD_DB_NAME,
|
database: PROD_DB_NAME,
|
||||||
});
|
});
|
||||||
thumbDetails.push(`Prod thumbs regenerated (${PROD_DB_NAME} @ ${prodDir})`);
|
extraDetails.push(`Prod thumbs regenerated (${PROD_DB_NAME} @ ${prodDir})`);
|
||||||
} else if (options.skipThumbnails) {
|
} else if (options.skipThumbnails) {
|
||||||
thumbDetails.push('Thumbnails skipped (--skip-thumbnails)');
|
extraDetails.push('Thumbnails skipped (--skip-thumbnails)');
|
||||||
} else {
|
} else {
|
||||||
thumbDetails.push('Thumbnails skipped (dry-run)');
|
extraDetails.push('Thumbnails skipped (dry-run)');
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(reportPath, JSON.stringify({
|
fs.writeFileSync(reportPath, JSON.stringify({
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
dryRun,
|
dryRun,
|
||||||
skipThumbnails: options.skipThumbnails,
|
skipThumbnails: options.skipThumbnails,
|
||||||
|
skipDb: options.skipDb,
|
||||||
|
prefer: options.prefer,
|
||||||
devDir,
|
devDir,
|
||||||
prodDir,
|
prodDir,
|
||||||
stats: {
|
stats: {
|
||||||
@@ -240,7 +295,7 @@ async function main() {
|
|||||||
prodToDev: stats.prodToDev,
|
prodToDev: stats.prodToDev,
|
||||||
skipped: stats.skipped,
|
skipped: stats.skipped,
|
||||||
},
|
},
|
||||||
thumbnails: thumbDetails,
|
steps: extraDetails,
|
||||||
actions: options.verbose ? stats.actions : stats.actions.slice(0, 500),
|
actions: options.verbose ? stats.actions : stats.actions.slice(0, 500),
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
|
|
||||||
@@ -253,7 +308,7 @@ async function main() {
|
|||||||
`Dev: ${devDir}`,
|
`Dev: ${devDir}`,
|
||||||
`Prod: ${prodDir}`,
|
`Prod: ${prodDir}`,
|
||||||
`Report: ${reportPath}`,
|
`Report: ${reportPath}`,
|
||||||
...thumbDetails,
|
...extraDetails,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user