diff --git a/.gitignore b/.gitignore index 0ce7ace7..d5767983 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ packages/vue/tsconfig.tsbuildinfo # Agent-tool internal state — worktree refs, scheduled-task locks, etc. /.claude/ +log.md +log-texture.md +website/.baseline-shots/ diff --git a/packages/core/src/helpers/arrowPolygons.ts b/packages/core/src/helpers/arrowPolygons.ts index ea47b74f..f5a983ea 100644 --- a/packages/core/src/helpers/arrowPolygons.ts +++ b/packages/core/src/helpers/arrowPolygons.ts @@ -25,6 +25,11 @@ export interface ArrowPolygonsOptions { headHalfThickness?: number; /** Fill color. */ color?: string; + /** Emit the rectangular shaft polygons. Default `true`. Set `false` to + * render just the pyramid head — used by transform-control gizmos to + * declutter back-facing axes (only the head still identifies direction + * while the shaft would visually overlap the front-facing arrow). */ + shaft?: boolean; } function makeAxisVec(axis: 0 | 1 | 2, along: number, sideA: number, sideB: number): Vec3 { @@ -106,6 +111,7 @@ export function arrowPolygons(options: ArrowPolygonsOptions): Polygon[] { const headLength = options.headLength ?? 0.8; const headHalf = options.headHalfThickness ?? 0.2; const color = options.color ?? "#ffffff"; + const includeShaft = options.shaft ?? true; // Shaft spans from origin to ±shaftLength along the axis. Use min/max // so the cuboid is built with from < to (axisBox convention) — the @@ -118,8 +124,10 @@ export function arrowPolygons(options: ArrowPolygonsOptions): Polygon[] { const headApex = (shaftLength + headLength) * sign; const head = pyramidPolygons(axis, headBase, headApex, headHalf, color); + const headPolys = sign === -1 ? reverseWinding(head) : head; + if (!includeShaft) return headPolys; return [ ...shaftPolygons(axis, shaftMin, shaftMax, shaftHalf, color), - ...(sign === -1 ? reverseWinding(head) : head), + ...headPolys, ]; } diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index ede73253..103390ff 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -4,5 +4,9 @@ export { arrowPolygons } from "./arrowPolygons"; export type { ArrowPolygonsOptions } from "./arrowPolygons"; export { ringPolygons } from "./ringPolygons"; export type { RingPolygonsOptions } from "./ringPolygons"; +export { ringQuadPolygons } from "./ringQuadPolygons"; +export type { RingQuadPolygonsOptions } from "./ringQuadPolygons"; +export { planePolygons } from "./planePolygons"; +export type { PlanePolygonsOptions } from "./planePolygons"; export { octahedronPolygons } from "./octahedronPolygons"; export type { OctahedronPolygonsOptions } from "./octahedronPolygons"; diff --git a/packages/core/src/helpers/planePolygons.ts b/packages/core/src/helpers/planePolygons.ts new file mode 100644 index 00000000..0ff48b03 --- /dev/null +++ b/packages/core/src/helpers/planePolygons.ts @@ -0,0 +1,64 @@ +/** + * A flat quad on one of the three axis-aligned planes, offset diagonally + * along the two in-plane axes. Used as a planar drag handle in + * `` — clicking and dragging this handle moves the + * attached mesh along two axes simultaneously (XY, XZ, or YZ), instead of + * the single-axis motion the arrow shafts provide. + * + * The polygon lives in standard polycss world space; wrap it in the + * framework's PolyMesh equivalent for rendering. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface PlanePolygonsOptions { + /** Axis perpendicular to the plane: 0 = YZ plane, 1 = XZ plane, + * 2 = XY plane. The quad lies on the OTHER two axes. */ + axis: 0 | 1 | 2; + /** Half-extent of the quad along each in-plane axis. Default `0.4`. */ + size?: number; + /** Center of the quad along the two in-plane axes. Pass a single number + * to use the same offset on both (positive places the handle in the + * +A/+B corner). Pass `[offsetA, offsetB]` to control each + * independently — sign flips move the handle to a different octant. + * `A = (axis+1)%3`, `B = (axis+2)%3`. Default `size * 2`. */ + offset?: number | [number, number]; + /** Position along the perpendicular axis. Default `0` (on the plane). */ + along?: number; + /** Fill color. */ + color?: string; +} + +/** Build the polygons for one axis-aligned planar drag handle. */ +export function planePolygons(options: PlanePolygonsOptions): Polygon[] { + const axis = options.axis; + const size = options.size ?? 0.4; + const offsetIn = options.offset ?? size * 2; + const offsetA = typeof offsetIn === "number" ? offsetIn : offsetIn[0]; + const offsetB = typeof offsetIn === "number" ? offsetIn : offsetIn[1]; + const along = options.along ?? 0; + const color = options.color ?? "#ffffff"; + + const a = (axis + 1) % 3; + const b = (axis + 2) % 3; + const make = (sideA: number, sideB: number): Vec3 => { + const v: Vec3 = [0, 0, 0]; + v[axis] = along; + v[a] = offsetA + sideA; + v[b] = offsetB + sideB; + return v; + }; + // CCW when viewed from the +axis side. The quad is double-sided in CSS + // (no back-face cull when rendered through .polycss-mesh), so winding is + // primarily a documentation aid here. + return [ + { + vertices: [ + make(-size, -size), + make( size, -size), + make( size, size), + make(-size, size), + ], + color, + }, + ]; +} diff --git a/packages/core/src/helpers/ringQuadPolygons.ts b/packages/core/src/helpers/ringQuadPolygons.ts new file mode 100644 index 00000000..26557c15 --- /dev/null +++ b/packages/core/src/helpers/ringQuadPolygons.ts @@ -0,0 +1,49 @@ +/** + * One square quad covering the bounding box of a ring (annulus) in the + * plane perpendicular to a chosen axis. Used by `` together with a CSS `mask: radial-gradient(...)` to + * render the visible donut, replacing the segmented quad-strip approach + * of `ringPolygons` with a single DOM element per ring. + * + * The caller is responsible for applying the mask CSS and using a donut- + * shaped hit-test (the quad's bounding rect alone would over-hit the + * inner hole). The recommended setup is to set the CSS custom property + * `--ring-inner-ratio` on the mesh element so the mask scales with the + * caller's chosen thickness ratio. + */ +import type { Polygon, Vec3 } from "../types"; + +export interface RingQuadPolygonsOptions { + /** World axis the ring is perpendicular to: 0=X, 1=Y, 2=Z. The quad + * lies in the plane spanned by the other two axes. */ + axis: 0 | 1 | 2; + /** Outer radius of the ring. The quad spans ±outerRadius in both + * in-plane axes. */ + outerRadius: number; + /** Fill color. */ + color?: string; +} + +/** Build a single 4-vertex polygon (a square) bounding the ring's outer + * circle. CSS `mask` is expected to clip this to the donut shape at + * render time. */ +export function ringQuadPolygons(options: RingQuadPolygonsOptions): Polygon[] { + const axis = options.axis; + const r = options.outerRadius; + const color = options.color ?? "#ffffff"; + const a = (axis + 1) % 3; + const b = (axis + 2) % 3; + const make = (sa: number, sb: number): Vec3 => { + const v: Vec3 = [0, 0, 0]; + v[axis] = 0; + v[a] = sa; + v[b] = sb; + return v; + }; + return [ + { + vertices: [make(-r, -r), make(r, -r), make(r, r), make(-r, r)], + color, + }, + ]; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index cc2d7496..fd4b01ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,14 @@ export type { // ── Rotation math ──────────────────────────────────────────────── export { rotateVec3, inverseRotateVec3 } from "./math/rotation"; +export { + quatFromAxisAngle, + quatFromEulerXYZ, + quatMultiply, + eulerXYZFromQuat, + QUAT_IDENTITY, +} from "./math/quaternion"; +export type { Quat } from "./math/quaternion"; // ── Camera ──────────────────────────────────────────────────────── export { @@ -110,8 +118,8 @@ export type { } from "./cull/cameraBackfaceCulling"; // ── Helper-gizmo geometry (axes, light marker, transform arrows / rings) ─ -export { axesHelperPolygons, arrowPolygons, ringPolygons, octahedronPolygons } from "./helpers"; -export type { AxesHelperOptions, ArrowPolygonsOptions, RingPolygonsOptions, OctahedronPolygonsOptions } from "./helpers"; +export { axesHelperPolygons, arrowPolygons, ringPolygons, ringQuadPolygons, planePolygons, octahedronPolygons } from "./helpers"; +export type { AxesHelperOptions, ArrowPolygonsOptions, RingPolygonsOptions, RingQuadPolygonsOptions, PlanePolygonsOptions, OctahedronPolygonsOptions } from "./helpers"; // ── Animation ───────────────────────────────────────────────────── export { diff --git a/packages/core/src/math/quaternion.test.ts b/packages/core/src/math/quaternion.test.ts new file mode 100644 index 00000000..6c4c3dc9 --- /dev/null +++ b/packages/core/src/math/quaternion.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from "vitest"; +import { + QUAT_IDENTITY, + eulerXYZFromQuat, + quatFromAxisAngle, + quatFromEulerXYZ, + quatMultiply, +} from "./quaternion"; + +const TAU = Math.PI * 2; + +function expectVec3Close(actual: readonly [number, number, number], expected: readonly [number, number, number], tol = 1e-6): void { + expect(actual[0]).toBeCloseTo(expected[0], tol < 1 ? 5 : 0); + expect(actual[1]).toBeCloseTo(expected[1], tol < 1 ? 5 : 0); + expect(actual[2]).toBeCloseTo(expected[2], tol < 1 ? 5 : 0); +} + +describe("quaternion helpers", () => { + it("identity round-trips through eulerXYZFromQuat", () => { + const e = eulerXYZFromQuat(QUAT_IDENTITY); + expectVec3Close(e, [0, 0, 0]); + }); + + it("quatFromEulerXYZ([0,0,0]) is identity", () => { + const q = quatFromEulerXYZ([0, 0, 0]); + expect(q).toEqual([1, 0, 0, 0]); + }); + + it("quatFromAxisAngle(X, 0) is identity", () => { + const q = quatFromAxisAngle([1, 0, 0], 0); + expect(q).toEqual([1, 0, 0, 0]); + }); + + it("Euler round-trips for pure single-axis rotations", () => { + for (const axis of [0, 1, 2] as const) { + const eIn: [number, number, number] = [0, 0, 0]; + eIn[axis] = 30; + const q = quatFromEulerXYZ(eIn); + const eOut = eulerXYZFromQuat(q); + expectVec3Close(eOut, eIn); + } + }); + + it("Euler round-trips for combined rotations away from gimbal lock", () => { + const eIn: [number, number, number] = [20, 35, 45]; + const eOut = eulerXYZFromQuat(quatFromEulerXYZ(eIn)); + expectVec3Close(eOut, eIn); + }); + + it("quatMultiply with identity is a no-op", () => { + const q = quatFromEulerXYZ([15, 25, 35]); + expect(quatMultiply(q, QUAT_IDENTITY)).toEqual(q); + expect(quatMultiply(QUAT_IDENTITY, q)).toEqual(q); + }); + + it("local-axis compose ≠ Euler-add when an axis is repeated after another", () => { + // Bug scenario: mesh has rotation [90, 45, 0] (rotated X=90 then Y=45). + // User drags the X ring again. Euler-add would set rotation[0] = + // 90 + 45 = 135, producing matrix X(135)*Y(45). Local-compose multiplies + // on the right: X(90)*Y(45)*X(45) ≠ X(135)*Y(45). The composed Euler is + // therefore different from the naïve add. + const qBase = quatFromEulerXYZ([90, 45, 0]); + const qDelta = quatFromAxisAngle([1, 0, 0], (Math.PI * 45) / 180); + const qLocal = quatMultiply(qBase, qDelta); + const localEuler = eulerXYZFromQuat(qLocal); + const naiveAddEuler: [number, number, number] = [135, 45, 0]; + const diff = + Math.abs(localEuler[0] - naiveAddEuler[0]) + + Math.abs(localEuler[1] - naiveAddEuler[1]) + + Math.abs(localEuler[2] - naiveAddEuler[2]); + expect(diff).toBeGreaterThan(1); + }); + + it("local-axis compose IS commutative with Euler-add for X-then-Y-once (XYZ order quirk)", () => { + // Sanity: when the user only ever rotates each axis at most once and in + // X-then-Y-then-Z order, local-compose collapses to Euler-add (because + // that's the very definition of Euler XYZ). Verifies the helpers are + // self-consistent — bugs in other tests above would also fail here. + const qStart = quatFromEulerXYZ([90, 0, 0]); + const qDelta = quatFromAxisAngle([0, 1, 0], (Math.PI * 45) / 180); + const qNew = quatMultiply(qStart, qDelta); + const e = eulerXYZFromQuat(qNew); + expectVec3Close(e, [90, 45, 0]); + }); + + it("composing a full TAU rotation around any axis returns to identity orientation", () => { + for (const axis of [ + [1, 0, 0] as const, + [0, 1, 0] as const, + [0, 0, 1] as const, + ]) { + const q = quatFromAxisAngle([axis[0], axis[1], axis[2]], TAU); + // 2π rotation = identity OR -identity (same orientation, opposite hemisphere). + const same = Math.abs(q[0] - 1) < 1e-6; + const flipped = Math.abs(q[0] + 1) < 1e-6; + expect(same || flipped).toBe(true); + } + }); +}); diff --git a/packages/core/src/math/quaternion.ts b/packages/core/src/math/quaternion.ts new file mode 100644 index 00000000..f1207ca0 --- /dev/null +++ b/packages/core/src/math/quaternion.ts @@ -0,0 +1,110 @@ +/** + * Minimal quaternion helpers for composing rotations. + * + * Why we need quaternions: the public PolyMesh API exposes rotation as a + * Euler triple `[rx, ry, rz]` in degrees (drives CSS `rotateX rotateY + * rotateZ`, applied right-to-left). Euler triples don't compose by + * component addition — rotating Y after X must happen around the mesh's + * NEW local-Y axis, not world-Y. The transform-controls ring drag handler + * uses these helpers to compose around the mesh's local axis correctly: + * + * q_start = quatFromEulerXYZ(currentRotationDeg) + * q_delta = quatFromAxisAngle(localAxis, deltaRadians) + * q_new = quatMultiply(q_start, q_delta) // RIGHT-multiply = local frame + * next = eulerXYZFromQuat(q_new) + * + * Convention: "XYZ" Euler means the composed rotation matrix is + * `Rx(rx) · Ry(ry) · Rz(rz)`, which matches CSS `rotateX rotateY rotateZ` + * (right-to-left application to a point ⇒ Z first, then Y, then X). + * + * Quaternion format: `[w, x, y, z]` (real-first, like three.js's internal + * `_x/_y/_z/_w` reordered). Stored as plain tuples — no constructor or + * runtime allocations per drag. + */ +import type { Vec3 } from "../types"; + +/** Quaternion `[w, x, y, z]`, real component first. Unit-length is not + * enforced by the type — callers normalize when needed. */ +export type Quat = [number, number, number, number]; + +const DEG_TO_RAD = Math.PI / 180; +const RAD_TO_DEG = 180 / Math.PI; + +/** Identity quaternion. */ +export const QUAT_IDENTITY: Quat = [1, 0, 0, 0]; + +/** Hamilton product `q1 * q2`. Apply to a vector as `q v q⁻¹`. Right- + * multiplication composes the second rotation in the LOCAL frame of the + * first — that's the property the gizmo relies on for local-axis drag. */ +export function quatMultiply(q1: Quat, q2: Quat): Quat { + const [w1, x1, y1, z1] = q1; + const [w2, x2, y2, z2] = q2; + return [ + w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2, + w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2, + w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2, + w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2, + ]; +} + +/** Quaternion from axis-angle. `axis` must be unit length (caller's + * responsibility — typically a CSS basis vector). `angleRad` in radians. */ +export function quatFromAxisAngle(axis: Vec3, angleRad: number): Quat { + const half = angleRad * 0.5; + const s = Math.sin(half); + return [Math.cos(half), axis[0] * s, axis[1] * s, axis[2] * s]; +} + +/** Quaternion from Euler XYZ degrees — the order CSS `rotateX rotateY + * rotateZ` applies. Matches the composed matrix `Rx(rx)·Ry(ry)·Rz(rz)`. */ +export function quatFromEulerXYZ(eulerDeg: Vec3): Quat { + const rx = eulerDeg[0] * DEG_TO_RAD; + const ry = eulerDeg[1] * DEG_TO_RAD; + const rz = eulerDeg[2] * DEG_TO_RAD; + const cx = Math.cos(rx * 0.5), sx = Math.sin(rx * 0.5); + const cy = Math.cos(ry * 0.5), sy = Math.sin(ry * 0.5); + const cz = Math.cos(rz * 0.5), sz = Math.sin(rz * 0.5); + // q = qx * qy * qz with qa(angle) = (cos(angle/2), axisVec*sin(angle/2)). + return [ + cx * cy * cz - sx * sy * sz, + sx * cy * cz + cx * sy * sz, + cx * sy * cz - sx * cy * sz, + cx * cy * sz + sx * sy * cz, + ]; +} + +/** Euler XYZ degrees from a quaternion — inverse of `quatFromEulerXYZ`. + * Handles gimbal lock (|ry| → 90°) by collapsing rz onto rx. The output + * matches the convention used by CSS `rotateX rotateY rotateZ` so it can + * be written straight back into a PolyMesh rotation prop. */ +export function eulerXYZFromQuat(q: Quat): Vec3 { + const [w, x, y, z] = q; + // Matrix elements we need (from quatToMatrix): + // m02 = 2(xz + wy) ← sin(ry) + // m12 = 2(yz - wx) ← -sin(rx)cos(ry) + // m22 = 1 - 2(x²+y²) ← cos(rx)cos(ry) + // m01 = 2(xy - wz) ← -cos(ry)sin(rz) + // m00 = 1 - 2(y²+z²) ← cos(ry)cos(rz) + const m02 = 2 * (x * z + w * y); + const m12 = 2 * (y * z - w * x); + const m22 = 1 - 2 * (x * x + y * y); + const m01 = 2 * (x * y - w * z); + const m00 = 1 - 2 * (y * y + z * z); + // sin(ry) clamped to handle floating-point overshoot. + const sy = Math.max(-1, Math.min(1, m02)); + const ry = Math.asin(sy); + // Gimbal lock threshold: cos(ry) ≈ 0 → ry near ±90°. Pick rz = 0 and + // recover rx from the remaining diagonal — same approach three.js uses. + if (Math.abs(sy) < 0.99999) { + return [ + Math.atan2(-m12, m22) * RAD_TO_DEG, + ry * RAD_TO_DEG, + Math.atan2(-m01, m00) * RAD_TO_DEG, + ]; + } + return [ + Math.atan2(2 * (y * z + w * x), 1 - 2 * (x * x + z * z)) * RAD_TO_DEG, + ry * RAD_TO_DEG, + 0, + ]; +} diff --git a/packages/polycss/src/api/createPolyFirstPersonControls.ts b/packages/polycss/src/api/createPolyFirstPersonControls.ts index 79aedce4..7de82b6a 100644 --- a/packages/polycss/src/api/createPolyFirstPersonControls.ts +++ b/packages/polycss/src/api/createPolyFirstPersonControls.ts @@ -431,6 +431,27 @@ export function createPolyFirstPersonControls( rafId = null; } + // FPV needs a perspective context on the host so scene Z motion shows as + // depth, not as a planar pan. We honor whatever perspective the host + // already has (e.g. user picked a value via sceneOptions.perspective); + // when the host has none (orthographic mode), fall back to 2000px to + // match lookOffset's fallback so the math and visual stay in sync. + // Applied via `.polycss-fpv-host` (see styles.ts) so the class's + // `!important` overrides any inline `perspective: none`. + function applyFpvHostPerspective(): void { + const view = host.ownerDocument?.defaultView; + const current = view?.getComputedStyle(host).perspective ?? ""; + const n = parseFloat(current); + const effective = Number.isFinite(n) && n > 0 ? n : 2000; + host.style.setProperty("--polycss-fpv-perspective", `${effective}px`); + host.classList.add("polycss-fpv-host"); + } + + function clearFpvHostPerspective(): void { + host.classList.remove("polycss-fpv-host"); + host.style.removeProperty("--polycss-fpv-perspective"); + } + function attach(): void { host.addEventListener("click", onHostClick); doc.addEventListener("pointerlockchange", onPointerLockChange); @@ -439,6 +460,7 @@ export function createPolyFirstPersonControls( win.addEventListener("keyup", onKeyUp); win.addEventListener("blur", onBlur); host.style.cursor = opts.lookEnabled ? "crosshair" : ""; + applyFpvHostPerspective(); } function detach(): void { @@ -453,6 +475,7 @@ export function createPolyFirstPersonControls( if (pointerLocked) { try { doc.exitPointerLock(); } catch { /* ignore */ } } + clearFpvHostPerspective(); } initializeOriginFromTarget(); diff --git a/packages/polycss/src/api/createTransformControls.test.ts b/packages/polycss/src/api/createTransformControls.test.ts index 4ef97397..cdf110a1 100644 --- a/packages/polycss/src/api/createTransformControls.test.ts +++ b/packages/polycss/src/api/createTransformControls.test.ts @@ -71,16 +71,21 @@ function triggerPointerDownOnGizmoEl( // pointer coordinates. const iEls = el.querySelectorAll("i,b,s,u"); const origRects = new Map DOMRect>(); + // Offset bbox so the click sits at the bbox's right edge instead of its + // center. Rings use a donut-shaped hit-test that rejects clicks AT the + // bbox center (it's the inner hole). A click at the boundary passes both + // the arrow rect test (clientX <= r.right) AND the donut test (normalized + // distance from bbox center = 1, on the outer edge). iEls.forEach((i) => { origRects.set(i, i.getBoundingClientRect.bind(i)); i.getBoundingClientRect = () => ({ - left: clientX - 1, + left: clientX - 2, top: clientY - 1, - right: clientX + 1, + right: clientX, bottom: clientY + 1, width: 2, height: 2, - x: clientX - 1, + x: clientX - 2, y: clientY - 1, toJSON() { return this; }, } as DOMRect); @@ -123,8 +128,8 @@ describe("createTransformControls", () => { expect(gizmos.length).toBe(0); }); - // ── Test 2: translate mode renders 6 arrows ───────────────────────────────── - it("attach(mesh) in translate mode mounts 6 .polycss-transform-arrow meshes", () => { + // ── Test 2: translate mode renders 6 arrows + 3 plane handles ─────────────── + it("attach(mesh) in translate mode mounts 6 arrows + 3 plane handles", () => { const mesh = scene.add(parseResult(), { id: "target" }); tc = createTransformControls(scene, { mode: "translate" }); tc.attach(mesh); @@ -132,15 +137,23 @@ describe("createTransformControls", () => { const arrows = host.querySelectorAll(".polycss-transform-arrow"); expect(arrows.length).toBe(6); - const keys = Array.from(arrows).map((el) => { + const arrowKeys = Array.from(arrows).map((el) => { const m = /polycss-transform-arrow--(-?[a-z])/.exec(el.className); return m ? m[1] : null; }); - expect(keys).toEqual(["x", "-x", "y", "-y", "z", "-z"]); + expect(arrowKeys).toEqual(["x", "-x", "y", "-y", "z", "-z"]); + + const planes = host.querySelectorAll(".polycss-transform-plane"); + expect(planes.length).toBe(3); + const planeKeys = Array.from(planes).map((el) => { + const m = /polycss-transform-plane--([a-z]+)/.exec(el.className); + return m ? m[1] : null; + }); + expect(planeKeys.sort()).toEqual(["xy", "xz", "yz"]); - // All should also carry the shared gizmo class + // All nine carry the shared gizmo class. const gizmos = host.querySelectorAll(".polycss-transform-gizmo"); - expect(gizmos.length).toBe(6); + expect(gizmos.length).toBe(9); }); // ── Test 3: rotate mode renders 3 rings ───────────────────────────────────── @@ -353,9 +366,12 @@ describe("createTransformControls", () => { expect(evt.rotation).toBeDefined(); // X-axis rotation is inverted; moving CW should produce positive rotation expect(evt.rotation![0]).toBeTypeOf("number"); - // y and z should remain 0 (X ring drag only affects cssAxis=0) - expect(evt.rotation![1]).toBe(0); - expect(evt.rotation![2]).toBe(0); + // y and z should remain ~0 (X ring drag only affects cssAxis=0). With + // quaternion compose the round-trip through Euler can yield -0 for + // nominally-zero components, so check the magnitude instead of strict + // +0 equality. + expect(Math.abs(evt.rotation![1])).toBeLessThan(1e-6); + expect(Math.abs(evt.rotation![2])).toBeLessThan(1e-6); expect(evt.object).toBe(mesh); window.dispatchEvent(new PointerEvent("pointerup", { clientX: 200, clientY: 100, pointerId: 1 })); @@ -370,7 +386,7 @@ describe("createTransformControls", () => { tc.attach(mesh); // Confirm gizmos are present - expect(host.querySelectorAll(".polycss-transform-gizmo").length).toBe(6); + expect(host.querySelectorAll(".polycss-transform-gizmo").length).toBe(9); tc.destroy(); tc = null; @@ -393,7 +409,7 @@ describe("createTransformControls", () => { const mesh = scene.add(parseResult(), { id: "target" }); tc = createTransformControls(scene, { mode: "translate" }); tc.attach(mesh); - expect(host.querySelectorAll(".polycss-transform-gizmo").length).toBe(6); + expect(host.querySelectorAll(".polycss-transform-gizmo").length).toBe(9); tc.detach(); expect(host.querySelectorAll(".polycss-transform-gizmo").length).toBe(0); diff --git a/packages/polycss/src/api/createTransformControls.ts b/packages/polycss/src/api/createTransformControls.ts index 4a47957d..7b168539 100644 --- a/packages/polycss/src/api/createTransformControls.ts +++ b/packages/polycss/src/api/createTransformControls.ts @@ -17,7 +17,15 @@ * tc.detach(); * tc.destroy(); */ -import { arrowPolygons, ringPolygons } from "@layoutit/polycss-core"; +import { + arrowPolygons, + eulerXYZFromQuat, + planePolygons, + quatFromAxisAngle, + quatFromEulerXYZ, + quatMultiply, + ringQuadPolygons, +} from "@layoutit/polycss-core"; import type { Polygon, Vec3 } from "@layoutit/polycss-core"; import type { PolyMeshHandle, PolySceneHandle } from "./createPolyScene"; @@ -34,8 +42,20 @@ const SHAFT_HALF_THICKNESS_RATIO = 0.0125; const HEAD_LENGTH_RATIO = 0.15; const HEAD_HALF_THICKNESS_RATIO = 0.04; const RING_RADIUS_RATIO = 1.0; -const RING_HALF_THICKNESS_RATIO = 0.012; -const RING_SEGMENTS = 64; +// Visible band half-width relative to the ring's mid-radius. Drives ONLY +// the CSS mask; the underlying click target (quad bbox) is sized separately +// by RING_QUAD_OUTER_RATIO so we can show a thin ring without shrinking +// the hit area. Keep small for a clean look. +const RING_HALF_THICKNESS_RATIO = 0.02; +// Outer radius of the ring's quad polygon as a multiple of mid-radius. The +// quad's bbox IS the click target — generous quad = generous click margin +// even when the visible band is very thin. 1.04 leaves a 2% margin past the +// visible ring's outer edge while keeping the previous hit footprint. +const RING_QUAD_OUTER_RATIO = 1.04; +// Plane handle proportions, relative to the arrow's shaft length: the square +// sits at ~25% of the arrow length and is ~20% of the arrow length wide. +const PLANE_HALF_SIZE_RATIO = 0.1; +const PLANE_OFFSET_RATIO = 0.25; const SCREEN_AXIS_DEAD_ZONE_SQ = 0.0001; const ALPHA_IDLE = 0.6; @@ -64,6 +84,52 @@ const RING_SPECS: Array<{ cssAxis: 0 | 1 | 2; key: string; color: string }> = [ { cssAxis: 2, key: "z", color: COLOR_Z }, ]; +/** Three plane specs (translate mode — planar drag). `perpAxis` is the + * axis perpendicular to the plane (the one the drag does NOT move along); + * `axisA` and `axisB` are the two axes the drag DOES update. All three + * refer to the CSS axes in `PolyMeshHandle.transform.position`. */ +// Each plane handle is colored with the axis it's PERPENDICULAR to — so the +// XY plane (containing the red+green arrows) reads as the blue (Z) handle, +// the XZ plane as the green (Y) handle, and the YZ plane as the red (X) +// handle. Inversion of three.js's convention but maps cleanly to "the axis +// you can't drag along is this color". +const PLANE_SPECS: Array<{ + perpAxis: 0 | 1 | 2; + axisA: 0 | 1 | 2; + axisB: 0 | 1 | 2; + key: "xy" | "xz" | "yz"; + color: string; +}> = [ + { perpAxis: 2, axisA: 0, axisB: 1, key: "xy", color: COLOR_Z }, + { perpAxis: 1, axisA: 0, axisB: 2, key: "xz", color: COLOR_Y }, + { perpAxis: 0, axisA: 1, axisB: 2, key: "yz", color: COLOR_X }, +]; + +/** Returns true when the given signed CSS-space axis points AWAY from the + * viewer under the scene's current rotation (rotateZ(rotY) · rotateX(rotX)). + * Computed from screen-Z: a CSS-Z component < 0 after applying the scene + * rotation = into the screen = back-facing. Used by `` + * to drop the shaft on the back-facing axis of each pair so the gizmo + * doesn't double-paint at the gizmo center. */ +function isAxisBackFacing( + cssAxis: 0 | 1 | 2, + sign: 1 | -1, + rotXDeg: number, + rotYDeg: number, +): boolean { + const rx = (rotXDeg * Math.PI) / 180; + const ry = (rotYDeg * Math.PI) / 180; + const a: [number, number, number] = [0, 0, 0]; + a[cssAxis] = sign; + // rotateZ(rotY) + const bx = a[0] * Math.cos(ry) - a[1] * Math.sin(ry); + const by = a[0] * Math.sin(ry) + a[1] * Math.cos(ry); + const bz = a[2]; + // rotateX(rotX) — only Y and Z change + const cz = by * Math.sin(rx) + bz * Math.cos(rx); + return cz < 0; +} + function withAlpha(hex: string, alpha: number): string { const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); if (!m) return hex; @@ -81,11 +147,10 @@ function snap(value: number, step: number | null | undefined): number { /** Compute the bbox center of a mesh's polygons in scene-CSS pixels. * polycss world→CSS axis remap: world-Y → CSS-x, world-X → CSS-y, * world-Z → CSS-z. The result is the offset we add to the gizmo - * position so the gizmo overlays the visible center of the mesh — - * required because scene-level `autoCenter` translates the - * centerWrapper by `-bboxCenter`, which would otherwise leave the - * gizmo (whose polygons are generated around world origin) sitting at - * `-bboxCenter` in screen space rather than aligned with the mesh. */ + * position so the gizmo overlays the visible center of the mesh. The + * mesh wrapper sets `transform-origin: var(--origin)` to the same bbox + * center, so its visible center is `position + bboxCenter` regardless + * of scale or rotation — no per-axis scale multiplication needed. */ function bboxCenterCss(polygons: Polygon[]): Vec3 { if (polygons.length === 0) return [0, 0, 0]; let minX = Infinity, minY = Infinity, minZ = Infinity; @@ -280,6 +345,98 @@ function startAxisDrag(opts: DragOptions): void { window.addEventListener("pointercancel", handleUp); } +interface PlaneDragOptions { + axisA: 0 | 1 | 2; + axisB: 0 | 1 | 2; + probeDistanceCss: number; + wrapper: HTMLElement; + target: PolyMeshHandle; + startClientX: number; + startClientY: number; + translationSnap: number | null; + onPlaneDelta(tA: number, tB: number, axisAVec: Vec3, axisBVec: Vec3): void; + onMouseDown?: () => void; + onMouseUp?: () => void; + onDraggingChanged?: (dragging: boolean) => void; +} + +/** Project pointer screen-px deltas onto a 2D basis (screen projections of + * two world axes) and solve a 2x2 system for the planar motion. Mirror of + * the single-axis projection in `startAxisDrag`, extended to two axes. */ +function startPlaneDrag(opts: PlaneDragOptions): void { + const { + axisA, + axisB, + probeDistanceCss, + wrapper, + startClientX, + startClientY, + translationSnap, + onPlaneDelta, + onMouseDown, + onMouseUp, + onDraggingChanged, + } = opts; + + // Probe both in-plane axes to measure their screen projections. Same + // technique as startAxisDrag: place a 0×0 element at `axis * dist`, read + // its bounding rect against the wrapper's, divide by `dist` to get the + // unit screen vector for that world axis. + const axisAVec: Vec3 = [0, 0, 0]; axisAVec[axisA] = 1; + const axisBVec: Vec3 = [0, 0, 0]; axisBVec[axisB] = 1; + function probe(axisVec: Vec3): { x: number; y: number } { + const el = wrapper.ownerDocument!.createElement("div"); + el.style.position = "absolute"; + el.style.left = "0"; + el.style.top = "0"; + el.style.width = "0"; + el.style.height = "0"; + el.style.transform = `translate3d(${axisVec[0] * probeDistanceCss}px, ${axisVec[1] * probeDistanceCss}px, ${axisVec[2] * probeDistanceCss}px)`; + wrapper.appendChild(el); + const wR = wrapper.getBoundingClientRect(); + const pR = el.getBoundingClientRect(); + wrapper.removeChild(el); + return { + x: (pR.left - wR.left) / probeDistanceCss, + y: (pR.top - wR.top) / probeDistanceCss, + }; + } + const pA = probe(axisAVec); + const pB = probe(axisBVec); + // Cramer's rule on the 2x2: [pA.x pB.x; pA.y pB.y] * [tA tB]' = [dx dy]' + const det = pA.x * pB.y - pB.x * pA.y; + if (Math.abs(det) < SCREEN_AXIS_DEAD_ZONE_SQ) return; // plane edge-on to camera + + onMouseDown?.(); + onDraggingChanged?.(true); + + const handleMove = (ev: PointerEvent): void => { + const dx = ev.clientX - startClientX; + const dy = ev.clientY - startClientY; + let tA = (pB.y * dx - pB.x * dy) / det; + let tB = (-pA.y * dx + pA.x * dy) / det; + tA = snap(tA, translationSnap); + tB = snap(tB, translationSnap); + onPlaneDelta(tA, tB, axisAVec, axisBVec); + }; + const handleUp = (): void => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + window.removeEventListener("pointercancel", handleUp); + const swallow = (e: Event): void => { + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + window.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => window.removeEventListener("click", swallow, true), 0); + onMouseUp?.(); + onDraggingChanged?.(false); + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + window.addEventListener("pointercancel", handleUp); +} + interface RingDragOptions { cssAxis: 0 | 1 | 2; wrapper: HTMLElement; @@ -368,12 +525,13 @@ export function createTransformControls( // gizmo mesh is positioned at `target.transform.position` via // setTransform — that's the gizmo origin. - // Per-key tracking. Each gizmo arrow / ring is a polycss PolyMeshHandle - // added to the scene, then re-parented under our wrapper. - type GizmoMesh = { - handle: PolyMeshHandle; - spec: { key: string; cssAxis: 0 | 1 | 2; sign?: 1 | -1; color: string }; - }; + // Per-key tracking. Each gizmo arrow / ring / plane is a polycss + // PolyMeshHandle added to the scene, then re-parented under our wrapper. + type GizmoSpec = + | { kind: "arrow"; key: string; cssAxis: 0 | 1 | 2; sign: 1 | -1; color: string } + | { kind: "ring"; key: string; cssAxis: 0 | 1 | 2; color: string } + | { kind: "plane"; key: string; perpAxis: 0 | 1 | 2; axisA: 0 | 1 | 2; axisB: 0 | 1 | 2; color: string }; + type GizmoMesh = { handle: PolyMeshHandle; spec: GizmoSpec }; const gizmos = new Map(); let hoveredKey: string | null = null; let draggingKey: string | null = null; @@ -403,36 +561,104 @@ export function createTransformControls( } } - function buildPolygonsFor( - spec: { key: string; cssAxis: 0 | 1 | 2; sign?: 1 | -1; color: string }, - alpha: number, - ): Polygon[] { + function buildPolygonsFor(spec: GizmoSpec, alpha: number): Polygon[] { const baseLength = gizmoLengthForMesh(target?.polygons ?? []); const shaftLengthCss = baseLength * size; const lengthWorld = shaftLengthCss / SCENE_TILE_SIZE; const color = withAlpha(spec.color, alpha); - if (mode === "translate") { + if (spec.kind === "arrow") { + // Strip the shaft for back-facing arrows so the visible-only-from- + // outside silhouette stays clean. Both halves of a pair otherwise + // share the same shaft volume at the gizmo origin. + const sceneOpts = scene.getOptions(); + const backFacing = isAxisBackFacing( + spec.cssAxis, + spec.sign, + sceneOpts.rotX ?? 65, + sceneOpts.rotY ?? 45, + ); return arrowPolygons({ axis: WORLD_AXIS_FOR_CSS[spec.cssAxis], - sign: spec.sign ?? 1, + sign: spec.sign, shaftLength: lengthWorld, shaftHalfThickness: lengthWorld * SHAFT_HALF_THICKNESS_RATIO, headLength: lengthWorld * HEAD_LENGTH_RATIO, headHalfThickness: lengthWorld * HEAD_HALF_THICKNESS_RATIO, color, + shaft: !backFacing, + }); + } + if (spec.kind === "plane") { + // Place the quad in the camera-facing octant: for each in-plane axis, + // flip the offset sign if the +axis is back-facing. planePolygons + // works in WORLD axes (a = (perp+1)%3, b = (perp+2)%3); since + // WORLD_AXIS_FOR_CSS is involutive, the CSS axis we test for back- + // facing is just WORLD_AXIS_FOR_CSS[worldA / worldB]. + const sceneOpts = scene.getOptions(); + const rotX = sceneOpts.rotX ?? 65; + const rotY = sceneOpts.rotY ?? 45; + const worldPerp = WORLD_AXIS_FOR_CSS[spec.perpAxis]; + const worldA = ((worldPerp + 1) % 3) as 0 | 1 | 2; + const worldB = ((worldPerp + 2) % 3) as 0 | 1 | 2; + const cssAForOffset = WORLD_AXIS_FOR_CSS[worldA]; + const cssBForOffset = WORLD_AXIS_FOR_CSS[worldB]; + const signA = isAxisBackFacing(cssAForOffset, 1, rotX, rotY) ? -1 : 1; + const signB = isAxisBackFacing(cssBForOffset, 1, rotX, rotY) ? -1 : 1; + const mag = lengthWorld * PLANE_OFFSET_RATIO; + return planePolygons({ + axis: worldPerp, + size: lengthWorld * PLANE_HALF_SIZE_RATIO, + offset: [signA * mag, signB * mag], + color, }); } - // rotate + // ring — single square quad masked to a donut via CSS (see + // .polycss-transform-ring rule in styles.ts). One DOM node per ring + // instead of N segment quads. Quad outer radius is sized by + // RING_QUAD_OUTER_RATIO so the hit footprint stays generous even when + // the visible band (driven by RING_HALF_THICKNESS_RATIO) is thin. const radiusWorld = (shaftLengthCss * RING_RADIUS_RATIO) / SCENE_TILE_SIZE; - return ringPolygons({ + const outerWorld = radiusWorld * RING_QUAD_OUTER_RATIO; + return ringQuadPolygons({ axis: WORLD_AXIS_FOR_CSS[spec.cssAxis], - radius: radiusWorld, - halfThickness: radiusWorld * RING_HALF_THICKNESS_RATIO, - segments: RING_SEGMENTS, + outerRadius: outerWorld, color, }); } + function classPrefixFor(spec: GizmoSpec): string { + if (spec.kind === "arrow") return "polycss-transform-arrow"; + if (spec.kind === "plane") return "polycss-transform-plane"; + return "polycss-transform-ring"; + } + + /** Resolve the active spec list for the current mode. Translate mode mixes + * the 6 axis arrows with the 3 planar handles; rotate mode just rings. */ + function activeSpecs(): GizmoSpec[] { + if (mode === "translate") { + const arrows: GizmoSpec[] = ARROW_SPECS.map((a) => ({ + kind: "arrow", + key: a.key, + cssAxis: a.cssAxis, + sign: a.sign, + color: a.color, + })); + const planes: GizmoSpec[] = PLANE_SPECS.map((p) => ({ + kind: "plane", + key: p.key, + perpAxis: p.perpAxis, + axisA: p.axisA, + axisB: p.axisB, + color: p.color, + })); + return [...arrows, ...planes]; + } + if (mode === "rotate") { + return RING_SPECS.map((r) => ({ kind: "ring", key: r.key, cssAxis: r.cssAxis, color: r.color })); + } + return []; + } + function buildGizmos(): void { teardownGizmos(); if (!target) return; @@ -441,12 +667,22 @@ export function createTransformControls( y: opts.showY !== false, z: opts.showZ !== false, }; - const specs = mode === "translate" ? ARROW_SPECS : mode === "rotate" ? RING_SPECS : []; - const classPrefix = mode === "translate" ? "polycss-transform-arrow" : "polycss-transform-ring"; + function specVisible(spec: GizmoSpec): boolean { + if (spec.kind === "arrow") { + const userAxis = spec.key.replace("-", "")[0] as "x" | "y" | "z"; + return showByKey[userAxis]; + } + if (spec.kind === "ring") { + return showByKey[spec.key as "x" | "y" | "z"]; + } + // Plane handles need BOTH in-plane axes visible. + const aName = (["x", "y", "z"] as const)[spec.axisA]; + const bName = (["x", "y", "z"] as const)[spec.axisB]; + return showByKey[aName] && showByKey[bName]; + } const targetPos = gizmoPosition(); - for (const spec of specs) { - const userAxis = spec.key.replace("-", "")[0] as "x" | "y" | "z"; - if (!showByKey[userAxis]) continue; + for (const spec of activeSpecs()) { + if (!specVisible(spec)) continue; const polys = buildPolygonsFor(spec, alphaFor(spec.key)); // Each gizmo mesh is added directly to the scene at the target's // position. scene.add appends to centerWrapper (the camera- @@ -461,11 +697,24 @@ export function createTransformControls( position: targetPos, }, ); + const classPrefix = classPrefixFor(spec); handle.element.classList.add( "polycss-transform-gizmo", classPrefix, `${classPrefix}--${spec.key}`, ); + if (spec.kind === "ring") { + // Two CSS vars consumed by the .polycss-transform-ring mask: where + // the visible band STARTS and ENDS, both as a fraction of the quad + // edge (50%). The quad's outer radius is RING_QUAD_OUTER_RATIO · + // mid-radius, so we normalize the visible inner/outer edges + // (mid ± halfThickness) against the quad outer to get the mask + // positions inside the quad. + const innerRatio = (1 - RING_HALF_THICKNESS_RATIO) / RING_QUAD_OUTER_RATIO; + const outerRatio = (1 + RING_HALF_THICKNESS_RATIO) / RING_QUAD_OUTER_RATIO; + handle.element.style.setProperty("--ring-inner-ratio", `${innerRatio}`); + handle.element.style.setProperty("--ring-outer-ratio", `${outerRatio}`); + } gizmos.set(spec.key, { handle, spec }); } } @@ -552,6 +801,54 @@ export function createTransformControls( z: opts.showZ !== false, }; if (mode === "translate") { + // Plane handles are hit-tested FIRST so they win when overlapping with + // the arrow shafts at the corner. + for (const spec of PLANE_SPECS) { + const aName = (["x", "y", "z"] as const)[spec.axisA]; + const bName = (["x", "y", "z"] as const)[spec.axisB]; + if (!showByKey[aName] || !showByKey[bName]) continue; + const gm = gizmos.get(spec.key); + if (!gm) continue; + if (!pointInMeshElement(gm.handle.element, event.clientX, event.clientY)) continue; + event.preventDefault(); + event.stopPropagation(); + draggingKey = spec.key; + rebuildGizmoColors(); + dragStartPosition = (target.transform.position ?? [0, 0, 0]).slice() as Vec3; + startPlaneDrag({ + axisA: spec.axisA, + axisB: spec.axisB, + probeDistanceCss: gizmoLengthForMesh(target.polygons) * size, + wrapper: gm.handle.element, + target, + startClientX: event.clientX, + startClientY: event.clientY, + translationSnap: opts.translationSnap ?? null, + onPlaneDelta: (tA, tB, aVec, bVec) => { + if (!target || !dragStartPosition) return; + const next: Vec3 = [ + dragStartPosition[0] + tA * aVec[0] + tB * bVec[0], + dragStartPosition[1] + tA * aVec[1] + tB * bVec[1], + dragStartPosition[2] + tA * aVec[2] + tB * bVec[2], + ]; + target.setTransform({ position: next }); + syncGizmoPositions(); + opts.onObjectChange?.({ object: target, position: next }); + opts.onChange?.(); + }, + onMouseDown: opts.onMouseDown, + onMouseUp: opts.onMouseUp, + onDraggingChanged: (d) => { + if (!d) { + draggingKey = null; + dragStartPosition = null; + rebuildGizmoColors(); + } + opts.onDraggingChanged?.(d); + }, + }); + return; + } for (const spec of ARROW_SPECS) { const userAxis = spec.key.replace("-", "")[0] as "x" | "y" | "z"; if (!showByKey[userAxis]) continue; @@ -599,6 +896,8 @@ export function createTransformControls( if (!showByKey[spec.key as "x" | "y" | "z"]) continue; const gm = gizmos.get(spec.key); if (!gm) continue; + // Plain bbox-containment hit-test. The donut mask is decoration; the + // entire ring quad bbox is clickable so the rings are easy to land on. if (!pointInMeshElement(gm.handle.element, event.clientX, event.clientY)) continue; event.preventDefault(); event.stopPropagation(); @@ -614,21 +913,19 @@ export function createTransformControls( rotationSnap: opts.rotationSnap ?? null, onAngleDelta: (degrees) => { if (!target || !dragStartRotation) return; - const next: Vec3 = [ - dragStartRotation[0], - dragStartRotation[1], - dragStartRotation[2], - ]; - // Invert the X-axis sign empirically — vanilla's rotateX - // applied around `transform-origin: bboxCenter` reads as - // backward from what users expect after dragging the red - // ring CW. Y and Z behave correctly with the raw sign. - // (The math is the same as React's; the perceptual - // difference comes from the chicken's polygon coords - // sitting at world coordinates rather than recentered to - // origin like React's PolyMesh does.) + // World-frame quaternion compose. Rings stay at world axes + // visually (the gizmo isn't rotated with the mesh), so each + // ring drag rotates the mesh around the WORLD axis the ring + // points to — pre-multiply Qdelta · Qstart. Cumulative across + // repeated drags. X-axis sign stays empirically inverted to + // match user expectation for CW drag on the red ring. const sign = spec.cssAxis === 0 ? -1 : 1; - next[spec.cssAxis] = dragStartRotation[spec.cssAxis] + degrees * sign; + const axisVec: Vec3 = [0, 0, 0]; + axisVec[spec.cssAxis] = 1; + const deltaRad = (degrees * sign * Math.PI) / 180; + const qStart = quatFromEulerXYZ(dragStartRotation); + const qDelta = quatFromAxisAngle(axisVec, deltaRad); + const next = eulerXYZFromQuat(quatMultiply(qDelta, qStart)); target.setTransform({ rotation: next }); opts.onObjectChange?.({ object: target, rotation: next }); opts.onChange?.(); diff --git a/packages/polycss/src/render/polyDOM.test.ts b/packages/polycss/src/render/polyDOM.test.ts index 529047e1..3af9691e 100644 --- a/packages/polycss/src/render/polyDOM.test.ts +++ b/packages/polycss/src/render/polyDOM.test.ts @@ -61,9 +61,6 @@ const UNSTABLE_PROJECTIVE_QUAD: Polygon = { color: "#ff00ff", }; -const QUAD_CANONICAL_SIZE = 64; -const ATLAS_SLICE_CANONICAL_SIZE = 128; - const OFFAXIS_TRIANGLE: Polygon = { vertices: [ [0, 0, 0], @@ -163,41 +160,15 @@ function computeExpectedMatrix( return computeExpectedPlan(vertices, tileSize, elev).matrix; } -function computeExpectedQuadMatrix( +function computeExpectedCanonicalMatrix( vertices: [number, number, number][], tileSize = 50, elev = tileSize, ): number[] { const { matrix, canvasW, canvasH } = computeExpectedPlan(vertices, tileSize, elev); return [ - matrix[0] * canvasW / QUAD_CANONICAL_SIZE, - matrix[1] * canvasW / QUAD_CANONICAL_SIZE, - matrix[2] * canvasW / QUAD_CANONICAL_SIZE, - 0, - matrix[4] * canvasH / QUAD_CANONICAL_SIZE, - matrix[5] * canvasH / QUAD_CANONICAL_SIZE, - matrix[6] * canvasH / QUAD_CANONICAL_SIZE, - 0, - matrix[8], matrix[9], matrix[10], 0, - matrix[12], matrix[13], matrix[14], 1, - ]; -} - -function computeExpectedAtlasMatrix( - vertices: [number, number, number][], - tileSize = 50, - elev = tileSize, -): number[] { - const { matrix, canvasW, canvasH } = computeExpectedPlan(vertices, tileSize, elev); - return [ - matrix[0] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - matrix[1] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - matrix[2] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - 0, - matrix[4] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - matrix[5] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - matrix[6] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - 0, + matrix[0] * canvasW, matrix[1] * canvasW, matrix[2] * canvasW, 0, + matrix[4] * canvasH, matrix[5] * canvasH, matrix[6] * canvasH, 0, matrix[8], matrix[9], matrix[10], 0, matrix[12], matrix[13], matrix[14], 1, ]; @@ -312,7 +283,7 @@ describe("renderPoly — matrix math parity", () => { it("vertical quad matrix3d values match expected", () => { const result = renderPoly(VERTICAL_QUAD)!; const actual = extractMatrix(result.element); - const expected = roundedMatrix(computeExpectedQuadMatrix(VERTICAL_QUAD.vertices as [number, number, number][])); + const expected = roundedMatrix(computeExpectedCanonicalMatrix(VERTICAL_QUAD.vertices as [number, number, number][])); expect(actual.length).toBe(16); for (let i = 0; i < 16; i++) expect(actual[i]).toBeCloseTo(expected[i], 6); result.dispose(); @@ -338,7 +309,7 @@ describe("renderPoly — matrix math parity", () => { }; const result = renderPoly(poly, { tileSize: 50, layerElevation: 25 })!; const actual = extractMatrix(result.element); - const expected = roundedMatrix(computeExpectedQuadMatrix(poly.vertices as [number, number, number][], 50, 25)); + const expected = roundedMatrix(computeExpectedCanonicalMatrix(poly.vertices as [number, number, number][], 50, 25)); for (let i = 0; i < 16; i++) expect(actual[i]).toBeCloseTo(expected[i], 6); result.dispose(); }); @@ -499,8 +470,8 @@ describe("renderPolygonsWithTextureAtlas", () => { const yScale = Math.hypot(matrix[4], matrix[5], matrix[6]); expect(element.tagName.toLowerCase()).toBe("i"); - expect(xScale).toBeGreaterThan(2 / 64); - expect(yScale).toBeGreaterThan(2 / 64); + expect(xScale).toBeGreaterThan(2 / 16); + expect(yScale).toBeGreaterThan(2 / 16); expect(element.style.getPropertyValue("border-shape")).toContain("polygon("); result.dispose(); }); @@ -631,10 +602,10 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(element.style.height).toBe(""); expect(element.style.getPropertyValue("--polycss-local-w")).toBe(""); expect(element.style.getPropertyValue("--polycss-local-h")).toBe(""); - expect(matrix[0]).toBeGreaterThan(10 / 64); + expect(matrix[0]).toBeGreaterThan(10 / 16); expect(matrix[1]).toBeCloseTo(0, 6); expect(matrix[4]).toBeCloseTo(0, 6); - expect(matrix[5]).toBeGreaterThan(1 / 64); + expect(matrix[5]).toBeGreaterThan(1 / 16); result.dispose(); }); @@ -653,7 +624,7 @@ describe("renderPolygonsWithTextureAtlas", () => { const result = renderPolygonsWithTextureAtlas([obliqueTriangle], { tileSize: 1 }); const element = result.rendered[0].element; const matrix = extractMatrix(element); - const expected = roundedMatrix(computeExpectedAtlasMatrix(obliqueTriangle.vertices as [number, number, number][], 1, 1), 6); + const expected = roundedMatrix(computeExpectedMatrix(obliqueTriangle.vertices as [number, number, number][], 1, 1)); expect(element.style.width).toBe(""); expect(element.style.height).toBe(""); @@ -720,7 +691,7 @@ describe("renderPolygonsWithTextureAtlas", () => { const isolated = renderPolygonsWithTextureAtlas([bladeFace], { tileSize: 1 }); const shared = renderPolygonsWithTextureAtlas([bladeFace, bevelFace], { tileSize: 1 }); const sharedMatrix = extractMatrix(shared.rendered[0].element); - const sharedEdgeMatrix = roundedMatrix(computeExpectedAtlasMatrix(bladeFace.vertices as [number, number, number][], 1, 1), 6); + const sharedEdgeMatrix = roundedMatrix(computeExpectedMatrix(bladeFace.vertices as [number, number, number][], 1, 1)); const isolatedMatrix = extractMatrix(isolated.rendered[0].element); expectColumnDirection(isolatedMatrix, sharedEdgeMatrix, 0); @@ -760,7 +731,7 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(repaired.rendered[0].element.style.height).toBe(""); expectMatrixClose( extractMatrix(repaired.rendered[0].element), - roundedMatrix(computeExpectedAtlasMatrix(left.vertices as [number, number, number][], 1, 1), 6), + roundedMatrix(computeExpectedMatrix(left.vertices as [number, number, number][], 1, 1)), ); expect(repaired.rendered[0].plan?.textureEdgeRepair).toBe(true); @@ -798,11 +769,11 @@ describe("renderPolygonsWithTextureAtlas", () => { expect(repaired.rendered[1].element.style.height).toBe(""); expectMatrixClose( extractMatrix(repaired.rendered[0].element), - roundedMatrix(computeExpectedAtlasMatrix(floor.vertices as [number, number, number][], 1, 1), 6), + roundedMatrix(computeExpectedMatrix(floor.vertices as [number, number, number][], 1, 1)), ); expectMatrixClose( extractMatrix(repaired.rendered[1].element), - roundedMatrix(computeExpectedAtlasMatrix(wall.vertices as [number, number, number][], 1, 1), 6), + roundedMatrix(computeExpectedMatrix(wall.vertices as [number, number, number][], 1, 1)), ); expect(repaired.rendered[0].plan?.textureEdgeRepair).toBe(true); @@ -1327,7 +1298,6 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { const element = result.rendered[0].element; const style = element.getAttribute("style") ?? ""; expect(element.tagName.toLowerCase()).toBe("b"); - expect(element.className).toBe(""); expect(result.rendered[0].kind).toBe("solid"); expect(style).toContain("transform:matrix3d("); expect(style).not.toContain("width"); @@ -1360,9 +1330,9 @@ describe("renderPolygonsWithTextureAtlas — strategies.disable", () => { ]); expectPointClose(transformMatrixPoint(matrix, 0, 0), expected[0]); - expectPointClose(transformMatrixPoint(matrix, 64, 0), expected[1]); - expectPointClose(transformMatrixPoint(matrix, 64, 64), expected[2]); - expectPointClose(transformMatrixPoint(matrix, 0, 64), expected[3]); + expectPointClose(transformMatrixPoint(matrix, 1, 0), expected[1]); + expectPointClose(transformMatrixPoint(matrix, 1, 1), expected[2]); + expectPointClose(transformMatrixPoint(matrix, 0, 1), expected[3]); result.dispose(); }); diff --git a/packages/polycss/src/render/textureAtlas.ts b/packages/polycss/src/render/textureAtlas.ts index aa55d9f9..c25555a0 100644 --- a/packages/polycss/src/render/textureAtlas.ts +++ b/packages/polycss/src/render/textureAtlas.ts @@ -232,17 +232,13 @@ const TEXTURE_EDGE_REPAIR_ALPHA_MIN = 1; const TEXTURE_EDGE_REPAIR_SOURCE_ALPHA_MIN = 250; const TEXTURE_EDGE_REPAIR_RADIUS = 1.5; const SOLID_TRIANGLE_BLEED = 0.75; -const SOLID_ATLAS_EDGE_BLEED = 0.9; const DEFAULT_MATRIX_DECIMALS = 3; const DEFAULT_BORDER_SHAPE_DECIMALS = 2; const DEFAULT_ATLAS_CSS_DECIMALS = 4; const BORDER_SHAPE_CENTER_PERCENT = 50; const BORDER_SHAPE_POINT_EPS = 1e-7; -const BORDER_SHAPE_CANONICAL_SIZE = 64; +const BORDER_SHAPE_CANONICAL_SIZE = 16; const BORDER_SHAPE_BLEED = 0.9; -const QUAD_CANONICAL_SIZE = 64; -const ATLAS_SLICE_CANONICAL_SIZE = 128; -const SOLID_TRIANGLE_CANONICAL_SIZE = 64; const PROJECTIVE_QUAD_DENOM_EPS = 0.05; const PROJECTIVE_QUAD_MAX_WEIGHT_RATIO = Number.POSITIVE_INFINITY; const PROJECTIVE_QUAD_BLEED = 0.6; @@ -522,7 +518,6 @@ function computeProjectiveQuadMatrix( if (!coeffs) return null; const { g, h, w1, w3 } = coeffs; const [q0, q1, , q3] = q; - const sourceSize = QUAD_CANONICAL_SIZE; const p0: Vec3 = [ tx + q0[0] * xAxis[0] + q0[1] * yAxis[0], @@ -536,11 +531,11 @@ function computeProjectiveQuadMatrix( ]; return formatMatrix3dValues([ - ...projectiveColumn(q1, w1).map((value) => value / sourceSize), g / sourceSize, - ...projectiveColumn(q3, w3).map((value) => value / sourceSize), h / sourceSize, + ...projectiveColumn(q1, w1), g, + ...projectiveColumn(q3, w3), h, normal[0], normal[1], normal[2], 0, p0[0], p0[1], p0[2], 1, - ], 6); + ]); } function formatPercent(value: number, decimals = DEFAULT_BORDER_SHAPE_DECIMALS): string { @@ -1515,17 +1510,11 @@ function computeTextureAtlasPlan( tx, ty, tz, 1, ]); const canonicalMatrix = formatMatrix3dValues([ - xAxis[0] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - xAxis[1] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - xAxis[2] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - 0, - yAxis[0] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - yAxis[1] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - yAxis[2] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - 0, + xAxis[0] * canvasW, xAxis[1] * canvasW, xAxis[2] * canvasW, 0, + yAxis[0] * canvasH, yAxis[1] * canvasH, yAxis[2] * canvasH, 0, normal[0], normal[1], normal[2], 0, tx, ty, tz, 1, - ], 6); + ]); const projectiveMatrix = !texture && vertices.length === 4 ? computeProjectiveQuadMatrix( screenPts, @@ -1758,13 +1747,12 @@ function computeSolidTrianglePlan( baseLeft[1] - txCol[1], baseLeft[2] - txCol[2], ]; - const sourceSize = SOLID_TRIANGLE_CANONICAL_SIZE; const canonicalMatrix = formatMatrix3dValues([ - xCol[0] / sourceSize, xCol[1] / sourceSize, xCol[2] / sourceSize, 0, - yCol[0] / sourceSize, yCol[1] / sourceSize, yCol[2] / sourceSize, 0, + xCol[0], xCol[1], xCol[2], 0, + yCol[0], yCol[1], yCol[2], 0, normal[0], normal[1], normal[2], 0, txCol[0], txCol[1], txCol[2], 1, - ], 6); + ]); const styleText = `transform:matrix3d(${canonicalMatrix});` + bakedColor + dynamicVars; @@ -1877,30 +1865,17 @@ function paintSolidAtlasEntry( textureLighting: PolyTextureLightingMode, atlasScale: number, ): void { - // Dynamic mode multiplies the tint at render time via background-blend-mode, - // so the atlas keeps the polygon's unshaded base color. - const paintColor = textureLighting === "dynamic" - ? (entry.polygon.color ?? "#cccccc") - : entry.shadedColor; - - ctx.save(); setCssTransform(ctx, atlasScale); ctx.beginPath(); tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); ctx.clip(); - ctx.fillStyle = paintColor; - ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); - ctx.restore(); - - ctx.save(); setCssTransform(ctx, atlasScale); - ctx.beginPath(); - tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); - ctx.strokeStyle = paintColor; - ctx.lineWidth = SOLID_ATLAS_EDGE_BLEED * 2; - ctx.lineJoin = "round"; - ctx.stroke(); - ctx.restore(); + // Dynamic mode multiplies the tint at render time via background-blend-mode, + // so the atlas keeps the polygon's unshaded base color. + ctx.fillStyle = textureLighting === "dynamic" + ? (entry.polygon.color ?? "#cccccc") + : entry.shadedColor; + ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); } function clampSourceCoord(value: number, max: number): number { @@ -2186,7 +2161,9 @@ async function buildAtlasPage( for (const entry of page.entries) { const srcImg = entry.texture ? loaded.get(entry.texture) : null; if (!entry.texture) { + ctx.save(); paintSolidAtlasEntry(ctx, entry, textureLighting, atlasScale); + ctx.restore(); continue; } @@ -2250,10 +2227,8 @@ function applyAtlasBackground( const url = `url(${page.url})`; const width = entry.canvasW || 1; const height = entry.canvasH || 1; - const scaleX = ATLAS_SLICE_CANONICAL_SIZE / width; - const scaleY = ATLAS_SLICE_CANONICAL_SIZE / height; - const pos = `${formatCssLength(-entry.x * scaleX)} ${formatCssLength(-entry.y * scaleY)}`; - const size = `${formatCssLength(page.width * scaleX)} ${formatCssLength(page.height * scaleY)}`; + const pos = `${formatCssLength(-entry.x / width)} ${formatCssLength(-entry.y / height)}`; + const size = `${formatCssLength(page.width / width)} ${formatCssLength(page.height / height)}`; if (textureLighting === "dynamic") { setInlineStyleProperty(el, "background-image", url); setInlineStyleProperty(el, "background-position", pos); @@ -2312,14 +2287,6 @@ function formatPlanElementStyle( return `transform:matrix3d(${entry.canonicalMatrix})${shape}`; } -function formatQuadMatrix(entry: TextureAtlasPlan): string { - return formatScaledMatrixFromPlan( - entry, - entry.canvasW / QUAD_CANONICAL_SIZE, - entry.canvasH / QUAD_CANONICAL_SIZE, - ); -} - function formatScaledMatrixFromPlan( entry: TextureAtlasPlan, scaleX: number, @@ -2433,14 +2400,8 @@ function stableMatrixFromPlan( return { normal, matrix: formatMatrix3dValues([ - xAxis[0] * (source.canvasW || 1) / ATLAS_SLICE_CANONICAL_SIZE, - xAxis[1] * (source.canvasW || 1) / ATLAS_SLICE_CANONICAL_SIZE, - xAxis[2] * (source.canvasW || 1) / ATLAS_SLICE_CANONICAL_SIZE, - 0, - yAxis[0] * (source.canvasH || 1) / ATLAS_SLICE_CANONICAL_SIZE, - yAxis[1] * (source.canvasH || 1) / ATLAS_SLICE_CANONICAL_SIZE, - yAxis[2] * (source.canvasH || 1) / ATLAS_SLICE_CANONICAL_SIZE, - 0, + xAxis[0], xAxis[1], xAxis[2], 0, + yAxis[0], yAxis[1], yAxis[2], 0, normal[0], normal[1], normal[2], 0, tx, ty, tz, 1, ]), @@ -2762,8 +2723,7 @@ function createSolidElement( solidPaintDefaults?: SolidPaintDefaults, ): HTMLElement { const el = doc.createElement("b"); - el.setAttribute("style", `transform:matrix3d(${formatQuadMatrix(entry)})`); - applyPolygonDataAttrs(el, entry.polygon); + applyPlanElementBase(el, entry); applySolidPaint(el, entry, textureLighting, solidPaintDefaults); return el; @@ -2806,11 +2766,7 @@ function createAtlasElement( applyPlanElementBase(el, entry); const width = entry.canvasW || 1; const height = entry.canvasH || 1; - setInlineStyleProperty( - el, - "background-position", - `${formatCssLength(-entry.x * ATLAS_SLICE_CANONICAL_SIZE / width)} ${formatCssLength(-entry.y * ATLAS_SLICE_CANONICAL_SIZE / height)}`, - ); + setInlineStyleProperty(el, "background-position", `${formatCssLength(-entry.x / width)} ${formatCssLength(-entry.y / height)}`); setInlineStyleProperty(el, "opacity", "0"); if (textureLighting === "dynamic") applyDynamicNormalVars(el, entry); diff --git a/packages/polycss/src/styles/styles.ts b/packages/polycss/src/styles/styles.ts index df62a390..cb05566b 100644 --- a/packages/polycss/src/styles/styles.ts +++ b/packages/polycss/src/styles/styles.ts @@ -41,14 +41,28 @@ const CORE_BASE_STYLES = ` will-change: transform; } +/* ── First-person controls perspective context ──────────────────────────── */ + +/* PolyFirstPersonControls toggles this class on its host element (vanilla: + scene.host; react/vue: the camera wrapper). FPV needs a real perspective + context so scene Z translation produces visible depth motion - without + it, walking forward looks like a planar pan. The class wins over inline + perspective styles (e.g. PolyOrthographicCamera's perspective: none) + via !important. The actual perspective value is set inline by the + controls as the --polycss-fpv-perspective custom property; the default + of 2000px matches the controls' lookOffset fallback so the FPV math and + visual perspective stay in sync. */ +.polycss-fpv-host { + perspective: var(--polycss-fpv-perspective, 2000px) !important; + transform-style: preserve-3d !important; +} + /* ── Mesh wrapper ───────────────────────────────────────────────────────── */ .polycss-mesh { position: absolute; transform-style: preserve-3d; transform-origin: var(--origin); - -webkit-user-select: none; - user-select: none; } /* ── Polygon leaf element ───────────────────────────────────────────────── */ @@ -70,8 +84,6 @@ const CORE_BASE_STYLES = ` text-decoration: none; backface-visibility: hidden; background-repeat: no-repeat; - -webkit-user-select: none; - user-select: none; } .polycss-scene b, @@ -82,19 +94,19 @@ const CORE_BASE_STYLES = ` .polycss-scene b { background: currentColor; - width: 64px; - height: 64px; + width: 1px; + height: 1px; } .polycss-scene i { - width: 64px; - height: 64px; + width: 16px; + height: 16px; border-color: currentColor; } .polycss-scene s { - width: 128px; - height: 128px; + width: 1px; + height: 1px; } .polycss-scene u { @@ -104,7 +116,7 @@ const CORE_BASE_STYLES = ` box-sizing: content-box; border: 0 solid transparent; border-color: transparent transparent currentColor transparent; - border-width: 0 64px 64px 64px; + border-width: 0 1px 1px 1px; } /* — dedicated shadow leaf. Same border-shape rendering trick as @@ -132,8 +144,6 @@ const CORE_BASE_STYLES = ` border-color: currentColor; pointer-events: none; will-change: transform; - -webkit-user-select: none; - user-select: none; } .polycss-scene q::before, .polycss-scene q::after { @@ -158,6 +168,33 @@ const CORE_BASE_STYLES = ` transition: color 150ms ease-out, border-color 150ms ease-out, background-color 150ms ease-out; } +/* + * Rotate rings are rendered as a single square quad per ring, then masked + * to a donut via a radial-gradient. The --ring-inner-ratio CSS var is set + * inline by createTransformControls (= innerR / outerR, where outerR maps + * to the quad's edge at 50%). Hit-testing has to use the donut shape too. + * Single DOM node per ring instead of N segment quads. + */ +.polycss-mesh.polycss-transform-ring i, +.polycss-mesh.polycss-transform-ring b, +.polycss-mesh.polycss-transform-ring s, +.polycss-mesh.polycss-transform-ring u { + --ring-inner-r: calc(var(--ring-inner-ratio, 0.92) * 50%); + --ring-outer-r: calc(var(--ring-outer-ratio, 1) * 50%); + -webkit-mask: radial-gradient(circle at 50% 50%, + transparent 0%, + transparent var(--ring-inner-r), + black var(--ring-inner-r), + black var(--ring-outer-r), + transparent var(--ring-outer-r)); + mask: radial-gradient(circle at 50% 50%, + transparent 0%, + transparent var(--ring-inner-r), + black var(--ring-inner-r), + black var(--ring-outer-r), + transparent var(--ring-outer-r)); +} + /* ── Dynamic lighting cascade vars (scene root → polygons) ─────────────── */ /* diff --git a/packages/react/src/controls/PolyFirstPersonControls.tsx b/packages/react/src/controls/PolyFirstPersonControls.tsx index fe639da0..6d28b4dc 100644 --- a/packages/react/src/controls/PolyFirstPersonControls.tsx +++ b/packages/react/src/controls/PolyFirstPersonControls.tsx @@ -453,6 +453,19 @@ export const PolyFirstPersonControls = forwardRef< // Apply initial cursor host.style.cursor = opts.lookEnabled ? "crosshair" : ""; + // FPV needs a perspective context on the host so scene Z motion shows + // as depth, not as a planar pan. Read the current effective perspective + // BEFORE adding the class so we honor any value the camera component + // set (PolyPerspectiveCamera's inline `perspective: Npx`); fall back to + // 2000px for orthographic (`perspective: none`) so the FPV math and + // visual perspective stay in sync. The `.polycss-fpv-host` class uses + // `!important` (see styles.ts) to override inline `perspective: none`. + const computedPersp = win.getComputedStyle(host).perspective; + const persp = parseFloat(computedPersp); + const effectivePersp = Number.isFinite(persp) && persp > 0 ? persp : 2000; + host.style.setProperty("--polycss-fpv-perspective", `${effectivePersp}px`); + host.classList.add("polycss-fpv-host"); + // ── Pointer-lock ────────────────────────────────────────────────────────── const onHostClick = (): void => { const o = optsRef.current; @@ -556,6 +569,8 @@ export const PolyFirstPersonControls = forwardRef< win.removeEventListener("keyup", onKeyUp); win.removeEventListener("blur", onBlur); host.style.cursor = ""; + host.classList.remove("polycss-fpv-host"); + host.style.removeProperty("--polycss-fpv-perspective"); keysHeldRef.current.clear(); if (pointerLockedRef.current) { try { doc.exitPointerLock(); } catch { /* ignore */ } diff --git a/packages/react/src/controls/TransformControls.test.tsx b/packages/react/src/controls/TransformControls.test.tsx index 90f0bd75..f3f48438 100644 --- a/packages/react/src/controls/TransformControls.test.tsx +++ b/packages/react/src/controls/TransformControls.test.tsx @@ -428,12 +428,24 @@ describe("", () => { ); const yRing = container.querySelector('.polycss-transform-ring--y') as HTMLElement; expect(yRing).not.toBeNull(); + // The ring is a single quad masked to a donut via CSS; the JS hit-test + // (pointInRingMeshElement) rejects clicks at the bbox center. Patch the + // leaf rect so the click at (100, 0) lands at the right edge of the + // bbox — that maps to normalized distance 1 from center, inside the + // donut band (innerRatio < 1). + const leaf = yRing.querySelector("i,b,s,u") as HTMLElement; + const origLeafRect = leaf.getBoundingClientRect.bind(leaf); + leaf.getBoundingClientRect = () => ({ + left: 0, top: -50, right: 100, bottom: 50, width: 100, height: 100, x: 0, y: -50, + toJSON() { return this; }, + } as DOMRect); // Trigger pointerdown via the PolyMesh synthetic event path act(() => { yRing.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, clientX: 100, clientY: 0, pointerId: 1 }), ); }); + leaf.getBoundingClientRect = origLeafRect; expect(onMouseDown).toHaveBeenCalledOnce(); // Move pointer to accumulate angle change act(() => { @@ -636,6 +648,14 @@ describe("", () => { , ); const xRing = container.querySelector('.polycss-transform-ring--x') as HTMLElement; + // Patch the leaf bbox so the click at (100, 0) is at the right edge of + // the bbox — passes the donut-shaped hit-test (clicks at the bbox + // center would land in the inner hole and be rejected). + const leaf = xRing.querySelector("i,b,s,u") as HTMLElement; + leaf.getBoundingClientRect = () => ({ + left: 0, top: -50, right: 100, bottom: 50, width: 100, height: 100, x: 0, y: -50, + toJSON() { return this; }, + } as DOMRect); act(() => { xRing.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, clientX: 100, clientY: 0, pointerId: 1 }), @@ -666,6 +686,12 @@ describe("", () => { const rebakeSpy = vi.spyOn(handle, "rebakeAtlas"); const xRing = container.querySelector('.polycss-transform-ring--x') as HTMLElement; + // Patch the leaf bbox so click at (100, 0) hits the donut band. + const leaf = xRing.querySelector("i,b,s,u") as HTMLElement; + leaf.getBoundingClientRect = () => ({ + left: 0, top: -50, right: 100, bottom: 50, width: 100, height: 100, x: 0, y: -50, + toJSON() { return this; }, + } as DOMRect); act(() => { xRing.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, clientX: 100, clientY: 0, pointerId: 1 }), diff --git a/packages/react/src/controls/TransformControls.tsx b/packages/react/src/controls/TransformControls.tsx index 8e69fd1e..de7ea71d 100644 --- a/packages/react/src/controls/TransformControls.tsx +++ b/packages/react/src/controls/TransformControls.tsx @@ -34,10 +34,27 @@ import { type CSSProperties, type RefObject, } from "react"; -import { arrowPolygons, ringPolygons, type Polygon, type Vec3 } from "@layoutit/polycss-core"; +import { + arrowPolygons, + DEFAULT_CAMERA_STATE, + eulerXYZFromQuat, + planePolygons, + quatFromAxisAngle, + quatFromEulerXYZ, + quatMultiply, + ringQuadPolygons, + type Polygon, + type Vec3, +} from "@layoutit/polycss-core"; import { PolyMesh } from "../scene/PolyMesh"; import { pointInMeshElement, type PolyMeshHandle, type PolyPointerEvent } from "../scene/events"; import { PolyCameraContext } from "../camera/context"; +import { createSceneStore, useStoreSelector } from "../store/sceneStore"; + +// Stable no-op store used as a hook-rule-compliant fallback when +// PolyTransformControls is rendered outside a PolyCamera. We always pass a +// store to useStoreSelector; this one never changes, so it never re-renders. +const FALLBACK_CAMERA_STORE = createSceneStore(DEFAULT_CAMERA_STATE); // Three.js convention: X red, Y green, Z blue. Kept identical so muscle // memory carries over. @@ -93,8 +110,20 @@ const HEAD_HALF_THICKNESS_RATIO = 0.04; // → 8% full // rotate gizmos look the same scale; thickness is similar to a shaft // so each ring reads as a thin band, not a disc. const RING_RADIUS_RATIO = 1.0; -const RING_HALF_THICKNESS_RATIO = 0.012; -const RING_SEGMENTS = 64; +// Visible band half-width relative to mid-radius. Drives ONLY the CSS mask; +// the click target (quad bbox) is sized by RING_QUAD_OUTER_RATIO so we can +// show a thin ring without shrinking the hit footprint. +const RING_HALF_THICKNESS_RATIO = 0.02; +// Outer radius of the ring's quad polygon as a multiple of mid-radius. The +// quad's bbox IS the click target. 1.04 leaves a 2% margin past the visible +// ring's outer edge while keeping the prior hit footprint. +const RING_QUAD_OUTER_RATIO = 1.04; + +// Plane handle proportions (translate-mode planar drag). Small square at +// the corner between two axis arrows — sits inside the arrow tips so it +// doesn't compete with single-axis hits on the shaft. +const PLANE_HALF_SIZE_RATIO = 0.1; +const PLANE_OFFSET_RATIO = 0.25; // Squared length (in screen-px-per-scene-px) below which the axis is // considered edge-on — its on-screen projection is too short for stable @@ -262,6 +291,47 @@ function userAxisLetterOf(key: string): "x" | "y" | "z" { return last as "x" | "y" | "z"; } + +/** True when the signed CSS-space axis points AWAY from the viewer after + * the scene's rotateZ(rotY) · rotateX(rotX) transform. Used to drop the + * shaft on back-facing translate arrows so the gizmo silhouette stays + * clean — both halves of a pair otherwise share a shaft volume at the + * gizmo origin and overdraw. */ +function isAxisBackFacing( + cssAxis: 0 | 1 | 2, + sign: 1 | -1, + rotXDeg: number, + rotYDeg: number, +): boolean { + const rx = (rotXDeg * Math.PI) / 180; + const ry = (rotYDeg * Math.PI) / 180; + const a: [number, number, number] = [0, 0, 0]; + a[cssAxis] = sign; + const bx = a[0] * Math.cos(ry) - a[1] * Math.sin(ry); + const by = a[0] * Math.sin(ry) + a[1] * Math.cos(ry); + const bz = a[2]; + const cz = by * Math.sin(rx) + bz * Math.cos(rx); + void bx; + return cz < 0; +} + +/** Three plane specs (translate mode — planar drag). `perpAxis` is the + * axis perpendicular to the plane (the one the drag does NOT move along); + * `axisA` and `axisB` are the two CSS axes the drag DOES update. */ +// Plane color = perpendicular axis color: XY plane → blue (Z), XZ → green +// (Y), YZ → red (X). "The axis you can't drag along is this color." +const PLANE_SPECS: Array<{ + perpAxis: 0 | 1 | 2; + axisA: 0 | 1 | 2; + axisB: 0 | 1 | 2; + key: "xy" | "xz" | "yz"; + color: string; +}> = [ + { perpAxis: 2, axisA: 0, axisB: 1, key: "xy", color: COLOR_Z }, + { perpAxis: 1, axisA: 0, axisB: 2, key: "xz", color: COLOR_Y }, + { perpAxis: 0, axisA: 1, axisB: 2, key: "yz", color: COLOR_X }, +]; + interface DragOptions { /** CSS axis index this arrow drives (0=x, 1=y, 2=z). The probe is * placed along this CSS direction; pointer-px deltas project onto @@ -367,6 +437,109 @@ function startAxisDrag(opts: DragOptions): void { window.addEventListener("pointercancel", handleUp); } +interface PlaneDragOptions { + axisA: 0 | 1 | 2; + axisB: 0 | 1 | 2; + /** Distance (CSS px) the probe is offset along each in-plane axis. Uses + * the same scale as the arrow's shaftLengthCss so screen projection is + * proportional to one axis-unit of mesh translation. */ + probeDistanceCss: number; + wrapper: HTMLElement; + target: PolyMeshHandle; + startClientX: number; + startClientY: number; + translationSnap: number | null; + onChange?: () => void; + onObjectChange?: (event: PolyTransformControlsObjectChangeEvent) => void; + onMouseDown?: () => void; + onMouseUp?: () => void; + onDraggingChanged?: (dragging: boolean) => void; +} + +/** Project pointer screen-px deltas onto the screen projections of TWO + * world axes (the plane's basis), solve a 2x2 system, and update the + * mesh position along both axes. Same probe trick as `startAxisDrag`, + * extended to two basis vectors. */ +function startPlaneDrag(opts: PlaneDragOptions): void { + const { + axisA, + axisB, + probeDistanceCss, + wrapper, + target, + startClientX, + startClientY, + translationSnap, + onChange, + onObjectChange, + onMouseDown, + onMouseUp, + onDraggingChanged, + } = opts; + + const axisAVec: Vec3 = [0, 0, 0]; axisAVec[axisA] = 1; + const axisBVec: Vec3 = [0, 0, 0]; axisBVec[axisB] = 1; + function probe(axisVec: Vec3): { x: number; y: number } { + const el = wrapper.ownerDocument!.createElement("div"); + el.style.position = "absolute"; + el.style.left = "0"; + el.style.top = "0"; + el.style.width = "0"; + el.style.height = "0"; + el.style.transform = `translate3d(${axisVec[0] * probeDistanceCss}px, ${axisVec[1] * probeDistanceCss}px, ${axisVec[2] * probeDistanceCss}px)`; + wrapper.appendChild(el); + const wR = wrapper.getBoundingClientRect(); + const pR = el.getBoundingClientRect(); + wrapper.removeChild(el); + return { + x: (pR.left - wR.left) / probeDistanceCss, + y: (pR.top - wR.top) / probeDistanceCss, + }; + } + const pA = probe(axisAVec); + const pB = probe(axisBVec); + const det = pA.x * pB.y - pB.x * pA.y; + if (Math.abs(det) < SCREEN_AXIS_DEAD_ZONE_SQ) return; + + const startPos = target.getPosition() ?? ([0, 0, 0] as Vec3); + onMouseDown?.(); + onDraggingChanged?.(true); + + const handleMove = (ev: PointerEvent): void => { + const dx = ev.clientX - startClientX; + const dy = ev.clientY - startClientY; + let tA = (pB.y * dx - pB.x * dy) / det; + let tB = (-pA.y * dx + pA.x * dy) / det; + if (translationSnap !== null) { + tA = Math.round(tA / translationSnap) * translationSnap; + tB = Math.round(tB / translationSnap) * translationSnap; + } + const next: Vec3 = [ + startPos[0] + tA * axisAVec[0] + tB * axisBVec[0], + startPos[1] + tA * axisAVec[1] + tB * axisBVec[1], + startPos[2] + tA * axisAVec[2] + tB * axisBVec[2], + ]; + onObjectChange?.({ object: target, position: next }); + onChange?.(); + }; + const handleUp = (): void => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + window.removeEventListener("pointercancel", handleUp); + const swallow = (e: Event): void => { + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + window.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => window.removeEventListener("click", swallow, true), 0); + onMouseUp?.(); + onDraggingChanged?.(false); + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + window.addEventListener("pointercancel", handleUp); +} + interface RingDragOptions { /** CSS axis the ring rotates around (0=x, 1=y, 2=z). Maps directly * to PolyMesh's `rotation[cssAxis]` slot (rotateX/rotateY/rotateZ). */ @@ -420,6 +593,14 @@ function startRingDrag(opts: RingDragOptions): void { onMouseDown?.(); onDraggingChanged?.(true); + // World-frame quaternion compose. The gizmo's rings stay at fixed world + // axes (the wrapper isn't rotated with the mesh), so the user clicks a + // ring expecting rotation around the WORLD axis they see. We pre-multiply: + // Qnew = Qdelta · Qstart + // → rotation applies in the WORLD frame, then the prior orientation. Each + // ring drag composes cumulatively on top of the mesh's current orientation + // without resetting it — which Euler-add couldn't do for repeated axes. + const qStart = quatFromEulerXYZ(startRotation); const handleMove = (ev: PointerEvent): void => { const a = Math.atan2(ev.clientY - centerY, ev.clientX - centerX); let d = a - lastAngle; @@ -430,12 +611,10 @@ function startRingDrag(opts: RingDragOptions): void { lastAngle = a; let degrees = (cumulative * 180) / Math.PI; degrees = snap(degrees, rotationSnap); - const newRotation: Vec3 = [ - startRotation[0], - startRotation[1], - startRotation[2], - ]; - newRotation[cssAxis] = startRotation[cssAxis] + degrees; + const axisVec: Vec3 = [0, 0, 0]; + axisVec[cssAxis] = 1; + const qDelta = quatFromAxisAngle(axisVec, (degrees * Math.PI) / 180); + const newRotation = eulerXYZFromQuat(quatMultiply(qDelta, qStart)); onObjectChange?.({ object: target, rotation: newRotation }); onChange?.(); }; @@ -491,6 +670,23 @@ export function PolyTransformControls({ forceRender((n) => n + 1); }, [object]); + // Camera rotation, reactively. Used to compute which translate arrows are + // pointing AWAY from the viewer so we can render them as head-only. The + // store subscription means the gizmo geometry re-evaluates whenever the + // user orbits the camera — no stale back/front state. + const cameraCtxForRot = useContext(PolyCameraContext); + // Two primitive selectors — returning an object literal each call would + // make useSyncExternalStore see a "changed" snapshot every render and + // trigger an infinite re-render loop. + const rotX = useStoreSelector( + cameraCtxForRot?.store ?? FALLBACK_CAMERA_STORE, + (s) => s.cameraState.rotX, + ); + const rotY = useStoreSelector( + cameraCtxForRot?.store ?? FALLBACK_CAMERA_STORE, + (s) => s.cameraState.rotY, + ); + // Per-arrow hover + dragging state. Lifted here so both the React // PolyMesh.onPointerDown handler and the cameraEl JS hit-test // fallback can keep them in sync. Each render re-evaluates which @@ -553,6 +749,44 @@ export function PolyTransformControls({ // on both translate arrows and rotate rings. if (targetEl?.closest(".polycss-transform-gizmo")) return; if (state.mode === "translate") { + // Plane handles hit-tested FIRST so they win over arrow shafts at + // overlapping corners. + for (const spec of PLANE_SPECS) { + const aL = (["x", "y", "z"] as const)[spec.axisA]; + const bL = (["x", "y", "z"] as const)[spec.axisB]; + if (!state.show[aL] || !state.show[bL]) continue; + const planeEl = document.querySelector( + `.polycss-transform-plane--${spec.key}`, + ) as HTMLElement | null; + if (!planeEl) continue; + if (!pointInMeshElement(planeEl, event.clientX, event.clientY)) continue; + event.preventDefault(); + event.stopPropagation(); + const wrapper = planeEl.closest( + "[data-poly-transform-controls]", + ) as HTMLElement | null; + if (!wrapper) return; + setDraggingKey(spec.key); + startPlaneDrag({ + axisA: spec.axisA, + axisB: spec.axisB, + probeDistanceCss: state.shaftLengthCss, + wrapper, + target: state.target, + startClientX: event.clientX, + startClientY: event.clientY, + translationSnap: state.translationSnap, + onChange: state.onChange, + onObjectChange: state.onObjectChange, + onMouseDown: state.onMouseDown, + onMouseUp: state.onMouseUp, + onDraggingChanged: (d) => { + if (!d) setDraggingKey(null); + state.onDraggingChanged?.(d); + }, + }); + return; + } for (const spec of ARROW_SPECS) { if (!state.show[userAxisLetterOf(spec.key)]) continue; const arrowEl = document.querySelector( @@ -594,6 +828,11 @@ export function PolyTransformControls({ `.polycss-transform-ring--${spec.key}`, ) as HTMLElement | null; if (!ringEl) continue; + // Regular bbox-containment hit. The visible donut mask is only + // decoration — the WHOLE ring quad bbox is clickable so the rings + // are easy to land on. Clicks inside the inner hole also trigger + // rotation; selecting the wrapped mesh is done via clicks outside + // any ring's bbox (or via the Scene panel). if (!pointInMeshElement(ringEl, event.clientX, event.clientY)) continue; event.preventDefault(); event.stopPropagation(); @@ -632,10 +871,10 @@ export function PolyTransformControls({ const position = target.getPosition() ?? ([0, 0, 0] as Vec3); const polygons = target.getPolygons(); const bboxCenter = gizmoCenterForMesh(polygons); - // Wrapper sits at mesh's visual center: position + bboxCenter. When the mesh - // recenters its own vertices (PolyMesh.autoCenter), bboxCenter is (0,0,0) - // and this collapses to the previous behavior. When PolyScene does the - // centering instead (vertices at native positions), bboxCenter compensates. + // Mesh wrapper pivots around `bboxCenter` via `transform-origin`, so the + // visible center stays at `position + bboxCenter` regardless of scale or + // rotation. The gizmo wrapper sits on the same point. When `autoCenter` is + // set on PolyMesh, bboxCenter collapses to (0,0,0) and this is a no-op. const wrapperPos: Vec3 = [ position[0] + bboxCenter[0], position[1] + bboxCenter[1], @@ -659,6 +898,10 @@ export function PolyTransformControls({ onDraggingChanged, }; + // Gizmo stays at world-axis orientation (NOT rotated with the mesh). This + // keeps the arrows in fixed screen positions so panning + repeated drags + // stay predictable. The MATH still composes rotations correctly — see + // `startRingDrag` for the world-frame quaternion compose. const wrapperStyle: CSSProperties = { transform: `translate3d(${wrapperPos[0]}px, ${wrapperPos[1]}px, ${wrapperPos[2]}px)`, position: "absolute", @@ -685,6 +928,7 @@ export function PolyTransformControls({ const hovered = hoveredKey === spec.key; const dragging = draggingKey === spec.key; const alpha = dragging ? ALPHA_DRAGGING : hovered ? ALPHA_HOVER : ALPHA_IDLE; + const backFacing = isAxisBackFacing(spec.cssAxis, spec.sign, rotX, rotY); return ( setHoveredKey(h ? spec.key : (cur) => (cur === spec.key ? null : cur))} + onDraggingStart={() => setDraggingKey(spec.key)} + onDraggingStop={() => setDraggingKey((cur) => (cur === spec.key ? null : cur))} + /> + ); + })} + {mode === "translate" && PLANE_SPECS.map((spec) => { + const aLetter = (["x", "y", "z"] as const)[spec.axisA]; + const bLetter = (["x", "y", "z"] as const)[spec.axisB]; + const show = ({ x: showX, y: showY, z: showZ }[aLetter]) + && ({ x: showX, y: showY, z: showZ }[bLetter]); + if (!show) return null; + const hovered = hoveredKey === spec.key; + const dragging = draggingKey === spec.key; + const alpha = dragging ? ALPHA_DRAGGING : hovered ? ALPHA_HOVER : ALPHA_IDLE; + return ( + ): void => { @@ -884,6 +1168,124 @@ function TranslateArrow({ ); } +interface TranslatePlaneProps { + axisA: 0 | 1 | 2; + axisB: 0 | 1 | 2; + perpAxis: 0 | 1 | 2; + planeKey: "xy" | "xz" | "yz"; + color: string; + shaftLengthCss: number; + /** Scene rotation (degrees) used to pick the camera-facing octant for + * the plane handle. */ + rotX: number; + rotY: number; + target: PolyMeshHandle; + enabled: boolean; + translationSnap: number | null; + onChange?: () => void; + onObjectChange?: (event: PolyTransformControlsObjectChangeEvent) => void; + onMouseDown?: () => void; + onMouseUp?: () => void; + onDraggingChanged?: (dragging: boolean) => void; + onHoverChange?: (hovered: boolean) => void; + onDraggingStart?: () => void; + onDraggingStop?: () => void; +} + +function TranslatePlane({ + axisA, + axisB, + perpAxis, + planeKey, + color, + shaftLengthCss, + rotX, + rotY, + target, + enabled, + translationSnap, + onChange, + onObjectChange, + onMouseDown, + onMouseUp, + onDraggingChanged, + onHoverChange, + onDraggingStart, + onDraggingStop, +}: TranslatePlaneProps) { + const cbRef = useRef({ + onChange, onObjectChange, onMouseDown, onMouseUp, + onDraggingChanged, onDraggingStart, onDraggingStop, enabled, translationSnap, + }); + cbRef.current = { + onChange, onObjectChange, onMouseDown, onMouseUp, + onDraggingChanged, onDraggingStart, onDraggingStop, enabled, translationSnap, + }; + + const polygons = useMemo(() => { + const lengthWorld = shaftLengthCss / SCENE_TILE_SIZE; + // Place the quad in the camera-facing octant: for each in-plane axis, + // flip the offset if its CSS +direction is back-facing the viewer. + // planePolygons uses WORLD axes (a/b derived from perp); WORLD_AXIS_FOR_CSS + // is involutive so the CSS axis we test is WORLD_AXIS_FOR_CSS[worldA]. + const worldPerp = WORLD_AXIS_FOR_CSS[perpAxis]; + const worldA = ((worldPerp + 1) % 3) as 0 | 1 | 2; + const worldB = ((worldPerp + 2) % 3) as 0 | 1 | 2; + const cssAForOffset = WORLD_AXIS_FOR_CSS[worldA]; + const cssBForOffset = WORLD_AXIS_FOR_CSS[worldB]; + const signA = isAxisBackFacing(cssAForOffset, 1, rotX, rotY) ? -1 : 1; + const signB = isAxisBackFacing(cssBForOffset, 1, rotX, rotY) ? -1 : 1; + const mag = lengthWorld * PLANE_OFFSET_RATIO; + return planePolygons({ + axis: worldPerp, + size: lengthWorld * PLANE_HALF_SIZE_RATIO, + offset: [signA * mag, signB * mag], + color, + }); + }, [perpAxis, color, shaftLengthCss, rotX, rotY]); + + const onPointerDown = useCallback( + (e: PolyPointerEvent): void => { + if (!cbRef.current.enabled) return; + e.stopPropagation(); + const meshEl = e.eventObject.element; + const wrapper = meshEl?.closest("[data-poly-transform-controls]") as HTMLElement | null; + if (!wrapper) return; + cbRef.current.onDraggingStart?.(); + startPlaneDrag({ + axisA, + axisB, + probeDistanceCss: shaftLengthCss, + wrapper, + target, + startClientX: e.nativeEvent.clientX, + startClientY: e.nativeEvent.clientY, + translationSnap: cbRef.current.translationSnap, + onChange: cbRef.current.onChange, + onObjectChange: cbRef.current.onObjectChange, + onMouseDown: cbRef.current.onMouseDown, + onMouseUp: cbRef.current.onMouseUp, + onDraggingChanged: (d) => { + if (!d) cbRef.current.onDraggingStop?.(); + cbRef.current.onDraggingChanged?.(d); + }, + }); + }, + [axisA, axisB, target, shaftLengthCss], + ); + + return ( + onHoverChange?.(true)} + onPointerOut={() => onHoverChange?.(false)} + className={`polycss-transform-gizmo polycss-transform-plane polycss-transform-plane--${planeKey}`} + textureLighting="baked" + /> + ); +} + interface RotateRingProps { cssAxis: 0 | 1 | 2; axisKey: string; @@ -942,25 +1344,32 @@ function RotateRing({ rotationSnap, }; - // Build the ring polygons. Vertex coords are in WORLD units; convert - // CSS-axis (the user-facing rotation axis) to the WORLD axis the - // ring is perpendicular to via the same WORLD_AXIS_FOR_CSS lookup - // the arrows use. Radius lives in CSS-px scene space (matches arrow - // shaft length), so divide by tileSize to feed `ringPolygons`. + // Single square quad covering the ring's outer bounding box; CSS mask + // (`mask: radial-gradient(...)`) clips it to the donut shape. The mask + // reads `--ring-inner-ratio` so the donut's inner cutout scales with our + // chosen RING_HALF_THICKNESS_RATIO without hardcoding it in CSS. One DOM + // node per ring instead of `segments` segment quads. const polygons = useMemo(() => { const radiusWorld = radiusCss / SCENE_TILE_SIZE; - return ringPolygons({ + const outerWorld = radiusWorld * RING_QUAD_OUTER_RATIO; + return ringQuadPolygons({ axis: WORLD_AXIS_FOR_CSS[cssAxis], - radius: radiusWorld, - halfThickness: radiusWorld * RING_HALF_THICKNESS_RATIO, - segments: RING_SEGMENTS, + outerRadius: outerWorld, color, }); }, [cssAxis, color, radiusCss]); + // Visible band start/end as fractions of the quad edge. The quad covers + // ±RING_QUAD_OUTER_RATIO · mid-radius; the visible ring is mid ± + // halfThickness. Normalize against the quad outer to get mask positions. + const ringInnerRatio = (1 - RING_HALF_THICKNESS_RATIO) / RING_QUAD_OUTER_RATIO; + const ringOuterRatio = (1 + RING_HALF_THICKNESS_RATIO) / RING_QUAD_OUTER_RATIO; const onPointerDown = useCallback( (e: PolyPointerEvent): void => { if (!cbRef.current.enabled) return; + // No donut hit-test — let any click on the ring's quad bbox start + // the drag. The whole bbox is the click target so the rings are + // easy to land on; the visible donut mask is decoration only. e.stopPropagation(); const meshEl = e.eventObject.element; const wrapper = meshEl?.closest("[data-poly-transform-controls]") as HTMLElement | null; @@ -993,6 +1402,12 @@ function RotateRing({ onPointerOver={() => onHoverChange?.(true)} onPointerOut={() => onHoverChange?.(false)} className={`polycss-transform-gizmo polycss-transform-ring polycss-transform-ring--${axisKey}`} + // CSS variable consumed by the .polycss-transform-ring radial-gradient + // mask in styles.ts. Carries the donut's inner/outer radius ratio. + style={{ + ["--ring-inner-ratio" as string]: ringInnerRatio, + ["--ring-outer-ratio" as string]: ringOuterRatio, + }} // Same baked-mode reasoning as the translate arrows: avoid the // dynamic-mode atlas-rebuild flash on hover/drag color changes. textureLighting="baked" diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 408ada58..ec9990aa 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -22,10 +22,11 @@ export type { PolyCameraContextValue, } from "./camera"; -export { PolyScene, PolyMesh, usePolySceneContext, usePolyMesh, findPolyMeshHandle, pointInMeshElement, findMeshUnderPoint, usePolyMaterial } from "./scene"; +export { PolyScene, PolyMesh, PolyGround, usePolySceneContext, usePolyMesh, findPolyMeshHandle, pointInMeshElement, findMeshUnderPoint, usePolyMaterial } from "./scene"; export type { PolySceneProps, PolyMeshProps, + PolyGroundProps, UseSceneContextOptions, UseSceneContextResult, UseMeshResult, diff --git a/packages/react/src/scene/PolyGround.tsx b/packages/react/src/scene/PolyGround.tsx new file mode 100644 index 00000000..57c7c293 --- /dev/null +++ b/packages/react/src/scene/PolyGround.tsx @@ -0,0 +1,59 @@ +/** + * `` — a flat ground-plane quad that shadow-casting meshes can + * render their `` shadows onto. Pure convenience over ``: + * generates a 4-vertex polygon in the world XY plane at `z` and renders it + * with `castShadow: false` (the floor doesn't cast onto itself). + * + * + * + * + * + * + * Sized in WORLD units (1 unit ≈ 50 CSS px at the standard tile). The default + * 6-unit quad is sized to match a typical normalized-fit mesh footprint; + * callers that place multiple meshes typically widen it. + */ +import { useMemo } from "react"; +import type { Polygon, Vec3 } from "@layoutit/polycss-core"; +import { PolyMesh } from "./PolyMesh"; + +export interface PolyGroundProps { + /** Side length of the ground quad in world units. Default `6`. */ + size?: number; + /** World-space Z (floor height). Default `0`. */ + z?: number; + /** World-space XY center. Default `[0, 0]`. */ + center?: [number, number]; + /** Fill color. Default `#7d848e` — medium gray, chosen so the 25% black + * `` shadow leaves on top have visible contrast against it. */ + color?: string; + className?: string; +} + +export function PolyGround({ + size = 6, + z = 0, + center = [0, 0], + color = "#7d848e", + className, +}: PolyGroundProps) { + const polygons = useMemo(() => { + const half = size / 2; + const [cx, cy] = center; + const vertices: [Vec3, Vec3, Vec3, Vec3] = [ + [cx - half, cy - half, z], + [cx + half, cy - half, z], + [cx + half, cy + half, z], + [cx - half, cy + half, z], + ]; + return [{ vertices, color }]; + }, [size, z, center, color]); + + return ( + + ); +} diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 3ba13f11..8b669e01 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -235,6 +235,31 @@ export const PolyMesh = forwardRef(function PolyM const transform = buildTransform(position, scale, rotation); + // Pivot rotation + scale around the polygon bbox center, matching vanilla's + // `.polycss-mesh { transform-origin: var(--origin) }`. Without this the + // wrapper would pivot at its own (0,0,0) — which usually doesn't coincide + // with the visible mesh center, so rotateX/Y/Z would orbit the mesh around + // the asset's authoring origin and scale would push it sideways. World→CSS + // axis swap: world[1]→CSS x, world[0]→CSS y, world[2]→CSS z. + const transformOrigin = useMemo(() => { + if (polygons.length === 0) return undefined; + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (const poly of polygons) { + for (const v of poly.vertices) { + if (v[0] < minX) minX = v[0]; if (v[0] > maxX) maxX = v[0]; + if (v[1] < minY) minY = v[1]; if (v[1] > maxY) maxY = v[1]; + if (v[2] < minZ) minZ = v[2]; if (v[2] > maxZ) maxZ = v[2]; + } + } + if (!Number.isFinite(minX)) return undefined; + const tile = 50; + const x = ((minY + maxY) / 2) * tile; + const y = ((minX + maxX) / 2) * tile; + const z = ((minZ + maxZ) / 2) * tile; + return `${x}px ${y}px ${z}px`; + }, [polygons]); + // ── Imperative ref handle + DOM registry ────────────────────────────── // The handle is a stable object whose getters always read the latest // props. Refs keep getters cheap without rebuilding the handle on every @@ -578,6 +603,7 @@ export const PolyMesh = forwardRef(function PolyM const wrapperStyle: CSSProperties = { transform, + ...(transformOrigin ? { transformOrigin } : null), ...dynamicLightOverride, ...style, ...defaultPaintVars, diff --git a/packages/react/src/scene/index.ts b/packages/react/src/scene/index.ts index 5d541e98..366e3c25 100644 --- a/packages/react/src/scene/index.ts +++ b/packages/react/src/scene/index.ts @@ -3,6 +3,8 @@ export type { PolySceneProps } from "./PolyScene"; export type { PolyRenderStrategy, PolyRenderStrategiesOption } from "./textureAtlas"; export { PolyMesh } from "./PolyMesh"; export type { PolyMeshProps } from "./PolyMesh"; +export { PolyGround } from "./PolyGround"; +export type { PolyGroundProps } from "./PolyGround"; export { usePolySceneContext } from "./useSceneContext"; export type { UseSceneContextOptions, UseSceneContextResult } from "./useSceneContext"; export { usePolyMesh } from "./useMesh"; diff --git a/packages/react/src/scene/textureAtlas.tsx b/packages/react/src/scene/textureAtlas.tsx index b76254c2..d6a2b3b4 100644 --- a/packages/react/src/scene/textureAtlas.tsx +++ b/packages/react/src/scene/textureAtlas.tsx @@ -42,16 +42,12 @@ const DEFAULT_BORDER_SHAPE_DECIMALS = 2; const DEFAULT_ATLAS_CSS_DECIMALS = 4; const BORDER_SHAPE_CENTER_PERCENT = 50; const BORDER_SHAPE_POINT_EPS = 1e-7; -const BORDER_SHAPE_CANONICAL_SIZE = 64; -const QUAD_CANONICAL_SIZE = 64; -const ATLAS_SLICE_CANONICAL_SIZE = 128; -const SOLID_TRIANGLE_CANONICAL_SIZE = 64; +const BORDER_SHAPE_CANONICAL_SIZE = 16; const PROJECTIVE_QUAD_DENOM_EPS = 0.05; const PROJECTIVE_QUAD_MAX_WEIGHT_RATIO = 4; const PROJECTIVE_QUAD_BLEED = 0.6; const BASIS_EPS = 1e-9; -const SOLID_TRIANGLE_BLEED = 0.75; -const SOLID_ATLAS_EDGE_BLEED = 0.9; +const SOLID_TRIANGLE_BLEED = 0.6; export type TextureQuality = number | "auto"; @@ -581,14 +577,6 @@ function formatScaledMatrixFromPlan( return formatMatrix3dValues(values); } -function formatQuadMatrix(entry: TextureAtlasPlan): string { - return formatScaledMatrixFromPlan( - entry, - entry.canvasW / QUAD_CANONICAL_SIZE, - entry.canvasH / QUAD_CANONICAL_SIZE, - ); -} - function formatBorderShapeMatrix(entry: TextureAtlasPlan): string { return formatScaledMatrixFromPlan( entry, @@ -776,14 +764,13 @@ function computeProjectiveQuadMatrix( p3[1] * w3 - p0[1], p3[2] * w3 - p0[2], ]; - const sourceSize = QUAD_CANONICAL_SIZE; - return formatMatrix3dValues([ - xCol[0] / sourceSize, xCol[1] / sourceSize, xCol[2] / sourceSize, g / sourceSize, - yCol[0] / sourceSize, yCol[1] / sourceSize, yCol[2] / sourceSize, h / sourceSize, + return [ + xCol[0], xCol[1], xCol[2], g, + yCol[0], yCol[1], yCol[2], h, normal[0], normal[1], normal[2], 0, p0[0], p0[1], p0[2], 1, - ], 6); + ].join(","); } function cssPoints(vertices: Vec3[], tile: number, elev: number): Vec3[] { @@ -1021,13 +1008,12 @@ function solidTriangleStyle( baseLeft[1] - txCol[1], baseLeft[2] - txCol[2], ]; - const sourceSize = SOLID_TRIANGLE_CANONICAL_SIZE; const canonicalMatrix = formatMatrix3dValues([ - xCol[0] / sourceSize, xCol[1] / sourceSize, xCol[2] / sourceSize, 0, - yCol[0] / sourceSize, yCol[1] / sourceSize, yCol[2] / sourceSize, 0, + xCol[0], xCol[1], xCol[2], 0, + yCol[0], yCol[1], yCol[2], 0, normal[0], normal[1], normal[2], 0, txCol[0], txCol[1], txCol[2], 1, - ], 6); + ]); return { transform: `matrix3d(${canonicalMatrix})`, ...sharedStyle, @@ -1127,38 +1113,6 @@ function drawImageCover( ctx.drawImage(img, x + (width - drawW) / 2, y + (height - drawH) / 2, drawW, drawH); } -function paintSolidAtlasEntry( - ctx: CanvasRenderingContext2D, - entry: PackedTextureAtlasEntry, - textureLighting: PolyTextureLightingMode, - atlasScale: number, -): void { - // Dynamic mode multiplies the tint at render time via background-blend-mode, - // so the atlas keeps the polygon's unshaded base color. - const paintColor = textureLighting === "dynamic" - ? (entry.polygon.color ?? "#cccccc") - : entry.shadedColor; - - ctx.save(); - setCssTransform(ctx, atlasScale); - ctx.beginPath(); - tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); - ctx.clip(); - ctx.fillStyle = paintColor; - ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); - ctx.restore(); - - ctx.save(); - setCssTransform(ctx, atlasScale); - ctx.beginPath(); - tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); - ctx.strokeStyle = paintColor; - ctx.lineWidth = SOLID_ATLAS_EDGE_BLEED * 2; - ctx.lineJoin = "round"; - ctx.stroke(); - ctx.restore(); -} - function computeUvAffine(points: Vec2[], uvs: Vec2[]): UvAffine | null { if (points.length < 3 || uvs.length < 3) return null; const [p0, p1, p2] = points; @@ -1637,14 +1591,8 @@ export function computeTextureAtlasPlan( tx, ty, tz, 1, ].join(","); const canonicalMatrix = [ - xAxis[0] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - xAxis[1] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - xAxis[2] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - 0, - yAxis[0] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - yAxis[1] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - yAxis[2] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - 0, + xAxis[0] * canvasW, xAxis[1] * canvasW, xAxis[2] * canvasW, 0, + yAxis[0] * canvasH, yAxis[1] * canvasH, yAxis[2] * canvasH, 0, nx, ny, nz, 0, tx, ty, tz, 1, ].join(","); @@ -1836,7 +1784,19 @@ async function buildAtlasPage( for (const entry of page.entries) { const srcImg = entry.texture ? loaded.get(entry.texture) : null; if (!entry.texture) { - paintSolidAtlasEntry(ctx, entry, textureLighting, atlasScale); + ctx.save(); + setCssTransform(ctx, atlasScale); + ctx.beginPath(); + tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); + ctx.clip(); + // Dynamic mode multiplies the tint at render time via + // background-blend-mode, so the atlas keeps the polygon's unshaded + // base color. Baked bakes the JS-computed shadedColor. + ctx.fillStyle = textureLighting === "dynamic" + ? (entry.polygon.color ?? "#cccccc") + : entry.shadedColor; + ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); + ctx.restore(); continue; } @@ -1998,7 +1958,7 @@ export function TextureBorderShapePoly({ else el.style.removeProperty("border-shape"); orderBrushInlineStyle(el); }, [borderShape]); - const transform = formatMatrix3d(borderShape ? formatBorderShapeMatrix(entry) : formatQuadMatrix(entry)); + const transform = formatMatrix3d(borderShape ? formatBorderShapeMatrix(entry) : entry.canonicalMatrix); const style: CSSProperties = fullRect ? { transform, @@ -2067,7 +2027,7 @@ export function TextureProjectiveSolidPoly({ const base = parseHex(entry.polygon.color ?? "#cccccc"); const useDefaultDynamicColor = dynamic && rgbKey(base) === solidPaintDefaults?.dynamicColorKey; const style: CSSProperties = { - transform: formatMatrix3d(entry.projectiveMatrix, 6), + transform: formatMatrix3d(entry.projectiveMatrix), color: dynamic || entry.shadedColor === solidPaintDefaults?.paintColor ? undefined : entry.shadedColor, @@ -2171,13 +2131,11 @@ export function TextureAtlasPoly({ const dynamic = textureLighting === "dynamic"; const atlasWidth = entry.canvasW || 1; const atlasHeight = entry.canvasH || 1; - const atlasScaleX = ATLAS_SLICE_CANONICAL_SIZE / atlasWidth; - const atlasScaleY = ATLAS_SLICE_CANONICAL_SIZE / atlasHeight; const atlasPosition = page - ? `${formatCssLength(-entry.x * atlasScaleX)} ${formatCssLength(-entry.y * atlasScaleY)}` + ? `${formatCssLength(-entry.x / atlasWidth)} ${formatCssLength(-entry.y / atlasHeight)}` : undefined; const atlasSize = page - ? `${formatCssLength(page.width * atlasScaleX)} ${formatCssLength(page.height * atlasScaleY)}` + ? `${formatCssLength(page.width / atlasWidth)} ${formatCssLength(page.height / atlasHeight)}` : undefined; // Dynamic mode: emit ONLY the per-polygon surface normal vars + the @@ -2193,7 +2151,7 @@ export function TextureAtlasPoly({ : undefined; const style: CSSProperties = { - transform: formatMatrix3d(entry.canonicalMatrix, 6), + transform: formatMatrix3d(entry.canonicalMatrix), background, backgroundImage: dynamic && page?.url ? `url(${page.url})` : undefined, backgroundPosition: dynamic ? atlasPosition : undefined, diff --git a/packages/react/src/shapes/Poly.tsx b/packages/react/src/shapes/Poly.tsx index ecd9f6dc..b84ebad2 100644 --- a/packages/react/src/shapes/Poly.tsx +++ b/packages/react/src/shapes/Poly.tsx @@ -18,7 +18,6 @@ import { // ── Material / direct render path ──────────────────────────────────────────── const DIRECT_TEXTURE_CSS_DECIMALS = 4; -const DIRECT_TEXTURE_CANONICAL_SIZE = 128; function formatCssLength(value: number, decimals = DIRECT_TEXTURE_CSS_DECIMALS): string { const next = value.toFixed(decimals).replace(/\.?0+$/, ""); @@ -97,8 +96,8 @@ function MaterialDirectPoly({ const style: CSSProperties = { transform: `matrix3d(${plan.canonicalMatrix})`, backgroundImage: `url(${material.texture})`, - backgroundSize: `${formatCssLength(sourceW * DIRECT_TEXTURE_CANONICAL_SIZE)} ${formatCssLength(sourceH * DIRECT_TEXTURE_CANONICAL_SIZE)}`, - backgroundPosition: `${formatCssLength(-offsetX * DIRECT_TEXTURE_CANONICAL_SIZE)} ${formatCssLength(-offsetY * DIRECT_TEXTURE_CANONICAL_SIZE)}`, + backgroundSize: `${formatCssLength(sourceW)} ${formatCssLength(sourceH)}`, + backgroundPosition: `${formatCssLength(-offsetX)} ${formatCssLength(-offsetY)}`, pointerEvents: pointerEvents === "none" ? "none" : undefined, ...styleProp, }; @@ -111,7 +110,7 @@ function MaterialDirectPoly({ const elementClassName = className?.trim() || undefined; return ( - { expect(el.textContent).toContain("transform-origin: 0 0"); expect(el.textContent).toContain("backface-visibility: hidden"); expect(el.textContent).toContain("background-repeat: no-repeat"); - expect(el.textContent).toContain("user-select: none"); expect(el.textContent).toContain("width: 0;"); expect(el.textContent).toContain("height: 0;"); }); diff --git a/packages/react/src/styles/styles.ts b/packages/react/src/styles/styles.ts index 7f377e11..ab9385c5 100644 --- a/packages/react/src/styles/styles.ts +++ b/packages/react/src/styles/styles.ts @@ -48,9 +48,20 @@ const CORE_BASE_STYLES = ` position: absolute; } -.polycss-mesh { - -webkit-user-select: none; - user-select: none; +/* ── First-person controls perspective context ──────────────────────────── */ + +/* PolyFirstPersonControls toggles this class on its host element (the camera + wrapper). FPV needs a real perspective context so scene Z translation + produces visible depth motion - without it, walking forward looks like a + planar pan. The class wins over inline perspective styles (e.g. + PolyOrthographicCamera's perspective: none) via !important. The actual + perspective value is set inline by the controls as the + --polycss-fpv-perspective custom property; the default of 2000px matches + the controls' lookOffset fallback so the FPV math and visual perspective + stay in sync. */ +.polycss-fpv-host { + perspective: var(--polycss-fpv-perspective, 2000px) !important; + transform-style: preserve-3d !important; } /* ── Polygon leaf element ───────────────────────────────────────────────── */ @@ -78,8 +89,6 @@ const CORE_BASE_STYLES = ` text-decoration: none; backface-visibility: hidden; background-repeat: no-repeat; - -webkit-user-select: none; - user-select: none; } .polycss-scene b, @@ -90,19 +99,19 @@ const CORE_BASE_STYLES = ` .polycss-scene b { background: currentColor; - width: 64px; - height: 64px; + width: 1px; + height: 1px; } .polycss-scene i { - width: 64px; - height: 64px; + width: 16px; + height: 16px; border-color: currentColor; } .polycss-scene s { - width: 128px; - height: 128px; + width: 1px; + height: 1px; } .polycss-scene u { @@ -112,7 +121,7 @@ const CORE_BASE_STYLES = ` box-sizing: content-box; border: 0 solid transparent; border-color: transparent transparent currentColor transparent; - border-width: 0 64px 64px 64px; + border-width: 0 1px 1px 1px; } /* ── Gizmo override ─────────────────────────────────────────────────────── */ @@ -137,6 +146,33 @@ const CORE_BASE_STYLES = ` transition: color 150ms ease-out, border-color 150ms ease-out, background-color 150ms ease-out; } +/* + * Rotate rings are rendered as a single square quad per ring, then masked + * to a donut via a radial-gradient. --ring-inner-ratio is set inline by + * (= innerR / outerR, where outerR is the edge of + * the quad mapped to 50%). Hit-testing also uses the donut shape — see + * the ring-aware path in TransformControls.tsx. Single DOM node per ring. + */ +.polycss-transform-ring i, +.polycss-transform-ring b, +.polycss-transform-ring s, +.polycss-transform-ring u { + --ring-inner-r: calc(var(--ring-inner-ratio, 0.92) * 50%); + --ring-outer-r: calc(var(--ring-outer-ratio, 1) * 50%); + -webkit-mask: radial-gradient(circle at 50% 50%, + transparent 0%, + transparent var(--ring-inner-r), + black var(--ring-inner-r), + black var(--ring-outer-r), + transparent var(--ring-outer-r)); + mask: radial-gradient(circle at 50% 50%, + transparent 0%, + transparent var(--ring-inner-r), + black var(--ring-inner-r), + black var(--ring-outer-r), + transparent var(--ring-outer-r)); +} + /* ── Dynamic lighting cascade vars (scene root → polygons) ─────────────── */ /* @@ -256,8 +292,6 @@ const CORE_BASE_STYLES = ` border-color: currentColor; pointer-events: none; will-change: transform; - -webkit-user-select: none; - user-select: none; } .polycss-scene q::before, .polycss-scene q::after { diff --git a/packages/vue/src/controls/PolyFirstPersonControls.ts b/packages/vue/src/controls/PolyFirstPersonControls.ts index 0209231b..cb29acd4 100644 --- a/packages/vue/src/controls/PolyFirstPersonControls.ts +++ b/packages/vue/src/controls/PolyFirstPersonControls.ts @@ -466,6 +466,18 @@ export const PolyFirstPersonControls = defineComponent({ win.addEventListener("keyup", onKeyUp); win.addEventListener("blur", onBlur); + // FPV needs a perspective context on the host so scene Z motion shows + // as depth, not as a planar pan. Read the current effective perspective + // BEFORE adding the class so we honor any value the camera component + // set; fall back to 2000px for orthographic so the FPV math and visual + // stay in sync. The `.polycss-fpv-host` class uses `!important` to + // override inline `perspective: none`. + const computedPersp = win.getComputedStyle(host).perspective; + const persp = parseFloat(computedPersp); + const effectivePersp = Number.isFinite(persp) && persp > 0 ? persp : 2000; + host.style.setProperty("--polycss-fpv-perspective", `${effectivePersp}px`); + host.classList.add("polycss-fpv-host"); + cleanupListeners = (): void => { host.removeEventListener("click", onHostClick); doc.removeEventListener("pointerlockchange", onPointerLockChange); @@ -474,6 +486,8 @@ export const PolyFirstPersonControls = defineComponent({ win.removeEventListener("keyup", onKeyUp); win.removeEventListener("blur", onBlur); host.style.cursor = ""; + host.classList.remove("polycss-fpv-host"); + host.style.removeProperty("--polycss-fpv-perspective"); keysHeld.clear(); if (pointerLocked) { try { doc.exitPointerLock(); } catch { /* ignore */ } diff --git a/packages/vue/src/controls/PolyTransformControls.test.ts b/packages/vue/src/controls/PolyTransformControls.test.ts index cdb8cfb3..fcb50bfe 100644 --- a/packages/vue/src/controls/PolyTransformControls.test.ts +++ b/packages/vue/src/controls/PolyTransformControls.test.ts @@ -23,6 +23,25 @@ afterEach(() => { // ── helpers ────────────────────────────────────────────────────────────────── +/** The ring is now one quad masked to a donut via CSS; its JS hit-test + * (pointInRingMeshElement) rejects clicks at the bbox center (= the inner + * hole). This helper patches the leaf bbox so the click coordinates land + * at the right edge of the bbox — normalized distance from center = 1, + * in the donut band. */ +function patchRingLeafBboxForDonut( + ringEl: HTMLElement, + clientX = 100, + clientY = 0, +): void { + const leaf = ringEl.querySelector("i,b,s,u") as HTMLElement; + if (!leaf) return; + leaf.getBoundingClientRect = () => ({ + left: clientX - 100, top: clientY - 50, right: clientX, bottom: clientY + 50, + width: 100, height: 100, x: clientX - 100, y: clientY - 50, + toJSON() { return this; }, + } as DOMRect); +} + function withFakeLayout(cameraScale: number, fn: () => void): void { const TRANSFORM_RE = /translate3d\(\s*(-?[\d.]+)px,\s*(-?[\d.]+)px,\s*(-?[\d.]+)px\s*\)/; const orig = Element.prototype.getBoundingClientRect; @@ -126,7 +145,11 @@ describe("PolyTransformControls (Vue)", () => { const wrapper = container.querySelector("[data-poly-transform-controls]") as HTMLElement; expect(wrapper).not.toBeNull(); expect(wrapper.getAttribute("data-poly-mode")).toBe("translate"); - expect(wrapper.style.transform).toContain("translate3d(50px, 60px, 70px)"); + // Wrapper plants itself on the mesh's visible center: + // position + bboxCenter * scale. TRIANGLE bbox center is (0.5, 0.5, 0) + // in world space → (25, 25, 0) CSS px at the standard tile (50). Scale 1 + // means it adds straight to [50, 60, 70]. + expect(wrapper.style.transform).toContain("translate3d(75px, 85px, 70px)"); const arrows = wrapper.querySelectorAll(".polycss-transform-arrow"); expect(arrows.length).toBe(6); expect(Array.from(arrows).map(axisKeyOf)).toEqual(["x", "-x", "y", "-y", "z", "-z"]); @@ -331,19 +354,24 @@ describe("PolyTransformControls (Vue)", () => { const yRing = container.querySelector(".polycss-transform-ring--y") as HTMLElement; expect(yRing).not.toBeNull(); + patchRingLeafBboxForDonut(yRing, 100, 0); withFakeLayout(2, () => { yRing.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, clientX: 100, clientY: 0, pointerId: 1 }), ); - window.dispatchEvent(new PointerEvent("pointermove", { clientX: 0, clientY: 100, pointerId: 1 })); + // Small angular delta keeps Euler XYZ unambiguous — at exactly 90° + // we hit the gimbal-lock branch and the round-trip can flip the + // rotation onto a different component. + window.dispatchEvent(new PointerEvent("pointermove", { clientX: 100, clientY: 10, pointerId: 1 })); }); expect(onObjectChange).toHaveBeenCalled(); const event = onObjectChange.mock.calls[0][0]; expect(typeof event.rotation[1]).toBe("number"); - expect(event.rotation[0]).toBe(0); - expect(event.rotation[2]).toBe(0); + expect(event.rotation[1]).not.toBe(0); + expect(Math.abs(event.rotation[0])).toBeLessThan(1e-6); + expect(Math.abs(event.rotation[2])).toBeLessThan(1e-6); }); it("dragging Z ring: rotation[2] changes after pointer move", async () => { @@ -356,19 +384,21 @@ describe("PolyTransformControls (Vue)", () => { const zRing = container.querySelector(".polycss-transform-ring--z") as HTMLElement; expect(zRing).not.toBeNull(); + patchRingLeafBboxForDonut(zRing, 100, 0); withFakeLayout(2, () => { zRing.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, clientX: 100, clientY: 0, pointerId: 1 }), ); - window.dispatchEvent(new PointerEvent("pointermove", { clientX: 0, clientY: 100, pointerId: 1 })); + window.dispatchEvent(new PointerEvent("pointermove", { clientX: 100, clientY: 10, pointerId: 1 })); }); expect(onObjectChange).toHaveBeenCalled(); const event = onObjectChange.mock.calls[0][0]; expect(typeof event.rotation[2]).toBe("number"); - expect(event.rotation[0]).toBe(0); - expect(event.rotation[1]).toBe(0); + expect(event.rotation[2]).not.toBe(0); + expect(Math.abs(event.rotation[0])).toBeLessThan(1e-6); + expect(Math.abs(event.rotation[1])).toBeLessThan(1e-6); }); it("rotationSnap=15 rounds raw rotation to nearest 15° step", async () => { @@ -381,6 +411,7 @@ describe("PolyTransformControls (Vue)", () => { const xRing = container.querySelector(".polycss-transform-ring--x") as HTMLElement; expect(xRing).not.toBeNull(); + patchRingLeafBboxForDonut(xRing, 1, 0); withFakeLayout(1, () => { xRing.dispatchEvent( @@ -406,6 +437,7 @@ describe("PolyTransformControls (Vue)", () => { const yRing = container.querySelector(".polycss-transform-ring--y") as HTMLElement; expect(yRing).not.toBeNull(); + patchRingLeafBboxForDonut(yRing, 100, 0); withFakeLayout(2, () => { yRing.dispatchEvent( @@ -432,6 +464,7 @@ describe("PolyTransformControls (Vue)", () => { const yRing = container.querySelector(".polycss-transform-ring--y") as HTMLElement; expect(yRing).not.toBeNull(); + patchRingLeafBboxForDonut(yRing, 100, 0); withFakeLayout(2, () => { yRing.dispatchEvent( @@ -472,6 +505,7 @@ describe("PolyTransformControls (Vue)", () => { const xRing = container.querySelector(".polycss-transform-ring--x") as HTMLElement; expect(xRing).not.toBeNull(); + patchRingLeafBboxForDonut(xRing, 100, 0); withFakeLayout(2, () => { xRing.dispatchEvent( @@ -518,6 +552,7 @@ describe("PolyTransformControls (Vue)", () => { const yRing = container.querySelector(".polycss-transform-ring--y") as HTMLElement; expect(yRing).not.toBeNull(); + patchRingLeafBboxForDonut(yRing, 100, 0); withFakeLayout(2, () => { yRing.dispatchEvent( diff --git a/packages/vue/src/controls/PolyTransformControls.ts b/packages/vue/src/controls/PolyTransformControls.ts index 9f1fcc60..b9e0c462 100644 --- a/packages/vue/src/controls/PolyTransformControls.ts +++ b/packages/vue/src/controls/PolyTransformControls.ts @@ -27,7 +27,17 @@ import { type PropType, type Ref, } from "vue"; -import { arrowPolygons, ringPolygons, type Polygon, type Vec3 } from "@layoutit/polycss-core"; +import { + arrowPolygons, + eulerXYZFromQuat, + planePolygons, + quatFromAxisAngle, + quatFromEulerXYZ, + quatMultiply, + ringQuadPolygons, + type Polygon, + type Vec3, +} from "@layoutit/polycss-core"; import { PolyMesh } from "../scene/PolyMesh"; import { pointInMeshElement, @@ -51,8 +61,14 @@ const SHAFT_HALF_THICKNESS_RATIO = 0.0125; const HEAD_LENGTH_RATIO = 0.15; const HEAD_HALF_THICKNESS_RATIO = 0.04; const RING_RADIUS_RATIO = 1.0; -const RING_HALF_THICKNESS_RATIO = 0.012; -const RING_SEGMENTS = 64; +// Thick enough to be an easy click target — matches arrow head half thickness. +const RING_HALF_THICKNESS_RATIO = 0.02; +// Outer radius of the ring's quad polygon as a multiple of mid-radius — +// independent of the visible band thickness, so the click target stays +// generous even when the ring looks thin. +const RING_QUAD_OUTER_RATIO = 1.04; +const PLANE_HALF_SIZE_RATIO = 0.1; +const PLANE_OFFSET_RATIO = 0.25; const SCREEN_AXIS_DEAD_ZONE_SQ = 0.0001; const WORLD_AXIS_FOR_CSS: Record<0 | 1 | 2, 0 | 1 | 2> = { 0: 1, 1: 0, 2: 2 }; @@ -72,6 +88,20 @@ const RING_SPECS: Array<{ cssAxis: 0 | 1 | 2; key: string; color: string }> = [ { cssAxis: 2, key: "z", color: COLOR_Z }, ]; +// Plane color = perpendicular axis color: XY plane → blue (Z), XZ → green +// (Y), YZ → red (X). +const PLANE_SPECS: Array<{ + perpAxis: 0 | 1 | 2; + axisA: 0 | 1 | 2; + axisB: 0 | 1 | 2; + key: "xy" | "xz" | "yz"; + color: string; +}> = [ + { perpAxis: 2, axisA: 0, axisB: 1, key: "xy", color: COLOR_Z }, + { perpAxis: 1, axisA: 0, axisB: 2, key: "xz", color: COLOR_Y }, + { perpAxis: 0, axisA: 1, axisB: 2, key: "yz", color: COLOR_X }, +]; + function withAlpha(hex: string, alpha: number): string { const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); if (!m) return hex; @@ -90,6 +120,25 @@ function userAxisLetterOf(key: string): "x" | "y" | "z" { return key.replace("-", "")[0] as "x" | "y" | "z"; } + +/** True when the signed CSS-space axis points AWAY from the viewer after + * the scene's rotateZ(rotY) · rotateX(rotX). Mirrors React's helper. */ +function isAxisBackFacing( + cssAxis: 0 | 1 | 2, + sign: 1 | -1, + rotXDeg: number, + rotYDeg: number, +): boolean { + const rx = (rotXDeg * Math.PI) / 180; + const ry = (rotYDeg * Math.PI) / 180; + const a: [number, number, number] = [0, 0, 0]; + a[cssAxis] = sign; + const by = a[0] * Math.sin(ry) + a[1] * Math.cos(ry); + const bz = a[2]; + const cz = by * Math.sin(rx) + bz * Math.cos(rx); + return cz < 0; +} + function gizmoLengthForMesh(polygons: Polygon[]): number { if (polygons.length === 0) return FALLBACK_SHAFT_LENGTH; let minX = Infinity, minY = Infinity, minZ = Infinity; @@ -108,6 +157,33 @@ function gizmoLengthForMesh(polygons: Polygon[]): number { return Math.max(maxX - minX, maxY - minY, maxZ - minZ) * SCENE_TILE_SIZE * SHAFT_LENGTH_RATIO; } +/** Polygon bbox center in scene-CSS pixels, via the standard polycss + * world→CSS axis remap (v[1]→x, v[0]→y, v[2]→z). Used to plant the + * gizmo wrapper at the mesh's visible center rather than at its + * wrapper origin — necessary whenever the mesh's vertices don't sit + * on `(0,0,0)` in mesh-local space (e.g. PolyMesh.autoCenter unset). */ +function gizmoCenterForMesh(polygons: Polygon[]): Vec3 { + if (polygons.length === 0) return [0, 0, 0]; + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (const poly of polygons) { + for (const v of poly.vertices) { + if (v[0] < minX) minX = v[0]; + if (v[0] > maxX) maxX = v[0]; + if (v[1] < minY) minY = v[1]; + if (v[1] > maxY) maxY = v[1]; + if (v[2] < minZ) minZ = v[2]; + if (v[2] > maxZ) maxZ = v[2]; + } + } + if (!Number.isFinite(minX)) return [0, 0, 0]; + return [ + ((minY + maxY) / 2) * SCENE_TILE_SIZE, + ((minX + maxX) / 2) * SCENE_TILE_SIZE, + ((minZ + maxZ) / 2) * SCENE_TILE_SIZE, + ]; +} + export interface PolyTransformControlsObjectChangeEvent { object: PolyMeshHandle; position?: Vec3; @@ -203,6 +279,82 @@ function startAxisDrag(opts: AxisDragOptions): void { window.addEventListener("pointercancel", handleUp); } +interface PlaneDragOptions { + axisA: 0 | 1 | 2; + axisB: 0 | 1 | 2; + probeDistanceCss: number; + wrapper: HTMLElement; + target: PolyMeshHandle; + startClientX: number; + startClientY: number; + translationSnap: number | null; + onChange?: () => void; + onObjectChange?: (e: PolyTransformControlsObjectChangeEvent) => void; + onMouseDown?: () => void; + onMouseUp?: () => void; + onDraggingChanged?: (d: boolean) => void; +} + +/** Planar drag: probe both in-plane axes for their screen projections, + * then solve a 2x2 system per move and apply position deltas along both + * axes simultaneously. Mirror of vanilla `startPlaneDrag`. */ +function startPlaneDrag(opts: PlaneDragOptions): void { + const axisAVec: Vec3 = [0, 0, 0]; axisAVec[opts.axisA] = 1; + const axisBVec: Vec3 = [0, 0, 0]; axisBVec[opts.axisB] = 1; + const probeDistance = opts.probeDistanceCss; + function probe(axisVec: Vec3): { x: number; y: number } { + const el = opts.wrapper.ownerDocument!.createElement("div"); + el.style.position = "absolute"; + el.style.left = "0"; + el.style.top = "0"; + el.style.width = "0"; + el.style.height = "0"; + el.style.transform = `translate3d(${axisVec[0] * probeDistance}px, ${axisVec[1] * probeDistance}px, ${axisVec[2] * probeDistance}px)`; + opts.wrapper.appendChild(el); + const wR = opts.wrapper.getBoundingClientRect(); + const pR = el.getBoundingClientRect(); + opts.wrapper.removeChild(el); + return { + x: (pR.left - wR.left) / probeDistance, + y: (pR.top - wR.top) / probeDistance, + }; + } + const pA = probe(axisAVec); + const pB = probe(axisBVec); + const det = pA.x * pB.y - pB.x * pA.y; + if (Math.abs(det) < SCREEN_AXIS_DEAD_ZONE_SQ) return; + + const startPos = (opts.target.getPosition() ?? [0, 0, 0]) as Vec3; + opts.onMouseDown?.(); + opts.onDraggingChanged?.(true); + + const handleMove = (ev: PointerEvent): void => { + const dx = ev.clientX - opts.startClientX; + const dy = ev.clientY - opts.startClientY; + let tA = (pB.y * dx - pB.x * dy) / det; + let tB = (-pA.y * dx + pA.x * dy) / det; + tA = snap(tA, opts.translationSnap); + tB = snap(tB, opts.translationSnap); + const next: Vec3 = [ + startPos[0] + tA * axisAVec[0] + tB * axisBVec[0], + startPos[1] + tA * axisAVec[1] + tB * axisBVec[1], + startPos[2] + tA * axisAVec[2] + tB * axisBVec[2], + ]; + opts.onObjectChange?.({ object: opts.target, position: next }); + opts.onChange?.(); + }; + const handleUp = (): void => { + window.removeEventListener("pointermove", handleMove); + window.removeEventListener("pointerup", handleUp); + window.removeEventListener("pointercancel", handleUp); + opts.onMouseUp?.(); + opts.onDraggingChanged?.(false); + }; + window.addEventListener("pointermove", handleMove); + window.addEventListener("pointerup", handleUp); + window.addEventListener("pointercancel", handleUp); +} + interface RingDragOptions { cssAxis: 0 | 1 | 2; wrapper: HTMLElement; @@ -224,6 +376,10 @@ function startRingDrag(opts: RingDragOptions): void { let lastAngle = Math.atan2(opts.startClientY - centerY, opts.startClientX - centerX); let cumulative = 0; const startRot = (opts.target.getRotation() ?? [0, 0, 0]) as Vec3; + // See React's RingDrag: snapshot Qstart, right-multiply Qdelta around + // the ring's CSS axis to compose in the mesh's LOCAL frame. Plain + // Euler-add breaks for repeated / non-XYZ-order ring drags. + const qStart = quatFromEulerXYZ(startRot); opts.onMouseDown?.(); opts.onDraggingChanged?.(true); const handleMove = (ev: PointerEvent): void => { @@ -235,8 +391,13 @@ function startRingDrag(opts: RingDragOptions): void { lastAngle = a; let degrees = (cumulative * 180) / Math.PI; degrees = snap(degrees, opts.rotationSnap); - const next: Vec3 = [startRot[0], startRot[1], startRot[2]]; - next[opts.cssAxis] = startRot[opts.cssAxis] + degrees; + const axisVec: Vec3 = [0, 0, 0]; + axisVec[opts.cssAxis] = 1; + const qDelta = quatFromAxisAngle(axisVec, (degrees * Math.PI) / 180); + // World-frame compose (pre-mult): rings stay at world axes visually, + // so each ring drag rotates around the world axis the ring points to, + // cumulatively on top of the mesh's current orientation. + const next = eulerXYZFromQuat(quatMultiply(qDelta, qStart)); opts.onObjectChange?.({ object: opts.target, rotation: next }); opts.onChange?.(); }; @@ -310,6 +471,24 @@ export const PolyTransformControls = defineComponent({ const tick = ref(0); onMounted(() => { tick.value++; }); + // Subscribe to camera state so back-facing arrow geometry re-evaluates + // when the user orbits the camera. cameraTick is read inside arrowEntries + // so the computed re-runs on every camera change. + const cameraTick = ref(0); + const cameraCtxForRot = inject(PolyCameraContextKey, undefined); + let unsubscribeCamera: (() => void) | null = null; + onMounted(() => { + const store = cameraCtxForRot?.store; + if (!store) return; + unsubscribeCamera = store.subscribe(() => { cameraTick.value++; }); + }); + onBeforeUnmount(() => { unsubscribeCamera?.(); }); + function currentRot(): { rotX: number; rotY: number } { + void cameraTick.value; + const s = cameraCtxForRot?.store.getState().cameraState; + return { rotX: s?.rotX ?? 65, rotY: s?.rotY ?? 45 }; + } + const baseLength = computed(() => { void tick.value; // re-evaluate after first render so target.polygons resolves return gizmoLengthForMesh(target.value?.getPolygons() ?? []); @@ -342,6 +521,43 @@ export const PolyTransformControls = defineComponent({ const targetEl = event.target as Element | null; if (targetEl?.closest(".polycss-transform-gizmo")) return; const showByKey = { x: props.showX, y: props.showY, z: props.showZ }; + if (props.mode === "translate") { + // Plane handles hit-tested first so they win at corner overlaps. + for (const spec of PLANE_SPECS) { + const aL = (["x", "y", "z"] as const)[spec.axisA]; + const bL = (["x", "y", "z"] as const)[spec.axisB]; + if (!showByKey[aL] || !showByKey[bL]) continue; + const planeEl = document.querySelector( + `.polycss-transform-plane--${spec.key}`, + ) as HTMLElement | null; + if (!planeEl) continue; + if (!pointInMeshElement(planeEl, event.clientX, event.clientY)) continue; + event.preventDefault(); + event.stopPropagation(); + const wrapper = planeEl.closest("[data-poly-transform-controls]") as HTMLElement | null; + if (!wrapper) return; + draggingKey.value = spec.key; + startPlaneDrag({ + axisA: spec.axisA, + axisB: spec.axisB, + probeDistanceCss: shaftLengthCss.value, + wrapper, + target: t, + startClientX: event.clientX, + startClientY: event.clientY, + translationSnap: props.translationSnap, + onChange: emitChange, + onObjectChange: emitObjectChange, + onMouseDown: emitMouseDown, + onMouseUp: emitMouseUp, + onDraggingChanged: (d) => { + if (!d) draggingKey.value = null; + emitDragging(d); + }, + }); + return; + } + } const specs = props.mode === "translate" ? ARROW_SPECS : RING_SPECS; for (const spec of specs) { const userAxis = spec.key.replace("-", "")[0] as "x" | "y" | "z"; @@ -350,7 +566,12 @@ export const PolyTransformControls = defineComponent({ `.polycss-transform-${props.mode === "translate" ? "arrow" : "ring"}--${spec.key}`, ) as HTMLElement | null; if (!arrowEl) continue; - if (!pointInMeshElement(arrowEl, event.clientX, event.clientY)) continue; + // Rings use donut-shaped hit-testing (CSS mask doesn't block + // pointer events, so we have to gate clicks here). + const hit = props.mode === "rotate" + ? pointInMeshElement(arrowEl, event.clientX, event.clientY) + : pointInMeshElement(arrowEl, event.clientX, event.clientY); + if (!hit) continue; event.preventDefault(); event.stopPropagation(); const wrapper = arrowEl.closest("[data-poly-transform-controls]") as HTMLElement | null; @@ -412,10 +633,12 @@ export const PolyTransformControls = defineComponent({ void tick.value; const length = shaftLengthCss.value; const lengthWorld = length / SCENE_TILE_SIZE; + const { rotX, rotY } = currentRot(); return ARROW_SPECS .filter((spec) => ({ x: props.showX, y: props.showY, z: props.showZ }[userAxisLetterOf(spec.key)])) .map((spec) => { const alpha = alphaFor(spec.key); + const backFacing = isAxisBackFacing(spec.cssAxis, spec.sign, rotX, rotY); return { spec, polygons: arrowPolygons({ @@ -426,6 +649,39 @@ export const PolyTransformControls = defineComponent({ headLength: lengthWorld * HEAD_LENGTH_RATIO, headHalfThickness: lengthWorld * HEAD_HALF_THICKNESS_RATIO, color: withAlpha(spec.color, alpha), + shaft: !backFacing, + }), + }; + }); + }); + const planeEntries = computed(() => { + if (props.mode !== "translate") return []; + void tick.value; + const length = shaftLengthCss.value; + const lengthWorld = length / SCENE_TILE_SIZE; + const show = { x: props.showX, y: props.showY, z: props.showZ }; + const ax = (["x", "y", "z"] as const); + const { rotX, rotY } = currentRot(); + const mag = lengthWorld * PLANE_OFFSET_RATIO; + return PLANE_SPECS + .filter((spec) => show[ax[spec.axisA]] && show[ax[spec.axisB]]) + .map((spec) => { + const alpha = alphaFor(spec.key); + // Place the quad in the camera-facing octant — see vanilla/React. + const worldPerp = WORLD_AXIS_FOR_CSS[spec.perpAxis]; + const worldA = ((worldPerp + 1) % 3) as 0 | 1 | 2; + const worldB = ((worldPerp + 2) % 3) as 0 | 1 | 2; + const cssAForOffset = WORLD_AXIS_FOR_CSS[worldA]; + const cssBForOffset = WORLD_AXIS_FOR_CSS[worldB]; + const signA = isAxisBackFacing(cssAForOffset, 1, rotX, rotY) ? -1 : 1; + const signB = isAxisBackFacing(cssBForOffset, 1, rotX, rotY) ? -1 : 1; + return { + spec, + polygons: planePolygons({ + axis: worldPerp, + size: lengthWorld * PLANE_HALF_SIZE_RATIO, + offset: [signA * mag, signB * mag], + color: withAlpha(spec.color, alpha), }), }; }); @@ -435,22 +691,28 @@ export const PolyTransformControls = defineComponent({ void tick.value; const length = shaftLengthCss.value; const radiusWorld = (length * RING_RADIUS_RATIO) / SCENE_TILE_SIZE; + const outerWorld = radiusWorld * RING_QUAD_OUTER_RATIO; return RING_SPECS .filter((spec) => ({ x: props.showX, y: props.showY, z: props.showZ }[spec.key as "x" | "y" | "z"])) .map((spec) => { const alpha = alphaFor(spec.key); + // Single square quad masked to a donut via CSS (see + // .polycss-transform-ring rule in styles.ts). return { spec, - polygons: ringPolygons({ + polygons: ringQuadPolygons({ axis: WORLD_AXIS_FOR_CSS[spec.cssAxis], - radius: radiusWorld, - halfThickness: radiusWorld * RING_HALF_THICKNESS_RATIO, - segments: RING_SEGMENTS, + outerRadius: outerWorld, color: withAlpha(spec.color, alpha), }), }; }); }); + // Visible band start/end as fractions of the quad edge. The quad covers + // ±RING_QUAD_OUTER_RATIO · mid-radius; the visible ring is mid ± + // halfThickness. Normalize against the quad outer to get mask positions. + const ringInnerRatio = (1 - RING_HALF_THICKNESS_RATIO) / RING_QUAD_OUTER_RATIO; + const ringOuterRatio = (1 + RING_HALF_THICKNESS_RATIO) / RING_QUAD_OUTER_RATIO; function makeArrowPointerDown(spec: typeof ARROW_SPECS[number]) { return (e: PolyPointerEvent): void => { @@ -483,7 +745,7 @@ export const PolyTransformControls = defineComponent({ }; } - function makeRingPointerDown(spec: typeof RING_SPECS[number]) { + function makePlanePointerDown(spec: typeof PLANE_SPECS[number]) { return (e: PolyPointerEvent): void => { if (!props.enabled) return; const t = target.value; @@ -493,6 +755,39 @@ export const PolyTransformControls = defineComponent({ const wrapper = meshEl?.closest("[data-poly-transform-controls]") as HTMLElement | null; if (!wrapper) return; draggingKey.value = spec.key; + startPlaneDrag({ + axisA: spec.axisA, + axisB: spec.axisB, + probeDistanceCss: shaftLengthCss.value, + wrapper, + target: t, + startClientX: e.nativeEvent.clientX, + startClientY: e.nativeEvent.clientY, + translationSnap: props.translationSnap, + onChange: emitChange, + onObjectChange: emitObjectChange, + onMouseDown: emitMouseDown, + onMouseUp: emitMouseUp, + onDraggingChanged: (d) => { + if (!d) draggingKey.value = null; + emitDragging(d); + }, + }); + }; + } + + function makeRingPointerDown(spec: typeof RING_SPECS[number]) { + return (e: PolyPointerEvent): void => { + if (!props.enabled) return; + const t = target.value; + if (!t) return; + // No donut hit-test — the whole ring quad bbox is the click target + // so the rings are easy to land on. The donut mask is decoration. + const meshEl = e.eventObject.element; + e.stopPropagation(); + const wrapper = meshEl?.closest("[data-poly-transform-controls]") as HTMLElement | null; + if (!wrapper) return; + draggingKey.value = spec.key; startRingDrag({ cssAxis: spec.cssAxis, wrapper, @@ -526,29 +821,55 @@ export const PolyTransformControls = defineComponent({ const t = target.value; if (!t) return null; const position = t.getPosition() ?? ([0, 0, 0] as Vec3); + // Mesh wrapper pivots around `bboxCenter` via `transform-origin`, so + // its visible bbox center stays at `position + bboxCenter` regardless + // of scale or rotation. The gizmo wrapper sits on the same point. When + // PolyMesh autoCenters its vertices, bboxCenter collapses to (0,0,0). + const bboxCenter = gizmoCenterForMesh(t.getPolygons()); + const wx = position[0] + bboxCenter[0]; + const wy = position[1] + bboxCenter[1]; + const wz = position[2] + bboxCenter[2]; const wrapperStyle: Record = { - transform: `translate3d(${position[0]}px, ${position[1]}px, ${position[2]}px)`, + transform: `translate3d(${wx}px, ${wy}px, ${wz}px)`, position: "absolute", transformStyle: "preserve-3d", zIndex: 1000, }; const children = props.mode === "translate" - ? arrowEntries.value.map(({ spec, polygons }) => - h(PolyMesh, { - key: spec.key, - polygons, - class: `polycss-transform-gizmo polycss-transform-arrow polycss-transform-arrow--${spec.key}`, - textureLighting: "baked", - onPointerDown: makeArrowPointerDown(spec), - ...makeHoverHandlers(spec.key), - }), - ) + ? [ + ...arrowEntries.value.map(({ spec, polygons }) => + h(PolyMesh, { + key: `arrow-${spec.key}`, + polygons, + class: `polycss-transform-gizmo polycss-transform-arrow polycss-transform-arrow--${spec.key}`, + textureLighting: "baked", + onPointerDown: makeArrowPointerDown(spec), + ...makeHoverHandlers(spec.key), + }), + ), + ...planeEntries.value.map(({ spec, polygons }) => + h(PolyMesh, { + key: `plane-${spec.key}`, + polygons, + class: `polycss-transform-gizmo polycss-transform-plane polycss-transform-plane--${spec.key}`, + textureLighting: "baked", + onPointerDown: makePlanePointerDown(spec), + ...makeHoverHandlers(spec.key), + }), + ), + ] : ringEntries.value.map(({ spec, polygons }) => h(PolyMesh, { key: spec.key, polygons, class: `polycss-transform-gizmo polycss-transform-ring polycss-transform-ring--${spec.key}`, + // CSS var read by the .polycss-transform-ring radial-gradient + // mask to size the donut cutout. + style: { + ["--ring-inner-ratio" as string]: ringInnerRatio, + ["--ring-outer-ratio" as string]: ringOuterRatio, + }, textureLighting: "baked", onPointerDown: makeRingPointerDown(spec), ...makeHoverHandlers(spec.key), diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index da81ce68..b8db10fd 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -16,6 +16,8 @@ export type { PolySceneProps } from "./scene"; export type { PolyRenderStrategy, PolyRenderStrategiesOption } from "./scene/textureAtlas"; export { PolyMesh } from "./scene"; export type { PolyMeshProps } from "./scene"; +export { PolyGround } from "./scene"; +export type { PolyGroundProps } from "./scene"; export { usePolySceneContext } from "./scene"; export type { UseSceneContextOptions, UseSceneContextResult } from "./scene"; export { usePolyMesh } from "./scene"; diff --git a/packages/vue/src/scene/PolyGround.ts b/packages/vue/src/scene/PolyGround.ts new file mode 100644 index 00000000..8820edff --- /dev/null +++ b/packages/vue/src/scene/PolyGround.ts @@ -0,0 +1,57 @@ +/** + * `` (Vue) — flat ground-plane quad that shadow-casting meshes + * render their `` shadows onto. Convenience over `` — generates + * a 4-vertex polygon in the world XY plane at `z` and renders it with + * `castShadow: false` (the floor doesn't cast onto itself). Mirrors the React + * `` API surface 1:1. + * + * Sized in WORLD units (1 unit ≈ 50 CSS px at the standard tile). + */ +import { defineComponent, h, computed } from "vue"; +import type { PropType } from "vue"; +import type { Polygon, Vec3 } from "@layoutit/polycss-core"; +import { PolyMesh } from "./PolyMesh"; + +export interface PolyGroundProps { + /** Side length of the ground quad in world units. Default `6`. */ + size?: number; + /** World-space Z (floor height). Default `0`. */ + z?: number; + /** World-space XY center. Default `[0, 0]`. */ + center?: [number, number]; + /** Fill color. Default `#7d848e` — medium gray, chosen so 25% black `` + * shadow leaves on top have visible contrast against it. */ + color?: string; + class?: string; +} + +export const PolyGround = defineComponent({ + name: "PolyGround", + props: { + size: { type: Number, default: 6 }, + z: { type: Number, default: 0 }, + center: { type: Array as unknown as PropType<[number, number]>, default: () => [0, 0] }, + color: { type: String, default: "#7d848e" }, + class: { type: String, default: undefined }, + }, + setup(props) { + const polygons = computed(() => { + const half = props.size / 2; + const [cx, cy] = props.center; + const vertices: [Vec3, Vec3, Vec3, Vec3] = [ + [cx - half, cy - half, props.z], + [cx + half, cy - half, props.z], + [cx + half, cy + half, props.z], + [cx - half, cy + half, props.z], + ]; + return [{ vertices, color: props.color }]; + }); + + return () => + h(PolyMesh, { + polygons: polygons.value, + castShadow: false, + class: props.class ? `polycss-ground ${props.class}` : "polycss-ground", + }); + }, +}); diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index a147509f..ef5c8478 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -429,8 +429,33 @@ export const PolyMesh = defineComponent({ return () => { const transform = buildTransform(props.position, props.scale, props.rotation); + // Pivot rotation + scale around the polygon bbox center, matching + // vanilla's `.polycss-mesh { transform-origin: var(--origin) }`. Without + // this the wrapper would pivot at (0,0,0) — usually NOT the visible + // center — so rotateX/Y/Z would orbit the mesh around the asset's + // authoring origin and scale would shift it sideways. World→CSS axis + // swap matches polygonGeometry: world[1]→CSS x, world[0]→CSS y, + // world[2]→CSS z. + const originPolys = polygons.value; + let transformOrigin: string | undefined; + if (originPolys.length > 0) { + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (const poly of originPolys) { + for (const v of poly.vertices) { + if (v[0] < minX) minX = v[0]; if (v[0] > maxX) maxX = v[0]; + if (v[1] < minY) minY = v[1]; if (v[1] > maxY) maxY = v[1]; + if (v[2] < minZ) minZ = v[2]; if (v[2] > maxZ) maxZ = v[2]; + } + } + if (Number.isFinite(minX)) { + const tile = 50; + transformOrigin = `${((minY + maxY) / 2) * tile}px ${((minX + maxX) / 2) * tile}px ${((minZ + maxZ) / 2) * tile}px`; + } + } const wrapperStyle: CSSProperties = { transform, + ...(transformOrigin ? { transformOrigin } : null), ...(dynamicLightOverride.value as CSSProperties | null ?? undefined), ...(attrs.style as CSSProperties | undefined), ...(defaultPaintVars.value ?? undefined), diff --git a/packages/vue/src/scene/index.ts b/packages/vue/src/scene/index.ts index 97e159d6..78413797 100644 --- a/packages/vue/src/scene/index.ts +++ b/packages/vue/src/scene/index.ts @@ -2,6 +2,8 @@ export { PolyScene } from "./PolyScene"; export type { PolySceneProps } from "./PolyScene"; export { PolyMesh } from "./PolyMesh"; export type { PolyMeshProps } from "./PolyMesh"; +export { PolyGround } from "./PolyGround"; +export type { PolyGroundProps } from "./PolyGround"; export { usePolySceneContext } from "./useSceneContext"; export type { UseSceneContextOptions, UseSceneContextResult } from "./useSceneContext"; export { usePolyMesh } from "./useMesh"; diff --git a/packages/vue/src/scene/textureAtlas.ts b/packages/vue/src/scene/textureAtlas.ts index 6b4cc8d7..0014c462 100644 --- a/packages/vue/src/scene/textureAtlas.ts +++ b/packages/vue/src/scene/textureAtlas.ts @@ -42,16 +42,12 @@ const DEFAULT_BORDER_SHAPE_DECIMALS = 2; const DEFAULT_ATLAS_CSS_DECIMALS = 4; const BORDER_SHAPE_CENTER_PERCENT = 50; const BORDER_SHAPE_POINT_EPS = 1e-7; -const BORDER_SHAPE_CANONICAL_SIZE = 64; -const QUAD_CANONICAL_SIZE = 64; -const ATLAS_SLICE_CANONICAL_SIZE = 128; -const SOLID_TRIANGLE_CANONICAL_SIZE = 64; +const BORDER_SHAPE_CANONICAL_SIZE = 16; const PROJECTIVE_QUAD_DENOM_EPS = 0.05; const PROJECTIVE_QUAD_MAX_WEIGHT_RATIO = 4; const PROJECTIVE_QUAD_BLEED = 0.6; const BASIS_EPS = 1e-9; -const SOLID_TRIANGLE_BLEED = 0.75; -const SOLID_ATLAS_EDGE_BLEED = 0.9; +const SOLID_TRIANGLE_BLEED = 0.6; export type TextureQuality = number | "auto"; @@ -589,14 +585,6 @@ function formatScaledMatrixFromPlan( return formatMatrix3dValues(values); } -function formatQuadMatrix(entry: TextureAtlasPlan): string { - return formatScaledMatrixFromPlan( - entry, - entry.canvasW / QUAD_CANONICAL_SIZE, - entry.canvasH / QUAD_CANONICAL_SIZE, - ); -} - function formatBorderShapeMatrix(entry: TextureAtlasPlan): string { return formatScaledMatrixFromPlan( entry, @@ -784,14 +772,13 @@ function computeProjectiveQuadMatrix( p3[1] * w3 - p0[1], p3[2] * w3 - p0[2], ]; - const sourceSize = QUAD_CANONICAL_SIZE; - return formatMatrix3dValues([ - xCol[0] / sourceSize, xCol[1] / sourceSize, xCol[2] / sourceSize, g / sourceSize, - yCol[0] / sourceSize, yCol[1] / sourceSize, yCol[2] / sourceSize, h / sourceSize, + return [ + xCol[0], xCol[1], xCol[2], g, + yCol[0], yCol[1], yCol[2], h, normal[0], normal[1], normal[2], 0, p0[0], p0[1], p0[2], 1, - ], 6); + ].join(","); } function cssPoints(vertices: Vec3[], tile: number, elev: number): Vec3[] { @@ -1029,13 +1016,12 @@ function solidTriangleStyle( baseLeft[1] - txCol[1], baseLeft[2] - txCol[2], ]; - const sourceSize = SOLID_TRIANGLE_CANONICAL_SIZE; const canonicalMatrix = formatMatrix3dValues([ - xCol[0] / sourceSize, xCol[1] / sourceSize, xCol[2] / sourceSize, 0, - yCol[0] / sourceSize, yCol[1] / sourceSize, yCol[2] / sourceSize, 0, + xCol[0], xCol[1], xCol[2], 0, + yCol[0], yCol[1], yCol[2], 0, normal[0], normal[1], normal[2], 0, txCol[0], txCol[1], txCol[2], 1, - ], 6); + ]); return { transform: `matrix3d(${canonicalMatrix})`, ...sharedStyle, @@ -1135,38 +1121,6 @@ function drawImageCover( ctx.drawImage(img, x + (width - drawW) / 2, y + (height - drawH) / 2, drawW, drawH); } -function paintSolidAtlasEntry( - ctx: CanvasRenderingContext2D, - entry: PackedTextureAtlasEntry, - textureLighting: PolyTextureLightingMode, - atlasScale: number, -): void { - // Dynamic mode multiplies the tint at render time via background-blend-mode, - // so the atlas keeps the polygon's unshaded base color. - const paintColor = textureLighting === "dynamic" - ? (entry.polygon.color ?? "#cccccc") - : entry.shadedColor; - - ctx.save(); - setCssTransform(ctx, atlasScale); - ctx.beginPath(); - tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); - ctx.clip(); - ctx.fillStyle = paintColor; - ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); - ctx.restore(); - - ctx.save(); - setCssTransform(ctx, atlasScale); - ctx.beginPath(); - tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); - ctx.strokeStyle = paintColor; - ctx.lineWidth = SOLID_ATLAS_EDGE_BLEED * 2; - ctx.lineJoin = "round"; - ctx.stroke(); - ctx.restore(); -} - function computeUvAffine(points: Vec2[], uvs: Vec2[]): UvAffine | null { if (points.length < 3 || uvs.length < 3) return null; const [p0, p1, p2] = points; @@ -1645,14 +1599,8 @@ export function computeTextureAtlasPlan( tx, ty, tz, 1, ].join(","); const canonicalMatrix = [ - xAxis[0] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - xAxis[1] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - xAxis[2] * canvasW / ATLAS_SLICE_CANONICAL_SIZE, - 0, - yAxis[0] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - yAxis[1] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - yAxis[2] * canvasH / ATLAS_SLICE_CANONICAL_SIZE, - 0, + xAxis[0] * canvasW, xAxis[1] * canvasW, xAxis[2] * canvasW, 0, + yAxis[0] * canvasH, yAxis[1] * canvasH, yAxis[2] * canvasH, 0, nx, ny, nz, 0, tx, ty, tz, 1, ].join(","); @@ -1844,7 +1792,19 @@ async function buildAtlasPage( for (const entry of page.entries) { const srcImg = entry.texture ? loaded.get(entry.texture) : null; if (!entry.texture) { - paintSolidAtlasEntry(ctx, entry, textureLighting, atlasScale); + ctx.save(); + setCssTransform(ctx, atlasScale); + ctx.beginPath(); + tracePolygonPath(ctx, entry.x, entry.y, entry.screenPts); + ctx.clip(); + // Dynamic mode multiplies the tint at render time via + // background-blend-mode, so the atlas keeps the polygon's unshaded + // base color. Baked bakes the JS-computed shadedColor. + ctx.fillStyle = textureLighting === "dynamic" + ? (entry.polygon.color ?? "#cccccc") + : entry.shadedColor; + ctx.fillRect(entry.x, entry.y, entry.canvasW, entry.canvasH); + ctx.restore(); continue; } @@ -2009,13 +1969,11 @@ export function renderTextureAtlasPoly({ const dynamic = textureLighting === "dynamic"; const atlasWidth = entry.canvasW || 1; const atlasHeight = entry.canvasH || 1; - const atlasScaleX = ATLAS_SLICE_CANONICAL_SIZE / atlasWidth; - const atlasScaleY = ATLAS_SLICE_CANONICAL_SIZE / atlasHeight; const atlasPosition = page - ? `${formatCssLength(-entry.x * atlasScaleX)} ${formatCssLength(-entry.y * atlasScaleY)}` + ? `${formatCssLength(-entry.x / atlasWidth)} ${formatCssLength(-entry.y / atlasHeight)}` : undefined; const atlasSize = page - ? `${formatCssLength(page.width * atlasScaleX)} ${formatCssLength(page.height * atlasScaleY)}` + ? `${formatCssLength(page.width / atlasWidth)} ${formatCssLength(page.height / atlasHeight)}` : undefined; // Dynamic mode: emit ONLY the per-polygon surface normal vars + the @@ -2031,7 +1989,7 @@ export function renderTextureAtlasPoly({ : undefined; const style: CSSProperties = { - transform: formatMatrix3d(entry.canonicalMatrix, 6), + transform: formatMatrix3d(entry.canonicalMatrix), background, backgroundImage: dynamic && page?.url ? `url(${page.url})` : undefined, backgroundPosition: dynamic ? atlasPosition : undefined, @@ -2097,7 +2055,7 @@ export function renderTextureBorderShapePoly({ const useIForFullRect = fullRect && forceBorderShape && borderShapeSupported(); const borderShape = (!fullRect || useIForFullRect) ? cssBorderShapeForPlan(entry) : null; const useDefaultPaint = entry.shadedColor === solidPaintDefaults?.paintColor; - const transform = formatMatrix3d(borderShape ? formatBorderShapeMatrix(entry) : formatQuadMatrix(entry)); + const transform = formatMatrix3d(borderShape ? formatBorderShapeMatrix(entry) : entry.canonicalMatrix); const style: CSSProperties = fullRect ? { transform, @@ -2158,7 +2116,7 @@ export function renderTextureProjectiveSolidPoly({ const base = parseHex(entry.polygon.color ?? "#cccccc"); const useDefaultDynamicColor = dynamic && rgbKey(base) === solidPaintDefaults?.dynamicColorKey; const style: CSSProperties = { - transform: formatMatrix3d(entry.projectiveMatrix, 6), + transform: formatMatrix3d(entry.projectiveMatrix), color: dynamic || entry.shadedColor === solidPaintDefaults?.paintColor ? undefined : entry.shadedColor, diff --git a/packages/vue/src/shapes/Poly.ts b/packages/vue/src/shapes/Poly.ts index 4699d053..d4ad2fb5 100644 --- a/packages/vue/src/shapes/Poly.ts +++ b/packages/vue/src/shapes/Poly.ts @@ -27,7 +27,6 @@ import { // ── Material / direct render path ──────────────────────────────────────────── const DIRECT_TEXTURE_CSS_DECIMALS = 4; -const DIRECT_TEXTURE_CANONICAL_SIZE = 128; function formatCssLength(value: number, decimals = DIRECT_TEXTURE_CSS_DECIMALS): string { const next = value.toFixed(decimals).replace(/\.?0+$/, ""); @@ -84,8 +83,8 @@ function renderMaterialDirectPoly({ const style: CSSProperties = { transform: `matrix3d(${plan.canonicalMatrix})`, backgroundImage: `url(${material.texture})`, - backgroundSize: `${formatCssLength(sourceW * DIRECT_TEXTURE_CANONICAL_SIZE)} ${formatCssLength(sourceH * DIRECT_TEXTURE_CANONICAL_SIZE)}`, - backgroundPosition: `${formatCssLength(-offsetX * DIRECT_TEXTURE_CANONICAL_SIZE)} ${formatCssLength(-offsetY * DIRECT_TEXTURE_CANONICAL_SIZE)}`, + backgroundSize: `${formatCssLength(sourceW)} ${formatCssLength(sourceH)}`, + backgroundPosition: `${formatCssLength(-offsetX)} ${formatCssLength(-offsetY)}`, pointerEvents: pointerEvents === "none" ? "none" : undefined, ...styleProp, }; @@ -97,7 +96,7 @@ function renderMaterialDirectPoly({ : {}; const elementClassName = className?.trim() || undefined; - return h("s", { + return h("i", { class: elementClassName, style, ...dataAttrs, diff --git a/packages/vue/src/styles/styles.test.ts b/packages/vue/src/styles/styles.test.ts index 5ea831be..3d8fd3e5 100644 --- a/packages/vue/src/styles/styles.test.ts +++ b/packages/vue/src/styles/styles.test.ts @@ -47,7 +47,6 @@ describe("injectPolyBaseStyles", () => { expect(el.textContent).toContain("transform-origin: 0 0"); expect(el.textContent).toContain("backface-visibility: hidden"); expect(el.textContent).toContain("background-repeat: no-repeat"); - expect(el.textContent).toContain("user-select: none"); expect(el.textContent).toContain("width: 0;"); expect(el.textContent).toContain("height: 0;"); }); diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index 809d9d61..2eac5e74 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -48,9 +48,20 @@ const CORE_BASE_STYLES = ` position: absolute; } -.polycss-mesh { - -webkit-user-select: none; - user-select: none; +/* ── First-person controls perspective context ──────────────────────────── */ + +/* PolyFirstPersonControls toggles this class on its host element (the camera + wrapper). FPV needs a real perspective context so scene Z translation + produces visible depth motion - without it, walking forward looks like a + planar pan. The class wins over inline perspective styles (e.g. + PolyOrthographicCamera's perspective: none) via !important. The actual + perspective value is set inline by the controls as the + --polycss-fpv-perspective custom property; the default of 2000px matches + the controls' lookOffset fallback so the FPV math and visual perspective + stay in sync. */ +.polycss-fpv-host { + perspective: var(--polycss-fpv-perspective, 2000px) !important; + transform-style: preserve-3d !important; } /* ── Polygon leaf element ───────────────────────────────────────────────── */ @@ -78,8 +89,6 @@ const CORE_BASE_STYLES = ` text-decoration: none; backface-visibility: hidden; background-repeat: no-repeat; - -webkit-user-select: none; - user-select: none; } .polycss-scene b, @@ -90,19 +99,19 @@ const CORE_BASE_STYLES = ` .polycss-scene b { background: currentColor; - width: 64px; - height: 64px; + width: 1px; + height: 1px; } .polycss-scene i { - width: 64px; - height: 64px; + width: 16px; + height: 16px; border-color: currentColor; } .polycss-scene s { - width: 128px; - height: 128px; + width: 1px; + height: 1px; } .polycss-scene u { @@ -112,7 +121,7 @@ const CORE_BASE_STYLES = ` box-sizing: content-box; border: 0 solid transparent; border-color: transparent transparent currentColor transparent; - border-width: 0 64px 64px 64px; + border-width: 0 1px 1px 1px; } /* ── Dynamic lighting cascade vars (scene root → polygons) ─────────────── */ @@ -237,14 +246,37 @@ const CORE_BASE_STYLES = ` backface-visibility: visible; border-color: currentColor; pointer-events: none; - -webkit-user-select: none; - user-select: none; } .polycss-scene q::before, .polycss-scene q::after { content: none; } +/* + * Rotate rings: one square quad per ring, masked to a donut. The mask uses + * --ring-inner-ratio (set inline by PolyTransformControls); hit-testing + * matches the donut shape so the inner hole isn't clickable. + */ +.polycss-transform-ring i, +.polycss-transform-ring b, +.polycss-transform-ring s, +.polycss-transform-ring u { + --ring-inner-r: calc(var(--ring-inner-ratio, 0.92) * 50%); + --ring-outer-r: calc(var(--ring-outer-ratio, 1) * 50%); + -webkit-mask: radial-gradient(circle at 50% 50%, + transparent 0%, + transparent var(--ring-inner-r), + black var(--ring-inner-r), + black var(--ring-outer-r), + transparent var(--ring-outer-r)); + mask: radial-gradient(circle at 50% 50%, + transparent 0%, + transparent var(--ring-inner-r), + black var(--ring-inner-r), + black var(--ring-outer-r), + transparent var(--ring-outer-r)); +} + /* Shadow projection matrix. Projects any 3D point P onto the horizontal ground plane (cssZ ≈ G) along the CSS-space light direction (--clx/y/z). diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 6247e0c7..0c8bf8c3 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -10,6 +10,7 @@ const repoPath = (path) => fileURLToPath(new URL(path, import.meta.url)); export default defineConfig({ site: 'https://polycss.com', + devToolbar: { enabled: false }, vite: { resolve: { dedupe: ['react', 'react-dom'], diff --git a/website/public/gallery/glb/city/Large Building-1bt4yYKmuK.glb b/website/public/gallery/glb/city/Large Building-1bt4yYKmuK.glb new file mode 100644 index 00000000..02352da5 Binary files /dev/null and b/website/public/gallery/glb/city/Large Building-1bt4yYKmuK.glb differ diff --git a/website/public/gallery/glb/city/Large Building-3IhrYZp6tP.glb b/website/public/gallery/glb/city/Large Building-3IhrYZp6tP.glb new file mode 100644 index 00000000..571700b7 Binary files /dev/null and b/website/public/gallery/glb/city/Large Building-3IhrYZp6tP.glb differ diff --git a/website/public/gallery/glb/city/Large Building-JgGLJH2iXj.glb b/website/public/gallery/glb/city/Large Building-JgGLJH2iXj.glb new file mode 100644 index 00000000..e8b5dd53 Binary files /dev/null and b/website/public/gallery/glb/city/Large Building-JgGLJH2iXj.glb differ diff --git a/website/public/gallery/glb/city/Large Building-h7Jaq7bqMq.glb b/website/public/gallery/glb/city/Large Building-h7Jaq7bqMq.glb new file mode 100644 index 00000000..46ebe33d Binary files /dev/null and b/website/public/gallery/glb/city/Large Building-h7Jaq7bqMq.glb differ diff --git a/website/public/gallery/glb/city/Large Building-ppwtREejXg.glb b/website/public/gallery/glb/city/Large Building-ppwtREejXg.glb new file mode 100644 index 00000000..711ecc2e Binary files /dev/null and b/website/public/gallery/glb/city/Large Building-ppwtREejXg.glb differ diff --git a/website/public/gallery/glb/city/Large Building-sxXonOmtct.glb b/website/public/gallery/glb/city/Large Building-sxXonOmtct.glb new file mode 100644 index 00000000..ddbca382 Binary files /dev/null and b/website/public/gallery/glb/city/Large Building-sxXonOmtct.glb differ diff --git a/website/public/gallery/glb/city/Large Building.glb b/website/public/gallery/glb/city/Large Building.glb new file mode 100644 index 00000000..91058a40 Binary files /dev/null and b/website/public/gallery/glb/city/Large Building.glb differ diff --git a/website/public/gallery/glb/city/Low Building-4RoPd9BkSx.glb b/website/public/gallery/glb/city/Low Building-4RoPd9BkSx.glb new file mode 100644 index 00000000..89e67dfc Binary files /dev/null and b/website/public/gallery/glb/city/Low Building-4RoPd9BkSx.glb differ diff --git a/website/public/gallery/glb/city/Low Building-9fEKMpTsAi.glb b/website/public/gallery/glb/city/Low Building-9fEKMpTsAi.glb new file mode 100644 index 00000000..09e1f99c Binary files /dev/null and b/website/public/gallery/glb/city/Low Building-9fEKMpTsAi.glb differ diff --git a/website/public/gallery/glb/city/Low Building-AXFdNPAEc9.glb b/website/public/gallery/glb/city/Low Building-AXFdNPAEc9.glb new file mode 100644 index 00000000..189b0d84 Binary files /dev/null and b/website/public/gallery/glb/city/Low Building-AXFdNPAEc9.glb differ diff --git a/website/public/gallery/glb/city/Low Building-XsFOzw8E5N.glb b/website/public/gallery/glb/city/Low Building-XsFOzw8E5N.glb new file mode 100644 index 00000000..e5e58a68 Binary files /dev/null and b/website/public/gallery/glb/city/Low Building-XsFOzw8E5N.glb differ diff --git a/website/public/gallery/glb/city/Low Building-dYEbYdPfJr.glb b/website/public/gallery/glb/city/Low Building-dYEbYdPfJr.glb new file mode 100644 index 00000000..ceee5880 Binary files /dev/null and b/website/public/gallery/glb/city/Low Building-dYEbYdPfJr.glb differ diff --git a/website/public/gallery/glb/city/Low Building-sObKC8Mio2.glb b/website/public/gallery/glb/city/Low Building-sObKC8Mio2.glb new file mode 100644 index 00000000..d7683e55 Binary files /dev/null and b/website/public/gallery/glb/city/Low Building-sObKC8Mio2.glb differ diff --git a/website/public/gallery/glb/city/Low Building-tuieC1Pj0a.glb b/website/public/gallery/glb/city/Low Building-tuieC1Pj0a.glb new file mode 100644 index 00000000..84e413e0 Binary files /dev/null and b/website/public/gallery/glb/city/Low Building-tuieC1Pj0a.glb differ diff --git a/website/public/gallery/glb/city/Low Building-zfjlejAxB7.glb b/website/public/gallery/glb/city/Low Building-zfjlejAxB7.glb new file mode 100644 index 00000000..5b25171a Binary files /dev/null and b/website/public/gallery/glb/city/Low Building-zfjlejAxB7.glb differ diff --git a/website/public/gallery/glb/city/Low Building.glb b/website/public/gallery/glb/city/Low Building.glb new file mode 100644 index 00000000..d0d61643 Binary files /dev/null and b/website/public/gallery/glb/city/Low Building.glb differ diff --git a/website/public/gallery/glb/city/Low Wide-DKgknsHjmr.glb b/website/public/gallery/glb/city/Low Wide-DKgknsHjmr.glb new file mode 100644 index 00000000..3958ef38 Binary files /dev/null and b/website/public/gallery/glb/city/Low Wide-DKgknsHjmr.glb differ diff --git a/website/public/gallery/glb/city/Low Wide.glb b/website/public/gallery/glb/city/Low Wide.glb new file mode 100644 index 00000000..fc27f8f6 Binary files /dev/null and b/website/public/gallery/glb/city/Low Wide.glb differ diff --git a/website/public/gallery/glb/city/Sign Hospital.glb b/website/public/gallery/glb/city/Sign Hospital.glb new file mode 100644 index 00000000..d4c778b0 Binary files /dev/null and b/website/public/gallery/glb/city/Sign Hospital.glb differ diff --git a/website/public/gallery/glb/city/Skyscraper-BwEXdOoUSO.glb b/website/public/gallery/glb/city/Skyscraper-BwEXdOoUSO.glb new file mode 100644 index 00000000..6e245146 Binary files /dev/null and b/website/public/gallery/glb/city/Skyscraper-BwEXdOoUSO.glb differ diff --git a/website/public/gallery/glb/city/Skyscraper-PsPe0MzK0E.glb b/website/public/gallery/glb/city/Skyscraper-PsPe0MzK0E.glb new file mode 100644 index 00000000..4f039831 Binary files /dev/null and b/website/public/gallery/glb/city/Skyscraper-PsPe0MzK0E.glb differ diff --git a/website/public/gallery/glb/city/Skyscraper-XST1j6kYsL.glb b/website/public/gallery/glb/city/Skyscraper-XST1j6kYsL.glb new file mode 100644 index 00000000..d3455309 Binary files /dev/null and b/website/public/gallery/glb/city/Skyscraper-XST1j6kYsL.glb differ diff --git a/website/public/gallery/glb/city/Skyscraper-jIRx0AhYOR.glb b/website/public/gallery/glb/city/Skyscraper-jIRx0AhYOR.glb new file mode 100644 index 00000000..95b2a64b Binary files /dev/null and b/website/public/gallery/glb/city/Skyscraper-jIRx0AhYOR.glb differ diff --git a/website/public/gallery/glb/city/Skyscraper-obYD8hWLTZ.glb b/website/public/gallery/glb/city/Skyscraper-obYD8hWLTZ.glb new file mode 100644 index 00000000..b5267852 Binary files /dev/null and b/website/public/gallery/glb/city/Skyscraper-obYD8hWLTZ.glb differ diff --git a/website/public/gallery/glb/city/Skyscraper.glb b/website/public/gallery/glb/city/Skyscraper.glb new file mode 100644 index 00000000..128cd93d Binary files /dev/null and b/website/public/gallery/glb/city/Skyscraper.glb differ diff --git a/website/public/gallery/glb/city/Small Building-QjL4Fo9dU9.glb b/website/public/gallery/glb/city/Small Building-QjL4Fo9dU9.glb new file mode 100644 index 00000000..299bb41f Binary files /dev/null and b/website/public/gallery/glb/city/Small Building-QjL4Fo9dU9.glb differ diff --git a/website/public/gallery/glb/city/Small Building-Rq572hdKEz.glb b/website/public/gallery/glb/city/Small Building-Rq572hdKEz.glb new file mode 100644 index 00000000..0959c94e Binary files /dev/null and b/website/public/gallery/glb/city/Small Building-Rq572hdKEz.glb differ diff --git a/website/public/gallery/glb/city/Small Building-gyjF60t7CG.glb b/website/public/gallery/glb/city/Small Building-gyjF60t7CG.glb new file mode 100644 index 00000000..738b10cd Binary files /dev/null and b/website/public/gallery/glb/city/Small Building-gyjF60t7CG.glb differ diff --git a/website/public/gallery/glb/city/Small Building-t9j9Lof5ul.glb b/website/public/gallery/glb/city/Small Building-t9j9Lof5ul.glb new file mode 100644 index 00000000..a2a76e17 Binary files /dev/null and b/website/public/gallery/glb/city/Small Building-t9j9Lof5ul.glb differ diff --git a/website/public/gallery/glb/city/Small Building-yLvnMqC9ZG.glb b/website/public/gallery/glb/city/Small Building-yLvnMqC9ZG.glb new file mode 100644 index 00000000..f4566ec5 Binary files /dev/null and b/website/public/gallery/glb/city/Small Building-yLvnMqC9ZG.glb differ diff --git a/website/public/gallery/glb/city/Small Building.glb b/website/public/gallery/glb/city/Small Building.glb new file mode 100644 index 00000000..8aa99446 Binary files /dev/null and b/website/public/gallery/glb/city/Small Building.glb differ diff --git a/website/public/gallery/glb/medieval/Bag Open.glb b/website/public/gallery/glb/medieval/Bag Open.glb new file mode 100644 index 00000000..0c134129 Binary files /dev/null and b/website/public/gallery/glb/medieval/Bag Open.glb differ diff --git a/website/public/gallery/glb/medieval/Bag.glb b/website/public/gallery/glb/medieval/Bag.glb new file mode 100644 index 00000000..10c9fa9c Binary files /dev/null and b/website/public/gallery/glb/medieval/Bag.glb differ diff --git a/website/public/gallery/glb/medieval/Bags.glb b/website/public/gallery/glb/medieval/Bags.glb new file mode 100644 index 00000000..732c2d22 Binary files /dev/null and b/website/public/gallery/glb/medieval/Bags.glb differ diff --git a/website/public/gallery/glb/medieval/Barrel.glb b/website/public/gallery/glb/medieval/Barrel.glb new file mode 100644 index 00000000..1457721d Binary files /dev/null and b/website/public/gallery/glb/medieval/Barrel.glb differ diff --git a/website/public/gallery/glb/medieval/Bell Tower.glb b/website/public/gallery/glb/medieval/Bell Tower.glb new file mode 100644 index 00000000..8b394178 Binary files /dev/null and b/website/public/gallery/glb/medieval/Bell Tower.glb differ diff --git a/website/public/gallery/glb/medieval/Bell.glb b/website/public/gallery/glb/medieval/Bell.glb new file mode 100644 index 00000000..58a22101 Binary files /dev/null and b/website/public/gallery/glb/medieval/Bell.glb differ diff --git a/website/public/gallery/glb/medieval/Bench-7uSlZo3n9Y.glb b/website/public/gallery/glb/medieval/Bench-7uSlZo3n9Y.glb new file mode 100644 index 00000000..cff40964 Binary files /dev/null and b/website/public/gallery/glb/medieval/Bench-7uSlZo3n9Y.glb differ diff --git a/website/public/gallery/glb/medieval/Bench.glb b/website/public/gallery/glb/medieval/Bench.glb new file mode 100644 index 00000000..2ae6b656 Binary files /dev/null and b/website/public/gallery/glb/medieval/Bench.glb differ diff --git a/website/public/gallery/glb/medieval/Blacksmith.glb b/website/public/gallery/glb/medieval/Blacksmith.glb new file mode 100644 index 00000000..83917c55 Binary files /dev/null and b/website/public/gallery/glb/medieval/Blacksmith.glb differ diff --git a/website/public/gallery/glb/medieval/Bonfire.glb b/website/public/gallery/glb/medieval/Bonfire.glb new file mode 100644 index 00000000..b936e46d Binary files /dev/null and b/website/public/gallery/glb/medieval/Bonfire.glb differ diff --git a/website/public/gallery/glb/medieval/Cart.glb b/website/public/gallery/glb/medieval/Cart.glb new file mode 100644 index 00000000..b69da9a5 Binary files /dev/null and b/website/public/gallery/glb/medieval/Cart.glb differ diff --git a/website/public/gallery/glb/medieval/Cauldron.glb b/website/public/gallery/glb/medieval/Cauldron.glb new file mode 100644 index 00000000..144156e4 Binary files /dev/null and b/website/public/gallery/glb/medieval/Cauldron.glb differ diff --git a/website/public/gallery/glb/medieval/Crate.glb b/website/public/gallery/glb/medieval/Crate.glb new file mode 100644 index 00000000..1643dc19 Binary files /dev/null and b/website/public/gallery/glb/medieval/Crate.glb differ diff --git a/website/public/gallery/glb/medieval/Door Round.glb b/website/public/gallery/glb/medieval/Door Round.glb new file mode 100644 index 00000000..a6011029 Binary files /dev/null and b/website/public/gallery/glb/medieval/Door Round.glb differ diff --git a/website/public/gallery/glb/medieval/Door Straight.glb b/website/public/gallery/glb/medieval/Door Straight.glb new file mode 100644 index 00000000..4e16d7ea Binary files /dev/null and b/website/public/gallery/glb/medieval/Door Straight.glb differ diff --git a/website/public/gallery/glb/medieval/Fantasy Barracks.glb b/website/public/gallery/glb/medieval/Fantasy Barracks.glb new file mode 100644 index 00000000..8beb250a Binary files /dev/null and b/website/public/gallery/glb/medieval/Fantasy Barracks.glb differ diff --git a/website/public/gallery/glb/medieval/Fantasy House-BH2XHWUNmF.glb b/website/public/gallery/glb/medieval/Fantasy House-BH2XHWUNmF.glb new file mode 100644 index 00000000..b2733641 Binary files /dev/null and b/website/public/gallery/glb/medieval/Fantasy House-BH2XHWUNmF.glb differ diff --git a/website/public/gallery/glb/medieval/Fantasy House-dcPho4SUA3.glb b/website/public/gallery/glb/medieval/Fantasy House-dcPho4SUA3.glb new file mode 100644 index 00000000..daaa54f0 Binary files /dev/null and b/website/public/gallery/glb/medieval/Fantasy House-dcPho4SUA3.glb differ diff --git a/website/public/gallery/glb/medieval/Fantasy House.glb b/website/public/gallery/glb/medieval/Fantasy House.glb new file mode 100644 index 00000000..74e71b5f Binary files /dev/null and b/website/public/gallery/glb/medieval/Fantasy House.glb differ diff --git a/website/public/gallery/glb/medieval/Fantasy Inn.glb b/website/public/gallery/glb/medieval/Fantasy Inn.glb new file mode 100644 index 00000000..773f9184 Binary files /dev/null and b/website/public/gallery/glb/medieval/Fantasy Inn.glb differ diff --git a/website/public/gallery/glb/medieval/Fantasy Sawmill.glb b/website/public/gallery/glb/medieval/Fantasy Sawmill.glb new file mode 100644 index 00000000..61bb00a5 Binary files /dev/null and b/website/public/gallery/glb/medieval/Fantasy Sawmill.glb differ diff --git a/website/public/gallery/glb/medieval/Fantasy Stable.glb b/website/public/gallery/glb/medieval/Fantasy Stable.glb new file mode 100644 index 00000000..0a5d8cf3 Binary files /dev/null and b/website/public/gallery/glb/medieval/Fantasy Stable.glb differ diff --git a/website/public/gallery/glb/medieval/Fence.glb b/website/public/gallery/glb/medieval/Fence.glb new file mode 100644 index 00000000..562e389e Binary files /dev/null and b/website/public/gallery/glb/medieval/Fence.glb differ diff --git a/website/public/gallery/glb/medieval/Gazebo.glb b/website/public/gallery/glb/medieval/Gazebo.glb new file mode 100644 index 00000000..7d96b269 Binary files /dev/null and b/website/public/gallery/glb/medieval/Gazebo.glb differ diff --git a/website/public/gallery/glb/medieval/Hay.glb b/website/public/gallery/glb/medieval/Hay.glb new file mode 100644 index 00000000..f6b0f882 Binary files /dev/null and b/website/public/gallery/glb/medieval/Hay.glb differ diff --git a/website/public/gallery/glb/medieval/Market Stand-DGIM5HGISb.glb b/website/public/gallery/glb/medieval/Market Stand-DGIM5HGISb.glb new file mode 100644 index 00000000..3894530a Binary files /dev/null and b/website/public/gallery/glb/medieval/Market Stand-DGIM5HGISb.glb differ diff --git a/website/public/gallery/glb/medieval/Market Stand.glb b/website/public/gallery/glb/medieval/Market Stand.glb new file mode 100644 index 00000000..049e04ad Binary files /dev/null and b/website/public/gallery/glb/medieval/Market Stand.glb differ diff --git a/website/public/gallery/glb/medieval/Mill.glb b/website/public/gallery/glb/medieval/Mill.glb new file mode 100644 index 00000000..bf421e0d Binary files /dev/null and b/website/public/gallery/glb/medieval/Mill.glb differ diff --git a/website/public/gallery/glb/medieval/Package-kYvD6QCQRd.glb b/website/public/gallery/glb/medieval/Package-kYvD6QCQRd.glb new file mode 100644 index 00000000..ada9cafe Binary files /dev/null and b/website/public/gallery/glb/medieval/Package-kYvD6QCQRd.glb differ diff --git a/website/public/gallery/glb/medieval/Package.glb b/website/public/gallery/glb/medieval/Package.glb new file mode 100644 index 00000000..a57fb458 Binary files /dev/null and b/website/public/gallery/glb/medieval/Package.glb differ diff --git a/website/public/gallery/glb/medieval/Path Straight.glb b/website/public/gallery/glb/medieval/Path Straight.glb new file mode 100644 index 00000000..1fb0e9e5 Binary files /dev/null and b/website/public/gallery/glb/medieval/Path Straight.glb differ diff --git a/website/public/gallery/glb/medieval/Rocks.glb b/website/public/gallery/glb/medieval/Rocks.glb new file mode 100644 index 00000000..7a5be72f Binary files /dev/null and b/website/public/gallery/glb/medieval/Rocks.glb differ diff --git a/website/public/gallery/glb/medieval/Round Window.glb b/website/public/gallery/glb/medieval/Round Window.glb new file mode 100644 index 00000000..7321b0c3 Binary files /dev/null and b/website/public/gallery/glb/medieval/Round Window.glb differ diff --git a/website/public/gallery/glb/medieval/Sawmill Saw.glb b/website/public/gallery/glb/medieval/Sawmill Saw.glb new file mode 100644 index 00000000..c7b8450b Binary files /dev/null and b/website/public/gallery/glb/medieval/Sawmill Saw.glb differ diff --git a/website/public/gallery/glb/medieval/Smoke.glb b/website/public/gallery/glb/medieval/Smoke.glb new file mode 100644 index 00000000..c7fd8f5e Binary files /dev/null and b/website/public/gallery/glb/medieval/Smoke.glb differ diff --git a/website/public/gallery/glb/medieval/Stairs.glb b/website/public/gallery/glb/medieval/Stairs.glb new file mode 100644 index 00000000..67ec6739 Binary files /dev/null and b/website/public/gallery/glb/medieval/Stairs.glb differ diff --git a/website/public/gallery/glb/medieval/Well.glb b/website/public/gallery/glb/medieval/Well.glb new file mode 100644 index 00000000..80996c09 Binary files /dev/null and b/website/public/gallery/glb/medieval/Well.glb differ diff --git a/website/public/gallery/glb/medieval/Window-EY1zrFcme9.glb b/website/public/gallery/glb/medieval/Window-EY1zrFcme9.glb new file mode 100644 index 00000000..197cace1 Binary files /dev/null and b/website/public/gallery/glb/medieval/Window-EY1zrFcme9.glb differ diff --git a/website/public/gallery/glb/medieval/Window.glb b/website/public/gallery/glb/medieval/Window.glb new file mode 100644 index 00000000..1633ad74 Binary files /dev/null and b/website/public/gallery/glb/medieval/Window.glb differ diff --git a/website/public/gallery/glb/urban/ATM.glb b/website/public/gallery/glb/urban/ATM.glb new file mode 100644 index 00000000..bfe8d4f9 Binary files /dev/null and b/website/public/gallery/glb/urban/ATM.glb differ diff --git a/website/public/gallery/glb/urban/Adventurer.glb b/website/public/gallery/glb/urban/Adventurer.glb new file mode 100644 index 00000000..883c5193 Binary files /dev/null and b/website/public/gallery/glb/urban/Adventurer.glb differ diff --git a/website/public/gallery/glb/urban/Air conditioner.glb b/website/public/gallery/glb/urban/Air conditioner.glb new file mode 100644 index 00000000..51554849 Binary files /dev/null and b/website/public/gallery/glb/urban/Air conditioner.glb differ diff --git a/website/public/gallery/glb/urban/Animated Woman-nIItLV9nxS.glb b/website/public/gallery/glb/urban/Animated Woman-nIItLV9nxS.glb new file mode 100644 index 00000000..bdbe4689 Binary files /dev/null and b/website/public/gallery/glb/urban/Animated Woman-nIItLV9nxS.glb differ diff --git a/website/public/gallery/glb/urban/Animated Woman-qJ2gsTUBHL.glb b/website/public/gallery/glb/urban/Animated Woman-qJ2gsTUBHL.glb new file mode 100644 index 00000000..818ea386 Binary files /dev/null and b/website/public/gallery/glb/urban/Animated Woman-qJ2gsTUBHL.glb differ diff --git a/website/public/gallery/glb/urban/Animated Woman.glb b/website/public/gallery/glb/urban/Animated Woman.glb new file mode 100644 index 00000000..f4d47235 Binary files /dev/null and b/website/public/gallery/glb/urban/Animated Woman.glb differ diff --git a/website/public/gallery/glb/urban/Bench.glb b/website/public/gallery/glb/urban/Bench.glb new file mode 100644 index 00000000..019c2242 Binary files /dev/null and b/website/public/gallery/glb/urban/Bench.glb differ diff --git a/website/public/gallery/glb/urban/Bicycle.glb b/website/public/gallery/glb/urban/Bicycle.glb new file mode 100644 index 00000000..ebbb3bbe Binary files /dev/null and b/website/public/gallery/glb/urban/Bicycle.glb differ diff --git a/website/public/gallery/glb/urban/Big Building.glb b/website/public/gallery/glb/urban/Big Building.glb new file mode 100644 index 00000000..40686ec5 Binary files /dev/null and b/website/public/gallery/glb/urban/Big Building.glb differ diff --git a/website/public/gallery/glb/urban/Billboard.glb b/website/public/gallery/glb/urban/Billboard.glb new file mode 100644 index 00000000..520a0873 Binary files /dev/null and b/website/public/gallery/glb/urban/Billboard.glb differ diff --git a/website/public/gallery/glb/urban/Box.glb b/website/public/gallery/glb/urban/Box.glb new file mode 100644 index 00000000..82f15ed7 Binary files /dev/null and b/website/public/gallery/glb/urban/Box.glb differ diff --git a/website/public/gallery/glb/urban/Brown Building.glb b/website/public/gallery/glb/urban/Brown Building.glb new file mode 100644 index 00000000..b094c355 Binary files /dev/null and b/website/public/gallery/glb/urban/Brown Building.glb differ diff --git a/website/public/gallery/glb/urban/Building Green.glb b/website/public/gallery/glb/urban/Building Green.glb new file mode 100644 index 00000000..66afd46b Binary files /dev/null and b/website/public/gallery/glb/urban/Building Green.glb differ diff --git a/website/public/gallery/glb/urban/Building Red Corner.glb b/website/public/gallery/glb/urban/Building Red Corner.glb new file mode 100644 index 00000000..f16f824f Binary files /dev/null and b/website/public/gallery/glb/urban/Building Red Corner.glb differ diff --git a/website/public/gallery/glb/urban/Building Red.glb b/website/public/gallery/glb/urban/Building Red.glb new file mode 100644 index 00000000..d4d81014 Binary files /dev/null and b/website/public/gallery/glb/urban/Building Red.glb differ diff --git a/website/public/gallery/glb/urban/Bus Stop.glb b/website/public/gallery/glb/urban/Bus Stop.glb new file mode 100644 index 00000000..896d7f3a Binary files /dev/null and b/website/public/gallery/glb/urban/Bus Stop.glb differ diff --git a/website/public/gallery/glb/urban/Bus stop sign.glb b/website/public/gallery/glb/urban/Bus stop sign.glb new file mode 100644 index 00000000..226bd067 Binary files /dev/null and b/website/public/gallery/glb/urban/Bus stop sign.glb differ diff --git a/website/public/gallery/glb/urban/Bus.glb b/website/public/gallery/glb/urban/Bus.glb new file mode 100644 index 00000000..7b510177 Binary files /dev/null and b/website/public/gallery/glb/urban/Bus.glb differ diff --git a/website/public/gallery/glb/urban/Car-unqqkULtRU.glb b/website/public/gallery/glb/urban/Car-unqqkULtRU.glb new file mode 100644 index 00000000..e9fae95f Binary files /dev/null and b/website/public/gallery/glb/urban/Car-unqqkULtRU.glb differ diff --git a/website/public/gallery/glb/urban/Car.glb b/website/public/gallery/glb/urban/Car.glb new file mode 100644 index 00000000..8a7cace9 Binary files /dev/null and b/website/public/gallery/glb/urban/Car.glb differ diff --git a/website/public/gallery/glb/urban/Cone.glb b/website/public/gallery/glb/urban/Cone.glb new file mode 100644 index 00000000..af03dc2a Binary files /dev/null and b/website/public/gallery/glb/urban/Cone.glb differ diff --git a/website/public/gallery/glb/urban/Debris Papers.glb b/website/public/gallery/glb/urban/Debris Papers.glb new file mode 100644 index 00000000..3255788d Binary files /dev/null and b/website/public/gallery/glb/urban/Debris Papers.glb differ diff --git a/website/public/gallery/glb/urban/Dumpster.glb b/website/public/gallery/glb/urban/Dumpster.glb new file mode 100644 index 00000000..4dec8c67 Binary files /dev/null and b/website/public/gallery/glb/urban/Dumpster.glb differ diff --git a/website/public/gallery/glb/urban/Fence End.glb b/website/public/gallery/glb/urban/Fence End.glb new file mode 100644 index 00000000..70911659 Binary files /dev/null and b/website/public/gallery/glb/urban/Fence End.glb differ diff --git a/website/public/gallery/glb/urban/Fence Piece.glb b/website/public/gallery/glb/urban/Fence Piece.glb new file mode 100644 index 00000000..06c17fe2 Binary files /dev/null and b/website/public/gallery/glb/urban/Fence Piece.glb differ diff --git a/website/public/gallery/glb/urban/Fence.glb b/website/public/gallery/glb/urban/Fence.glb new file mode 100644 index 00000000..953f5f9c Binary files /dev/null and b/website/public/gallery/glb/urban/Fence.glb differ diff --git a/website/public/gallery/glb/urban/Fire Exit.glb b/website/public/gallery/glb/urban/Fire Exit.glb new file mode 100644 index 00000000..8d2bc81a Binary files /dev/null and b/website/public/gallery/glb/urban/Fire Exit.glb differ diff --git a/website/public/gallery/glb/urban/Fire hydrant.glb b/website/public/gallery/glb/urban/Fire hydrant.glb new file mode 100644 index 00000000..a5c1c1f5 Binary files /dev/null and b/website/public/gallery/glb/urban/Fire hydrant.glb differ diff --git a/website/public/gallery/glb/urban/Floor Hole.glb b/website/public/gallery/glb/urban/Floor Hole.glb new file mode 100644 index 00000000..20094f82 Binary files /dev/null and b/website/public/gallery/glb/urban/Floor Hole.glb differ diff --git a/website/public/gallery/glb/urban/Flower Pot-Kgt363WkKd.glb b/website/public/gallery/glb/urban/Flower Pot-Kgt363WkKd.glb new file mode 100644 index 00000000..74e41374 Binary files /dev/null and b/website/public/gallery/glb/urban/Flower Pot-Kgt363WkKd.glb differ diff --git a/website/public/gallery/glb/urban/Flower Pot.glb b/website/public/gallery/glb/urban/Flower Pot.glb new file mode 100644 index 00000000..07327b36 Binary files /dev/null and b/website/public/gallery/glb/urban/Flower Pot.glb differ diff --git a/website/public/gallery/glb/urban/Gb Blank.glb b/website/public/gallery/glb/urban/Gb Blank.glb new file mode 100644 index 00000000..752524bf Binary files /dev/null and b/website/public/gallery/glb/urban/Gb Blank.glb differ diff --git a/website/public/gallery/glb/urban/Greenhouse.glb b/website/public/gallery/glb/urban/Greenhouse.glb new file mode 100644 index 00000000..219614df Binary files /dev/null and b/website/public/gallery/glb/urban/Greenhouse.glb differ diff --git a/website/public/gallery/glb/urban/Mailbox.glb b/website/public/gallery/glb/urban/Mailbox.glb new file mode 100644 index 00000000..7b60cbe4 Binary files /dev/null and b/website/public/gallery/glb/urban/Mailbox.glb differ diff --git a/website/public/gallery/glb/urban/Man.glb b/website/public/gallery/glb/urban/Man.glb new file mode 100644 index 00000000..938feebc Binary files /dev/null and b/website/public/gallery/glb/urban/Man.glb differ diff --git a/website/public/gallery/glb/urban/Manhole Cover.glb b/website/public/gallery/glb/urban/Manhole Cover.glb new file mode 100644 index 00000000..5f35dce6 Binary files /dev/null and b/website/public/gallery/glb/urban/Manhole Cover.glb differ diff --git a/website/public/gallery/glb/urban/Motorcycle.glb b/website/public/gallery/glb/urban/Motorcycle.glb new file mode 100644 index 00000000..3afca70d Binary files /dev/null and b/website/public/gallery/glb/urban/Motorcycle.glb differ diff --git a/website/public/gallery/glb/urban/Pickup Truck.glb b/website/public/gallery/glb/urban/Pickup Truck.glb new file mode 100644 index 00000000..17344bbf Binary files /dev/null and b/website/public/gallery/glb/urban/Pickup Truck.glb differ diff --git a/website/public/gallery/glb/urban/Pizza Corner.glb b/website/public/gallery/glb/urban/Pizza Corner.glb new file mode 100644 index 00000000..a76d8b27 Binary files /dev/null and b/website/public/gallery/glb/urban/Pizza Corner.glb differ diff --git a/website/public/gallery/glb/urban/Planter & Bushes.glb b/website/public/gallery/glb/urban/Planter & Bushes.glb new file mode 100644 index 00000000..0ac53282 Binary files /dev/null and b/website/public/gallery/glb/urban/Planter & Bushes.glb differ diff --git a/website/public/gallery/glb/urban/Police Car.glb b/website/public/gallery/glb/urban/Police Car.glb new file mode 100644 index 00000000..57c2348c Binary files /dev/null and b/website/public/gallery/glb/urban/Police Car.glb differ diff --git a/website/public/gallery/glb/urban/Power Box.glb b/website/public/gallery/glb/urban/Power Box.glb new file mode 100644 index 00000000..81c9d46c Binary files /dev/null and b/website/public/gallery/glb/urban/Power Box.glb differ diff --git a/website/public/gallery/glb/urban/RB Blank.glb b/website/public/gallery/glb/urban/RB Blank.glb new file mode 100644 index 00000000..b14ba332 Binary files /dev/null and b/website/public/gallery/glb/urban/RB Blank.glb differ diff --git a/website/public/gallery/glb/urban/Road Bits.glb b/website/public/gallery/glb/urban/Road Bits.glb new file mode 100644 index 00000000..4fa5f34d Binary files /dev/null and b/website/public/gallery/glb/urban/Road Bits.glb differ diff --git a/website/public/gallery/glb/urban/Rock band poster.glb b/website/public/gallery/glb/urban/Rock band poster.glb new file mode 100644 index 00000000..2059d810 Binary files /dev/null and b/website/public/gallery/glb/urban/Rock band poster.glb differ diff --git a/website/public/gallery/glb/urban/Roof Exit.glb b/website/public/gallery/glb/urban/Roof Exit.glb new file mode 100644 index 00000000..42f89808 Binary files /dev/null and b/website/public/gallery/glb/urban/Roof Exit.glb differ diff --git a/website/public/gallery/glb/urban/SUV.glb b/website/public/gallery/glb/urban/SUV.glb new file mode 100644 index 00000000..db247701 Binary files /dev/null and b/website/public/gallery/glb/urban/SUV.glb differ diff --git a/website/public/gallery/glb/urban/Sports Car-Gzj704DXdr.glb b/website/public/gallery/glb/urban/Sports Car-Gzj704DXdr.glb new file mode 100644 index 00000000..92dd327e Binary files /dev/null and b/website/public/gallery/glb/urban/Sports Car-Gzj704DXdr.glb differ diff --git a/website/public/gallery/glb/urban/Sports Car.glb b/website/public/gallery/glb/urban/Sports Car.glb new file mode 100644 index 00000000..dd5fd111 Binary files /dev/null and b/website/public/gallery/glb/urban/Sports Car.glb differ diff --git a/website/public/gallery/glb/urban/Stop sign.glb b/website/public/gallery/glb/urban/Stop sign.glb new file mode 100644 index 00000000..bceca9fa Binary files /dev/null and b/website/public/gallery/glb/urban/Stop sign.glb differ diff --git a/website/public/gallery/glb/urban/Traffic Light.glb b/website/public/gallery/glb/urban/Traffic Light.glb new file mode 100644 index 00000000..4c362b2b Binary files /dev/null and b/website/public/gallery/glb/urban/Traffic Light.glb differ diff --git a/website/public/gallery/glb/urban/Trash Can.glb b/website/public/gallery/glb/urban/Trash Can.glb new file mode 100644 index 00000000..15862b2e Binary files /dev/null and b/website/public/gallery/glb/urban/Trash Can.glb differ diff --git a/website/public/gallery/glb/urban/Tree.glb b/website/public/gallery/glb/urban/Tree.glb new file mode 100644 index 00000000..216fe773 Binary files /dev/null and b/website/public/gallery/glb/urban/Tree.glb differ diff --git a/website/public/gallery/glb/urban/Van.glb b/website/public/gallery/glb/urban/Van.glb new file mode 100644 index 00000000..23d902b9 Binary files /dev/null and b/website/public/gallery/glb/urban/Van.glb differ diff --git a/website/public/gallery/glb/urban/Washing Line.glb b/website/public/gallery/glb/urban/Washing Line.glb new file mode 100644 index 00000000..600e5918 Binary files /dev/null and b/website/public/gallery/glb/urban/Washing Line.glb differ diff --git a/website/public/gallery/glb/urban/Yellow Post-it.glb b/website/public/gallery/glb/urban/Yellow Post-it.glb new file mode 100644 index 00000000..46ad51bd Binary files /dev/null and b/website/public/gallery/glb/urban/Yellow Post-it.glb differ diff --git a/website/public/gallery/glb/urban/trah bag grey.glb b/website/public/gallery/glb/urban/trah bag grey.glb new file mode 100644 index 00000000..cd9fc746 Binary files /dev/null and b/website/public/gallery/glb/urban/trah bag grey.glb differ diff --git a/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx b/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx new file mode 100644 index 00000000..a90e4a03 --- /dev/null +++ b/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx @@ -0,0 +1,316 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + PolyFirstPersonControlsHandle, + PolyMeshHandle, + PolyTransformControlsObjectChangeEvent, +} from "@layoutit/polycss-react"; +import { directionalFromOptions, ambientFromOptions } from "../GalleryWorkbench/helpers/lighting"; +import { PRESETS } from "../GalleryWorkbench/presets"; +import type { SceneOptionsState } from "../types"; +import { ModelsSidebar } from "../ModelsSidebar"; +import { StatsOverlay } from "../StatsOverlay"; +import "../GalleryWorkbench/gallery-workbench.css"; +import "./builder-workbench.css"; +import { SCENE_PRESET_ID_PREFIX } from "./scenes"; +import { DEFAULT_SCENE } from "./defaults"; +import { usePlacements } from "./hooks/usePlacements"; +import { useSceneLoader } from "./hooks/useSceneLoader"; +import { usePlacementMode } from "./hooks/usePlacementMode"; +import { useCameraShortcuts } from "./hooks/useCameraShortcuts"; +import { useSceneRender } from "./hooks/useSceneRender"; +import { useSidebarItems } from "./hooks/useSidebarItems"; +import { useTerrain } from "./hooks/useTerrain"; +import { meshBbox } from "./geometry/meshBbox"; +import { placeMeshOnFloor } from "./geometry/placement"; +import { sampleTerrain, rotationForSlope, type TerrainVertices } from "./geometry/terrain"; +import { BuilderScene } from "./components/BuilderScene"; +import { BuilderSceneOutliner } from "./components/BuilderSceneOutliner"; +import { BuilderCameraModePill } from "./components/BuilderCameraModePill"; +import { BuilderToolPalette } from "./components/BuilderToolPalette"; +import { BuilderTargetMode } from "./components/BuilderTargetMode"; +import { BuilderDock } from "./components/BuilderDock"; +import type { PlacedItem, TargetMode, ToolMode } from "./types"; + +/** Re-anchor a placed item to the current terrain at its (worldX, worldY): + * recomputes Z so the mesh's bottom sits on the sampled surface (with + * the COMBINED scale fitScale × scale, so user-scaling preserves the + * floor anchor) and rotation so the mesh tilts to match the local slope. + * Items without `rawPolygons` (scene-preset placeholders before lazy + * load) are passed through unchanged. */ +function snapPlacement( + item: PlacedItem, + terrainVertices: TerrainVertices, + gridResolution: number, +): PlacedItem { + if (!item.rawPolygons) return item; + const bbox = meshBbox(item.rawPolygons); + const sample = sampleTerrain(terrainVertices, gridResolution, item.worldX, item.worldY); + const position = placeMeshOnFloor(item.worldX, item.worldY, bbox, item.fitScale * item.scale, sample.z); + const rotation = rotationForSlope(sample.slopeX, sample.slopeY); + return { ...item, position, rotation }; +} + +export default function BuilderWorkbench() { + const fileInputRef = useRef(null); + // Imperative handle for PolyFirstPersonControls — read by useFpvCull to + // pull the live camera origin without round-tripping through React state. + const fpvControlsRef = useRef(null); + + const [sceneOptions, setSceneOptions] = useState(() => ({ ...DEFAULT_SCENE })); + const updateScene = useCallback((partial: Partial) => { + setSceneOptions((prev) => ({ ...prev, ...partial })); + }, []); + + const [gizmoDragging, setGizmoDragging] = useState(false); + const [gizmoMode, setGizmoMode] = useState<"translate" | "rotate">("translate"); + const [toolMode, setToolMode] = useState("pointer"); + // Default to "face" — raising a face turns the whole cell into a + // flat plateau, which reads more naturally as "stamping geometry" + // than vertex-target tent-shapes. The user can flip to vertex for + // finer control. + const [targetMode, setTargetMode] = useState("face"); + + const { + placedItems, + selectedId, + setSelectedId, + placementCounter, + buildPlacement, + appendItems, + updateItem, + mapItems, + handleDeleteItem, + meshHandlesRef, + getMeshRefCallback, + selectedIdRef, + handleDeleteSelectedRef, + } = usePlacements({ meshResolution: sceneOptions.meshResolution }); + + const { handleAddScene } = useSceneLoader({ + placedItems, + appendItems, + updateItem, + buildPlacement, + placementCounter, + dragMode: sceneOptions.dragMode, + fpvRenderDistance: sceneOptions.fpvRenderDistance, + targetWorld: sceneOptions.target, + fpvControlsRef, + meshResolution: sceneOptions.meshResolution, + updateScene, + }); + + // Terrain editor — engaged when toolMode is anything other than "pointer". + // Declared BEFORE usePlacementMode because placement reads the + // heightmap to land meshes on raised terrain with the local slope + // tilt. The grid polygons in useSceneRender also consume this so the + // floor grid bends with the terrain — there's no separate solid-fill + // mesh anymore, the grid IS the terrain. + const { hoverPolygons, vertices: terrainVertices } = useTerrain({ toolMode, targetMode, sceneOptions }); + + const { + placementDraft, + ghostPolygons, + handleAddPreset, + loadingPresetId, + } = usePlacementMode({ + sceneOptions, + appendItems, + setSelectedId, + placementCounter, + updateScene, + terrainVertices, + targetMode, + }); + + useCameraShortcuts({ dragMode: sceneOptions.dragMode, updateScene }); + + // Terrain-follow: when the heightmap changes, re-snap every placed + // item to the current surface at its (worldX, worldY). Note: this + // overwrites any user-applied gizmo rotation on the next terrain + // edit, which mirrors what the original placement does on commit — + // keep terrain shape stable when fine-tuning rotation. + useEffect(() => { + mapItems((it) => snapPlacement(it, terrainVertices, sceneOptions.gridResolution)); + }, [terrainVertices, mapItems, sceneOptions.gridResolution]); + + const { renderedPolygonsById, renderItems, gridPolygons } = useSceneRender({ + placedItems, + selectedId, + sceneOptions, + fpvControlsRef, + updateScene, + terrainVertices, + }); + + const { modelSearch, setModelSearch, modelCategories, modelTreeId, isCategoryOpen, handleToggleCategory } = + useSidebarItems(); + + // Derived lighting + perspective mode for Dock + scene rendering. + const directionalLight = useMemo( + () => directionalFromOptions(sceneOptions), + // eslint-disable-next-line react-hooks/exhaustive-deps + [sceneOptions.lightAzimuth, sceneOptions.lightElevation, sceneOptions.lightIntensity, sceneOptions.lightColor], + ); + const ambientLight = useMemo( + () => ambientFromOptions(sceneOptions), + // eslint-disable-next-line react-hooks/exhaustive-deps + [sceneOptions.ambientIntensity, sceneOptions.ambientColor], + ); + const perspectiveMode = sceneOptions.perspective === false ? "orthographic" : "perspective"; + const perspectivePx = sceneOptions.perspective === false ? 8000 : sceneOptions.perspective; + + const selected = useMemo( + () => placedItems.find((it) => it.id === selectedId) ?? null, + [placedItems, selectedId], + ); + + const handleSidebarClick = useCallback((id: string) => { + if (id.startsWith(SCENE_PRESET_ID_PREFIX)) { + void handleAddScene(id); + } else { + void handleAddPreset(id); + } + }, [handleAddPreset, handleAddScene]); + + // Delete (or Backspace on Mac) removes the selected item. Ignored while + // focus is in a text input so it doesn't fire when typing in the search box. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!selectedIdRef.current) return; + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + if (e.key === "Delete" || e.key === "Backspace") { + e.preventDefault(); + handleDeleteSelectedRef.current?.(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const handleSelectionChange = useCallback((handles: PolyMeshHandle[]) => { + const first = handles[0] ?? null; + if (!first) { setSelectedId(null); return; } + const id = (first as unknown as { id?: string }).id; + if (typeof id === "string") setSelectedId(id); + }, [setSelectedId]); + + const handleGizmoObjectChange = useCallback((event: PolyTransformControlsObjectChangeEvent) => { + if (!selected) return; + const nextPosition = event.position; + if (nextPosition) { + const TILE = 50; + const dxCss = nextPosition[1] - selected.position[1]; + const dyCss = nextPosition[0] - selected.position[0]; + // Translate via the gizmo updates (worldX, worldY); the Z and tilt + // are re-derived from the terrain at the new XY so the dragged + // mesh follows the surface instead of floating off it. The Z arm + // of the gizmo therefore has no effect on a floor-anchored item — + // intentional (use scale to grow upward; floor stays the floor). + const newWorldX = selected.worldX + dxCss / TILE; + const newWorldY = selected.worldY + dyCss / TILE; + const snapped = snapPlacement( + { ...selected, worldX: newWorldX, worldY: newWorldY }, + terrainVertices, + sceneOptions.gridResolution, + ); + updateItem(selected.id, { + worldX: newWorldX, + worldY: newWorldY, + position: snapped.position, + rotation: snapped.rotation, + }); + } else if (event.rotation) { + updateItem(selected.id, { rotation: event.rotation }); + } + }, [selected, updateItem, terrainVertices, sceneOptions.gridResolution]); + + // Scale slider — apply new scale AND re-anchor the bottom of the mesh + // to the surface. Without this, scaling around the bbox centre would + // make the item sink into / lift off the floor. + const handleScaleSelected = useCallback((scale: number) => { + mapItems((it) => + it.id === selectedIdRef.current + ? snapPlacement({ ...it, scale }, terrainVertices, sceneOptions.gridResolution) + : it, + ); + }, [mapItems, terrainVertices, sceneOptions.gridResolution, selectedIdRef]); + + const sceneFolderContent = ( + + ); + + return ( +
+ fileInputRef.current?.click()} + fileInputRef={fileInputRef} + onFileInputChange={() => {/* Drag-import not supported yet */}} + onRandomPreset={() => { + const rnd = PRESETS[Math.floor(Math.random() * PRESETS.length)]; + handleAddPreset(rnd.id); + }} + modelCategories={modelCategories} + isCategoryOpen={isCategoryOpen} + onToggleCategory={handleToggleCategory} + modelTreeId={modelTreeId} + presetId={loadingPresetId ?? ""} + onPresetClick={handleSidebarClick} + /> + +
+
+ + + + +
+
+ + + + +
+ ); +} diff --git a/website/src/components/BuilderWorkbench/builder-workbench.css b/website/src/components/BuilderWorkbench/builder-workbench.css new file mode 100644 index 00000000..0b4fd29c --- /dev/null +++ b/website/src/components/BuilderWorkbench/builder-workbench.css @@ -0,0 +1,319 @@ +/* Placement mode — cursor feedback on the viewport */ +.dn-viewport.is-placement-mode { + cursor: crosshair; +} + +/* Floating tool palette — top-centre of the builder viewport. + Four buttons (Pointer / Raise / Lower / Smooth). The target-mode + toggle (Vertex / Face) sits in a sibling pill 8px below this one, + anchored at the same top region — same glass styling. */ +.builder-tool-palette { + position: absolute; + left: 50%; + top: 16px; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + background: rgba(17, 19, 22, 0.85); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + user-select: none; + z-index: 5; +} +.builder-tool-palette__btn { + appearance: none; + border: 0; + background: transparent; + color: rgba(220, 224, 232, 0.6); + padding: 4px 12px; + font: inherit; + font-size: 12px; + border-radius: 999px; + cursor: pointer; + transition: background-color 120ms ease-out, color 120ms ease-out; +} +.builder-tool-palette__btn:hover { + color: rgba(220, 224, 232, 0.9); + background: rgba(255, 255, 255, 0.04); +} +.builder-tool-palette__btn.is-active { + color: #0a0b0d; + background: rgba(34, 211, 238, 0.9); +} +.builder-tool-palette__btn.is-active:hover { + background: rgba(34, 211, 238, 1); +} + +/* Target-mode pill — sits below the tool palette so the two pills + read as a paired toolbar. Same glass treatment as the rest. */ +.builder-target-mode { + position: absolute; + left: 50%; + top: 62px; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + background: rgba(17, 19, 22, 0.85); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + user-select: none; + z-index: 5; +} +.builder-target-mode__btn { + appearance: none; + border: 0; + background: transparent; + color: rgba(220, 224, 232, 0.6); + padding: 3px 10px; + font: inherit; + font-size: 11px; + border-radius: 999px; + cursor: pointer; + transition: background-color 120ms ease-out, color 120ms ease-out; +} +.builder-target-mode__btn:hover { + color: rgba(220, 224, 232, 0.9); + background: rgba(255, 255, 255, 0.04); +} +.builder-target-mode__btn.is-active { + color: #0a0b0d; + background: rgba(34, 211, 238, 0.9); +} +.builder-target-mode__btn.is-active:hover { + background: rgba(34, 211, 238, 1); +} + +/* Floating camera-mode pill — bottom-centre of the builder viewport. + Two buttons (Orbit / Pan) plus a hint about the Cmd-hold shortcut. */ +.builder-camera-mode { + position: absolute; + left: 50%; + bottom: 16px; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + background: rgba(17, 19, 22, 0.85); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + user-select: none; + z-index: 5; +} +.builder-camera-mode__btn { + appearance: none; + border: 0; + background: transparent; + color: rgba(220, 224, 232, 0.6); + padding: 4px 12px; + font: inherit; + font-size: 12px; + border-radius: 999px; + cursor: pointer; + transition: background-color 120ms ease-out, color 120ms ease-out; +} +.builder-camera-mode__btn:hover { + color: rgba(220, 224, 232, 0.9); + background: rgba(255, 255, 255, 0.04); +} +.builder-camera-mode__btn.is-active { + color: #0a0b0d; + background: rgba(34, 211, 238, 0.9); +} +.builder-camera-mode__btn.is-active:hover { + background: rgba(34, 211, 238, 1); +} +.builder-camera-mode__hint { + color: rgba(220, 224, 232, 0.4); + font-size: 11px; + padding: 0 10px 0 6px; + border-left: 1px solid rgba(255, 255, 255, 0.08); + margin-left: 2px; +} + +/* Terrain hover preview — translucent cyan tile over the cell the next + click will modify. Same opacity caveat as `.builder-ghost`: alpha + lives in the polygon's color (rgba), never on a CSS wrapper. */ +.builder-terrain-hover { + pointer-events: none; +} +.builder-terrain { + pointer-events: none; +} + +/* Ghost mesh — rendered during placement hover. + IMPORTANT: do NOT set `opacity` on this element. CSS opacity creates + a flattened stacking context that breaks transform-style: preserve-3d + on all descendants — every polygon collapses into the XY plane and + you only see the bottom face of the bbox. Bake transparency into the + polygon color (rgba in GHOST_COLOR) instead. Same trap that's + documented at the top of PolyTransformControls.tsx. */ +.builder-ghost { + pointer-events: none; +} + +/* Force both faces of every wireframe edge to render. polycss applies + `backface-visibility: hidden` to polygon leaves via a rule + `.polycss-scene b/i/s/u {...}` that gets injected at runtime AFTER + this stylesheet loads, so equal-specificity overrides lose. The + `!important` flag is the cleanest fix — the ghost cage needs to be + visible no matter which way the camera looks. */ +.builder-ghost b, +.builder-ghost i, +.builder-ghost s, +.builder-ghost u { + backface-visibility: visible !important; +} + +/* The Scene folder body is built in two halves inside one lil-gui folder: + a React block (this stylesheet) for the items list + gizmo button set, + then the lil-gui-native Scale slider below it. The styles below mimic the + Inspector panel idiom (.dn-mesh-* in gallery-workbench.css) so the rows + read consistently with the rest of the app. */ + +.builder-placed.is-selected { + outline: none; +} + +.builder-scene-folder { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 0 6px; +} + +.builder-scene-folder__empty { + margin: 0; + padding: 4px 12px 6px; + font-size: 11px; + font-style: italic; + color: rgba(232, 237, 242, 0.45); +} + +.builder-scene-folder__list { + list-style: none; + margin: 0; + padding: 0; + max-height: 220px; + overflow-y: auto; +} + +.builder-scene-folder__row { + display: flex; + align-items: center; + gap: 4px; + padding: 0 8px 0 10px; + border-radius: 4px; + color: #e2e8f0; + font-size: 12px; + position: relative; +} + +.builder-scene-folder__row:hover { + background: rgba(255, 255, 255, 0.04); +} + +.builder-scene-folder__row.is-selected { + background: rgba(34, 211, 238, 0.12); +} + +/* lil-gui forces `.lil-gui button { width: 100% }`, which throws off the + flex math inside our rows. We override with `width: auto` and a non-zero + min-width so flex distribution honors flex-basis on the row's children. */ +.dn-scene-folder-content .builder-scene-folder__select { + flex: 1 1 0; + min-width: 0; + width: auto; + display: flex; + align-items: center; + gap: 7px; + background: none; + border: 0; + padding: 6px 0; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + overflow: hidden; + height: auto; +} + +.builder-scene-folder__icon { + color: #22d3ee; + font-size: 11px; + flex: 0 0 auto; +} + +.builder-scene-folder__label { + flex: 1 1 auto; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dn-scene-folder-content .builder-scene-folder__remove { + flex: 0 0 auto; + width: 18px; + height: 18px; + border: 0; + background: transparent; + color: rgba(232, 237, 242, 0.45); + border-radius: 3px; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} + +.dn-scene-folder-content .builder-scene-folder__remove:hover { + background: rgba(220, 50, 50, 0.6); + color: #fff; +} + +.builder-scene-folder__gizmo { + display: flex; + gap: 4px; + padding: 4px 8px 2px; +} + +.dn-scene-folder-content .builder-scene-folder__gizmo-btn { + flex: 1 1 0; + width: auto; + min-width: 0; + background: rgba(17, 19, 22, 0.85); + border: 1px solid rgba(255, 255, 255, 0.06); + color: rgba(232, 237, 242, 0.75); + font: inherit; + font-size: 11px; + padding: 5px 8px; + border-radius: 4px; + cursor: pointer; + height: auto; +} + +.dn-scene-folder-content .builder-scene-folder__gizmo-btn:hover:not(:disabled) { + color: #e8edf2; + border-color: rgba(255, 255, 255, 0.12); +} + +.dn-scene-folder-content .builder-scene-folder__gizmo-btn.is-active { + background: rgba(34, 211, 238, 0.18); + border-color: rgba(34, 211, 238, 0.5); + color: #e8edf2; +} + +.dn-scene-folder-content .builder-scene-folder__gizmo-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/website/src/components/BuilderWorkbench/components/BuilderCameraModePill.tsx b/website/src/components/BuilderWorkbench/components/BuilderCameraModePill.tsx new file mode 100644 index 00000000..49e80f17 --- /dev/null +++ b/website/src/components/BuilderWorkbench/components/BuilderCameraModePill.tsx @@ -0,0 +1,25 @@ +import type { DragMode, SceneOptionsState } from "../../types"; + +export interface BuilderCameraModePillProps { + dragMode: DragMode; + updateScene: (partial: Partial) => void; +} + +export function BuilderCameraModePill({ dragMode, updateScene }: BuilderCameraModePillProps) { + if (dragMode === "fpv") return null; + return ( +
+ + + ⌘ to pan +
+ ); +} diff --git a/website/src/components/BuilderWorkbench/components/BuilderDock.tsx b/website/src/components/BuilderWorkbench/components/BuilderDock.tsx new file mode 100644 index 00000000..752da64f --- /dev/null +++ b/website/src/components/BuilderWorkbench/components/BuilderDock.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from "react"; +import { + Dock, + DockScene, + DockRendering, + DockCamera, + DockLighting, +} from "../../Dock"; +import { defaultZoomForModel } from "../../GalleryWorkbench/helpers/smartDefaults"; +import type { PresetModel } from "../../GalleryWorkbench/types"; +import type { Polygon } from "@layoutit/polycss-react"; +import type { SceneOptionsState, PerspectiveMode } from "../../types"; +import type { PlacedItem } from "../types"; +import { DockGrid } from "../slots/DockGrid"; + +export interface BuilderDockProps { + sceneOptions: SceneOptionsState; + updateScene: (partial: Partial) => void; + placedItems: PlacedItem[]; + selectedId: string | null; + selectedScale: number; + onScaleChange: (scale: number) => void; + perspectiveMode: PerspectiveMode; + perspectivePx: number | false; + sceneFolderContent: ReactNode; +} + +export function BuilderDock({ + sceneOptions, + updateScene, + selectedId, + selectedScale, + onScaleChange, + perspectiveMode, + perspectivePx, + sceneFolderContent, +}: BuilderDockProps) { + const stubPreset = { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY }; + + return ( + + + + + defaultZoomForModel(preset as PresetModel, polys as Polygon[])} + onUpdateScene={updateScene} + /> + + + ); +} diff --git a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx new file mode 100644 index 00000000..043117a3 --- /dev/null +++ b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx @@ -0,0 +1,188 @@ +import { + PolyAxesHelper, + PolyDirectionalLightHelper, + PolyFirstPersonControls, + PolyMapControls, + PolyMesh, + PolyOrbitControls, + PolyOrthographicCamera, + PolyPerspectiveCamera, + PolyScene, + PolySelect, + PolyTransformControls, +} from "@layoutit/polycss-react"; +import type { + PolyAmbientLight, + PolyDirectionalLight, + PolyFirstPersonControlsHandle, + PolyMeshHandle, + PolyTransformControlsObjectChangeEvent, + Polygon, +} from "@layoutit/polycss-react"; +import { type RefObject } from "react"; +import type { SceneOptionsState, GizmoMode } from "../../types"; +import type { PlacedItem } from "../types"; + +export interface BuilderSceneProps { + sceneOptions: SceneOptionsState; + updateScene: (partial: Partial) => void; + directionalLight: PolyDirectionalLight; + ambientLight: PolyAmbientLight; + /** Unified floor grid — also carries the terrain elevation. Lines + * bend at raised vertices instead of passing flat through hills. */ + gridPolygons: Polygon[]; + ghostPolygons: Polygon[]; + /** Single-quad outline showing the vertex the terrain-tool cursor is + * currently over. Empty when no terrain tool is active. */ + terrainHoverPolygons: Polygon[]; + placementDraft: boolean; + renderItems: Array; + renderedPolygonsById: Map; + selectedId: string | null; + gizmoMode: GizmoMode; + gizmoDragging: boolean; + meshHandlesRef: RefObject>; + getMeshRefCallback: (id: string) => (h: PolyMeshHandle | null) => void; + fpvControlsRef: RefObject; + onSelectionChange: (handles: PolyMeshHandle[]) => void; + onGizmoDraggingChanged: (dragging: boolean) => void; + onGizmoObjectChange: (event: PolyTransformControlsObjectChangeEvent) => void; + selected: PlacedItem | null; +} + +export function BuilderScene({ + sceneOptions, + updateScene, + directionalLight, + ambientLight, + gridPolygons, + ghostPolygons, + terrainHoverPolygons, + placementDraft, + renderItems, + renderedPolygonsById, + selectedId, + gizmoMode, + gizmoDragging, + meshHandlesRef, + getMeshRefCallback, + fpvControlsRef, + onSelectionChange, + onGizmoDraggingChanged, + onGizmoObjectChange, + selected, +}: BuilderSceneProps) { + const Cam = sceneOptions.perspective === false ? PolyOrthographicCamera : PolyPerspectiveCamera; + const camProps = sceneOptions.perspective === false + ? { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target } + : { zoom: sceneOptions.zoom, rotX: sceneOptions.rotX, rotY: sceneOptions.rotY, target: sceneOptions.target, perspective: sceneOptions.perspective }; + + return ( + + {sceneOptions.dragMode === "pan" ? ( + updateScene({ + rotX: cam.rotX, + rotY: cam.rotY, + zoom: cam.zoom, + ...(cam.target ? { target: cam.target } : {}), + })} + /> + ) : sceneOptions.dragMode === "fpv" ? ( + + ) : ( + updateScene({ + rotX: cam.rotX, + rotY: cam.rotY, + zoom: cam.zoom, + ...(cam.target ? { target: cam.target } : {}), + })} + /> + )} + + {/* Unified floor + terrain grid — the gridlines themselves + carry the heightmap elevation, so raised vertices bend the + grid rather than peeking out from under a separate fill. */} + {sceneOptions.showGround && } + {/* Terrain hover ghost — small cyan marker over the vertex the + next click will modify. */} + {terrainHoverPolygons.length > 0 && ( + + )} + {sceneOptions.showAxes && } + {sceneOptions.showLight && ( + + )} + {/* Placement-mode ghost wireframe — bbox edges of the + preset, positioned with its bottom face touching the + floor at the cursor's projected ground point. Pointer + events that drive the cursor + commit live on the + viewport DOM — no catcher mesh. */} + {placementDraft && ( + + )} + + {renderItems.map((it) => ( + + ))} + + {selected && ( + + )} + + + ); +} diff --git a/website/src/components/BuilderWorkbench/components/BuilderSceneOutliner.tsx b/website/src/components/BuilderWorkbench/components/BuilderSceneOutliner.tsx new file mode 100644 index 00000000..894b26fe --- /dev/null +++ b/website/src/components/BuilderWorkbench/components/BuilderSceneOutliner.tsx @@ -0,0 +1,69 @@ +import { stripParenthesizedText } from "../../GalleryWorkbench/presets"; +import type { GizmoMode } from "../../types"; +import type { PlacedItem } from "../types"; + +export interface BuilderSceneOutlinerProps { + placedItems: PlacedItem[]; + selectedId: string | null; + gizmoMode: GizmoMode; + onSelectItem: (id: string) => void; + onDeleteItem: (id: string) => void; + onGizmoModeChange: (mode: GizmoMode) => void; +} + +export function BuilderSceneOutliner({ + placedItems, + selectedId, + gizmoMode, + onSelectItem, + onDeleteItem, + onGizmoModeChange, +}: BuilderSceneOutlinerProps) { + return ( +
+ {placedItems.length === 0 ? ( +

Click a model on the left to add it.

+ ) : ( +
    + {placedItems.map((it) => ( +
  • +
    + + +
    +
  • + ))} +
+ )} +
+ + +
+
+ ); +} diff --git a/website/src/components/BuilderWorkbench/components/BuilderTargetMode.tsx b/website/src/components/BuilderWorkbench/components/BuilderTargetMode.tsx new file mode 100644 index 00000000..498358a7 --- /dev/null +++ b/website/src/components/BuilderWorkbench/components/BuilderTargetMode.tsx @@ -0,0 +1,36 @@ +/** + * Floating "what does the click target?" toggle — sits next to the + * tool palette at the top of the viewport. Vertex picks individual + * grid intersections (raise warps 4 cells into a tent); Face picks + * whole cells (raise pushes all 4 corners up so the cell becomes a + * flat plateau). + */ +import type { TargetMode } from "../types"; + +export interface BuilderTargetModeProps { + targetMode: TargetMode; + onChange: (mode: TargetMode) => void; +} + +export function BuilderTargetMode({ targetMode, onChange }: BuilderTargetModeProps) { + return ( +
+ + +
+ ); +} diff --git a/website/src/components/BuilderWorkbench/components/BuilderToolPalette.tsx b/website/src/components/BuilderWorkbench/components/BuilderToolPalette.tsx new file mode 100644 index 00000000..b2a3a932 --- /dev/null +++ b/website/src/components/BuilderWorkbench/components/BuilderToolPalette.tsx @@ -0,0 +1,31 @@ +import type { ToolMode } from "../types"; + +const TOOLS: Array<{ mode: ToolMode; glyph: string; label: string }> = [ + { mode: "pointer", glyph: "↖", label: "Pointer" }, + { mode: "raise", glyph: "↑", label: "Raise" }, + { mode: "lower", glyph: "↓", label: "Lower" }, + { mode: "smooth", glyph: "~", label: "Smooth" }, +]; + +export interface BuilderToolPaletteProps { + toolMode: ToolMode; + onChange: (mode: ToolMode) => void; +} + +export function BuilderToolPalette({ toolMode, onChange }: BuilderToolPaletteProps) { + return ( +
+ {TOOLS.map(({ mode, glyph, label }) => ( + + ))} +
+ ); +} diff --git a/website/src/components/BuilderWorkbench/defaults.ts b/website/src/components/BuilderWorkbench/defaults.ts new file mode 100644 index 00000000..239d330f --- /dev/null +++ b/website/src/components/BuilderWorkbench/defaults.ts @@ -0,0 +1,65 @@ +import type { SceneOptionsState } from "../types"; + +export const BUILDER_KIT_CATEGORIES: string[] = ["City Kit", "Urban Pack", "Medieval Village"]; + +export const PARSER_DEFAULTS = { targetSize: 60, gridShift: 1, defaultColor: "#8b95a1" }; +export const NORMALIZED_MAX_DIM = 8; +export const GRID_STEP = 10; +export const GRID_COLS = 3; + +// Builder starts with the same scene defaults as the gallery's chicken preset +// so the camera / lighting / strategies feel familiar to anyone coming from +// /gallery. The fields the builder doesn't currently use (selection/hover/ +// animation/etc.) still have to be present because the Dock reads them. +export const DEFAULT_SCENE: SceneOptionsState = { + renderer: "react", + animationPaused: false, + animationTimeScale: 1, + autoCenter: true, + interactive: true, + animate: false, + showAxes: true, + // Selection is always on in the builder — picking a placed mesh is core + // to its workflow. The Interaction folder is hidden in this surface so + // there's no toggle to flip this off. + selection: true, + hoverEffects: false, + showLight: false, + zoom: 0.3, + rotX: 65, + rotY: 45, + perspective: false, + lightAzimuth: 50, + lightElevation: 45, + lightIntensity: 1, + lightColor: "#ffffff", + ambientIntensity: 0.4, + ambientColor: "#ffffff", + textureLighting: "baked", + textureQuality: "auto", + solidMaterials: false, + matrixPrecision: "exact", + borderShapePrecision: "exact", + meshResolution: "lossy", + meshInteriorFill: false, + outlinePolygons: false, + dragMode: "orbit", + target: [0, 0, 0], + disableStrategies: [], + castShadow: false, + showGround: true, + fpvLook: true, + fpvMove: true, + fpvJump: true, + fpvCrouch: true, + fpvMoveSpeed: 30, + fpvJumpVelocity: 25, + fpvGravity: 60, + fpvEyeHeight: 6, + fpvCrouchHeight: 3, + fpvLookSensitivity: 0.15, + fpvInvertY: false, + fpvRenderDistance: 40, + snapToGrid: true, + gridResolution: 5, +}; diff --git a/website/src/components/BuilderWorkbench/geometry/ghost.ts b/website/src/components/BuilderWorkbench/geometry/ghost.ts new file mode 100644 index 00000000..eee0b965 --- /dev/null +++ b/website/src/components/BuilderWorkbench/geometry/ghost.ts @@ -0,0 +1,269 @@ +/** + * Ghost bounding-box helpers for placement mode. + * + * Builds the 6 face quads of the bbox cuboid directly in WORLD coords — + * the caller passes the world XY center + half-extents + height, and the + * faces come out positioned exactly where they should appear. The + * resulting PolyMesh is rendered with NO `position` / `scale` props — + * same pattern as PolyTransformControls' planar handles (which we know + * render correctly in all three planes). + * + * Earlier attempts passed polygons in model-local coords and let + * PolyMesh's `scale` + `position` transform place them. That kept + * collapsing the box to the floor — most likely because the scale+ + * position-around-bbox-center wrapping interacts with polycss's + * basis chooser in a way I can't easily debug. Direct world coords + * sidestep all of that. + */ + +import type { Polygon } from "@layoutit/polycss-react"; + +export interface Bbox { + minX: number; + minY: number; + minZ: number; + maxX: number; + maxY: number; + maxZ: number; +} + +export interface GhostWorldRect { + /** Center of the bbox footprint on the floor. */ + worldX: number; + worldY: number; + /** Half-extents on each axis, in WORLD units. */ + hx: number; + hy: number; + /** Total height of the bbox in WORLD units. Bottom sits at baseZ. */ + height: number; + /** Base elevation in world units. Wireframe spans `baseZ` → + * `baseZ + height`. 0 for the flat floor. Used to lift the ghost + * onto an elevated terrain cell. Default 0. */ + baseZ?: number; +} + +/** Solid cyan wireframe edge color. Alpha must live in the COLOR (rgba) + * if we ever want transparency — never set CSS `opacity` on the ghost + * wrapper because it would flatten the 3D context. See + * builder-workbench.css for the long-form warning. */ +export const GHOST_COLOR = "#00d9ff"; + +/** Edge half-thickness in world units. ~0.06 world units ≈ 3 CSS px at + * BASE_TILE=50 — readable as a wireframe dot at typical zoom. */ +const EDGE_HALF = 0.06; + +/** Approx length of a single dot in world units. */ +const DOT_LENGTH = 0.5; +/** Approx gap between consecutive dots. */ +const GAP_LENGTH = 0.5; + +/** Build the 6 axis-aligned face quads of an arbitrary cuboid using + * axisBox's vertex labelling + CCW-from-outside winding. Each face's + * surface normal points OUTWARD so polycss's basis chooser keeps the + * matrix3d determinant positive (negative determinants flatten). */ +function cuboidFaces( + x0: number, x1: number, + y0: number, y1: number, + z0: number, z1: number, + color: string, +): Polygon[] { + const c0: [number, number, number] = [x0, y0, z0]; + const c1: [number, number, number] = [x1, y0, z0]; + const c2: [number, number, number] = [x1, y1, z0]; + const c3: [number, number, number] = [x0, y1, z0]; + const c4: [number, number, number] = [x0, y0, z1]; + const c5: [number, number, number] = [x1, y0, z1]; + const c6: [number, number, number] = [x1, y1, z1]; + const c7: [number, number, number] = [x0, y1, z1]; + return [ + { vertices: [c0, c1, c2, c3], color }, // -Z + { vertices: [c4, c5, c6, c7], color }, // +Z + { vertices: [c0, c1, c5, c4], color }, // -Y + { vertices: [c1, c2, c6, c5], color }, // +X + { vertices: [c2, c3, c7, c6], color }, // +Y + { vertices: [c3, c0, c4, c7], color }, // -X + ]; +} + +/** Compute the (start, end) pairs of dots along a 1D edge of `length` + * world units. Dot count adapts to length so dot SIZE stays uniform + * across edges of different lengths — short edges get fewer dots. + * Dots always include both endpoints (so corners of the bbox always + * have visible markers). */ +function dotSpans(length: number): Array<[number, number]> { + const pattern = DOT_LENGTH + GAP_LENGTH; + const count = Math.max(2, Math.round(length / pattern)); + // Distribute evenly: the centres of `count` dots sit at fractions + // i/(count-1) of the edge for i=0..count-1. + const halfDot = DOT_LENGTH / 2; + const spans: Array<[number, number]> = []; + for (let i = 0; i < count; i++) { + const centre = (i / (count - 1)) * length; + const a = Math.max(0, centre - halfDot); + const b = Math.min(length, centre + halfDot); + spans.push([a, b]); + } + return spans; +} + +/** Build a dotted 12-edge wireframe of the bbox. Each edge becomes a + * run of short cuboid "dots" instead of one continuous stick, so the + * outline reads as a dashed bbox at the placement cursor. Closed + * cuboids (not flat slabs) so each dot stays 3D regardless of the + * camera angle, with axisBox winding for stable rendering. */ +export function buildGhostWireframePolygons(rect: GhostWorldRect, color: string = GHOST_COLOR): Polygon[] { + const { worldX, worldY, hx, hy, height } = rect; + const x0 = worldX - hx; + const x1 = worldX + hx; + const y0 = worldY - hy; + const y1 = worldY + hy; + const z0 = rect.baseZ ?? 0; + const z1 = z0 + height; + const t = EDGE_HALF; + + const polys: Polygon[] = []; + + // 4 X-direction edges — dot spans run along X. + const xSpans = dotSpans(x1 - x0); + for (const y of [y0, y1]) { + for (const z of [z0, z1]) { + for (const [a, b] of xSpans) { + polys.push(...cuboidFaces(x0 + a, x0 + b, y - t, y + t, z - t, z + t, color)); + } + } + } + // 4 Y-direction edges — dot spans run along Y. + const ySpans = dotSpans(y1 - y0); + for (const x of [x0, x1]) { + for (const z of [z0, z1]) { + for (const [a, b] of ySpans) { + polys.push(...cuboidFaces(x - t, x + t, y0 + a, y0 + b, z - t, z + t, color)); + } + } + } + // 4 Z-direction edges — dot spans run along Z. + const zSpans = dotSpans(z1 - z0); + for (const x of [x0, x1]) { + for (const y of [y0, y1]) { + for (const [a, b] of zSpans) { + polys.push(...cuboidFaces(x - t, x + t, y - t, y + t, z0 + a, z0 + b, color)); + } + } + } + + return polys; +} + +/** + * Rotate every vertex of every polygon around `pivot` so that the + * resulting world-coord polygons, once rendered through `cssPoints`' + * world→CSS axis swap, look identical to a `PolyMesh` with + * `rotation = [rotXDeg, rotYDeg, 0]`. PolyMesh's CSS transform is + * `rotateX(α) rotateY(β)` (rotateY applied to vectors first), so we + * mirror that ordering here. + * + * Because `cssPoints` swaps world-X and world-Y axes, CSS rotateX + * (which preserves CSS-X) corresponds to a world-frame rotation that + * preserves world-Y, and CSS rotateY (preserves CSS-Y) corresponds to + * a world rotation that preserves world-X — that's why the world math + * below preserves the "opposite" axis to what the CSS name suggests. + */ +export function rotatePolygonsAroundPivot( + polygons: Polygon[], + pivot: [number, number, number], + rotXDeg: number, + rotYDeg: number, +): Polygon[] { + if (rotXDeg === 0 && rotYDeg === 0) return polygons; + const rx = (rotXDeg * Math.PI) / 180; + const ry = (rotYDeg * Math.PI) / 180; + const cx = Math.cos(rx); + const sx = Math.sin(rx); + const cy = Math.cos(ry); + const sy = Math.sin(ry); + + const rotateVertex = (v: [number, number, number]): [number, number, number] => { + let x = v[0] - pivot[0]; + let y = v[1] - pivot[1]; + let z = v[2] - pivot[2]; + // CSS rotateY first (applied to vectors first). In world coords: + // preserves x, transforms (y, z). + const y1 = y * cy + z * sy; + const z1 = -y * sy + z * cy; + y = y1; z = z1; + // CSS rotateX next. In world coords: preserves y, transforms (x, z). + const x2 = x * cx - z * sx; + const z2 = x * sx + z * cx; + x = x2; z = z2; + return [x + pivot[0], y + pivot[1], z + pivot[2]]; + }; + + return polygons.map((p) => ({ + ...p, + vertices: p.vertices.map(rotateVertex), + })); +} + +/** + * Build the 6 faces of a bounding box positioned at (worldX, worldY) + * on the floor with the given half-extents and height. Vertex order + * mirrors `axisBox` in core/helpers/axesPolygons.ts — the same helper + * `` uses to render its thin cuboids in 3D. Each face's + * winding is CCW from the OUTWARD-facing side so the surface normal + * points outward and polycss's basis chooser keeps the matrix3d + * determinant positive (negative determinants get treated as + * back-facing and silently flatten). + * + * All vertices are in WORLD coords; caller passes the list to a + * `` with no `position` or `scale` prop. + */ +export function buildGhostBoxPolygons(rect: GhostWorldRect, color: string = GHOST_COLOR): Polygon[] { + const { worldX, worldY, hx, hy, height } = rect; + const x0 = worldX - hx; + const x1 = worldX + hx; + const y0 = worldY - hy; + const y1 = worldY + hy; + const z0 = 0; + const z1 = height; + + // 8 corners in axisBox naming convention: c0-c3 ring around the bottom + // CCW from +Z, c4-c7 directly above them. + const c0: [number, number, number] = [x0, y0, z0]; + const c1: [number, number, number] = [x1, y0, z0]; + const c2: [number, number, number] = [x1, y1, z0]; + const c3: [number, number, number] = [x0, y1, z0]; + const c4: [number, number, number] = [x0, y0, z1]; + const c5: [number, number, number] = [x1, y0, z1]; + const c6: [number, number, number] = [x1, y1, z1]; + const c7: [number, number, number] = [x0, y1, z1]; + + return [ + { vertices: [c0, c1, c2, c3], color }, // bottom (XY at z0) — normal -Z + { vertices: [c4, c5, c6, c7], color }, // top (XY at z1) — normal +Z + { vertices: [c0, c1, c5, c4], color }, // front (XZ at y0) — normal -Y + { vertices: [c1, c2, c6, c5], color }, // right (YZ at x1) — normal +X + { vertices: [c2, c3, c7, c6], color }, // back (XZ at y1) — normal +Y + { vertices: [c3, c0, c4, c7], color }, // left (YZ at x0) — normal -X + ]; +} + +/** Compute a `GhostWorldRect` for placing a model at (worldX, worldY) + * given its model-local bbox and the auto-fit scale. `baseZ` lifts + * the wireframe off the floor for placements on elevated terrain. */ +export function ghostRectFromBbox( + bbox: Bbox, + worldX: number, + worldY: number, + fitScale: number, + baseZ: number = 0, +): GhostWorldRect { + return { + worldX, + worldY, + hx: ((bbox.maxX - bbox.minX) * fitScale) / 2, + hy: ((bbox.maxY - bbox.minY) * fitScale) / 2, + height: (bbox.maxZ - bbox.minZ) * fitScale, + baseZ, + }; +} + diff --git a/website/src/components/BuilderWorkbench/geometry/grid.ts b/website/src/components/BuilderWorkbench/geometry/grid.ts new file mode 100644 index 00000000..c184006f --- /dev/null +++ b/website/src/components/BuilderWorkbench/geometry/grid.ts @@ -0,0 +1,162 @@ +/** + * Editor floor grid for the /builder viewport — terrain-aware. + * + * Each gridline is broken into per-cell segments whose endpoints sit at + * the heightmap's vertex elevations. Flat regions (every segment in a + * row has both endpoints at z = 0) collapse into one long slab so a + * pristine heightmap stays cheap (~80 polygons, same as before). Each + * raised vertex breaks the lines that pass through it into a short + * elevated segment + adjacent flat runs — the grid bends to meet the + * new bump. + */ +import type { Polygon, Vec3 } from "@layoutit/polycss-core"; +import { vertexKey, type TerrainVertices } from "./terrain"; + +export interface BuilderGridOptions { + /** Side length of the grid in world units. Default 200. */ + size?: number; + /** Distance between adjacent gridlines in world units. Default 5. */ + spacing?: number; + /** Line width in world units. Default 0.05 — reads as a hairline at + * orbit distance. */ + thickness?: number; + /** Color of each gridline. */ + color?: string; + /** Heightmap. Empty map ⇒ flat grid (every line is one long slab). */ + vertices?: TerrainVertices; +} + +/** Emit a flat slab between two vertex indices along a constant-Y row + * (X-direction line). Both endpoints are at z = 0 — used for flat + * runs that collapsed during scan. */ +function flatXSlab( + i0: number, i1: number, j: number, + spacing: number, halfT: number, color: string, +): Polygon { + const x0 = i0 * spacing; + const x1 = i1 * spacing; + const y = j * spacing; + return { + vertices: [ + [x0, y - halfT, 0], + [x1, y - halfT, 0], + [x1, y + halfT, 0], + [x0, y + halfT, 0], + ] as [Vec3, Vec3, Vec3, Vec3], + color, + }; +} + +/** Single X-direction cell segment from (i, j) to (i+1, j). The slab + * lies in the plane that contains the line and the perpendicular + * (constant-Y) thickness axis — always planar even when z0 != z1. */ +function xSegment( + i: number, j: number, z0: number, z1: number, + spacing: number, halfT: number, color: string, +): Polygon { + const x0 = i * spacing; + const x1 = (i + 1) * spacing; + const y = j * spacing; + return { + vertices: [ + [x0, y - halfT, z0], + [x1, y - halfT, z1], + [x1, y + halfT, z1], + [x0, y + halfT, z0], + ] as [Vec3, Vec3, Vec3, Vec3], + color, + }; +} + +function flatYSlab( + i: number, j0: number, j1: number, + spacing: number, halfT: number, color: string, +): Polygon { + const x = i * spacing; + const y0 = j0 * spacing; + const y1 = j1 * spacing; + return { + vertices: [ + [x - halfT, y0, 0], + [x + halfT, y0, 0], + [x + halfT, y1, 0], + [x - halfT, y1, 0], + ] as [Vec3, Vec3, Vec3, Vec3], + color, + }; +} + +function ySegment( + i: number, j: number, z0: number, z1: number, + spacing: number, halfT: number, color: string, +): Polygon { + const x = i * spacing; + const y0 = j * spacing; + const y1 = (j + 1) * spacing; + return { + vertices: [ + [x - halfT, y0, z0], + [x + halfT, y0, z0], + [x + halfT, y1, z1], + [x - halfT, y1, z1], + ] as [Vec3, Vec3, Vec3, Vec3], + color, + }; +} + +export function buildGridPolygons(options: BuilderGridOptions = {}): Polygon[] { + const size = options.size ?? 200; + const spacing = options.spacing ?? 5; + const thickness = options.thickness ?? 0.05; + const color = options.color ?? "#3a4250"; + const vertices = options.vertices ?? new Map(); + + const halfT = thickness / 2; + const halfCells = Math.floor(size / 2 / spacing); + const getZ = (i: number, j: number): number => vertices.get(vertexKey(i, j)) ?? 0; + + const polys: Polygon[] = []; + + // X-direction lines at each j. Walk i; collapse runs of flat + // segments into one long slab, emit elevated segments individually. + for (let j = -halfCells; j <= halfCells; j++) { + let runStart: number | null = null; + for (let i = -halfCells; i < halfCells; i++) { + const zL = getZ(i, j); + const zR = getZ(i + 1, j); + const isFlat = zL === 0 && zR === 0; + if (isFlat) { + if (runStart === null) runStart = i; + } else { + if (runStart !== null) { + polys.push(flatXSlab(runStart, i, j, spacing, halfT, color)); + runStart = null; + } + polys.push(xSegment(i, j, zL, zR, spacing, halfT, color)); + } + } + if (runStart !== null) polys.push(flatXSlab(runStart, halfCells, j, spacing, halfT, color)); + } + + // Y-direction lines at each i. + for (let i = -halfCells; i <= halfCells; i++) { + let runStart: number | null = null; + for (let j = -halfCells; j < halfCells; j++) { + const zL = getZ(i, j); + const zU = getZ(i, j + 1); + const isFlat = zL === 0 && zU === 0; + if (isFlat) { + if (runStart === null) runStart = j; + } else { + if (runStart !== null) { + polys.push(flatYSlab(i, runStart, j, spacing, halfT, color)); + runStart = null; + } + polys.push(ySegment(i, j, zL, zU, spacing, halfT, color)); + } + } + if (runStart !== null) polys.push(flatYSlab(i, runStart, halfCells, spacing, halfT, color)); + } + + return polys; +} diff --git a/website/src/components/BuilderWorkbench/geometry/meshBbox.ts b/website/src/components/BuilderWorkbench/geometry/meshBbox.ts new file mode 100644 index 00000000..8dcceac7 --- /dev/null +++ b/website/src/components/BuilderWorkbench/geometry/meshBbox.ts @@ -0,0 +1,37 @@ +import type { Polygon } from "@layoutit/polycss-react"; + +export function meshBbox(polygons: Polygon[]): { + span: number; + midX: number; + midY: number; + midZ: number; + minX: number; + minY: number; + minZ: number; + maxX: number; + maxY: number; + maxZ: number; +} { + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (const p of polygons) { + for (const v of p.vertices) { + if (v[0] < minX) minX = v[0]; if (v[0] > maxX) maxX = v[0]; + if (v[1] < minY) minY = v[1]; if (v[1] > maxY) maxY = v[1]; + if (v[2] < minZ) minZ = v[2]; if (v[2] > maxZ) maxZ = v[2]; + } + } + const finite = Number.isFinite(minX); + return { + span: Math.max(maxX - minX, maxY - minY, maxZ - minZ, 0), + midX: finite ? (minX + maxX) / 2 : 0, + midY: finite ? (minY + maxY) / 2 : 0, + midZ: finite ? (minZ + maxZ) / 2 : 0, + minX: finite ? minX : 0, + minY: finite ? minY : 0, + minZ: finite ? minZ : 0, + maxX: finite ? maxX : 0, + maxY: finite ? maxY : 0, + maxZ: finite ? maxZ : 0, + }; +} diff --git a/website/src/components/BuilderWorkbench/geometry/placement.ts b/website/src/components/BuilderWorkbench/geometry/placement.ts new file mode 100644 index 00000000..fb6ea5f2 --- /dev/null +++ b/website/src/components/BuilderWorkbench/geometry/placement.ts @@ -0,0 +1,36 @@ +import type { Vec3 } from "@layoutit/polycss-react"; + +const BASE_TILE = 50; + +/** + * Wrapper translate (CSS px) that lands the mesh's visible bbox center at + * `desiredWorld` (XY) and its lowest visible vertex at Z=0. + * + * PolyMesh sets transform-origin to the bbox center, so for any vertex `v`: + * visible(v) = T + O + S*(v - O) = T + O*(1-S) + S*v + * At v = bbox center, the (1-S)*O term collapses to leaving the center at + * `T + O`. So to land the center at `desired*tile`, set `T = desired*tile - O`. + * For Z we want the BOTTOM (v = minZ) at 0, which gives the closed form below. + */ +export function placeMeshOnFloor( + desiredWorldX: number, + desiredWorldY: number, + bbox: { midX: number; midY: number; midZ: number; minZ: number }, + scale: number, + /** Surface elevation in world units (default 0 = floor). Pass the + * heightmap-sampled value to land the mesh on top of an elevated + * cell instead of the floor. */ + surfaceZ: number = 0, +): Vec3 { + return [ + // CSS X = worldY · tile; origin X = midY · tile + (desiredWorldY - bbox.midY) * BASE_TILE, + // CSS Y = worldX · tile; origin Y = midX · tile + (desiredWorldX - bbox.midX) * BASE_TILE, + // CSS Z in scene-local coords maps directly to world Z (the cssPoints + // axis swap is identity for Z). To lift the mesh so its lowest vertex + // sits at world z = surfaceZ, ADD surfaceZ * tile to the CSS Z that + // would land the bottom at world z = 0. + -BASE_TILE * (bbox.midZ * (1 - scale) + scale * bbox.minZ) + BASE_TILE * surfaceZ, + ]; +} diff --git a/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts b/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts new file mode 100644 index 00000000..b2ebbbca --- /dev/null +++ b/website/src/components/BuilderWorkbench/geometry/screenToWorld.ts @@ -0,0 +1,183 @@ +/** + * Project a screen-space pointer position to a world-space point on the + * Z=0 ground plane. + * + * The polycss CSS transform stack (on the `.polycss-scene` element) is: + * + * M = scale(zoom) rotateX(rotX) rotate(rotY) translate3d(-cssX, -cssY, -cssZ) + * + * where cssX = (target[1] + autoCenterOffset[1]) * BASE_TILE, + * cssY = (target[0] + autoCenterOffset[0]) * BASE_TILE, + * cssZ = (target[2] + autoCenterOffset[2]) * BASE_TILE. + * + * The camera element (.polycss-camera) has CSS `perspective: P` (or "none" + * for orthographic). The eye position in camera-local space is (0, 0, P); + * the viewer plane sits at cssZ=0. + * + * To convert a pointer at (clientX, clientY) → world (X, Y): + * 1. Convert to camera-element-local coords centered at element middle: + * sx = clientX - rect.left - rect.width/2 + * sy = clientY - rect.top - rect.height/2 + * 2. Build a picking ray from eye=(0, 0, P) through (sx, sy, 0) in + * camera-local (= scene-parent) space. The ray is: + * R(t) = eye + t*(viewpoint - eye) + * = (0,0,P) + t*((sx,sy,0)-(0,0,P)) + * = (t*sx, t*sy, P*(1-t)) + * 3. Apply M^-1 to bring both points into scene-local space. + * M^-1 = translate(+cssTarget) * rotate(-rotY) * rotateX(-rotX) * scale(1/zoom) + * Apply to eye and a far point (t=2) on the ray. + * 4. In scene-local space, CSS-Z = 0 IS world Z = 0 (because the + * polycss axis swap maps worldZ to cssZ). Parameterise the scene-local + * ray and solve for the t that gives cssZ = 0. + * 5. Read cssX, cssY at that t. Convert back: + * worldX = cssY / BASE_TILE + * worldY = cssX / BASE_TILE + */ + +import type { SceneOptionsState } from "../types"; + +const BASE_TILE = 50; + +/** 3D vector [x, y, z]. */ +type V3 = [number, number, number]; + +function deg2rad(d: number): number { + return (d * Math.PI) / 180; +} + +/** + * Apply a single row of the inverse transform: + * translate(+cssTarget) ∘ rotateZ(-rotY) ∘ rotateX(-rotX) ∘ scale(1/zoom) + * + * We apply the steps in order (innermost first in M^-1 = T * RZ * RX * S): + * 1. scale(1/zoom) + * 2. rotateX(-rotX) — tilt back + * 3. rotateZ(-rotY) — rotate back (CSS rotate() is actually rotateZ) + * 4. translate(+cssX, +cssY, +cssZ) + */ +function applyInverseTransform( + p: V3, + zoom: number, + rotXDeg: number, + rotYDeg: number, + cssX: number, + cssY: number, + cssZ: number, +): V3 { + let [x, y, z] = p; + + // 1. scale(1/zoom) + const inv = 1 / zoom; + x *= inv; + y *= inv; + z *= inv; + + // 2. rotateX(-rotX) — undo the tilt + const rxRad = deg2rad(-rotXDeg); + const cosRx = Math.cos(rxRad); + const sinRx = Math.sin(rxRad); + // rotateX: y' = y*cos - z*sin, z' = y*sin + z*cos + const y2 = y * cosRx - z * sinRx; + const z2 = y * sinRx + z * cosRx; + y = y2; + z = z2; + + // 3. rotate(-rotY) — CSS rotate() is rotateZ; undo the compass heading + const rzRad = deg2rad(-rotYDeg); + const cosRz = Math.cos(rzRad); + const sinRz = Math.sin(rzRad); + // rotateZ: x' = x*cos - y*sin, y' = x*sin + y*cos + const x3 = x * cosRz - y * sinRz; + const y3 = x * sinRz + y * cosRz; + x = x3; + y = y3; + + // 4. translate(+cssX, +cssY, +cssZ) + x += cssX; + y += cssY; + z += cssZ; + + return [x, y, z]; +} + +export interface ProjectScreenToWorldGroundArgs { + clientX: number; + clientY: number; + /** The `.polycss-camera` DOM element — the element that has `perspective` in style. */ + cameraEl: HTMLElement; + sceneOptions: Pick; + /** autoCenterOffset from the scene store — [worldX, worldY, worldZ]. */ + autoCenterOffset: [number, number, number]; +} + +/** + * Returns [worldX, worldY] on the Z=0 ground plane for a pointer event, or + * `null` if the ray is parallel to the ground (degenerate camera angle). + */ +export function projectScreenToWorldGround({ + clientX, + clientY, + cameraEl, + sceneOptions, + autoCenterOffset, +}: ProjectScreenToWorldGroundArgs): [number, number] | null { + const rect = cameraEl.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return null; + + // Screen-space coords centered on the camera element's midpoint. + const sx = clientX - rect.left - rect.width / 2; + const sy = clientY - rect.top - rect.height / 2; + + // CSS perspective value on the camera element. + const perspStr = getComputedStyle(cameraEl).perspective; + const isOrthographic = !perspStr || perspStr === "none"; + const P = isOrthographic ? 0 : parseFloat(perspStr); + + // CSS-space target = (target + autoCenterOffset) * BASE_TILE + // Axis swap: cssX = worldY * tile, cssY = worldX * tile, cssZ = worldZ * tile + const { zoom, rotX, rotY, target } = sceneOptions; + const [ox, oy, oz] = autoCenterOffset; + const cssX = (target[1] + oy) * BASE_TILE; + const cssY = (target[0] + ox) * BASE_TILE; + const cssZ = (target[2] + oz) * BASE_TILE; + + let rayOriginScene: V3; + let rayFarScene: V3; + + if (isOrthographic || P === 0 || !Number.isFinite(P)) { + // Orthographic: the eye is effectively at infinity along +Z. The ray + // direction in camera-local space is straight toward -Z through (sx, sy). + // We use two points on the ray: (sx, sy, 1000) and (sx, sy, -1000). + rayOriginScene = applyInverseTransform([sx, sy, 1000], zoom, rotX, rotY, cssX, cssY, cssZ); + rayFarScene = applyInverseTransform([sx, sy, -1000], zoom, rotX, rotY, cssX, cssY, cssZ); + } else { + // Perspective: eye is at (0, 0, P) in camera-local space. + // The ray passes through (sx, sy, 0) on the viewer plane at cssZ=0. + // Two points: the eye itself and a far point along the ray. + const eye: V3 = [0, 0, P]; + // Parametric: R(t) = eye + t*(viewpoint - eye). At t=1: viewpoint = (sx, sy, 0). + // Pick t=10 as the far point so the direction is well-defined. + const far: V3 = [sx * 10 - 0 * 9, sy * 10 - 0 * 9, 0 * 10 - P * 9]; + rayOriginScene = applyInverseTransform(eye, zoom, rotX, rotY, cssX, cssY, cssZ); + rayFarScene = applyInverseTransform(far, zoom, rotX, rotY, cssX, cssY, cssZ); + } + + // In scene-local space, CSS-Z = 0 IS world Z = 0. + // Ray: R(t) = rayOriginScene + t * (rayFarScene - rayOriginScene) + // Solve for t such that R(t)[2] = 0. + const dz = rayFarScene[2] - rayOriginScene[2]; + if (Math.abs(dz) < 1e-10) { + // Ray is parallel to the ground plane — can't intersect. + return null; + } + const t = -rayOriginScene[2] / dz; + + const hitCssX = rayOriginScene[0] + t * (rayFarScene[0] - rayOriginScene[0]); + const hitCssY = rayOriginScene[1] + t * (rayFarScene[1] - rayOriginScene[1]); + + // polycss axis swap: cssX = worldY * tile, cssY = worldX * tile. + const worldX = hitCssY / BASE_TILE; + const worldY = hitCssX / BASE_TILE; + + return [worldX, worldY]; +} diff --git a/website/src/components/BuilderWorkbench/geometry/terrain.ts b/website/src/components/BuilderWorkbench/geometry/terrain.ts new file mode 100644 index 00000000..4b2a60df --- /dev/null +++ b/website/src/components/BuilderWorkbench/geometry/terrain.ts @@ -0,0 +1,239 @@ +/** + * Terrain geometry — vertex-based sparse heightmap for the /builder editor. + * + * Heightmap model: + * Keys are integer GRID VERTEX indices (i, j); each vertex sits at + * world position (i * cellSize, j * cellSize). The value is the + * vertex's elevation in WORLD units. Absent keys mean z = 0. + * + * Rendering model — **tilted quads, not boxes.** + * Each CELL is the square between vertices (i, j) ↔ (i+1, j+1). + * If ANY of those 4 corners is non-zero, the cell renders as a + * single tilted quad with the actual corner heights. Cells whose + * 4 corners are all 0 don't render — the flat floor grid is + * visible in their place. + * + * Because adjacent cells SHARE corners, raising a vertex + * automatically tilts the 4 cells touching it: the cell with the + * raised vertex as a corner becomes a ramp pulled toward that + * corner, and the cells on the other sides of the raised vertex + * form matching ramps. No box rendering, no z-fighting with the + * floor — the warp emerges from sharing vertex heights. + * + * The hover ghost marks the nearest VERTEX (where the next click + * will land) — a small translucent cyan square at the vertex. + */ + +import type { Polygon } from "@layoutit/polycss-react"; + +/** Sparse heightmap: vertex (i, j) → elevation in world units. */ +export type TerrainVertices = Map; + +export const vertexKey = (i: number, j: number): string => `${i},${j}`; +export function parseVertexKey(key: string): [number, number] { + const [i, j] = key.split(",").map(Number); + return [i, j]; +} + +/** Project a world XY to the nearest VERTEX index. Used for tools — + * clicks snap to the closest grid intersection. */ +export function worldToVertex(worldX: number, worldY: number, cellSize: number): [number, number] { + return [Math.round(worldX / cellSize), Math.round(worldY / cellSize)]; +} + +/** Project a world XY to the CELL index that contains it. Used to + * determine which cells touch the hovered vertex (the cell whose + * corners are (i, j), (i+1, j), (i+1, j+1), (i, j+1)). */ +export function worldToCell(worldX: number, worldY: number, cellSize: number): [number, number] { + return [Math.floor(worldX / cellSize), Math.floor(worldY / cellSize)]; +} + +/** Build the polygons for every cell that has at least one elevated + * corner. Each such cell renders as a single tilted quad spanning + * (i, j) → (i+1, j+1) with the actual corner heights. */ +export interface TerrainRenderOptions { + vertices: TerrainVertices; + cellSize: number; + color?: string; +} + +export function buildTerrainPolygons(opts: TerrainRenderOptions): Polygon[] { + const color = opts.color ?? "rgba(34, 211, 238, 0.35)"; + const polys: Polygon[] = []; + + // Walk the set of CELLS that have at least one non-zero corner. A + // vertex is shared by up to 4 cells (its NW, NE, SE, SW), so for + // each non-zero vertex we mark all 4 touching cells dirty. + const dirtyCells = new Set(); + for (const key of opts.vertices.keys()) { + const [i, j] = parseVertexKey(key); + dirtyCells.add(vertexKey(i - 1, j - 1)); + dirtyCells.add(vertexKey(i, j - 1)); + dirtyCells.add(vertexKey(i - 1, j)); + dirtyCells.add(vertexKey(i, j)); + } + + const getZ = (i: number, j: number): number => opts.vertices.get(vertexKey(i, j)) ?? 0; + + for (const cellKey of dirtyCells) { + const [ci, cj] = parseVertexKey(cellKey); + const x0 = ci * opts.cellSize; + const x1 = (ci + 1) * opts.cellSize; + const y0 = cj * opts.cellSize; + const y1 = (cj + 1) * opts.cellSize; + const z00 = getZ(ci, cj); + const z10 = getZ(ci + 1, cj); + const z11 = getZ(ci + 1, cj + 1); + const z01 = getZ(ci, cj + 1); + const p00: [number, number, number] = [x0, y0, z00]; + const p10: [number, number, number] = [x1, y0, z10]; + const p11: [number, number, number] = [x1, y1, z11]; + const p01: [number, number, number] = [x0, y1, z01]; + // Split each cell into 2 triangles along the (p00 → p11) diagonal. + // A non-planar quad would be auto-snapped by polycss (see CLAUDE.md + // "Coplanarity is a hard requirement at render time…") which opens + // visible seams with neighbouring cells; triangles are inherently + // planar so the warped surface stays gap-free. CCW from +Z on both + // tris so the surface normal points up. + polys.push({ vertices: [p00, p10, p11], color }); + polys.push({ vertices: [p00, p11, p01], color }); + } + + return polys; +} + +/** Sample the heightmap at a continuous world (x, y) and return both + * the surface elevation z AND the slope gradients dz/dx, dz/dy at + * that point. The interpolation matches the rendered triangulation + * (cells are split along the (p00 → p11) diagonal, see + * `buildTerrainPolygons`) so a placement queried via this function + * lands exactly on the visible surface. Both gradients are constant + * within each triangle, so a placement on a slope reads the same + * tilt anywhere inside that triangle. */ +export interface TerrainSample { + z: number; + slopeX: number; + slopeY: number; +} + +export function sampleTerrain( + vertices: TerrainVertices, + cellSize: number, + worldX: number, + worldY: number, +): TerrainSample { + const ci = Math.floor(worldX / cellSize); + const cj = Math.floor(worldY / cellSize); + const u = (worldX - ci * cellSize) / cellSize; + const v = (worldY - cj * cellSize) / cellSize; + + const z00 = vertices.get(vertexKey(ci, cj)) ?? 0; + const z10 = vertices.get(vertexKey(ci + 1, cj)) ?? 0; + const z11 = vertices.get(vertexKey(ci + 1, cj + 1)) ?? 0; + const z01 = vertices.get(vertexKey(ci, cj + 1)) ?? 0; + + // Z matches the rendered tris: tri1 = p00, p10, p11 (u > v); + // tri2 = p00, p11, p01 (u <= v). The two triangles share the diagonal + // p00↔p11, so Z is continuous across it — only the slope is not. + let z: number; + if (u > v) { + z = (1 - u) * z00 + (u - v) * z10 + v * z11; + } else { + z = (1 - v) * z00 + u * z11 + (v - u) * z01; + } + + // Slope uses a single best-fit plane across all 4 corners of the + // cell, not the per-triangle gradient. On a non-planar cell (e.g. + // one corner raised) the two triangles have very different slopes, + // and an object whose footprint straddles the diagonal can't match + // both. The cell-average plane is the linear approximation that + // minimises the maximum deviation across the whole face — and it + // gives the placement a single stable orientation no matter which + // side of the diagonal its centre lands on. Object's centre still + // sits exactly on the visible ridge via the per-triangle Z above. + const slopeX = ((z10 + z11) - (z00 + z01)) / (2 * cellSize); + const slopeY = ((z01 + z11) - (z00 + z10)) / (2 * cellSize); + + return { z, slopeX, slopeY }; +} + +/** Convert slope gradients into a [rotX, rotY, 0] Euler triple (in + * degrees) that tilts a horizontal mesh so its local +Z aligns with + * the surface normal — i.e. `PolyMesh.rotation` values to pass. + * + * Slot mapping accounts for the world↔CSS axis swap (`cssPoints` + * maps world (x,y,z) → CSS (y,x,z)). CSS `rotateX` preserves CSS-X + * = world-Y, so it tilts the world-X side up/down → it carries + * `slopeX`. CSS `rotateY` preserves CSS-Y = world-X, so it tilts + * the world-Y side → it carries `slopeY`. The negative sign on the + * rotY arm comes from CSS rotateY's left-handed direction in this + * axis convention: `rotateY(+α)` takes +CSS-X (= +world-Y) toward + * -CSS-Z (down), so to lift +world-Y for a positive slopeY we need + * the opposite sign. */ +export function rotationForSlope(slopeX: number, slopeY: number): [number, number, number] { + const rotX = (Math.atan(slopeX) * 180) / Math.PI; + const rotY = (-Math.atan(slopeY) * 180) / Math.PI; + return [rotX, rotY, 0]; +} + +/** Build the hover ghost — visual feedback for where the next click + * will land. Vertex target = a small cyan square centred on the + * vertex. Face target = a translucent quad covering the cell at its + * current corner heights (2 triangles to stay planar). */ +export type HoverTarget = + | { kind: "vertex"; i: number; j: number } + | { kind: "face"; i: number; j: number }; + +export interface HoverGhostOptions { + target: HoverTarget | null; + cellSize: number; + /** Heightmap. Used to read the cell's corner heights in face mode + * and the vertex elevation in vertex mode (so the marker doesn't + * sink inside a raised surface). */ + vertices: TerrainVertices; + /** Half-side of the vertex marker, in world units. Default 0.4. */ + size?: number; + color?: string; +} + +export function buildHoverGhostPolygons(opts: HoverGhostOptions): Polygon[] { + if (!opts.target) return []; + const color = opts.color ?? "rgba(0, 217, 255, 0.5)"; + if (opts.target.kind === "face") { + const { i, j } = opts.target; + const x0 = i * opts.cellSize; + const x1 = (i + 1) * opts.cellSize; + const y0 = j * opts.cellSize; + const y1 = (j + 1) * opts.cellSize; + const z00 = opts.vertices.get(vertexKey(i, j)) ?? 0; + const z10 = opts.vertices.get(vertexKey(i + 1, j)) ?? 0; + const z11 = opts.vertices.get(vertexKey(i + 1, j + 1)) ?? 0; + const z01 = opts.vertices.get(vertexKey(i, j + 1)) ?? 0; + // Slight z-offset so the highlight doesn't z-fight with the grid. + const off = 0.05; + const p00: [number, number, number] = [x0, y0, z00 + off]; + const p10: [number, number, number] = [x1, y0, z10 + off]; + const p11: [number, number, number] = [x1, y1, z11 + off]; + const p01: [number, number, number] = [x0, y1, z01 + off]; + return [ + { vertices: [p00, p10, p11], color }, + { vertices: [p00, p11, p01], color }, + ]; + } + // Vertex target — small marker square at the vertex's current + // elevation so it doesn't disappear inside a raised surface. + const size = opts.size ?? 0.4; + const { i, j } = opts.target; + const cx = i * opts.cellSize; + const cy = j * opts.cellSize; + const z = (opts.vertices.get(vertexKey(i, j)) ?? 0) + 0.05; + return [{ + vertices: [ + [cx - size, cy - size, z], + [cx + size, cy - size, z], + [cx + size, cy + size, z], + [cx - size, cy + size, z], + ], + color, + }]; +} diff --git a/website/src/components/BuilderWorkbench/hooks/useCameraShortcuts.ts b/website/src/components/BuilderWorkbench/hooks/useCameraShortcuts.ts new file mode 100644 index 00000000..7f4a7244 --- /dev/null +++ b/website/src/components/BuilderWorkbench/hooks/useCameraShortcuts.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef } from "react"; +import type { DragMode, SceneOptionsState } from "../../types"; + +export interface UseCameraShortcutsOptions { + dragMode: DragMode; + updateScene: (partial: Partial) => void; +} + +// Hold Cmd (Mac) / Win (Windows) to temporarily switch from orbit → pan. +// Mirrors the three.js editor convention. Original mode is restored on +// key-up, on window blur, and on Escape so the hold can't get stuck. +// FPV mode is left alone — Cmd inside FPV would conflict with browser +// shortcuts the user might actually want there. +export function useCameraShortcuts({ dragMode, updateScene }: UseCameraShortcutsOptions): void { + const dragModeRef = useRef(dragMode); + dragModeRef.current = dragMode; + + useEffect(() => { + let resumeMode: DragMode | null = null; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Meta" || e.repeat) return; + if (resumeMode !== null) return; + if (dragModeRef.current !== "orbit") return; + resumeMode = "orbit"; + updateScene({ dragMode: "pan" }); + }; + const restore = () => { + if (resumeMode === null) return; + updateScene({ dragMode: resumeMode }); + resumeMode = null; + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === "Meta") restore(); + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("blur", restore); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", restore); + }; + }, [updateScene]); +} diff --git a/website/src/components/BuilderWorkbench/hooks/usePlacementMode.ts b/website/src/components/BuilderWorkbench/hooks/usePlacementMode.ts new file mode 100644 index 00000000..56ef06d2 --- /dev/null +++ b/website/src/components/BuilderWorkbench/hooks/usePlacementMode.ts @@ -0,0 +1,243 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { optimizeMeshPolygons } from "@layoutit/polycss-react"; +import type { Polygon } from "@layoutit/polycss-react"; +import { loadPresetModel } from "../../GalleryWorkbench/helpers/loaders"; +import { PRESETS } from "../../GalleryWorkbench/presets"; +import { PARSER_DEFAULTS, NORMALIZED_MAX_DIM } from "../defaults"; +import { meshBbox } from "../geometry/meshBbox"; +import { placeMeshOnFloor } from "../geometry/placement"; +import { buildGhostWireframePolygons, ghostRectFromBbox, GHOST_COLOR, rotatePolygonsAroundPivot } from "../geometry/ghost"; +import type { Bbox } from "../geometry/ghost"; +import { projectScreenToWorldGround } from "../geometry/screenToWorld"; +import { sampleTerrain, rotationForSlope, type TerrainVertices } from "../geometry/terrain"; +import type { PlacedItem, PlacementDraft, TargetMode } from "../types"; +import type { SceneOptionsState } from "../../types"; + +export interface UsePlacementModeOptions { + sceneOptions: SceneOptionsState; + appendItems: (items: PlacedItem[]) => void; + setSelectedId: (id: string | null) => void; + placementCounter: RefObject; + updateScene: (partial: Partial) => void; + /** Current heightmap vertices. Empty map ⇒ flat floor everywhere + * (placement falls back to z = 0, rotation = identity). */ + terrainVertices: TerrainVertices; + /** Snap target — `face` puts the placement at the cell centre, + * `vertex` snaps to the nearest grid intersection. */ + targetMode: TargetMode; +} + +export interface UsePlacementModeResult { + placementDraft: PlacementDraft | null; + setPlacementDraft: (d: PlacementDraft | null) => void; + ghostWorld: [number, number]; + ghostPolygons: Polygon[]; + handleAddPreset: (presetId: string) => Promise; + loadingPresetId: string | null; +} + +export function usePlacementMode({ + sceneOptions, + appendItems, + setSelectedId, + placementCounter, + updateScene, + terrainVertices, + targetMode, +}: UsePlacementModeOptions): UsePlacementModeResult { + const [placementDraft, setPlacementDraft] = useState(null); + const [ghostWorld, setGhostWorld] = useState<[number, number]>([0, 0]); + const [loadingPresetId, setLoadingPresetId] = useState(null); + + // Track whether autoCenter was on before placement started, so we can + // restore it on exit. Disabling autoCenter during placement makes + // autoCenterOffset = [0, 0, 0], simplifying the screen-to-world math. + const autoCenterBeforePlacement = useRef(undefined); + + // Click in sidebar = ENTER PLACEMENT MODE (load model, arm ghost, wait for + // floor click). The user then clicks somewhere on the floor to commit. + const handleAddPreset = useCallback(async (presetId: string) => { + const preset = PRESETS.find((p) => p.id === presetId); + if (!preset || loadingPresetId) return; + setLoadingPresetId(presetId); + // Exit any existing placement mode before entering a new one. + setPlacementDraft(null); + try { + const loaded = await loadPresetModel(preset, PARSER_DEFAULTS); + const optimized = optimizeMeshPolygons(loaded.rawPolygons, { + meshResolution: sceneOptions.meshResolution, + }); + const bboxResult = meshBbox(optimized); + const fitScale = bboxResult.span > 0 ? NORMALIZED_MAX_DIM / bboxResult.span : 1; + const bbox: Bbox = { + minX: bboxResult.minX, + minY: bboxResult.minY, + minZ: bboxResult.minZ, + maxX: bboxResult.maxX, + maxY: bboxResult.maxY, + maxZ: bboxResult.maxZ, + }; + // Disable autoCenter during placement so autoCenterOffset = [0, 0, 0], + // which simplifies the inverse-projection math in screenToWorld. + autoCenterBeforePlacement.current = sceneOptions.autoCenter; + if (sceneOptions.autoCenter) updateScene({ autoCenter: false }); + setPlacementDraft({ + preset, + rawPolygons: loaded.rawPolygons, + bbox, + meshBboxResult: bboxResult, + fitScale, + }); + } catch (e) { + console.error("[builder] failed to load preset for placement", preset.id, e); + } finally { + setLoadingPresetId(null); + } + }, [loadingPresetId, sceneOptions.meshResolution, sceneOptions.autoCenter, updateScene]); + + // Commit the current placementDraft at ghostWorld, add to placedItems, exit. + // Reads the heightmap at the placement XY so the mesh lands on top of + // any raised terrain and tilts to match the local slope normal. + const commitPlacement = useCallback(() => { + if (!placementDraft) return; + const [wx, wy] = ghostWorld; + const { preset, rawPolygons, meshBboxResult, fitScale } = placementDraft; + const sample = sampleTerrain(terrainVertices, sceneOptions.gridResolution, wx, wy); + const position = placeMeshOnFloor(wx, wy, meshBboxResult, fitScale, sample.z); + const rotation = rotationForSlope(sample.slopeX, sample.slopeY); + const n = placementCounter.current++; + const placed: PlacedItem = { + id: `placed-${Date.now()}-${n}`, + preset, + rawPolygons, + position, + rotation, + scale: 1, + fitScale, + worldX: wx, + worldY: wy, + }; + appendItems([placed]); + setSelectedId(placed.id); + setPlacementDraft(null); + if (autoCenterBeforePlacement.current) updateScene({ autoCenter: true }); + }, [placementDraft, ghostWorld, appendItems, setSelectedId, placementCounter, updateScene, terrainVertices, sceneOptions.gridResolution]); + + // Ghost polygons in WORLD coords — recomputed on every cursor move + // and re-lifted to the current terrain elevation under the cursor. + // The slope tilt is baked into the vertices by rotating around the + // bbox CENTRE (matching PolyMesh's transform-origin so the preview + // and committed placement line up). + const ghostPolygons = useMemo(() => { + if (!placementDraft) return []; + const sample = sampleTerrain(terrainVertices, sceneOptions.gridResolution, ghostWorld[0], ghostWorld[1]); + const rect = ghostRectFromBbox( + placementDraft.bbox, + ghostWorld[0], + ghostWorld[1], + placementDraft.fitScale, + sample.z, + ); + const polys = buildGhostWireframePolygons(rect, GHOST_COLOR); + const [rotX, rotY] = rotationForSlope(sample.slopeX, sample.slopeY); + if (rotX === 0 && rotY === 0) return polys; + const pivot: [number, number, number] = [ + rect.worldX, + rect.worldY, + sample.z + rect.height / 2, + ]; + return rotatePolygonsAroundPivot(polys, pivot, rotX, rotY); + }, [placementDraft, ghostWorld, terrainVertices, sceneOptions.gridResolution]); + + // ESC cancels placement mode and restores autoCenter. + useEffect(() => { + if (!placementDraft) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setPlacementDraft(null); + if (autoCenterBeforePlacement.current) updateScene({ autoCenter: true }); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [placementDraft, updateScene]); + + // Placement-mode pointer capture. Listens on the viewport in capture + // phase so the move/click reach our handler BEFORE PolyOrbitControls or + // PolySelect get a chance to react. We stopPropagation on click so the + // commit doesn't double as a select / orbit-drag start. + // + // We attach to `.dn-viewport` (the wrapper element around ) so + // events outside the viewport (over the sidebar, dock, etc.) are not + // captured — only pointer activity over the 3D area drives placement. + // A transparent PolyMesh catcher used to handle this, but polycss's color + // pipeline doesn't render "transparent" colors as truly invisible (they + // came out opaque white), so we now drive placement entirely from DOM + // events without a catcher mesh. + useEffect(() => { + if (!placementDraft) return; + const viewport = document.querySelector(".dn-viewport") as HTMLElement | null; + const cameraEl = document.querySelector(".polycss-camera") as HTMLElement | null; + if (!viewport || !cameraEl) return; + + const projectAt = (clientX: number, clientY: number): [number, number] | null => { + const hit = projectScreenToWorldGround({ + clientX, + clientY, + cameraEl, + sceneOptions, + autoCenterOffset: [0, 0, 0], + }); + if (!hit) return null; + if (!sceneOptions.snapToGrid || sceneOptions.gridResolution <= 0) return hit; + const step = sceneOptions.gridResolution; + // Face target → snap to cell CENTRE (floor + ½ step). Vertex + // target → snap to nearest grid intersection (round). + if (targetMode === "face") { + return [Math.floor(hit[0] / step) * step + step / 2, Math.floor(hit[1] / step) * step + step / 2]; + } + return [Math.round(hit[0] / step) * step, Math.round(hit[1] / step) * step]; + }; + + // Capture-phase events on the viewport reach every descendant first. + // Skip clicks/moves on the floating UI overlays (tool palette, + // camera-mode pill) so their own handlers still fire — otherwise the + // user can't change modes while in placement mode. + const isUiOverlay = (target: EventTarget | null): boolean => { + const el = target as HTMLElement | null; + if (!el || !el.closest) return false; + return Boolean(el.closest(".builder-tool-palette, .builder-target-mode, .builder-camera-mode")); + }; + + const onMove = (e: PointerEvent) => { + if (isUiOverlay(e.target)) return; + const hit = projectAt(e.clientX, e.clientY); + if (hit) setGhostWorld(hit); + }; + const onClick = (e: MouseEvent) => { + if (isUiOverlay(e.target)) return; + const hit = projectAt(e.clientX, e.clientY); + if (!hit) return; + e.preventDefault(); + e.stopPropagation(); + setGhostWorld(hit); + commitPlacement(); + }; + + viewport.addEventListener("pointermove", onMove, true); + viewport.addEventListener("click", onClick, true); + return () => { + viewport.removeEventListener("pointermove", onMove, true); + viewport.removeEventListener("click", onClick, true); + }; + }, [placementDraft, sceneOptions, commitPlacement, targetMode]); + + return { + placementDraft, + setPlacementDraft, + ghostWorld, + ghostPolygons, + handleAddPreset, + loadingPresetId, + }; +} diff --git a/website/src/components/BuilderWorkbench/hooks/usePlacements.ts b/website/src/components/BuilderWorkbench/hooks/usePlacements.ts new file mode 100644 index 00000000..aabe54f9 --- /dev/null +++ b/website/src/components/BuilderWorkbench/hooks/usePlacements.ts @@ -0,0 +1,171 @@ +import { useCallback, useRef, useState, type RefObject } from "react"; +import { optimizeMeshPolygons } from "@layoutit/polycss-react"; +import type { MeshResolution, PolyMeshHandle, Vec3 } from "@layoutit/polycss-react"; +import type { PresetModel } from "../../GalleryWorkbench/types"; +import { loadPresetModel } from "../../GalleryWorkbench/helpers/loaders"; +import { PARSER_DEFAULTS, NORMALIZED_MAX_DIM } from "../defaults"; +import { meshBbox } from "../geometry/meshBbox"; +import { placeMeshOnFloor } from "../geometry/placement"; +import type { PlacedItem } from "../types"; + +export interface UsePlacementsOptions { + meshResolution: MeshResolution; +} + +export interface UsePlacementsResult { + placedItems: PlacedItem[]; + selectedId: string | null; + setSelectedId: (id: string | null) => void; + placementCounter: RefObject; + buildPlacement: ( + preset: PresetModel, + worldX: number, + worldY: number, + opts?: { rotation?: Vec3; scale?: number }, + ) => Promise; + appendItems: (items: PlacedItem[]) => void; + updateItem: (id: string, partial: Partial) => void; + mapItems: (updater: (item: PlacedItem) => PlacedItem) => void; + handleDeleteItem: (id: string) => void; + meshHandlesRef: RefObject>; + meshRefCallbacksRef: RefObject void>>; + getMeshRefCallback: (id: string) => (h: PolyMeshHandle | null) => void; + selectedIdRef: RefObject; + handleDeleteSelectedRef: RefObject<() => void>; + meshHandlesTick: number; +} + +export function usePlacements({ meshResolution }: UsePlacementsOptions): UsePlacementsResult { + const [placedItems, setPlacedItems] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [meshHandlesTick, setMeshHandlesTick] = useState(0); + const placementCounter = useRef(0); + + // Per-item handles indexed by id. Populated by each PolyMesh's callback + // ref on mount and updated/removed on unmount. Storing in a Map (instead of + // a single shared ref) is what makes selection switching work: a shared ref + // is updated during commit, but PolyTransformControls reads it during + // render, so it would render with the previous selection's handle and + // never see the new one. The Map is populated by the previous commit, so + // looking up `selectedId` during render always returns the right handle. + const meshHandlesRef = useRef>(new Map()); + // A stable callback ref per id, memoized via a Map. Inline `(h) => + // registerMeshHandle(id, h)` would create a new function each render and + // React would re-fire the ref callback (clear + set), bumping the tick and + // looping. Caching the closure keeps the ref identity stable across + // renders for the same id; entries are reused as the items list churns. + const meshRefCallbacksRef = useRef void>>(new Map()); + const getMeshRefCallback = useCallback((id: string) => { + let cb = meshRefCallbacksRef.current.get(id); + if (!cb) { + cb = (handle: PolyMeshHandle | null) => { + if (handle) meshHandlesRef.current.set(id, handle); + else meshHandlesRef.current.delete(id); + setMeshHandlesTick((n) => n + 1); + }; + meshRefCallbacksRef.current.set(id, cb); + } + return cb; + }, []); + + // Keep selectedId mirrored in a ref so the Dock callbacks (created once via + // useCallback) can read the latest id without recreating the handlers. + const selectedIdRef = useRef(null); + selectedIdRef.current = selectedId; + + // Build a PlacedItem from a preset + a target world (X, Y, Z) and + // optional rotation/scale. Shared between single-click placement and + // scene-preset batch loading. Returns null on parse failure. + // + // Floor snap + fitScale must use the SAME polygon set that + // renders, because its transform-origin is derived from that set. The + // dock's meshResolution setting affects optimizeMeshPolygons output, + // so we run the same pipeline here. The placement is snapped once with + // this bbox; if the user later toggles meshResolution post-placement, + // a small Z drift may appear (the user is in control of placement + // after that). + const buildPlacement = useCallback( + async ( + preset: PresetModel, + worldX: number, + worldY: number, + opts: { rotation?: Vec3; scale?: number } = {}, + ): Promise => { + try { + const loaded = await loadPresetModel(preset, PARSER_DEFAULTS); + const optimized = optimizeMeshPolygons(loaded.rawPolygons, { meshResolution }); + const bbox = meshBbox(optimized); + const fitScale = bbox.span > 0 ? NORMALIZED_MAX_DIM / bbox.span : 1; + const placement = placeMeshOnFloor(worldX, worldY, bbox, fitScale); + const n = placementCounter.current++; + return { + id: `placed-${Date.now()}-${n}`, + preset, + rawPolygons: loaded.rawPolygons, + position: placement, + rotation: opts.rotation ?? [0, 0, 0], + scale: opts.scale ?? 1, + fitScale, + worldX, + worldY, + }; + } catch (e) { + console.error("[builder] failed to load preset", preset.id, e); + return null; + } + }, + [meshResolution], + ); + + const appendItems = useCallback((items: PlacedItem[]) => { + setPlacedItems((prev) => [...prev, ...items]); + }, []); + + const updateItem = useCallback((id: string, partial: Partial) => { + setPlacedItems((items) => items.map((it) => (it.id === id ? { ...it, ...partial } : it))); + }, []); + + /** Bulk update every item via `updater`. Returning the same object + * reference skips replacement for that item — single render for the + * whole batch. Used by the terrain-follow effect to re-snap every + * placement when the heightmap changes. */ + const mapItems = useCallback((updater: (item: PlacedItem) => PlacedItem) => { + setPlacedItems((items) => items.map(updater)); + }, []); + + const handleDeleteItem = useCallback((id: string) => { + setPlacedItems((items) => items.filter((it) => it.id !== id)); + setSelectedId((cur) => (cur === id ? null : cur)); + meshRefCallbacksRef.current.delete(id); + meshHandlesRef.current.delete(id); + }, []); + + // Keyboard Delete keeps using a stable ref so the once-mounted effect picks + // up the latest selection without re-binding. + const handleDeleteSelectedRef = useRef(() => { + const id = selectedIdRef.current; + if (id) handleDeleteItem(id); + }); + handleDeleteSelectedRef.current = () => { + const id = selectedIdRef.current; + if (id) handleDeleteItem(id); + }; + + return { + placedItems, + selectedId, + setSelectedId, + placementCounter, + buildPlacement, + appendItems, + updateItem, + mapItems, + handleDeleteItem, + meshHandlesRef, + meshRefCallbacksRef, + getMeshRefCallback, + selectedIdRef, + handleDeleteSelectedRef, + meshHandlesTick, + }; +} diff --git a/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts b/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts new file mode 100644 index 00000000..bc16edb5 --- /dev/null +++ b/website/src/components/BuilderWorkbench/hooks/useSceneLoader.ts @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useRef, type RefObject } from "react"; +import { optimizeMeshPolygons } from "@layoutit/polycss-react"; +import type { MeshResolution, PolyFirstPersonControlsHandle, Vec3 } from "@layoutit/polycss-react"; +import type { PresetModel } from "../../GalleryWorkbench/types"; +import { loadPresetModel } from "../../GalleryWorkbench/helpers/loaders"; +import { PARSER_DEFAULTS, NORMALIZED_MAX_DIM } from "../defaults"; +import { meshBbox } from "../geometry/meshBbox"; +import { placeMeshOnFloor } from "../geometry/placement"; +import { SCENE_PRESETS } from "../scenes"; +import { PRESETS } from "../../GalleryWorkbench/presets"; +import type { PlacedItem } from "../types"; +import type { SceneOptionsState } from "../../types"; +import type { DragMode } from "../../types"; + +export interface UseSceneLoaderOptions { + placedItems: PlacedItem[]; + appendItems: (items: PlacedItem[]) => void; + updateItem: (id: string, partial: Partial) => void; + buildPlacement: ( + preset: PresetModel, + worldX: number, + worldY: number, + opts?: { rotation?: Vec3; scale?: number }, + ) => Promise; + placementCounter: RefObject; + dragMode: DragMode; + fpvRenderDistance: number; + targetWorld: Vec3; + fpvControlsRef: RefObject; + meshResolution: MeshResolution; + updateScene: (partial: Partial) => void; +} + +export interface UseSceneLoaderResult { + handleAddScene: (sceneId: string) => void; +} + +export function useSceneLoader({ + placedItems, + appendItems, + updateItem, + placementCounter, + dragMode, + fpvRenderDistance, + targetWorld, + fpvControlsRef, + meshResolution, + updateScene, +}: UseSceneLoaderOptions): UseSceneLoaderResult { + const meshResolutionRef = useRef(meshResolution); + meshResolutionRef.current = meshResolution; + + // Dedupe in-flight loads so the same item can't kick off twice between + // the setState callback and the next effect tick. + const loadingItemIdsRef = useRef>(new Set()); + + // Scene click = batch ADD as PENDING placeholders. Each scene item + // becomes a PlacedItem with `rawPolygons: null` and a placeholder + // `position`/`fitScale`; the proximity loader below promotes them to + // loaded items when the camera comes within `fpvRenderDistance * 2` + // world units. This avoids fetching + parsing every asset upfront on a + // dense scene (Medieval Village = 38 placements) — most assets only + // load if the player actually walks near them. + const handleAddScene = useCallback((sceneId: string) => { + const scene = SCENE_PRESETS.find((s) => s.id === sceneId); + if (!scene) return; + if (scene.defaultSceneOptions) { + updateScene(scene.defaultSceneOptions); + } + const baseId = Date.now(); + const pending: PlacedItem[] = scene.items + .map((item, i): PlacedItem | null => { + const preset = PRESETS.find((p) => p.id === item.presetId); + if (!preset) { + console.warn("[builder] scene references unknown preset", item.presetId); + return null; + } + return { + id: `placed-${baseId}-${placementCounter.current++}-${i}`, + preset, + rawPolygons: null, + position: [0, 0, 0], + rotation: item.rotation ?? [0, 0, 0], + scale: item.scale ?? 1, + fitScale: 1, + worldX: item.position[0], + worldY: item.position[1], + }; + }) + .filter((p): p is PlacedItem => p !== null); + if (pending.length === 0) return; + appendItems(pending); + }, [appendItems, placementCounter, updateScene]); + + // Proximity-driven lazy loader: promotes pending items (`rawPolygons` + // null) to loaded items when the camera comes within `loadDistance` + // world units. `loadDistance` is twice the render distance so models + // load BEFORE they pop into the visible cull set — no visible "popping + // in" right at the edge. + // + // Origin source: FPV camera origin when in FPV, otherwise the orbit + // camera target (so orbiting around the scene also pulls nearby items + // in). The effect resubscribes when the mode changes; in FPV it + // listens for "change" events to keep loading as the player walks. + useEffect(() => { + const loadDistance = fpvRenderDistance > 0 + ? fpvRenderDistance * 2 + : Infinity; + const ld2 = loadDistance * loadDistance; + + const loadOne = async (item: PlacedItem): Promise => { + try { + const loaded = await loadPresetModel(item.preset, PARSER_DEFAULTS); + const optimized = optimizeMeshPolygons(loaded.rawPolygons, { + meshResolution: meshResolutionRef.current, + }); + const bbox = meshBbox(optimized); + const fitScale = bbox.span > 0 ? NORMALIZED_MAX_DIM / bbox.span : 1; + const placement = placeMeshOnFloor(item.worldX, item.worldY, bbox, fitScale); + updateItem(item.id, { rawPolygons: loaded.rawPolygons, fitScale, position: placement }); + } catch (e) { + console.error("[builder] lazy load failed", item.preset.id, e); + } finally { + loadingItemIdsRef.current.delete(item.id); + } + }; + + const checkAndLoad = (ox: number, oy: number): void => { + for (const item of placedItems) { + if (item.rawPolygons !== null) continue; + if (loadingItemIdsRef.current.has(item.id)) continue; + const dx = item.worldX - ox; + const dy = item.worldY - oy; + if (dx * dx + dy * dy > ld2) continue; + loadingItemIdsRef.current.add(item.id); + void loadOne(item); + } + }; + + const ctrl = fpvControlsRef.current; + const inFpv = dragMode === "fpv" && !!ctrl; + if (inFpv && ctrl) { + const [ox, oy] = ctrl.getOrigin(); + checkAndLoad(ox, oy); + const onChange = (): void => { + const [nx, ny] = ctrl.getOrigin(); + checkAndLoad(nx, ny); + }; + ctrl.addEventListener("change", onChange); + return () => ctrl.removeEventListener("change", onChange); + } else { + // Fallback origin: orbit camera target. Loads what's near the + // viewpoint when not in FPV (e.g. scene-add lands the user at + // target = [0,0,0] so center-of-scene items load first). + checkAndLoad(targetWorld[0], targetWorld[1]); + } + }, [placedItems, dragMode, fpvRenderDistance, targetWorld, fpvControlsRef, updateItem]); + + return { handleAddScene }; +} diff --git a/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts b/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts new file mode 100644 index 00000000..29dc3488 --- /dev/null +++ b/website/src/components/BuilderWorkbench/hooks/useSceneRender.ts @@ -0,0 +1,98 @@ +import { useMemo, type RefObject } from "react"; +import { optimizeMeshPolygons } from "@layoutit/polycss-react"; +import type { PolyFirstPersonControlsHandle, Polygon } from "@layoutit/polycss-react"; +import { interiorFillPolygons } from "../../GalleryWorkbench/helpers/interiorFill"; +import { useFpvHost, useFpvCull } from "../../fpv"; +import type { SceneOptionsState } from "../../types"; +import { buildGridPolygons } from "../geometry/grid"; +import type { TerrainVertices } from "../geometry/terrain"; +import type { PlacedItem } from "../types"; + +export interface UseSceneRenderOptions { + placedItems: PlacedItem[]; + selectedId: string | null; + sceneOptions: SceneOptionsState; + fpvControlsRef: RefObject; + updateScene: (partial: Partial) => void; + /** Heightmap. Drives the grid's per-cell segment elevation so the + * floor grid is unified with the terrain — raised vertices bend + * the grid lines instead of leaving a separate fill mesh on top. */ + terrainVertices: TerrainVertices; +} + +export interface UseSceneRenderResult { + renderedPolygonsById: Map; + renderItems: Array; + gridPolygons: Polygon[]; +} + +export function useSceneRender({ + placedItems, + selectedId, + sceneOptions, + fpvControlsRef, + updateScene, + terrainVertices, +}: UseSceneRenderOptions): UseSceneRenderResult { + const renderedPolygonsById = useMemo(() => { + const out = new Map(); + for (const it of placedItems) { + if (it.rawPolygons === null) continue; + const optimized = optimizeMeshPolygons(it.rawPolygons, { + meshResolution: sceneOptions.meshResolution, + }); + out.set(it.id, sceneOptions.meshInteriorFill ? [...optimized, ...interiorFillPolygons(optimized)] : optimized); + } + return out; + }, [placedItems, sceneOptions.meshResolution, sceneOptions.meshInteriorFill]); + + // World-space polygons for FPV bbox sampling. `useFpvHost` only reads + // vertex extents when `dragMode` transitions to "fpv". + const worldPolygons = useMemo(() => { + const out: Polygon[] = []; + for (const it of placedItems) { + const polys = renderedPolygonsById.get(it.id); + if (!polys) continue; + const s = it.scale * it.fitScale; + const [px, py, pz] = it.position; + for (const polygon of polys) { + out.push({ + ...polygon, + vertices: polygon.vertices.map(([x, y, z]) => [px + x * s, py + y * s, pz + z * s]), + }); + } + } + return out; + }, [placedItems, renderedPolygonsById]); + + useFpvHost({ + dragMode: sceneOptions.dragMode, + autoCenter: sceneOptions.autoCenter, + perspective: sceneOptions.perspective, + rotY: sceneOptions.rotY, + scenePolygons: worldPolygons, + updateScene, + }); + + const visibleIds = useFpvCull({ + controlsRef: fpvControlsRef, + items: placedItems, + renderDistance: sceneOptions.fpvRenderDistance, + enabled: sceneOptions.dragMode === "fpv" && sceneOptions.fpvRenderDistance > 0, + alwaysIncludeId: selectedId, + }); + + const renderItems = useMemo(() => { + const loaded = placedItems.filter( + (it): it is PlacedItem & { rawPolygons: Polygon[] } => it.rawPolygons !== null, + ); + return visibleIds === null ? loaded : loaded.filter((it) => visibleIds.has(it.id)); + }, [placedItems, visibleIds]); + + const gridPolygons = useMemo( + () => buildGridPolygons({ spacing: sceneOptions.gridResolution, vertices: terrainVertices }), + [sceneOptions.gridResolution, terrainVertices], + ); + + return { renderedPolygonsById, renderItems, gridPolygons }; +} diff --git a/website/src/components/BuilderWorkbench/hooks/useSidebarItems.ts b/website/src/components/BuilderWorkbench/hooks/useSidebarItems.ts new file mode 100644 index 00000000..5a3ed30e --- /dev/null +++ b/website/src/components/BuilderWorkbench/hooks/useSidebarItems.ts @@ -0,0 +1,48 @@ +import { useCallback, useMemo, useState } from "react"; +import { PRESETS, stripParenthesizedText } from "../../GalleryWorkbench/presets"; +import { BUILDER_KIT_CATEGORIES } from "../defaults"; + +export interface UseSidebarItemsResult { + modelSearch: string; + setModelSearch: (s: string) => void; + modelCategories: Array<{ id: string; label: string; models: Array<{ id: string; label: string; category: string }> }>; + modelTreeId: string[]; + isCategoryOpen: (id: string) => boolean; + handleToggleCategory: (id: string) => void; +} + +export function useSidebarItems(): UseSidebarItemsResult { + const [modelSearch, setModelSearch] = useState(""); + const [openCategory, setOpenCategory] = useState(BUILDER_KIT_CATEGORIES[0]); + + const presetItems = useMemo( + () => PRESETS + .filter((p) => BUILDER_KIT_CATEGORIES.includes(p.category)) + .map((p) => ({ id: p.id, label: stripParenthesizedText(p.label), category: p.category })), + [], + ); + const trimmedSearch = modelSearch.trim().toLowerCase(); + const modelCategories = useMemo(() => { + const filtered = trimmedSearch + ? presetItems.filter((p) => p.label.toLowerCase().includes(trimmedSearch)) + : presetItems; + const byCat = new Map(); + for (const p of filtered) { + const arr = byCat.get(p.category) ?? []; + arr.push(p); + byCat.set(p.category, arr); + } + // Fixed kit order: City Kit → Urban Pack → Medieval Village. + return BUILDER_KIT_CATEGORIES + .filter((cat) => byCat.has(cat)) + .map((cat) => ({ id: cat, label: cat, models: byCat.get(cat)! })); + }, [presetItems, trimmedSearch]); + const modelTreeId = useMemo(() => modelCategories.map((_, i) => `builder-tree-${i}`), [modelCategories]); + const isCategoryOpen = useCallback( + (id: string) => (trimmedSearch ? true : openCategory === id), + [trimmedSearch, openCategory], + ); + const handleToggleCategory = useCallback((id: string) => setOpenCategory((prev) => (prev === id ? null : id)), []); + + return { modelSearch, setModelSearch, modelCategories, modelTreeId, isCategoryOpen, handleToggleCategory }; +} diff --git a/website/src/components/BuilderWorkbench/hooks/useTerrain.ts b/website/src/components/BuilderWorkbench/hooks/useTerrain.ts new file mode 100644 index 00000000..e245d95c --- /dev/null +++ b/website/src/components/BuilderWorkbench/hooks/useTerrain.ts @@ -0,0 +1,216 @@ +/** + * Terrain editor state + viewport pointer capture. + * + * When `toolMode` is anything other than "pointer", the user is editing + * the heightmap rather than placing meshes. We capture pointermove (to + * update the hover ghost) and click (to apply the active tool) on the + * viewport in CAPTURE phase, mirroring `usePlacementMode` so orbit + * drag / mesh selection don't double-fire. + * + * Heightmap is VERTEX-based: clicks snap to the nearest grid vertex + * and raise / lower / smooth that vertex. The 4 cells touching the + * raised vertex automatically deform (one corner pulled up) so the + * surrounding terrain reads as a smooth warp instead of a stamped + * box. See `geometry/terrain.ts` for the rendering model. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Polygon } from "@layoutit/polycss-react"; +import type { SceneOptionsState } from "../../types"; +import type { TargetMode, ToolMode } from "../types"; +import { projectScreenToWorldGround } from "../geometry/screenToWorld"; +import { + buildHoverGhostPolygons, + vertexKey, + worldToCell, + worldToVertex, + type HoverTarget, + type TerrainVertices, +} from "../geometry/terrain"; + +/** World units added / removed per click for Raise / Lower. */ +const BRUSH_STRENGTH = 1; +/** Strength of the Smooth tool — fraction of the way toward the + * neighbour average per click. */ +const SMOOTH_STRENGTH = 0.5; + +export interface UseTerrainOptions { + toolMode: ToolMode; + targetMode: TargetMode; + sceneOptions: SceneOptionsState; +} + +export interface UseTerrainResult { + /** All vertices with non-zero elevation. Consumed by useSceneRender + * (to build the warped grid) and usePlacementMode (to land meshes + * on top of the terrain with the local slope tilt). */ + vertices: TerrainVertices; + /** Polygons for the hover vertex marker (empty when not editing). */ + hoverPolygons: Polygon[]; +} + +export function useTerrain({ toolMode, targetMode, sceneOptions }: UseTerrainOptions): UseTerrainResult { + const [vertices, setVertices] = useState(() => new Map()); + // Single hover target descriptor that captures vertex OR face — the + // hover-ghost builder picks the right rendering off this discriminator. + const [hoverTarget, setHoverTarget] = useState(null); + + // Pointerdown coords for drag-vs-click discrimination. Kept in a ref + // so they survive useEffect re-runs (sceneOptions changes between + // pointerdown and click would otherwise reset them and every click + // would look like a long drag). + const downRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + + const cellSize = sceneOptions.gridResolution; + + // Apply the active tool to a target — either a single vertex (vertex + // mode) or all 4 corners of a face (face mode). Tiny residuals are + // dropped so vertices returning to flat leave the sparse map. + const applyTool = useCallback( + (target: HoverTarget): void => { + setVertices((prev) => { + const next = new Map(prev); + + // List of vertex indices this tool action will touch: + // vertex target → just the clicked vertex; face target → + // the 4 corners of the clicked cell. + const targets: Array<[number, number]> = + target.kind === "vertex" + ? [[target.i, target.j]] + : [ + [target.i, target.j], + [target.i + 1, target.j], + [target.i + 1, target.j + 1], + [target.i, target.j + 1], + ]; + + for (const [i, j] of targets) { + const key = vertexKey(i, j); + const current = next.get(key) ?? 0; + + if (toolMode === "raise") { + next.set(key, current + BRUSH_STRENGTH); + } else if (toolMode === "lower") { + next.set(key, current - BRUSH_STRENGTH); + } else if (toolMode === "smooth") { + // Average with the 8 neighbour vertices (default 0). + let sum = 0; + let count = 0; + for (let dj = -1; dj <= 1; dj++) { + for (let di = -1; di <= 1; di++) { + if (di === 0 && dj === 0) continue; + sum += next.get(vertexKey(i + di, j + dj)) ?? 0; + count++; + } + } + const avg = count > 0 ? sum / count : 0; + const blended = current + (avg - current) * SMOOTH_STRENGTH; + if (Math.abs(blended) < 1e-3) next.delete(key); + else next.set(key, blended); + } + + const updated = next.get(key); + if (updated !== undefined && Math.abs(updated) < 1e-6) next.delete(key); + } + return next; + }); + }, + [toolMode], + ); + + // Viewport pointer capture — only engaged when a terrain tool is + // active. Capture phase + stopPropagation keeps the click out of + // orbit drag / mesh selection. + useEffect(() => { + if (toolMode === "pointer") { + setHoverTarget(null); + return; + } + const viewport = document.querySelector(".dn-viewport") as HTMLElement | null; + const cameraEl = document.querySelector(".polycss-camera") as HTMLElement | null; + if (!viewport || !cameraEl) return; + + const projectAt = (clientX: number, clientY: number): [number, number] | null => + projectScreenToWorldGround({ + clientX, + clientY, + cameraEl, + sceneOptions, + autoCenterOffset: [0, 0, 0], + }); + + // We're in CAPTURE phase on the viewport, so events on every descendant + // route through here first — including the floating tool palette and + // camera-mode pill that live inside the viewport. Skip those so the + // button's own click handler still fires; otherwise the user can't + // change modes while a terrain tool is active. + const isUiOverlay = (target: EventTarget | null): boolean => { + const el = target as HTMLElement | null; + if (!el || !el.closest) return false; + return Boolean(el.closest(".builder-tool-palette, .builder-target-mode, .builder-camera-mode")); + }; + + // Click-vs-drag discrimination: the user might pointer-drag to orbit + // the camera, and we don't want every drag to also raise the floor. + // 8 px tolerance — covers trackpad jitter while still catching real + // drags. The downRef is hoisted above the effect so its value + // survives effect re-runs caused by sceneOptions updates that fire + // between pointerdown and click. + const CLICK_THRESHOLD_PX = 8; + + const onDown = (e: PointerEvent) => { + if (isUiOverlay(e.target)) return; + downRef.current = { x: e.clientX, y: e.clientY }; + }; + + const worldToTarget = (world: [number, number]): HoverTarget => { + if (targetMode === "face") { + const [i, j] = worldToCell(world[0], world[1], cellSize); + return { kind: "face", i, j }; + } + const [i, j] = worldToVertex(world[0], world[1], cellSize); + return { kind: "vertex", i, j }; + }; + + const onMove = (e: PointerEvent) => { + if (isUiOverlay(e.target)) return; + const world = projectAt(e.clientX, e.clientY); + if (!world) return; + const next = worldToTarget(world); + setHoverTarget((prev) => + prev && prev.kind === next.kind && prev.i === next.i && prev.j === next.j ? prev : next, + ); + }; + const onClick = (e: MouseEvent) => { + if (isUiOverlay(e.target)) return; + const dx = e.clientX - downRef.current.x; + const dy = e.clientY - downRef.current.y; + if (dx * dx + dy * dy > CLICK_THRESHOLD_PX * CLICK_THRESHOLD_PX) { + return; + } + const world = projectAt(e.clientX, e.clientY); + if (!world) return; + e.preventDefault(); + e.stopPropagation(); + applyTool(worldToTarget(world)); + }; + const onLeave = () => setHoverTarget(null); + + viewport.addEventListener("pointerdown", onDown, true); + viewport.addEventListener("pointermove", onMove, true); + viewport.addEventListener("pointerleave", onLeave, true); + viewport.addEventListener("click", onClick, true); + return () => { + viewport.removeEventListener("pointerdown", onDown, true); + viewport.removeEventListener("pointermove", onMove, true); + viewport.removeEventListener("pointerleave", onLeave, true); + viewport.removeEventListener("click", onClick, true); + }; + }, [toolMode, targetMode, sceneOptions, cellSize, applyTool]); + + const hoverPolygons = useMemo(() => { + if (toolMode === "pointer" || !hoverTarget) return []; + return buildHoverGhostPolygons({ target: hoverTarget, cellSize, vertices }); + }, [toolMode, hoverTarget, vertices, cellSize]); + + return { vertices, hoverPolygons }; +} diff --git a/website/src/components/BuilderWorkbench/index.ts b/website/src/components/BuilderWorkbench/index.ts new file mode 100644 index 00000000..52577571 --- /dev/null +++ b/website/src/components/BuilderWorkbench/index.ts @@ -0,0 +1 @@ +export { default as BuilderWorkbench } from "./BuilderWorkbench"; diff --git a/website/src/components/BuilderWorkbench/scenes.ts b/website/src/components/BuilderWorkbench/scenes.ts new file mode 100644 index 00000000..5031536d --- /dev/null +++ b/website/src/components/BuilderWorkbench/scenes.ts @@ -0,0 +1,135 @@ +/** + * Builder scene presets — composite layouts that load multiple model + * presets at once with prebuilt relative positions/rotations/scales. + * + * Scene items reference model presets by FILE (relative to + * `website/public/gallery/glb/`) and the matching preset ID is derived + * via `presetIdFromFile`. This keeps the scene definitions + * human-readable AND impossible to desync from the preset list — the + * filename is the single source of truth. + * + * Positions are in WORLD units (post `fitScale` normalization, models + * are ~NORMALIZED_MAX_DIM = 8 units wide). X is right, Y is depth (back), + * Z snaps to floor via `placeMeshOnFloor`. Rotation is in degrees (Euler + * XYZ). Scale is a multiplier on the auto-fit scale; 1 keeps the + * normalized size, 1.5 makes it 50 % larger, etc. + */ + +import type { SceneOptionsState } from "../types"; +import { presetIdFromFile } from "../GalleryWorkbench/presets/presetBuilders"; + +export interface ScenePresetItem { + presetId: string; + /** Desired world position (X, Y, Z). Z is usually 0 (floor); the + * builder's `placeMeshOnFloor` adds vertical correction so the + * visible bottom of the mesh lands on the ground plane. */ + position: [number, number, number]; + /** Euler XYZ in degrees. Defaults to [0, 0, 0]. */ + rotation?: [number, number, number]; + /** Multiplier on the auto-fit scale. Defaults to 1. */ + scale?: number; +} + +export interface ScenePreset { + id: string; + label: string; + category: string; + items: ScenePresetItem[]; + /** Patch applied to `sceneOptions` when the scene loads. Lets a + * scene declare "I want the ground plane visible" or similar without + * the user having to flip the toggle in the dock. Applied via + * `updateScene` in `handleAddScene` before the items are placed. */ + defaultSceneOptions?: Partial; +} + +/** Scene IDs are prefixed so BuilderWorkbench's click handler can route + * them to `handleAddScene` instead of `handleAddPreset`. */ +export const SCENE_PRESET_ID_PREFIX = "scene-"; + +/** Preset ID for a GLB file under `public/gallery/glb/`. */ +const glb = (file: string): string => presetIdFromFile("glb", file); + +/** Single city block — buildings arranged around a small empty plot, + * all facing outward toward the surrounding streets. Roughly 3×3 + * building footprints (~25×25 world units) so it sits comfortably + * inside the default render distance of 40. */ +export const CITY_BLOCK: ScenePreset = { + id: `${SCENE_PRESET_ID_PREFIX}city-block`, + label: "City Block", + category: "Scenes", + items: [ + // Back row (north edge of the block) — taller anchors facing outward + { presetId: glb("city/Skyscraper.glb"), position: [0, 10, 0], rotation: [0, 0, 180] }, + { presetId: glb("city/Large Building.glb"), position: [-10, 10, 0], rotation: [0, 0, 180] }, + { presetId: glb("city/Large Building-3IhrYZp6tP.glb"), position: [10, 10, 0], rotation: [0, 0, 180] }, + + // Side rows — small/low buildings facing east and west + { presetId: glb("city/Small Building.glb"), position: [-10, 0, 0], rotation: [0, 0, 90] }, + { presetId: glb("city/Small Building-QjL4Fo9dU9.glb"), position: [10, 0, 0], rotation: [0, 0, -90] }, + + // Front row (south edge) — mid-height buildings facing the camera default + { presetId: glb("city/Low Building.glb"), position: [-10, -10, 0] }, + { presetId: glb("city/Sign Hospital.glb"), position: [0, -10, 0] }, + { presetId: glb("city/Low Wide.glb"), position: [10, -10, 0] }, + ], +}; + +/** Roads + trees on a big ground plane — no buildings. A north/south + * road and an east/west road cross at the origin; trees + a few props + * are scattered on the surrounding "lawn". The scene asks for + * `showGround: true` via defaultSceneOptions so the ground plane + * (200 world units in builder) sits underneath the road tiles. */ +export const CITY_STREET: ScenePreset = { + id: `${SCENE_PRESET_ID_PREFIX}city-street`, + label: "City Roads", + category: "Scenes", + defaultSceneOptions: { + showGround: true, + }, + items: [ + // North–south road tiles through the origin + { presetId: glb("urban/Road Bits.glb"), position: [0, -24, 0] }, + { presetId: glb("urban/Road Bits.glb"), position: [0, -16, 0] }, + { presetId: glb("urban/Road Bits.glb"), position: [0, -8, 0] }, + { presetId: glb("urban/Road Bits.glb"), position: [0, 0, 0] }, + { presetId: glb("urban/Road Bits.glb"), position: [0, 8, 0] }, + { presetId: glb("urban/Road Bits.glb"), position: [0, 16, 0] }, + { presetId: glb("urban/Road Bits.glb"), position: [0, 24, 0] }, + + // East–west road tiles (rotated 90 around Z) crossing at origin + { presetId: glb("urban/Road Bits.glb"), position: [-24, 0, 0], rotation: [0, 0, 90] }, + { presetId: glb("urban/Road Bits.glb"), position: [-16, 0, 0], rotation: [0, 0, 90] }, + { presetId: glb("urban/Road Bits.glb"), position: [-8, 0, 0], rotation: [0, 0, 90] }, + { presetId: glb("urban/Road Bits.glb"), position: [8, 0, 0], rotation: [0, 0, 90] }, + { presetId: glb("urban/Road Bits.glb"), position: [16, 0, 0], rotation: [0, 0, 90] }, + { presetId: glb("urban/Road Bits.glb"), position: [24, 0, 0], rotation: [0, 0, 90] }, + + // Trees in each quadrant — corners of the cross + { presetId: glb("urban/Tree.glb"), position: [-8, 8, 0] }, + { presetId: glb("urban/Tree.glb"), position: [-14, 12, 0] }, + { presetId: glb("urban/Tree.glb"), position: [-6, 16, 0] }, + { presetId: glb("urban/Tree.glb"), position: [-18, 6, 0] }, + + { presetId: glb("urban/Tree.glb"), position: [8, 8, 0] }, + { presetId: glb("urban/Tree.glb"), position: [14, 14, 0] }, + { presetId: glb("urban/Tree.glb"), position: [18, 6, 0] }, + { presetId: glb("urban/Tree.glb"), position: [6, 18, 0] }, + + { presetId: glb("urban/Tree.glb"), position: [-8, -8, 0] }, + { presetId: glb("urban/Tree.glb"), position: [-14, -14, 0] }, + { presetId: glb("urban/Tree.glb"), position: [-18, -6, 0] }, + { presetId: glb("urban/Tree.glb"), position: [-6, -16, 0] }, + + { presetId: glb("urban/Tree.glb"), position: [8, -8, 0] }, + { presetId: glb("urban/Tree.glb"), position: [16, -12, 0] }, + { presetId: glb("urban/Tree.glb"), position: [18, -18, 0] }, + { presetId: glb("urban/Tree.glb"), position: [6, -20, 0] }, + + // A couple of cars on the road just for life + { presetId: glb("urban/Car.glb"), position: [0, -12, 0] }, + { presetId: glb("urban/Police Car.glb"), position: [0, 12, 0], rotation: [0, 0, 180] }, + { presetId: glb("urban/SUV.glb"), position: [-12, 0, 0], rotation: [0, 0, 90] }, + ], +}; + +export const SCENE_PRESETS: ScenePreset[] = [CITY_STREET, CITY_BLOCK]; diff --git a/website/src/components/BuilderWorkbench/slots/DockGrid.tsx b/website/src/components/BuilderWorkbench/slots/DockGrid.tsx new file mode 100644 index 00000000..3a0c39d7 --- /dev/null +++ b/website/src/components/BuilderWorkbench/slots/DockGrid.tsx @@ -0,0 +1,39 @@ +/** + * Builder-only Dock slot for the floor-grid + snap-to-grid controls. + * + * Mirrors the slot-component pattern used by DockScene / DockModel etc.: + * picks up the lil-gui parent from `useDockGui()`, then creates a + * "Grid" folder with three controls (show / snap / resolution). Lives + * in BuilderWorkbench because the grid is a builder-specific concern — + * gallery has no placement workflow and doesn't need this folder. + */ +import { useDockGui, useFolder, useToggle, useSlider } from "../../Dock"; + +export interface DockGridInputs { + showGround: boolean; + snapToGrid: boolean; + gridResolution: number; + onUpdateScene: (partial: { + showGround?: boolean; + snapToGrid?: boolean; + gridResolution?: number; + }) => void; +} + +export function DockGrid(inputs: DockGridInputs): null { + const folder = useFolder(useDockGui(), "Grid"); + useToggle(folder, "Show grid", inputs.showGround, (value) => + inputs.onUpdateScene({ showGround: value }), + ); + useToggle(folder, "Snap to grid", inputs.snapToGrid, (value) => + inputs.onUpdateScene({ snapToGrid: value }), + ); + useSlider( + folder, + "Grid size", + { min: 1, max: 25, step: 0.5 }, + inputs.gridResolution, + (value) => inputs.onUpdateScene({ gridResolution: value }), + ); + return null; +} diff --git a/website/src/components/BuilderWorkbench/types.ts b/website/src/components/BuilderWorkbench/types.ts new file mode 100644 index 00000000..d17f418e --- /dev/null +++ b/website/src/components/BuilderWorkbench/types.ts @@ -0,0 +1,46 @@ +import type { Polygon, Vec3 } from "@layoutit/polycss-react"; +import type { PresetModel } from "../GalleryWorkbench/types"; +import type { Bbox } from "./geometry/ghost"; + +export interface PlacedItem { + id: string; + preset: PresetModel; + /** Pre-optimization polygons from the parser. Stored so we can re-apply + * `optimizeMeshPolygons` + interior-fill at render time when the Dock's + * meshResolution / meshInteriorFill change without re-fetching the asset. + * `null` means the item is placed but its model hasn't been fetched yet — + * scene-preset items load lazily on proximity (see the lazy-load effect + * below). Pending items have placeholder `position` + `fitScale` until + * the load completes; they don't render. */ + rawPolygons: Polygon[] | null; + position: Vec3; + rotation: Vec3; + /** User-facing scale multiplier. 1× = normalized-fit size. */ + scale: number; + /** Per-mesh normalization factor so different presets render at similar size. */ + fitScale: number; + /** World-space center of the placement (the bbox center after scale). + * Stored separately from `position` (which is in CSS-pixel space and + * carries the origin shift) so distance-based culling has a stable + * world-coord reference. Updated on placement and on gizmo drag. */ + worldX: number; + worldY: number; +} + +/** Transient state while the user is hovering a preset over the floor. */ +export interface PlacementDraft { + preset: PresetModel; + rawPolygons: Polygon[]; + bbox: Bbox; + /** meshBbox result — needed by placeMeshOnFloor at commit time. */ + meshBboxResult: { midX: number; midY: number; midZ: number; minZ: number }; + fitScale: number; +} + +export type ToolMode = "pointer" | "raise" | "lower" | "smooth"; + +/** What a terrain-tool click targets. Independent of `ToolMode` so the + * user can raise/lower/smooth either a single grid VERTEX (deforms 4 + * adjacent cells around it) or a whole FACE (deforms all 4 of its + * corners, creating a flat-top step). */ +export type TargetMode = "vertex" | "face"; diff --git a/website/src/components/Dock/Dock.tsx b/website/src/components/Dock/Dock.tsx index d20ede21..e8ad540d 100644 --- a/website/src/components/Dock/Dock.tsx +++ b/website/src/components/Dock/Dock.tsx @@ -1,854 +1,33 @@ -import { useEffect, useRef } from "react"; -import { GUI, type Controller } from "lil-gui"; -import type { MeshResolution, PolyRenderStrategy, PolyTextureLightingMode } from "@layoutit/polycss-react"; -import type { ParseAnimationController } from "@layoutit/polycss-react"; -import type { GizmoMode, SceneOptionsState, DomMetrics, DragMode, PerspectiveMode } from "../types"; - -// Internal type — not exported as it's an implementation detail of the GUI instance. -type GuiControllerMap = Record; -type TextureMode = "disabled" | PolyTextureLightingMode; - -function stringRecordEqual( - a: Record | undefined, - b: Record, -): boolean { - if (!a) return false; - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) return false; - return bKeys.every((key) => a[key] === b[key]); -} - -function textureModeForScene(sceneOptions: SceneOptionsState): TextureMode { - return sceneOptions.solidMaterials ? "disabled" : sceneOptions.textureLighting; -} - -function disableWithoutDisabledClass(controller: T): T { - controller.disable(); - controller.domElement.classList.remove("disabled"); - return controller; -} - -const DEBUG_SHAPE_LABELS = { - rectangle: "Quads ", - triangle: "Triangles ", - irregular: "Polygons ", -}; - -interface LoadedModelMinimal { - label: string; - kind: string; - rawPolygons: Array<{ vertices: [number, number, number][] }>; - sourcePolygons: number; - animation?: ParseAnimationController; -} - -interface PresetModelMinimal { - zoom?: number; - rotX?: number; - rotY?: number; -} +import { useRef, type ReactNode } from "react"; +import { useGui } from "./primitives"; +import { DockGuiContext } from "./slots"; export interface DockProps { - // Scene state - sceneOptions: SceneOptionsState; - metrics: DomMetrics; - hasSpriteLeaves: boolean; - selectedAnimation: string; - selectedPreset: PresetModelMinimal; - loaded: LoadedModelMinimal | null; - animationOptions: Record; - animationClipCount: number; - hasActiveAnimation: boolean; - activeAnimation: boolean; - perspectivePx: number; - perspectiveMode: PerspectiveMode; - gizmoMode: GizmoMode; - defaultZoomForModel: (preset: PresetModelMinimal, rawPolygons: Array<{ vertices: [number, number, number][] }>) => number; - - // Callbacks - onUpdateScene: (partial: Partial) => void; - onAnimationChange: (value: string) => void; - onResetAnimatedPolygons: () => void; - onGizmoModeChange: (mode: GizmoMode) => void; - onSelectAnimationClear: () => void; - - // Loading state - loading: boolean; - loadError: string | null; + children?: ReactNode; + loading?: boolean; + loadError?: string | null; } -export function Dock({ - sceneOptions, - metrics, - hasSpriteLeaves, - selectedAnimation, - selectedPreset, - loaded, - animationOptions, - animationClipCount, - hasActiveAnimation, - activeAnimation, - perspectivePx, - perspectiveMode, - gizmoMode, - defaultZoomForModel, - onUpdateScene, - onAnimationChange, - onResetAnimatedPolygons, - onGizmoModeChange, - onSelectAnimationClear, - loading, - loadError, -}: DockProps) { - const guiHostRef = useRef(null); - const guiRef = useRef(null); - const guiControllersRef = useRef({}); - // Keep a ref to disableStrategies so the checkbox onChange closure always - // reads the current value without recreating the GUI. - const disableStrategiesRef = useRef(sceneOptions.disableStrategies); - disableStrategiesRef.current = sceneOptions.disableStrategies; - - // Setup effect — runs once, builds the lil-gui tree. - useEffect(() => { - const host = guiHostRef.current; - if (!host || guiRef.current) return; - - const gui = new GUI({ autoPlace: false, container: host, width: 360, closeFolders: true }); - gui.open(); - guiRef.current = gui; - - const modelState = { - meshResolution: sceneOptions.meshResolution, - meshInteriorFill: sceneOptions.meshInteriorFill, - domCount: 0, - sprites: 0, - shapeRectangle: 0, - shapeTriangle: 0, - shapeIrregular: 0, - // The Texture Quality row binds the slider to `textureQualityValue` - // and the Auto toggle to `textureQualityAuto`. The effective option - // passed to the scene is "auto" when textureQualityAuto is true, else - // textureQualityValue (clamped to 0.1..1). Keeping them as two fields - // lets the slider preserve its last numeric value while Auto is on. - textureQualityValue: typeof sceneOptions.textureQuality === "number" - ? sceneOptions.textureQuality - : 1, - textureQualityAuto: sceneOptions.textureQuality === "auto", - textureMode: textureModeForScene(sceneOptions), - }; - - const animationState = { - animation: selectedAnimation, - animationPaused: sceneOptions.animationPaused, - animationTimeScale: sceneOptions.animationTimeScale, - }; - - const interactionState = { - interactive: sceneOptions.interactive, - selection: sceneOptions.selection, - hoverEffects: sceneOptions.hoverEffects, - gizmoMode, - }; - - const cameraState = { - autoRotate: sceneOptions.animate, - autoCenter: sceneOptions.autoCenter, - showAxes: sceneOptions.showAxes, - dragMode: sceneOptions.dragMode, - fpvLook: sceneOptions.fpvLook, - fpvMove: sceneOptions.fpvMove, - fpvJump: sceneOptions.fpvJump, - fpvCrouch: sceneOptions.fpvCrouch, - fpvMoveSpeed: sceneOptions.fpvMoveSpeed, - fpvJumpVelocity: sceneOptions.fpvJumpVelocity, - fpvGravity: sceneOptions.fpvGravity, - fpvEyeHeight: sceneOptions.fpvEyeHeight, - fpvCrouchHeight: sceneOptions.fpvCrouchHeight, - fpvLookSensitivity: sceneOptions.fpvLookSensitivity, - fpvInvertY: sceneOptions.fpvInvertY, - projection: perspectiveMode, - perspectivePx, - zoom: sceneOptions.zoom, - rotX: sceneOptions.rotX, - rotY: sceneOptions.rotY, - targetX: sceneOptions.target[0], - targetY: sceneOptions.target[1], - targetZ: sceneOptions.target[2], - }; - - const lightState = { - showLight: sceneOptions.showLight, - lightAzimuth: sceneOptions.lightAzimuth, - lightElevation: sceneOptions.lightElevation, - lightIntensity: sceneOptions.lightIntensity, - lightColor: sceneOptions.lightColor, - ambientIntensity: sceneOptions.ambientIntensity, - ambientColor: sceneOptions.ambientColor, - castShadow: sceneOptions.castShadow, - showGround: sceneOptions.showGround, - }; - - const model = gui.addFolder("Model"); - model.open(); - const domCountController = disableWithoutDisabledClass( - model.add(modelState, "domCount").name("DOM nodes"), - ); - const spritesController = disableWithoutDisabledClass( - model.add(modelState, "sprites").name("Sprites "), - ); - const shapeRectangleController = disableWithoutDisabledClass( - model.add(modelState, "shapeRectangle").name(DEBUG_SHAPE_LABELS.rectangle), - ); - const shapeTriangleController = disableWithoutDisabledClass( - model.add(modelState, "shapeTriangle").name(DEBUG_SHAPE_LABELS.triangle), - ); - const shapeIrregularController = disableWithoutDisabledClass( - model.add(modelState, "shapeIrregular").name(DEBUG_SHAPE_LABELS.irregular), - ); - - function injectStrategyCheckbox( - controller: { domElement?: HTMLElement } | undefined | null, - strategy: PolyRenderStrategy, - ): HTMLInputElement | null { - const dom = controller?.domElement; - const widget = dom?.querySelector?.(".widget"); - if (!widget) return null; - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.className = "dn-strategy-toggle"; - checkbox.checked = !disableStrategiesRef.current.includes(strategy); - checkbox.addEventListener("change", () => { - const current = disableStrategiesRef.current; - onUpdateScene({ - disableStrategies: checkbox.checked - ? current.filter((s) => s !== strategy) - : [...current.filter((s) => s !== strategy), strategy], - }); - }); - widget.appendChild(checkbox); - return checkbox; - } - - const bToggle = injectStrategyCheckbox(shapeRectangleController, "b"); - const uToggle = injectStrategyCheckbox(shapeTriangleController, "u"); - const iToggle = injectStrategyCheckbox(shapeIrregularController, "i"); - - const rendering = gui.addFolder("Rendering"); - rendering.open(); - const meshResolutionController = rendering - .add(modelState, "meshResolution", { Lossless: "lossless", Lossy: "lossy" }) - .name("Mesh resolution") - .onChange((value: MeshResolution) => onUpdateScene({ meshResolution: value })); - const meshInteriorFillController = rendering - .add(modelState, "meshInteriorFill") - .name("Interior fill") - .onChange((value: boolean) => onUpdateScene({ meshInteriorFill: value })); - const textureModeController = rendering - .add(modelState, "textureMode", { disabled: "disabled", baked: "baked", dynamic: "dynamic" }) - .name("Texture") - .onChange((value: TextureMode) => { - if (value === "disabled") { - onUpdateScene({ solidMaterials: true }); - return; - } - onUpdateScene({ solidMaterials: false, textureLighting: value }); - }); - - const textureQualityController = rendering - .add(modelState, "textureQualityValue", 0.1, 1, 0.05) - .name("Texture quality") - .onChange((value: number) => { - // Touching the slider switches off Auto and commits the numeric value. - modelState.textureQualityAuto = false; - if (textureQualityAutoCheckbox) textureQualityAutoCheckbox.checked = false; - onUpdateScene({ textureQuality: value }); - }); - - let textureQualityAutoCheckbox: HTMLInputElement | null = null; - function injectAutoToggle( - controller: { domElement?: HTMLElement } | undefined | null, - ): HTMLInputElement | null { - const dom = controller?.domElement; - const widget = dom?.querySelector?.(".widget"); - if (!widget) return null; - // Layout matches the slider rows above (Azimuth / Elev / Key / Ambient): - // [Texture quality (label)] [checkbox Auto] [slider] [number]. The - // checkbox + label are injected at the START of the widget; lil-gui's - // slider + value input occupy the rest of the row. - const wrap = document.createElement("label"); - wrap.className = "dn-auto-toggle"; - const cb = document.createElement("input"); - cb.type = "checkbox"; - cb.checked = modelState.textureQualityAuto; - const lbl = document.createElement("span"); - lbl.textContent = "Auto"; - wrap.appendChild(cb); - wrap.appendChild(lbl); - cb.addEventListener("change", () => { - modelState.textureQualityAuto = cb.checked; - if (cb.checked) { - disableWithoutDisabledClass(textureQualityController); - onUpdateScene({ textureQuality: "auto" }); - } else { - textureQualityController.enable(); - onUpdateScene({ textureQuality: modelState.textureQualityValue }); - } - }); - widget.insertBefore(wrap, widget.firstChild); - if (modelState.textureQualityAuto) disableWithoutDisabledClass(textureQualityController); - return cb; - } - textureQualityAutoCheckbox = injectAutoToggle(textureQualityController); - if (!hasSpriteLeaves) { - textureModeController.hide(); - textureQualityController.hide(); - } - - const animation = gui.addFolder("Animation"); - animation.open(); - const animationController = animation - .add(animationState, "animation", animationOptions) - .name("Sequence") - .onChange((value: string) => { - onAnimationChange(value); - onResetAnimatedPolygons(); - }); - const animationPausedController = animation - .add(animationState, "animationPaused") - .name("Paused") - .onChange((value: boolean) => onUpdateScene({ animationPaused: value })); - const animationTimeScaleController = animation - .add(animationState, "animationTimeScale", -3, 3, 0.05) - .name("Playback speed") - .onChange((value: number) => onUpdateScene({ animationTimeScale: value })); - - const interaction = gui.addFolder("Interaction"); - const interactiveController = interaction - .add(interactionState, "interactive") - .name("Scene interactive") - .onChange((value: boolean) => onUpdateScene({ interactive: value })); - const hoverController = interaction - .add(interactionState, "hoverEffects") - .name("Mesh hover") - .onChange((value: boolean) => onUpdateScene({ hoverEffects: value })); - const selectionController = interaction - .add(interactionState, "selection") - .name("Mesh selection") - .onChange((value: boolean) => onUpdateScene({ selection: value })); - const gizmoController = interaction - .add(interactionState, "gizmoMode", { translate: "translate", rotate: "rotate" }) - .name("Gizmo") - .onChange((value: GizmoMode) => onGizmoModeChange(value)); - - const camera = gui.addFolder("Camera"); - camera.close(); - camera - .add({ resetCamera: () => { - const resetZoom = loaded ? defaultZoomForModel(selectedPreset, loaded.rawPolygons) : selectedPreset.zoom ?? 0.35; - onUpdateScene({ - zoom: resetZoom, - rotX: selectedPreset.rotX ?? 65, - rotY: selectedPreset.rotY ?? 45, - target: [0, 0, 0], - }); - } }, "resetCamera") - .name("Reset camera"); - const autoCenterController = camera - .add(cameraState, "autoCenter") - .name("Auto center") - .onChange((value: boolean) => onUpdateScene({ autoCenter: value })); - const axesController = camera - .add(cameraState, "showAxes") - .name("Axes") - .onChange((value: boolean) => onUpdateScene({ showAxes: value })); - const autoRotateController = camera - .add(cameraState, "autoRotate") - .name("Auto rotate") - .onChange((value: boolean) => onUpdateScene({ animate: value })); - const dragModeController = camera - .add(cameraState, "dragMode", { Orbit: "orbit", Pan: "pan", FPV: "fpv" }) - .name("Drag") - .onChange((value: DragMode) => onUpdateScene({ dragMode: value })); - const fpvFolder = camera.addFolder("FPV"); - fpvFolder.close(); - const fpvLookController = fpvFolder - .add(cameraState, "fpvLook") - .name("Look") - .onChange((value: boolean) => onUpdateScene({ fpvLook: value })); - const fpvMoveController = fpvFolder - .add(cameraState, "fpvMove") - .name("Move") - .onChange((value: boolean) => onUpdateScene({ fpvMove: value })); - const fpvJumpController = fpvFolder - .add(cameraState, "fpvJump") - .name("Jump") - .onChange((value: boolean) => onUpdateScene({ fpvJump: value })); - const fpvCrouchController = fpvFolder - .add(cameraState, "fpvCrouch") - .name("Crouch") - .onChange((value: boolean) => onUpdateScene({ fpvCrouch: value })); - const fpvMoveSpeedController = fpvFolder - .add(cameraState, "fpvMoveSpeed", 1, 300, 1) - .name("Move speed") - .onChange((value: number) => onUpdateScene({ fpvMoveSpeed: value })); - const fpvJumpVelocityController = fpvFolder - .add(cameraState, "fpvJumpVelocity", 1, 200, 1) - .name("Jump velocity") - .onChange((value: number) => onUpdateScene({ fpvJumpVelocity: value })); - const fpvGravityController = fpvFolder - .add(cameraState, "fpvGravity", 1, 500, 1) - .name("Gravity") - .onChange((value: number) => onUpdateScene({ fpvGravity: value })); - const fpvEyeHeightController = fpvFolder - .add(cameraState, "fpvEyeHeight", 0.1, 100, 0.5) - .name("Eye height") - .onChange((value: number) => onUpdateScene({ fpvEyeHeight: value })); - const fpvCrouchHeightController = fpvFolder - .add(cameraState, "fpvCrouchHeight", 0.1, 100, 0.5) - .name("Crouch height") - .onChange((value: number) => onUpdateScene({ fpvCrouchHeight: value })); - const fpvLookSensitivityController = fpvFolder - .add(cameraState, "fpvLookSensitivity", 0.02, 1, 0.01) - .name("Look sensitivity") - .onChange((value: number) => onUpdateScene({ fpvLookSensitivity: value })); - const fpvInvertYController = fpvFolder - .add(cameraState, "fpvInvertY") - .name("Invert Y") - .onChange((value: boolean) => onUpdateScene({ fpvInvertY: value })); - const projectionController = camera - .add(cameraState, "projection", { Perspective: "perspective", Orthographic: "orthographic" }) - .name("Projection") - .onChange((value: PerspectiveMode) => { - onUpdateScene({ perspective: value === "perspective" ? cameraState.perspectivePx : false }); - }); - const perspectivePxController = camera - .add(cameraState, "perspectivePx", { - "500 px": 500, - "1000 px": 1000, - "2000 px": 2000, - "4000 px": 4000, - "8000 px": 8000, - "16000 px": 16000, - "32000 px": 32000, - "64000 px": 64000, - }) - .name("Perspective px") - .onChange((value: number) => onUpdateScene({ perspective: value })); - const zoomController = camera - .add(cameraState, "zoom", 0.05, 2.5, 0.01) - .name("Zoom") - .onChange((value: number) => onUpdateScene({ zoom: value })); - const rotXController = camera - .add(cameraState, "rotX", 0, 100, 1) - .name("Rot X") - .onChange((value: number) => onUpdateScene({ rotX: value })); - const rotYController = camera - .add(cameraState, "rotY", 0, 360, 1) - .name("Rot Y") - .onChange((value: number) => onUpdateScene({ rotY: value })); - const targetXController = camera - .add(cameraState, "targetX", -50, 50, 0.1) - .name("Target X") - .onChange((value: number) => onUpdateScene({ target: [value, cameraState.targetY, cameraState.targetZ] })); - const targetYController = camera - .add(cameraState, "targetY", -50, 50, 0.1) - .name("Target Y") - .onChange((value: number) => onUpdateScene({ target: [cameraState.targetX, value, cameraState.targetZ] })); - const targetZController = camera - .add(cameraState, "targetZ", -50, 50, 0.1) - .name("Target Z") - .onChange((value: number) => onUpdateScene({ target: [cameraState.targetX, cameraState.targetY, value] })); - - const lights = gui.addFolder("Lighting"); - lights.open(); - const castShadowController = lights - .add(lightState, "castShadow") - .name("Cast shadow") - .onChange((value: boolean) => onUpdateScene({ castShadow: value })); - const showGroundController = lights - .add(lightState, "showGround") - .name("Show ground") - .onChange((value: boolean) => onUpdateScene({ showGround: value })); - const lightController = lights - .add(lightState, "showLight") - .name("Light helper") - .onChange((value: boolean) => onUpdateScene({ showLight: value })); - const azimuthController = lights - .add(lightState, "lightAzimuth", 0, 360, 1) - .name("Azimuth") - .onChange((value: number) => onUpdateScene({ lightAzimuth: value })); - const elevationController = lights - .add(lightState, "lightElevation", -90, 90, 1) - .name("Elev.") - .onChange((value: number) => onUpdateScene({ lightElevation: value })); - const intensityController = lights - .add(lightState, "lightIntensity", 0, 2, 0.05) - .name("Key") - .onChange((value: number) => onUpdateScene({ lightIntensity: value })); - const keyColorController = lights - .addColor(lightState, "lightColor") - .name("Key color") - .onChange((value: string) => onUpdateScene({ lightColor: value })); - const ambientIntensityController = lights - .add(lightState, "ambientIntensity", 0, 2, 0.05) - .name("Ambient") - .onChange((value: number) => onUpdateScene({ ambientIntensity: value })); - const ambientColorController = lights - .addColor(lightState, "ambientColor") - .name("Amb. color") - .onChange((value: string) => onUpdateScene({ ambientColor: value })); - - if (sceneOptions.perspective === false) { - perspectivePxController.hide(); - } - if (activeAnimation) { - meshResolutionController.disable(); - meshInteriorFillController.disable(); - } - if (!sceneOptions.selection) { - gizmoController.disable(); - } - if (animationClipCount === 0) { - animation.hide(); - animationController.disable(); - animationPausedController.disable(); - animationTimeScaleController.disable(); - } - - guiControllersRef.current = { - animation: animationController, - animationPaused: animationPausedController, - animationTimeScale: animationTimeScaleController, - domCount: domCountController, - sprites: spritesController, - shapeRectangle: shapeRectangleController, - shapeTriangle: shapeTriangleController, - shapeIrregular: shapeIrregularController, - bToggle, - uToggle, - iToggle, - meshResolution: meshResolutionController, - meshInteriorFill: meshInteriorFillController, - textureQuality: textureQualityController, - textureQualityAutoCheckbox, - interactive: interactiveController, - autoRotate: autoRotateController, - selection: selectionController, - hoverEffects: hoverController, - gizmoMode: gizmoController, - textureMode: textureModeController, - autoCenter: autoCenterController, - showAxes: axesController, - dragMode: dragModeController, - fpvLook: fpvLookController, - fpvMove: fpvMoveController, - fpvJump: fpvJumpController, - fpvCrouch: fpvCrouchController, - fpvMoveSpeed: fpvMoveSpeedController, - fpvJumpVelocity: fpvJumpVelocityController, - fpvGravity: fpvGravityController, - fpvEyeHeight: fpvEyeHeightController, - fpvCrouchHeight: fpvCrouchHeightController, - fpvLookSensitivity: fpvLookSensitivityController, - fpvInvertY: fpvInvertYController, - fpvFolder, - projection: projectionController, - perspectivePx: perspectivePxController, - zoom: zoomController, - rotX: rotXController, - rotY: rotYController, - targetX: targetXController, - targetY: targetYController, - targetZ: targetZController, - castShadow: castShadowController, - showGround: showGroundController, - showLight: lightController, - lightAzimuth: azimuthController, - lightElevation: elevationController, - lightIntensity: intensityController, - lightColor: keyColorController, - ambientIntensity: ambientIntensityController, - ambientColor: ambientColorController, - modelState, - animationState, - animationOptions, - animationFolder: animation, - interactionState, - cameraState, - lightState, - }; - - return () => { - gui.destroy(); - guiRef.current = null; - guiControllersRef.current = {}; - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Sync effect — keeps lil-gui in sync with React state on every prop change. - useEffect(() => { - const controllers = guiControllersRef.current; - if (!guiRef.current || !controllers.modelState) return; - - const setCtrlValue = (key: string, value: unknown) => { - const controller = controllers[key] as { object: Record; updateDisplay: () => void } | undefined; - if (!controller?.object) return; - const stateKey = key; - if (controller.object[stateKey] === value) return; - controller.object[stateKey] = value; - controller.updateDisplay(); - }; - const setEnabled = (key: string, enabled: boolean) => { - const controller = controllers[key] as { disable: () => void; enable: () => void } | undefined; - if (!controller?.disable || !controller?.enable) return; - if (enabled) controller.enable(); - else controller.disable(); - }; - const setVisible = (key: string, visible: boolean) => { - const controller = controllers[key] as { hide: () => void; show: () => void } | undefined; - if (!controller?.hide || !controller?.show) return; - if (visible) controller.show(); - else controller.hide(); - }; - - setCtrlValue("animation", selectedAnimation); - setCtrlValue("animationPaused", sceneOptions.animationPaused); - setCtrlValue("animationTimeScale", sceneOptions.animationTimeScale); - setCtrlValue("meshResolution", sceneOptions.meshResolution); - setCtrlValue("meshInteriorFill", sceneOptions.meshInteriorFill); - setCtrlValue("textureMode", textureModeForScene(sceneOptions)); - setVisible("textureMode", hasSpriteLeaves); - setVisible("textureQuality", hasSpriteLeaves); - setCtrlValue("domCount", metrics.nodeCount); - setCtrlValue("sprites", metrics.sprites); - setCtrlValue("shapeRectangle", metrics.rects); - setCtrlValue("shapeTriangle", metrics.triangles); - setCtrlValue("shapeIrregular", metrics.irregular); - const bToggleEl = controllers.bToggle as HTMLInputElement | undefined; - const uToggleEl = controllers.uToggle as HTMLInputElement | undefined; - const iToggleEl = controllers.iToggle as HTMLInputElement | undefined; - if (bToggleEl) bToggleEl.checked = !sceneOptions.disableStrategies.includes("b"); - if (uToggleEl) uToggleEl.checked = !sceneOptions.disableStrategies.includes("u"); - if (iToggleEl) iToggleEl.checked = !sceneOptions.disableStrategies.includes("i"); - - const validAnimation = Object.values(animationOptions).includes(selectedAnimation); - const nextAnimation = validAnimation ? selectedAnimation : ""; - const animationController = controllers.animation as { options: (opts: Record) => void } | undefined; - if (animationController && !stringRecordEqual(controllers.animationOptions, animationOptions)) { - animationController.options(animationOptions); - controllers.animationOptions = animationOptions; - } - setCtrlValue("animation", nextAnimation); - const animationFolder = controllers.animationFolder as { show: (show?: boolean) => void } | undefined; - animationFolder?.show(animationClipCount > 0); - if (animationController) { - setEnabled("animation", animationClipCount > 0); - setEnabled("animationPaused", animationClipCount > 0); - setEnabled("animationTimeScale", animationClipCount > 0); - if (!validAnimation && selectedAnimation !== "") { - onSelectAnimationClear(); - } - } - - setCtrlValue("interactive", sceneOptions.interactive); - setCtrlValue("autoRotate", sceneOptions.animate); - setCtrlValue("selection", sceneOptions.selection); - setCtrlValue("hoverEffects", sceneOptions.hoverEffects); - setCtrlValue("gizmoMode", gizmoMode); - - setCtrlValue("autoCenter", sceneOptions.autoCenter); - setCtrlValue("showAxes", sceneOptions.showAxes); - setCtrlValue("castShadow", sceneOptions.castShadow); - setCtrlValue("showGround", sceneOptions.showGround); - - setCtrlValue("dragMode", sceneOptions.dragMode); - setCtrlValue("fpvLook", sceneOptions.fpvLook); - setCtrlValue("fpvMove", sceneOptions.fpvMove); - setCtrlValue("fpvJump", sceneOptions.fpvJump); - setCtrlValue("fpvCrouch", sceneOptions.fpvCrouch); - setCtrlValue("fpvMoveSpeed", sceneOptions.fpvMoveSpeed); - setCtrlValue("fpvJumpVelocity", sceneOptions.fpvJumpVelocity); - setCtrlValue("fpvGravity", sceneOptions.fpvGravity); - setCtrlValue("fpvEyeHeight", sceneOptions.fpvEyeHeight); - setCtrlValue("fpvCrouchHeight", sceneOptions.fpvCrouchHeight); - setCtrlValue("fpvLookSensitivity", sceneOptions.fpvLookSensitivity); - setCtrlValue("fpvInvertY", sceneOptions.fpvInvertY); - const isFpv = sceneOptions.dragMode === "fpv"; - setEnabled("fpvLook", isFpv); - setEnabled("fpvMove", isFpv); - setEnabled("fpvJump", isFpv); - setEnabled("fpvCrouch", isFpv); - setEnabled("fpvMoveSpeed", isFpv); - setEnabled("fpvJumpVelocity", isFpv); - setEnabled("fpvGravity", isFpv); - setEnabled("fpvEyeHeight", isFpv); - setEnabled("fpvCrouchHeight", isFpv); - setEnabled("fpvLookSensitivity", isFpv); - setEnabled("fpvInvertY", isFpv); - setCtrlValue("projection", perspectiveMode); - setCtrlValue("perspectivePx", perspectivePx); - setCtrlValue("zoom", sceneOptions.zoom); - setCtrlValue("rotX", sceneOptions.rotX); - setCtrlValue("rotY", sceneOptions.rotY); - setCtrlValue("targetX", sceneOptions.target[0]); - setCtrlValue("targetY", sceneOptions.target[1]); - setCtrlValue("targetZ", sceneOptions.target[2]); - - setCtrlValue("showLight", sceneOptions.showLight); - setCtrlValue("lightAzimuth", sceneOptions.lightAzimuth); - setCtrlValue("lightElevation", sceneOptions.lightElevation); - setCtrlValue("lightIntensity", sceneOptions.lightIntensity); - setCtrlValue("lightColor", sceneOptions.lightColor); - setCtrlValue("ambientIntensity", sceneOptions.ambientIntensity); - setCtrlValue("ambientColor", sceneOptions.ambientColor); - - setEnabled("meshResolution", !hasActiveAnimation); - setEnabled("meshInteriorFill", !hasActiveAnimation); - setEnabled("gizmoMode", sceneOptions.selection); - - if (sceneOptions.perspective === false) { - (controllers.perspectivePx as { hide: () => void })?.hide(); - } else { - (controllers.perspectivePx as { show: () => void })?.show(); - } - - const modelState = controllers.modelState as { - meshResolution?: MeshResolution; - meshInteriorFill?: boolean; - domCount?: number; - sprites?: number; - shapeRectangle?: number; - shapeTriangle?: number; - shapeIrregular?: number; - textureQualityValue?: number; - textureQualityAuto?: boolean; - textureMode?: TextureMode; - }; - if (modelState) { - modelState.meshResolution = sceneOptions.meshResolution; - modelState.meshInteriorFill = sceneOptions.meshInteriorFill; - modelState.domCount = metrics.nodeCount; - modelState.sprites = metrics.sprites; - modelState.shapeRectangle = metrics.rects; - modelState.shapeTriangle = metrics.triangles; - modelState.shapeIrregular = metrics.irregular; - modelState.textureMode = textureModeForScene(sceneOptions); - // Mirror external textureQuality changes back into the slider state. - // Numeric → slider value + auto off (slider enabled); "auto" → keep - // last numeric value, auto on (slider disabled). User unchecks Auto - // first to drag the slider — explicit mode switch. - const tq = sceneOptions.textureQuality; - const nextAuto = tq === "auto"; - modelState.textureQualityAuto = nextAuto; - if (typeof tq === "number") modelState.textureQualityValue = tq; - const tqCb = controllers.textureQualityAutoCheckbox as HTMLInputElement | undefined; - const tqCtl = controllers.textureQuality as Controller | undefined; - if (tqCb) tqCb.checked = nextAuto; - if (tqCtl) { - if (nextAuto) disableWithoutDisabledClass(tqCtl); - else tqCtl.enable(); - } - } - const animationState = controllers.animationState as { - animation?: string; - animationPaused?: boolean; - animationTimeScale?: number; - }; - if (animationState) { - animationState.animation = nextAnimation; - animationState.animationPaused = sceneOptions.animationPaused; - animationState.animationTimeScale = sceneOptions.animationTimeScale; - } - const interactionState = controllers.interactionState as { - interactive?: boolean; - selection?: boolean; - hoverEffects?: boolean; - gizmoMode?: GizmoMode; - }; - if (interactionState) { - interactionState.interactive = sceneOptions.interactive; - interactionState.selection = sceneOptions.selection; - interactionState.hoverEffects = sceneOptions.hoverEffects; - interactionState.gizmoMode = gizmoMode; - } - const cameraState = controllers.cameraState as { - autoRotate?: boolean; - autoCenter?: boolean; - showAxes?: boolean; - dragMode?: DragMode; - fpvLook?: boolean; - fpvMove?: boolean; - fpvJump?: boolean; - fpvCrouch?: boolean; - fpvMoveSpeed?: number; - fpvJumpVelocity?: number; - fpvGravity?: number; - fpvEyeHeight?: number; - fpvCrouchHeight?: number; - fpvLookSensitivity?: number; - fpvInvertY?: boolean; - projection?: PerspectiveMode; - perspectivePx?: number; - zoom?: number; - rotX?: number; - rotY?: number; - targetX?: number; - targetY?: number; - targetZ?: number; - }; - if (cameraState) { - cameraState.autoRotate = sceneOptions.animate; - cameraState.autoCenter = sceneOptions.autoCenter; - cameraState.showAxes = sceneOptions.showAxes; - cameraState.dragMode = sceneOptions.dragMode; - cameraState.fpvLook = sceneOptions.fpvLook; - cameraState.fpvMove = sceneOptions.fpvMove; - cameraState.fpvJump = sceneOptions.fpvJump; - cameraState.fpvCrouch = sceneOptions.fpvCrouch; - cameraState.fpvMoveSpeed = sceneOptions.fpvMoveSpeed; - cameraState.fpvJumpVelocity = sceneOptions.fpvJumpVelocity; - cameraState.fpvGravity = sceneOptions.fpvGravity; - cameraState.fpvEyeHeight = sceneOptions.fpvEyeHeight; - cameraState.fpvCrouchHeight = sceneOptions.fpvCrouchHeight; - cameraState.fpvLookSensitivity = sceneOptions.fpvLookSensitivity; - cameraState.fpvInvertY = sceneOptions.fpvInvertY; - cameraState.projection = perspectiveMode; - cameraState.perspectivePx = perspectivePx; - cameraState.zoom = sceneOptions.zoom; - cameraState.rotX = sceneOptions.rotX; - cameraState.rotY = sceneOptions.rotY; - cameraState.targetX = sceneOptions.target[0]; - cameraState.targetY = sceneOptions.target[1]; - cameraState.targetZ = sceneOptions.target[2]; - } - const lightState = controllers.lightState as { - showLight?: boolean; - lightAzimuth?: number; - lightElevation?: number; - lightIntensity?: number; - lightColor?: string; - ambientIntensity?: number; - ambientColor?: string; - castShadow?: boolean; - showGround?: boolean; - }; - if (lightState) { - lightState.showLight = sceneOptions.showLight; - lightState.lightAzimuth = sceneOptions.lightAzimuth; - lightState.lightElevation = sceneOptions.lightElevation; - lightState.lightIntensity = sceneOptions.lightIntensity; - lightState.lightColor = sceneOptions.lightColor; - lightState.ambientIntensity = sceneOptions.ambientIntensity; - lightState.ambientColor = sceneOptions.ambientColor; - lightState.castShadow = sceneOptions.castShadow; - lightState.showGround = sceneOptions.showGround; - } - }); - +/** + * Container component that owns the lil-gui instance and exposes it to + * `Dock*` slot children via context. Pages compose the dock by listing + * the slots they want — gallery picks Model/Rendering/Animation/ + * Interaction/Camera/Lighting; builder picks Scene/Model/Rendering/ + * Camera/Lighting. + * + * The optional `loading` + `loadError` props render Dock chrome under + * the GUI (status notes for model loading), which is Dock-level UI + * rather than per-folder state. + */ +export function Dock({ children, loading, loadError }: DockProps) { + const hostRef = useRef(null); + const gui = useGui(hostRef); return (
-
+
+ + {children} + {loading &&

Loading model...

} {loadError &&

{loadError}

}
diff --git a/website/src/components/Dock/folders/useAnimationFolder.ts b/website/src/components/Dock/folders/useAnimationFolder.ts new file mode 100644 index 00000000..f9e0989f --- /dev/null +++ b/website/src/components/Dock/folders/useAnimationFolder.ts @@ -0,0 +1,104 @@ +/** + * Animation folder — extracted from the legacy Dock.tsx mega-effect. + * + * Owns three controllers (Sequence / Paused / Playback speed) plus the folder + * shell itself. When the model has no animation clips the whole folder is + * hidden via lil-gui's `.hide()` and the three controllers are dimmed so any + * direct DOM access doesn't fire stale onChange callbacks. The Sequence + * dropdown's option list is refreshed at runtime whenever `animationOptions` + * changes reference (model swap), and the current `selectedAnimation` is + * re-validated against the new list — if it's missing we ask the parent to + * clear it. + */ +import { useEffect, useRef } from "react"; +import type { GUI } from "lil-gui"; +import { useFolder, useOption, useSlider, useToggle } from "../primitives"; + +export interface AnimationFolderInputs { + selectedAnimation: string; + animationOptions: Record; + animationPaused: boolean; + animationTimeScale: number; + animationClipCount: number; + onAnimationChange: (value: string) => void; + onResetAnimatedPolygons: () => void; + onSelectAnimationClear: () => void; + onUpdateScene: (partial: { animationPaused?: boolean; animationTimeScale?: number }) => void; +} + +export function useAnimationFolder(parent: GUI | null, inputs: AnimationFolderInputs): void { + const { + selectedAnimation, + animationOptions, + animationPaused, + animationTimeScale, + animationClipCount, + onAnimationChange, + onResetAnimatedPolygons, + onSelectAnimationClear, + onUpdateScene, + } = inputs; + + const folder = useFolder(parent, "Animation"); + + const sequenceController = useOption( + folder, + "Sequence", + animationOptions, + selectedAnimation, + (value) => { + onAnimationChange(value); + onResetAnimatedPolygons(); + }, + ); + + const pausedController = useToggle( + folder, + "Paused", + animationPaused, + (value) => onUpdateScene({ animationPaused: value }), + ); + + const speedController = useSlider( + folder, + "Playback speed", + { min: -3, max: 3, step: 0.05 }, + animationTimeScale, + (value) => onUpdateScene({ animationTimeScale: value }), + ); + + // Refresh the dropdown options when the model changes. lil-gui's `options()` + // call replaces the underlying controller; the primitive's `setOptions` + // hides that swap. Re-validate `selectedAnimation` against the new list and + // clear it upstream if it's gone — leaving a stale value in the dropdown + // would let `onChange` fire with a key the model can no longer resolve. + const prevOptionsRef = useRef(animationOptions); + useEffect(() => { + if (!sequenceController) return; + if (prevOptionsRef.current === animationOptions) return; + prevOptionsRef.current = animationOptions; + sequenceController.setOptions(animationOptions); + const valid = Object.values(animationOptions).includes(selectedAnimation); + if (!valid) { + sequenceController.setValue(""); + if (selectedAnimation !== "") onSelectAnimationClear(); + } + }, [sequenceController, animationOptions, selectedAnimation, onSelectAnimationClear]); + + // Folder visibility + controller enabled state follow clip availability. + // Hiding the folder also collapses the controllers visually, but we still + // disable them so any programmatic access (or lil-gui internals) doesn't + // route through dead controls. + useEffect(() => { + if (!folder) return; + if (animationClipCount > 0) folder.show(); + else folder.hide(); + }, [folder, animationClipCount]); + + useEffect(() => { + const enabled = animationClipCount > 0; + sequenceController?.setEnabled(enabled, { dim: true }); + pausedController?.setEnabled(enabled, { dim: true }); + speedController?.setEnabled(enabled, { dim: true }); + }, [animationClipCount, sequenceController, pausedController, speedController]); +} diff --git a/website/src/components/Dock/folders/useCameraFolder.ts b/website/src/components/Dock/folders/useCameraFolder.ts new file mode 100644 index 00000000..4f4984b8 --- /dev/null +++ b/website/src/components/Dock/folders/useCameraFolder.ts @@ -0,0 +1,325 @@ +/** + * Camera folder — extracted from the legacy Dock.tsx mega-effect. + * + * Largest of the per-folder hooks: ~25 controllers split across the top-level + * "Camera" folder and a nested "FPV" sub-folder. The whole thing starts closed + * because most users never touch it, and the FPV sub-folder is gated by the + * Drag mode dropdown — when Drag isn't "fpv", every FPV row is dimmed (kept + * visible to advertise the feature, just non-interactive) and the + * "Perspective px" row is hidden whenever projection is orthographic. + */ +import { useEffect, useRef } from "react"; +import type { GUI } from "lil-gui"; +import type { Vec3 } from "@layoutit/polycss-react"; + +import { + useButton, + useFolder, + useOption, + useSlider, + useToggle, +} from "../primitives"; +import type { DragMode, PerspectiveMode, SceneOptionsState } from "../../types"; + +interface PresetModelMinimal { + zoom?: number; + rotX?: number; + rotY?: number; +} + +interface LoadedModelMinimal { + rawPolygons: Array<{ vertices: [number, number, number][] }>; +} + +export interface CameraFolderInputs { + autoCenter: boolean; + showAxes: boolean; + animate: boolean; + dragMode: DragMode; + fpvLook: boolean; + fpvMove: boolean; + fpvJump: boolean; + fpvCrouch: boolean; + fpvMoveSpeed: number; + fpvJumpVelocity: number; + fpvGravity: number; + fpvEyeHeight: number; + fpvCrouchHeight: number; + fpvLookSensitivity: number; + fpvInvertY: boolean; + fpvRenderDistance: number; + perspectiveMode: PerspectiveMode; + perspectivePx: number; + perspective: number | false; + zoom: number; + rotX: number; + rotY: number; + target: Vec3; + /** For the Reset button: derives the reset zoom from the loaded model. */ + loaded: LoadedModelMinimal | null; + selectedPreset: PresetModelMinimal; + defaultZoomForModel: ( + preset: PresetModelMinimal, + rawPolygons: LoadedModelMinimal["rawPolygons"], + ) => number; + onUpdateScene: (partial: Partial) => void; +} + +const DRAG_MODE_OPTIONS: Record = { + Orbit: "orbit", + Pan: "pan", + FPV: "fpv", +}; + +const PROJECTION_OPTIONS: Record = { + Perspective: "perspective", + Orthographic: "orthographic", +}; + +const PERSPECTIVE_PX_OPTIONS: Record = { + "500 px": 500, + "1000 px": 1000, + "2000 px": 2000, + "4000 px": 4000, + "8000 px": 8000, + "16000 px": 16000, + "32000 px": 32000, + "64000 px": 64000, +}; + +export function useCameraFolder(parent: GUI | null, inputs: CameraFolderInputs): void { + const { + autoCenter, + showAxes, + animate, + dragMode, + fpvLook, + fpvMove, + fpvJump, + fpvCrouch, + fpvMoveSpeed, + fpvJumpVelocity, + fpvGravity, + fpvEyeHeight, + fpvCrouchHeight, + fpvLookSensitivity, + fpvInvertY, + fpvRenderDistance, + perspectiveMode, + perspectivePx, + perspective, + zoom, + rotX, + rotY, + target, + loaded, + selectedPreset, + defaultZoomForModel, + onUpdateScene, + } = inputs; + + // Reset-camera closure must always read the latest preset + loaded model + // without re-creating the button (which would also re-mount the lil-gui row, + // shuffling controller order). Keep these in a single ref bag and read + // through it in the click handler. + const resetCtxRef = useRef({ loaded, selectedPreset, defaultZoomForModel, onUpdateScene }); + resetCtxRef.current = { loaded, selectedPreset, defaultZoomForModel, onUpdateScene }; + + // Target moves three sliders at once — keep the latest tuple in a ref so each + // slider's onChange can splat the other two axes without forcing the hook to + // re-create the controllers when `target` reference changes. + const targetRef = useRef(target); + targetRef.current = target; + + // Same story for perspectivePx: the Projection dropdown reads it when + // flipping from orthographic → perspective. + const perspectivePxRef = useRef(perspectivePx); + perspectivePxRef.current = perspectivePx; + + const folder = useFolder(parent, "Camera", { open: false }); + + useButton(folder, "Reset camera", () => { + const { loaded: l, selectedPreset: p, defaultZoomForModel: f, onUpdateScene: u } = + resetCtxRef.current; + const resetZoom = l ? f(p, l.rawPolygons) : p.zoom ?? 0.35; + u({ + zoom: resetZoom, + rotX: p.rotX ?? 65, + rotY: p.rotY ?? 45, + target: [0, 0, 0], + }); + }); + + useToggle(folder, "Auto center", autoCenter, (value) => + onUpdateScene({ autoCenter: value }), + ); + useToggle(folder, "Axes", showAxes, (value) => onUpdateScene({ showAxes: value })); + useToggle(folder, "Auto rotate", animate, (value) => onUpdateScene({ animate: value })); + useOption(folder, "Drag", DRAG_MODE_OPTIONS, dragMode, (value) => + onUpdateScene({ dragMode: value }), + ); + + // FPV sub-folder — nested directly under the Camera folder. All 11 + // controllers below are dimmed when Drag isn't "fpv" (see effect at end). + const fpvFolder = useFolder(folder, "FPV", { open: false }); + + const fpvLookCtrl = useToggle(fpvFolder, "Look", fpvLook, (value) => + onUpdateScene({ fpvLook: value }), + ); + const fpvMoveCtrl = useToggle(fpvFolder, "Move", fpvMove, (value) => + onUpdateScene({ fpvMove: value }), + ); + const fpvJumpCtrl = useToggle(fpvFolder, "Jump", fpvJump, (value) => + onUpdateScene({ fpvJump: value }), + ); + const fpvCrouchCtrl = useToggle(fpvFolder, "Crouch", fpvCrouch, (value) => + onUpdateScene({ fpvCrouch: value }), + ); + const fpvMoveSpeedCtrl = useSlider( + fpvFolder, + "Move speed", + { min: 1, max: 300, step: 1 }, + fpvMoveSpeed, + (value) => onUpdateScene({ fpvMoveSpeed: value }), + ); + const fpvJumpVelocityCtrl = useSlider( + fpvFolder, + "Jump velocity", + { min: 1, max: 200, step: 1 }, + fpvJumpVelocity, + (value) => onUpdateScene({ fpvJumpVelocity: value }), + ); + const fpvGravityCtrl = useSlider( + fpvFolder, + "Gravity", + { min: 1, max: 500, step: 1 }, + fpvGravity, + (value) => onUpdateScene({ fpvGravity: value }), + ); + const fpvEyeHeightCtrl = useSlider( + fpvFolder, + "Eye height", + { min: 0.1, max: 100, step: 0.5 }, + fpvEyeHeight, + (value) => onUpdateScene({ fpvEyeHeight: value }), + ); + const fpvCrouchHeightCtrl = useSlider( + fpvFolder, + "Crouch height", + { min: 0.1, max: 100, step: 0.5 }, + fpvCrouchHeight, + (value) => onUpdateScene({ fpvCrouchHeight: value }), + ); + const fpvLookSensitivityCtrl = useSlider( + fpvFolder, + "Look sensitivity", + { min: 0.02, max: 1, step: 0.01 }, + fpvLookSensitivity, + (value) => onUpdateScene({ fpvLookSensitivity: value }), + ); + const fpvInvertYCtrl = useToggle(fpvFolder, "Invert Y", fpvInvertY, (value) => + onUpdateScene({ fpvInvertY: value }), + ); + const fpvRenderDistanceCtrl = useSlider( + fpvFolder, + "Render distance", + { min: 0, max: 200, step: 1 }, + fpvRenderDistance, + (value) => onUpdateScene({ fpvRenderDistance: value }), + ); + + useOption( + folder, + "Projection", + PROJECTION_OPTIONS, + perspectiveMode, + (value) => + onUpdateScene({ + perspective: value === "perspective" ? perspectivePxRef.current : false, + }), + ); + const perspectivePxCtrl = useOption( + folder, + "Perspective px", + PERSPECTIVE_PX_OPTIONS, + perspectivePx, + (value) => onUpdateScene({ perspective: value }), + ); + + useSlider(folder, "Zoom", { min: 0.05, max: 2.5, step: 0.01 }, zoom, (value) => + onUpdateScene({ zoom: value }), + ); + useSlider(folder, "Rot X", { min: 0, max: 100, step: 1 }, rotX, (value) => + onUpdateScene({ rotX: value }), + ); + useSlider(folder, "Rot Y", { min: 0, max: 360, step: 1 }, rotY, (value) => + onUpdateScene({ rotY: value }), + ); + useSlider( + folder, + "Target X", + { min: -50, max: 50, step: 0.1 }, + target[0], + (value) => { + const t = targetRef.current; + onUpdateScene({ target: [value, t[1], t[2]] }); + }, + ); + useSlider( + folder, + "Target Y", + { min: -50, max: 50, step: 0.1 }, + target[1], + (value) => { + const t = targetRef.current; + onUpdateScene({ target: [t[0], value, t[2]] }); + }, + ); + useSlider( + folder, + "Target Z", + { min: -50, max: 50, step: 0.1 }, + target[2], + (value) => { + const t = targetRef.current; + onUpdateScene({ target: [t[0], t[1], value] }); + }, + ); + + // FPV enable/disable: dim every FPV row when not in FPV drag mode. Keeping + // the rows visible (rather than hiding the folder) preserves muscle memory + // and signals the feature exists. + useEffect(() => { + const isFpv = dragMode === "fpv"; + fpvLookCtrl?.setEnabled(isFpv, { dim: true }); + fpvMoveCtrl?.setEnabled(isFpv, { dim: true }); + fpvJumpCtrl?.setEnabled(isFpv, { dim: true }); + fpvCrouchCtrl?.setEnabled(isFpv, { dim: true }); + fpvMoveSpeedCtrl?.setEnabled(isFpv, { dim: true }); + fpvJumpVelocityCtrl?.setEnabled(isFpv, { dim: true }); + fpvGravityCtrl?.setEnabled(isFpv, { dim: true }); + fpvEyeHeightCtrl?.setEnabled(isFpv, { dim: true }); + fpvCrouchHeightCtrl?.setEnabled(isFpv, { dim: true }); + fpvLookSensitivityCtrl?.setEnabled(isFpv, { dim: true }); + fpvInvertYCtrl?.setEnabled(isFpv, { dim: true }); + }, [ + dragMode, + fpvLookCtrl, + fpvMoveCtrl, + fpvJumpCtrl, + fpvCrouchCtrl, + fpvMoveSpeedCtrl, + fpvJumpVelocityCtrl, + fpvGravityCtrl, + fpvEyeHeightCtrl, + fpvCrouchHeightCtrl, + fpvLookSensitivityCtrl, + fpvInvertYCtrl, + ]); + + // Perspective-px row only makes sense in perspective projection; hide it + // outright when projection is orthographic (`perspective === false`). + useEffect(() => { + perspectivePxCtrl?.setVisible(perspective !== false); + }, [perspectivePxCtrl, perspective]); +} diff --git a/website/src/components/Dock/folders/useInteractionFolder.ts b/website/src/components/Dock/folders/useInteractionFolder.ts new file mode 100644 index 00000000..0732cdf1 --- /dev/null +++ b/website/src/components/Dock/folders/useInteractionFolder.ts @@ -0,0 +1,48 @@ +/** + * Interaction folder for the Dock: scene-level pointer flags + gizmo mode. + * + * Extracted from the monolithic `Dock.tsx` so callers that don't want the + * folder simply omit this hook (no `hideInteractionFolder` flag required). + */ +import { useEffect } from "react"; +import type { GUI } from "lil-gui"; + +import { useFolder, useOption, useToggle } from "../primitives"; +import type { GizmoMode } from "../../types"; + +export interface InteractionFolderInputs { + interactive: boolean; + hoverEffects: boolean; + selection: boolean; + gizmoMode: GizmoMode; + onUpdateScene: (partial: { + interactive?: boolean; + hoverEffects?: boolean; + selection?: boolean; + }) => void; + onGizmoModeChange: (mode: GizmoMode) => void; +} + +const GIZMO_OPTIONS: Record = { translate: "translate", rotate: "rotate" }; + +export function useInteractionFolder(parent: GUI | null, inputs: InteractionFolderInputs): void { + const folder = useFolder(parent, "Interaction"); + + useToggle(folder, "Scene interactive", inputs.interactive, (value) => + inputs.onUpdateScene({ interactive: value }), + ); + useToggle(folder, "Mesh hover", inputs.hoverEffects, (value) => + inputs.onUpdateScene({ hoverEffects: value }), + ); + useToggle(folder, "Mesh selection", inputs.selection, (value) => + inputs.onUpdateScene({ selection: value }), + ); + const gizmo = useOption(folder, "Gizmo", GIZMO_OPTIONS, inputs.gizmoMode, (value) => + inputs.onGizmoModeChange(value), + ); + + // Gizmo is meaningless without selection — dim it when selection is off. + useEffect(() => { + if (gizmo) gizmo.setEnabled(inputs.selection); + }, [gizmo, inputs.selection]); +} diff --git a/website/src/components/Dock/folders/useLightingFolder.ts b/website/src/components/Dock/folders/useLightingFolder.ts new file mode 100644 index 00000000..c822c88c --- /dev/null +++ b/website/src/components/Dock/folders/useLightingFolder.ts @@ -0,0 +1,71 @@ +/** + * "Lighting" folder of the Dock GUI. + * + * Three toggles (cast shadow, ground plane, light helper) plus the directional + * key-light azimuth/elevation/intensity/color and an ambient + * intensity/color pair. All controllers funnel into a single + * `onUpdateScene` callback so the parent owns the scene-options state. + */ +import type { GUI } from "lil-gui"; + +import { useColor, useFolder, useSlider, useToggle } from "../primitives"; + +export interface LightingFolderInputs { + castShadow: boolean; + showGround: boolean; + showLight: boolean; + lightAzimuth: number; + lightElevation: number; + lightIntensity: number; + lightColor: string; + ambientIntensity: number; + ambientColor: string; + onUpdateScene: (partial: { + castShadow?: boolean; + showGround?: boolean; + showLight?: boolean; + lightAzimuth?: number; + lightElevation?: number; + lightIntensity?: number; + lightColor?: string; + ambientIntensity?: number; + ambientColor?: string; + }) => void; +} + +export function useLightingFolder(parent: GUI | null, inputs: LightingFolderInputs): void { + const { + castShadow, + showGround, + showLight, + lightAzimuth, + lightElevation, + lightIntensity, + lightColor, + ambientIntensity, + ambientColor, + onUpdateScene, + } = inputs; + + const folder = useFolder(parent, "Lighting", { open: true }); + + useToggle(folder, "Cast shadow", castShadow, (value) => onUpdateScene({ castShadow: value })); + useToggle(folder, "Show ground", showGround, (value) => onUpdateScene({ showGround: value })); + useToggle(folder, "Light helper", showLight, (value) => onUpdateScene({ showLight: value })); + + useSlider(folder, "Azimuth", { min: 0, max: 360, step: 1 }, lightAzimuth, (value) => + onUpdateScene({ lightAzimuth: value }), + ); + useSlider(folder, "Elev.", { min: -90, max: 90, step: 1 }, lightElevation, (value) => + onUpdateScene({ lightElevation: value }), + ); + useSlider(folder, "Key", { min: 0, max: 2, step: 0.05 }, lightIntensity, (value) => + onUpdateScene({ lightIntensity: value }), + ); + useColor(folder, "Key color", lightColor, (value) => onUpdateScene({ lightColor: value })); + + useSlider(folder, "Ambient", { min: 0, max: 2, step: 0.05 }, ambientIntensity, (value) => + onUpdateScene({ ambientIntensity: value }), + ); + useColor(folder, "Amb. color", ambientColor, (value) => onUpdateScene({ ambientColor: value })); +} diff --git a/website/src/components/Dock/folders/useModelFolder.ts b/website/src/components/Dock/folders/useModelFolder.ts new file mode 100644 index 00000000..69a25f30 --- /dev/null +++ b/website/src/components/Dock/folders/useModelFolder.ts @@ -0,0 +1,114 @@ +/** + * Model folder — live render metrics (DOM nodes, per-strategy counts) with + * inline strategy-disable checkboxes injected next to the count rows. + * + * The four readonly counts come from `metrics`; the three injected checkboxes + * (b / u / i) write into `disableStrategies` via `onUpdateScene`. The sprite + * count has no checkbox because `` is the universal fallback strategy and + * cannot be disabled (see AGENTS.md → Rendering model). + */ +import { useEffect, useRef } from "react"; +import type { GUI } from "lil-gui"; +import type { PolyRenderStrategy } from "@layoutit/polycss-react"; +import type { DomMetrics } from "../../types"; +import { useFolder, useReadonlyNumber, type DockController } from "../primitives"; + +const SHAPE_LABELS = { + rectangle: "Quads ", + triangle: "Triangles ", + irregular: "Polygons ", +}; + +export interface ModelFolderInputs { + metrics: DomMetrics; + disableStrategies: PolyRenderStrategy[]; + onUpdateScene: (partial: { disableStrategies: PolyRenderStrategy[] }) => void; +} + +/** + * Inject an inline checkbox into a readonly-number row that toggles whether + * the given render strategy is in `disableStrategies`. lil-gui doesn't have a + * native "label + value + checkbox" widget so we append to the `.widget` div + * of the underlying controller after mount. Returns the created element (or + * null if the widget DOM isn't ready yet) so the caller can update + * `checkbox.checked` when external state changes. + */ +function injectStrategyCheckbox( + controller: DockController | null, + strategy: PolyRenderStrategy, + disableStrategiesRef: React.MutableRefObject, + onUpdate: (partial: { disableStrategies: PolyRenderStrategy[] }) => void, +): HTMLInputElement | null { + const dom = controller?.raw.domElement; + const widget = dom?.querySelector?.(".widget"); + if (!widget) return null; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "dn-strategy-toggle"; + checkbox.checked = !disableStrategiesRef.current.includes(strategy); + checkbox.addEventListener("change", () => { + const current = disableStrategiesRef.current; + onUpdate({ + disableStrategies: checkbox.checked + ? current.filter((s) => s !== strategy) + : [...current.filter((s) => s !== strategy), strategy], + }); + }); + widget.appendChild(checkbox); + return checkbox; +} + +export function useModelFolder(parent: GUI | null, inputs: ModelFolderInputs): void { + const { metrics, disableStrategies, onUpdateScene } = inputs; + + // Live refs so the injection effect's event listener always sees the latest + // values without re-running (and re-injecting) on every render. + const disableStrategiesRef = useRef(disableStrategies); + disableStrategiesRef.current = disableStrategies; + const onUpdateSceneRef = useRef(onUpdateScene); + onUpdateSceneRef.current = onUpdateScene; + + const folder = useFolder(parent, "Model", { open: true }); + + useReadonlyNumber(folder, "DOM nodes", metrics.nodeCount); + useReadonlyNumber(folder, "Sprites ", metrics.sprites); + const rectsCtrl = useReadonlyNumber(folder, SHAPE_LABELS.rectangle, metrics.rects); + const trianglesCtrl = useReadonlyNumber(folder, SHAPE_LABELS.triangle, metrics.triangles); + const irregularCtrl = useReadonlyNumber(folder, SHAPE_LABELS.irregular, metrics.irregular); + + // Hold the injected checkbox elements so the sync effect below can update + // their `.checked` state when `disableStrategies` changes externally. + const bCheckboxRef = useRef(null); + const uCheckboxRef = useRef(null); + const iCheckboxRef = useRef(null); + + useEffect(() => { + if (!rectsCtrl || !trianglesCtrl || !irregularCtrl) return; + bCheckboxRef.current = injectStrategyCheckbox(rectsCtrl, "b", disableStrategiesRef, (p) => + onUpdateSceneRef.current(p), + ); + uCheckboxRef.current = injectStrategyCheckbox(trianglesCtrl, "u", disableStrategiesRef, (p) => + onUpdateSceneRef.current(p), + ); + iCheckboxRef.current = injectStrategyCheckbox(irregularCtrl, "i", disableStrategiesRef, (p) => + onUpdateSceneRef.current(p), + ); + return () => { + bCheckboxRef.current?.remove(); + uCheckboxRef.current?.remove(); + iCheckboxRef.current?.remove(); + bCheckboxRef.current = null; + uCheckboxRef.current = null; + iCheckboxRef.current = null; + }; + }, [rectsCtrl, trianglesCtrl, irregularCtrl]); + + // Mirror external `disableStrategies` changes into the checkbox UI. The + // checkboxes are DOM nodes outside React's tree, so we drive `.checked` + // imperatively. + useEffect(() => { + if (bCheckboxRef.current) bCheckboxRef.current.checked = !disableStrategies.includes("b"); + if (uCheckboxRef.current) uCheckboxRef.current.checked = !disableStrategies.includes("u"); + if (iCheckboxRef.current) iCheckboxRef.current.checked = !disableStrategies.includes("i"); + }, [disableStrategies]); +} diff --git a/website/src/components/Dock/folders/useRenderingFolder.ts b/website/src/components/Dock/folders/useRenderingFolder.ts new file mode 100644 index 00000000..41596853 --- /dev/null +++ b/website/src/components/Dock/folders/useRenderingFolder.ts @@ -0,0 +1,164 @@ +/** + * "Rendering" folder of the Dock GUI. + * + * Mesh resolution + Interior fill toggles, plus a single "Texture mode" + * dropdown that collapses the old separate Solid-materials toggle and + * Texture-lighting selector into one row (disabled | baked | dynamic). + * + * Texture quality is a slider with an Auto checkbox injected inside the + * slider's `.widget`. Auto handling: the React-side `textureQuality` value + * is either the string `"auto"` or a number in [0.1, 1]. The slider always + * needs *some* numeric value to display, so we remember the last numeric + * value in a ref and use that whenever the effective value is `"auto"`. + * Touching the slider commits the number (implicitly clears Auto). + * + * Texture mode + quality are both hidden when `hasSpriteLeaves` is false — + * a model with no atlas leaves has nothing for these controls to affect. + */ +import { useEffect, useRef } from "react"; +import type { GUI } from "lil-gui"; +import type { MeshResolution, PolyTextureLightingMode } from "@layoutit/polycss-react"; + +import { useFolder, useOption, useSlider, useToggle } from "../primitives"; + +export type TextureMode = "disabled" | PolyTextureLightingMode; + +export interface RenderingFolderInputs { + meshResolution: MeshResolution; + meshInteriorFill: boolean; + solidMaterials: boolean; + textureLighting: PolyTextureLightingMode; + /** Either "auto" or a number in [0.1, 1]. */ + textureQuality: "auto" | number; + hasActiveAnimation: boolean; + hasSpriteLeaves: boolean; + onUpdateScene: (partial: { + meshResolution?: MeshResolution; + meshInteriorFill?: boolean; + solidMaterials?: boolean; + textureLighting?: PolyTextureLightingMode; + textureQuality?: "auto" | number; + }) => void; +} + +const MESH_RESOLUTION_OPTIONS: Record = { + Lossless: "lossless", + Lossy: "lossy", +}; + +const TEXTURE_MODE_OPTIONS: Record = { + disabled: "disabled", + baked: "baked", + dynamic: "dynamic", +}; + +function textureModeFor(solidMaterials: boolean, textureLighting: PolyTextureLightingMode): TextureMode { + return solidMaterials ? "disabled" : textureLighting; +} + +export function useRenderingFolder(parent: GUI | null, inputs: RenderingFolderInputs): void { + const { + meshResolution, + meshInteriorFill, + solidMaterials, + textureLighting, + textureQuality, + hasActiveAnimation, + hasSpriteLeaves, + onUpdateScene, + } = inputs; + + const folder = useFolder(parent, "Rendering"); + + const isAuto = textureQuality === "auto"; + + const lastNumericRef = useRef(typeof textureQuality === "number" ? textureQuality : 1); + if (typeof textureQuality === "number") lastNumericRef.current = textureQuality; + const sliderValue = typeof textureQuality === "number" ? textureQuality : lastNumericRef.current; + + const meshResolutionCtrl = useOption( + folder, + "Mesh resolution", + MESH_RESOLUTION_OPTIONS, + meshResolution, + (value) => onUpdateScene({ meshResolution: value }), + ); + + const meshInteriorFillCtrl = useToggle(folder, "Interior fill", meshInteriorFill, (value) => + onUpdateScene({ meshInteriorFill: value }), + ); + + const textureMode = textureModeFor(solidMaterials, textureLighting); + const textureModeCtrl = useOption( + folder, + "Texture mode", + TEXTURE_MODE_OPTIONS, + textureMode, + (value) => { + if (value === "disabled") { + onUpdateScene({ solidMaterials: true }); + return; + } + onUpdateScene({ solidMaterials: false, textureLighting: value }); + }, + ); + + const textureQualityCtrl = useSlider( + folder, + "Texture quality", + { min: 0.1, max: 1, step: 0.05 }, + sliderValue, + (value) => { + lastNumericRef.current = value; + onUpdateScene({ textureQuality: value }); + }, + ); + + const onUpdateSceneRef = useRef(onUpdateScene); + onUpdateSceneRef.current = onUpdateScene; + + useEffect(() => { + meshResolutionCtrl?.setEnabled(!hasActiveAnimation); + meshInteriorFillCtrl?.setEnabled(!hasActiveAnimation); + }, [meshResolutionCtrl, meshInteriorFillCtrl, hasActiveAnimation]); + + useEffect(() => { + textureModeCtrl?.setVisible(hasSpriteLeaves); + textureQualityCtrl?.setVisible(hasSpriteLeaves); + }, [textureModeCtrl, textureQualityCtrl, hasSpriteLeaves]); + + const autoCheckboxRef = useRef(null); + useEffect(() => { + if (!textureQualityCtrl) return; + const widget = textureQualityCtrl.raw.domElement.querySelector(".widget"); + if (!widget) return; + + const wrap = document.createElement("label"); + wrap.className = "dn-auto-toggle"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + const lbl = document.createElement("span"); + lbl.textContent = "Auto"; + wrap.appendChild(cb); + wrap.appendChild(lbl); + cb.addEventListener("change", () => { + if (cb.checked) { + onUpdateSceneRef.current({ textureQuality: "auto" }); + } else { + onUpdateSceneRef.current({ textureQuality: lastNumericRef.current }); + } + }); + widget.insertBefore(wrap, widget.firstChild); + autoCheckboxRef.current = cb; + + return () => { + wrap.remove(); + autoCheckboxRef.current = null; + }; + }, [textureQualityCtrl]); + + useEffect(() => { + if (autoCheckboxRef.current) autoCheckboxRef.current.checked = isAuto; + textureQualityCtrl?.setEnabled(!isAuto, { dim: false }); + }, [textureQualityCtrl, isAuto]); +} diff --git a/website/src/components/Dock/folders/useSceneFolder.ts b/website/src/components/Dock/folders/useSceneFolder.ts new file mode 100644 index 00000000..9d88696a --- /dev/null +++ b/website/src/components/Dock/folders/useSceneFolder.ts @@ -0,0 +1,76 @@ +/** + * Scene folder for the Dock: builder-only outliner. + * + * Hosts two pieces of UI inside a real lil-gui "Scene" folder: + * 1. A React portal mount point at the TOP of the folder body — the builder + * uses it for its placed-items list and gizmo button set. + * 2. A lil-gui "Scale" slider BELOW the portal div, bound to the selected + * item's scale. The slider is dimmed when nothing is selected. + * + * The portal target is held in React state; the returned ReactNode is a + * `createPortal(content, portalEl)` the caller must render somewhere in its + * tree so React drives the children inside the lil-gui DOM. + */ +import { useEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import type { GUI } from "lil-gui"; + +import { useFolder, useSlider } from "../primitives"; + +export interface SceneFolderInputs { + /** React content rendered into a portal inside the Scene folder body + * (above the Scale slider). The builder uses this for its items list + + * gizmo button set. */ + content: ReactNode; + /** ID of the currently selected placed item. `null` disables the Scale + * slider. */ + selectedId: string | null; + /** Current scale of the selected item — drives the lil-gui slider. */ + selectedScale: number; + /** Fires when the user drags the slider. */ + onScaleChange: (next: number) => void; +} + +/** Returns a JSX element you must include in the render output. Internally + * it's a `createPortal(content, portalEl)` — the hook owns the portal + * target and renders the React content into the lil-gui folder body. */ +export function useSceneFolder(parent: GUI | null, inputs: SceneFolderInputs): ReactNode { + const folder = useFolder(parent, "Scene"); + const [portalEl, setPortalEl] = useState(null); + + // Insert the portal host into the folder body BEFORE the Scale slider so + // it ends up above the slider in DOM order. The slider is added via + // `useSlider` AFTER this effect (well, after this hook returns its + // folder) — but since `useSlider` runs in the same render pass and only + // appends on mount, ordering is guaranteed by call order in the + // setup effect: `useFolder` resolves first, this effect runs first + // (it depends only on `folder`), then `useSlider`'s mount effect + // appends the slider underneath. + useEffect(() => { + if (!folder) return; + const children = (folder as unknown as { $children: HTMLElement }).$children; + const div = children.ownerDocument.createElement("div"); + div.className = "dn-scene-folder-content"; + children.appendChild(div); + setPortalEl(div); + return () => { + div.remove(); + setPortalEl(null); + }; + }, [folder]); + + const slider = useSlider( + folder, + "Scale", + { min: 0.1, max: 5, step: 0.05 }, + inputs.selectedScale, + inputs.onScaleChange, + ); + + // Dim the slider when nothing is selected — drag has no target. + useEffect(() => { + if (slider) slider.setEnabled(inputs.selectedId != null, { dim: true }); + }, [slider, inputs.selectedId]); + + return portalEl ? createPortal(inputs.content, portalEl) : null; +} diff --git a/website/src/components/Dock/index.ts b/website/src/components/Dock/index.ts index 91284606..de5cc820 100644 --- a/website/src/components/Dock/index.ts +++ b/website/src/components/Dock/index.ts @@ -1,2 +1,24 @@ export { Dock } from "./Dock"; export type { DockProps } from "./Dock"; +export { + DockGuiContext, + useDockGui, + DockModel, + DockRendering, + DockAnimation, + DockInteraction, + DockCamera, + DockLighting, + DockScene, +} from "./slots"; +export { + useGui, + useFolder, + useToggle, + useSlider, + useOption, + useColor, + useButton, + useReadonlyNumber, +} from "./primitives"; +export type { DockController, DockOptionController } from "./primitives"; diff --git a/website/src/components/Dock/primitives.tsx b/website/src/components/Dock/primitives.tsx new file mode 100644 index 00000000..5fc45373 --- /dev/null +++ b/website/src/components/Dock/primitives.tsx @@ -0,0 +1,335 @@ +/** + * lil-gui wrappers used by the per-folder Dock hooks. + * + * Why this file exists: `Dock.tsx` historically open-coded the entire lil-gui + * tree in one ~900-line `useEffect`, mixing scaffolding (`gui.addFolder`, + * `controller.add`, `.onChange`, `.updateDisplay`, `.disable`) with state + * plumbing (reading every field of `sceneOptions` and pushing it back into + * the corresponding controller). The split made folders impossible to reuse + * and reorder. + * + * Each helper here: + * - takes the current value + an onChange callback, + * - creates a lil-gui controller on mount and destroys it on unmount, + * - keeps the displayed value in sync with the React-side value via a + * follow-up effect — callers just pass `state.foo` and the controller + * mirrors it automatically (no `setValue(...)` boilerplate per slot). + * + * The returned `DockController` is a ref-stable handle the hook can hold for + * runtime mutations (`setEnabled`, `setVisible`, the underlying lil-gui + * controller for inject-style customizations). + */ +import { useEffect, useRef, useState } from "react"; +import { GUI, type Controller } from "lil-gui"; + +/** Handle returned by every primitive hook. Stable identity for the lifetime + * of the underlying lil-gui controller. */ +export interface DockController { + /** Underlying lil-gui controller. Exposed so hooks can do advanced ops + * (e.g. dom-element injection of extra checkboxes). */ + readonly raw: Controller; + /** Update the displayed value AND the controller's internal binding. The + * primitive hook also calls this automatically when the `value` arg + * changes between renders — most callers won't need to invoke it. */ + setValue(value: T): void; + /** Toggle the controller's enabled state. `dim` controls whether lil-gui's + * visual "disabled" treatment is applied (true) or hidden (false — used + * for always-disabled read-only displays so they still read normally). */ + setEnabled(enabled: boolean, opts?: { dim?: boolean }): void; + /** Hide/show the controller (folder collapses don't count — this fully + * removes the row from layout). */ + setVisible(visible: boolean): void; +} + +/** Option controller adds runtime options() so dropdowns can refresh their + * list (e.g. animation clip names, placed-item lists). */ +export interface DockOptionController extends DockController { + setOptions(options: Record): void; +} + +/** Mount a lil-gui root in `host` once and tear it down on unmount. The + * returned state is `null` on the first render and the GUI instance after. + * Use it as the `parent` arg to `useFolder`. */ +export function useGui( + hostRef: React.RefObject, + options?: { width?: number; closeFolders?: boolean }, +): GUI | null { + const [gui, setGui] = useState(null); + // Captured at mount so changing the options object after the fact doesn't + // recreate the GUI (which would lose every folder + controller added by + // children). + const optsRef = useRef(options); + optsRef.current = options; + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + const o = optsRef.current; + const instance = new GUI({ + autoPlace: false, + container: host, + width: o?.width ?? 360, + closeFolders: o?.closeFolders ?? false, + }); + instance.open(); + setGui(instance); + return () => { + instance.destroy(); + setGui(null); + }; + }, [hostRef]); + + return gui; +} + +/** Add a child folder to `parent` and remove it on unmount. Children call + * primitives against the returned folder reference. Returns `null` while + * the parent GUI isn't ready (first render). */ +export function useFolder( + parent: GUI | null, + title: string, + options?: { open?: boolean }, +): GUI | null { + const [folder, setFolder] = useState(null); + const optsRef = useRef(options); + optsRef.current = options; + + useEffect(() => { + if (!parent) return; + const f = parent.addFolder(title); + if (optsRef.current?.open === false) f.close(); + else f.open(); + setFolder(f); + return () => { + f.destroy(); + setFolder(null); + }; + }, [parent, title]); + + return folder; +} + +// ── Internal helpers ─────────────────────────────────────────────────────── + +/** Strip lil-gui's "disabled" class so a read-only display doesn't look + * grayed out. Used by `setEnabled(false, { dim: false })`. */ +function applyEnabled(c: Controller, enabled: boolean, dim: boolean): void { + if (enabled) c.enable(); + else { + c.disable(); + if (!dim) c.domElement.classList.remove("disabled"); + } +} + +/** Wrap a lil-gui `Controller` in our `DockController` interface. Owns the + * underlying `{ value }` proxy object lil-gui binds to. */ +function makeDockController(controller: Controller, proxy: { value: T }): DockController { + return { + raw: controller, + setValue(value) { + proxy.value = value; + controller.updateDisplay(); + }, + setEnabled(enabled, opts) { + applyEnabled(controller, enabled, opts?.dim ?? true); + }, + setVisible(visible) { + if (visible) controller.show(); + else controller.hide(); + }, + }; +} + +/** Shared lifecycle: mount/sync/teardown for any single-value controller. + * `factory` is called once per mount to produce the controller; subsequent + * value changes go through `setValue` automatically. */ +function useControllerLifecycle( + parent: GUI | null, + label: string, + value: T, + onChange: (next: T) => void, + factory: (parent: GUI, proxy: { value: T }, onChange: (next: T) => void) => Controller, +): DockController | null { + const [ctrl, setCtrl] = useState | null>(null); + // The latest onChange — controller closures capture this ref at creation + // and re-read on every event so callers can pass fresh arrow functions + // without recreating the controller every render. + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + useEffect(() => { + if (!parent) return; + const proxy = { value }; + const raw = factory(parent, proxy, (next) => onChangeRef.current(next)); + raw.name(label); + const wrapper = makeDockController(raw, proxy); + setCtrl(wrapper); + return () => { + raw.destroy(); + setCtrl(null); + }; + // Intentionally skip `value` and `onChange` — value is mirrored via the + // follow-up sync effect; onChange is read through the ref above. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parent, label]); + + // Mirror the React-side value into the controller. Cheap if unchanged + // (setValue is just a property assign + updateDisplay). + useEffect(() => { + if (ctrl) ctrl.setValue(value); + }, [ctrl, value]); + + return ctrl; +} + +// ── Primitive hooks ──────────────────────────────────────────────────────── + +/** Boolean checkbox. */ +export function useToggle( + parent: GUI | null, + label: string, + value: boolean, + onChange: (next: boolean) => void, +): DockController | null { + return useControllerLifecycle(parent, label, value, onChange, (folder, proxy, cb) => + folder.add(proxy, "value").onChange((v: boolean) => cb(v)), + ); +} + +/** Numeric slider + number-input combo. `range.step` defaults to lil-gui's + * auto step. */ +export function useSlider( + parent: GUI | null, + label: string, + range: { min: number; max: number; step?: number }, + value: number, + onChange: (next: number) => void, +): DockController | null { + // Range captured at mount — changing it would require destroying and re- + // adding the controller. None of our uses change range at runtime. + const rangeRef = useRef(range); + rangeRef.current = range; + return useControllerLifecycle(parent, label, value, onChange, (folder, proxy, cb) => { + const r = rangeRef.current; + const ctrl = folder.add(proxy, "value", r.min, r.max, r.step); + ctrl.onChange((v: number) => cb(v)); + return ctrl; + }); +} + +/** Dropdown bound to a record of `{ displayLabel: value }`. Returns a + * controller that exposes `setOptions` for runtime list changes. */ +export function useOption( + parent: GUI | null, + label: string, + options: Record, + value: T, + onChange: (next: T) => void, +): DockOptionController | null { + const [ctrl, setCtrl] = useState | null>(null); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + // Options snapshot at mount — runtime changes go through `setOptions`. + const initialOptionsRef = useRef(options); + initialOptionsRef.current = options; + + useEffect(() => { + if (!parent) return; + const proxy = { value }; + const raw = parent.add(proxy, "value", initialOptionsRef.current).name(label); + raw.onChange((v: T) => onChangeRef.current(v)); + const base = makeDockController(raw, proxy); + const wrapper: DockOptionController = { + ...base, + setOptions(next) { + // lil-gui's `.options(newOpts)` REPLACES the controller — the old + // `raw` reference is destroyed. Returned controller swaps in. + // Callers rarely need the new ref since `setValue/setEnabled` go + // through this wrapper's closure; we just rebind internally. + const replaced = (raw as unknown as { options: (o: Record) => Controller }).options(next); + replaced.onChange((v: T) => onChangeRef.current(v)); + }, + }; + setCtrl(wrapper); + return () => { + raw.destroy(); + setCtrl(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parent, label]); + + useEffect(() => { + if (ctrl) ctrl.setValue(value); + }, [ctrl, value]); + + return ctrl; +} + +/** Color picker. lil-gui handles `#rrggbb` strings + named CSS colors. */ +export function useColor( + parent: GUI | null, + label: string, + value: string, + onChange: (next: string) => void, +): DockController | null { + return useControllerLifecycle(parent, label, value, onChange, (folder, proxy, cb) => + folder.addColor(proxy, "value").onChange((v: string) => cb(v)), + ); +} + +/** Action button — no value, just a click handler. Display label set via + * `name()`. */ +export function useButton( + parent: GUI | null, + label: string, + onClick: () => void, +): DockController | null { + const [ctrl, setCtrl] = useState | null>(null); + const onClickRef = useRef(onClick); + onClickRef.current = onClick; + + useEffect(() => { + if (!parent) return; + const proxy = { value: (() => onClickRef.current()) as () => void }; + const raw = parent.add(proxy, "value").name(label); + setCtrl(makeDockController(raw, proxy as unknown as { value: never })); + return () => { + raw.destroy(); + setCtrl(null); + }; + }, [parent, label]); + + return ctrl; +} + +/** Always-disabled numeric display. Used for live metrics (DOM node count, + * sprite count, etc.) where the value is push-only from outside. The + * controller renders without the "disabled" CSS class so it still reads + * as a normal row. */ +export function useReadonlyNumber( + parent: GUI | null, + label: string, + value: number, +): DockController | null { + const [ctrl, setCtrl] = useState | null>(null); + + useEffect(() => { + if (!parent) return; + const proxy = { value }; + const raw = parent.add(proxy, "value").name(label); + const wrapper = makeDockController(raw, proxy); + wrapper.setEnabled(false, { dim: false }); + setCtrl(wrapper); + return () => { + raw.destroy(); + setCtrl(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parent, label]); + + useEffect(() => { + if (ctrl) ctrl.setValue(value); + }, [ctrl, value]); + + return ctrl; +} diff --git a/website/src/components/Dock/slots.tsx b/website/src/components/Dock/slots.tsx new file mode 100644 index 00000000..5966a497 --- /dev/null +++ b/website/src/components/Dock/slots.tsx @@ -0,0 +1,57 @@ +/** + * Dock slot components — one per folder. Each reads the lil-gui instance + * from `DockGuiContext` and calls its corresponding folder hook. Pages + * compose Dock by listing the slots they want as children of ``, + * which lets gallery and builder share the container while picking + * different folders. + */ +import { createContext, useContext, type ReactNode } from "react"; +import type { GUI } from "lil-gui"; + +import { useModelFolder, type ModelFolderInputs } from "./folders/useModelFolder"; +import { useRenderingFolder, type RenderingFolderInputs } from "./folders/useRenderingFolder"; +import { useAnimationFolder, type AnimationFolderInputs } from "./folders/useAnimationFolder"; +import { useInteractionFolder, type InteractionFolderInputs } from "./folders/useInteractionFolder"; +import { useCameraFolder, type CameraFolderInputs } from "./folders/useCameraFolder"; +import { useLightingFolder, type LightingFolderInputs } from "./folders/useLightingFolder"; +import { useSceneFolder, type SceneFolderInputs } from "./folders/useSceneFolder"; + +export const DockGuiContext = createContext(null); + +export function useDockGui(): GUI | null { + return useContext(DockGuiContext); +} + +export function DockModel(inputs: ModelFolderInputs): null { + useModelFolder(useDockGui(), inputs); + return null; +} + +export function DockRendering(inputs: RenderingFolderInputs): null { + useRenderingFolder(useDockGui(), inputs); + return null; +} + +export function DockAnimation(inputs: AnimationFolderInputs): null { + useAnimationFolder(useDockGui(), inputs); + return null; +} + +export function DockInteraction(inputs: InteractionFolderInputs): null { + useInteractionFolder(useDockGui(), inputs); + return null; +} + +export function DockCamera(inputs: CameraFolderInputs): null { + useCameraFolder(useDockGui(), inputs); + return null; +} + +export function DockLighting(inputs: LightingFolderInputs): null { + useLightingFolder(useDockGui(), inputs); + return null; +} + +export function DockScene(inputs: SceneFolderInputs): ReactNode { + return useSceneFolder(useDockGui(), inputs); +} diff --git a/website/src/components/DocsHeader.astro b/website/src/components/DocsHeader.astro index 01c8fd05..0d9f541f 100644 --- a/website/src/components/DocsHeader.astro +++ b/website/src/components/DocsHeader.astro @@ -10,9 +10,11 @@ const shouldRenderSearch = const pathname = Astro.url.pathname.replace(/\/$/, '') || '/'; const isLanding = pathname === '/'; const isGallery = pathname.startsWith('/gallery'); -// Hide search dock on landing + gallery (workbench-style pages where the -// Starlight floating search position doesn't fit). Branding + GitHub still -// render on /gallery — only /landing strips them entirely. +const isBuilder = pathname.startsWith('/builder'); +// Hide search dock on landing + workbench pages (gallery, builder) where +// the Starlight floating search position overlaps the workbench's own +// sidebar / search input. Branding + GitHub still render on workbenches — +// only /landing strips them entirely. const topLinks = [ { href: '/', label: 'Home', active: pathname === '/' }, { @@ -28,6 +30,7 @@ const topLinks = [ active: pathname.startsWith('/api'), }, { href: '/gallery', label: 'Gallery', active: pathname.startsWith('/gallery') }, + { href: '/builder', label: 'Builder', active: isBuilder }, ]; --- @@ -37,7 +40,7 @@ const topLinks = [
)} - {!isLanding && !isGallery && ( + {!isLanding && !isGallery && !isBuilder && ( diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index 967f340b..119c638d 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -14,7 +14,15 @@ import { } from "../Inspector"; import { VanillaScene } from "../VanillaScene"; import { ReactScene } from "../ReactScene"; -import { Dock } from "../Dock"; +import { + Dock, + DockModel, + DockRendering, + DockAnimation, + DockInteraction, + DockCamera, + DockLighting, +} from "../Dock"; import { ModelsSidebar } from "../ModelsSidebar"; import { DropOverlay } from "../DropOverlay"; import { StatsOverlay } from "../StatsOverlay"; @@ -30,12 +38,10 @@ import { GALLERY_BUCKET_ORDER, galleryBucketForPreset, galleryBucketRank, - labelFromFile, stripParenthesizedText, } from "./presets"; import { EMPTY_METRICS, - domMetricCountsEqual, measureDom, } from "./helpers/domMetrics"; import { @@ -53,12 +59,12 @@ import { usePresetLoader, useScenePolygons, useAnimationFrames, - useFpvSpawn, useRouteSync, useGuiCameraSync, setRoutePresetId, routeInitialPresetId, } from "./hooks"; +import { useFpvHost } from "../fpv"; import type { ObjParseOptions, GltfParseOptions, VoxParseOptions } from "@layoutit/polycss"; function presetPickerItem(preset: PresetModel, local = false) { @@ -117,6 +123,9 @@ const DEFAULT_SCENE: SceneOptionsState = { fpvCrouchHeight: 3, fpvLookSensitivity: 0.15, fpvInvertY: false, + fpvRenderDistance: 40, + snapToGrid: true, + gridResolution: 5, }; const DEFAULT_PARSER: ParserOptionsState = { @@ -223,7 +232,7 @@ export default function GalleryWorkbench() { // `selectedMeshes` because vanilla MeshHandles aren't comparable to // React PolyMeshHandles. Stored as IDs since that's what both paths // can agree on for the toolbar display. - const [vanillaSelectedIds, setVanillaSelectedIds] = useState([]); + const [, setVanillaSelectedIds] = useState([]); const updateScene = useCallback((partial: Partial) => { setSceneOptions((current) => ({ ...current, ...partial })); @@ -373,7 +382,6 @@ export default function GalleryWorkbench() { const textureQuality = sceneOptions.textureQuality; const animationClips = loaded?.animation?.clips ?? []; - const hasAnimation = animationClips.length > 0; const activeAnimation = useMemo( () => animationClips.find((clip) => String(clip.index) === selectedAnimation) ?? null, [animationClips, selectedAnimation], @@ -429,7 +437,7 @@ export default function GalleryWorkbench() { parserOptions.defaultColor, ]); - useFpvSpawn({ + useFpvHost({ dragMode: sceneOptions.dragMode, autoCenter: sceneOptions.autoCenter, perspective: sceneOptions.perspective, @@ -479,8 +487,7 @@ export default function GalleryWorkbench() { let raf = 0; const update = () => { raf = 0; - const next = measureDom(root); - setMetrics((current) => domMetricCountsEqual(current, next) ? current : next); + setMetrics(measureDom(root)); }; const schedule = () => { if (!raf) raf = requestAnimationFrame(update); @@ -634,18 +641,6 @@ export default function GalleryWorkbench() { return (
- animation.setReactAnimatedPolygons(null)} - onGizmoModeChange={setGizmoMode} - onSelectAnimationClear={() => setSelectedAnimation("")} - loading={loading} - loadError={loadError} - /> + + + + animation.setReactAnimatedPolygons(null)} + onSelectAnimationClear={() => setSelectedAnimation("")} + onUpdateScene={updateScene} + /> + + defaultZoomForModel(preset as PresetModel, polys as Polygon[])} + onUpdateScene={updateScene} + /> + +
); } diff --git a/website/src/components/GalleryWorkbench/gallery-workbench.css b/website/src/components/GalleryWorkbench/gallery-workbench.css index 4cf90f8d..f0157f8e 100644 --- a/website/src/components/GalleryWorkbench/gallery-workbench.css +++ b/website/src/components/GalleryWorkbench/gallery-workbench.css @@ -12,19 +12,6 @@ overflow: hidden; } -/* FPV needs the vanilla host to be a preserve-3d perspective context so - that scene Z translation actually moves it in 3D space relative to a - fixed viewer. The perspective value is driven by --fpv-perspective from - React (matches sceneOptions.perspective) — otherwise the FPV camera's - lookOffset math (perspective/tile) and the host's perspective disagree - and the scene gets shoved past the viewer plane at high perspective - values. Scoped to FPV via data-camera-mode so orbit/pan keep their - default flat-host rendering. */ -.dn-root[data-camera-mode="fpv"] .dn-vanilla-host { - perspective: var(--fpv-perspective, 2000px); - transform-style: preserve-3d; -} - .dn-root .lil-gui { font-family: inherit; diff --git a/website/src/components/GalleryWorkbench/helpers/cssValues.ts b/website/src/components/GalleryWorkbench/helpers/cssValues.ts index 317ebcd8..2ca91934 100644 --- a/website/src/components/GalleryWorkbench/helpers/cssValues.ts +++ b/website/src/components/GalleryWorkbench/helpers/cssValues.ts @@ -1,5 +1,98 @@ +import { parsePureColor } from "@layoutit/polycss-react"; + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(Math.max(value, min), max); +} + +export function colorLuminance(color: string | undefined): number | null { + if (!color) return null; + const parsed = parsePureColor(color); + if (!parsed) return null; + const [r, g, b] = parsed.rgb; + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +} + +export function roundToStep(value: number, step: number): number { + return Number((Math.round(value / step) * step).toFixed(4)); +} + export function getInlineStyleDeclaration(styleAttr: string, property: string): string | null { const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const match = styleAttr.match(new RegExp(`(?:^|;)\\s*${escaped}\\s*:\\s*([^;]*)`, "i")); return match?.[1]?.trim() ?? null; } + +export function cssColorAlpha(value: string | null | undefined): number | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + if (trimmed.toLowerCase() === "transparent") return 0; + const parsed = parsePureColor(trimmed); + if (parsed) return parsed.alpha; + const slashAlpha = trimmed.match(/\/\s*([\d.]+%?)\s*\)?$/); + if (!slashAlpha) return null; + const raw = slashAlpha[1]; + const valueAsNumber = raw.endsWith("%") + ? Number(raw.slice(0, -1)) / 100 + : Number(raw); + return Number.isFinite(valueAsNumber) ? clamp(valueAsNumber, 0, 1) : null; +} + +export function inlineStyleValue(element: HTMLElement, property: string): string | null { + const styleAttr = element.getAttribute("style") ?? ""; + return getInlineStyleDeclaration(styleAttr, property) + ?? element.style.getPropertyValue(property).trim() + ?? null; +} + +export function resolvedStyleValue(element: HTMLElement, property: string): string | null { + const inline = inlineStyleValue(element, property); + if (inline) return inline; + const view = element.ownerDocument.defaultView; + return view?.getComputedStyle(element).getPropertyValue(property).trim() ?? null; +} + +export function cssPxValue(value: string | null | undefined): number | null { + const match = value?.trim().match(/^(-?\d*\.?\d+)/); + if (!match) return null; + const parsed = Number(match[1]); + return Number.isFinite(parsed) ? Math.abs(parsed) : null; +} + +export function cssNumberValue(value: string | null | undefined): number | null { + const parsed = Number.parseFloat(value?.trim() ?? ""); + return Number.isFinite(parsed) ? parsed : null; +} + +export function cssUrlValue(value: string | null | undefined): string | null { + const match = value?.match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*?))\)/i); + const raw = match?.[1] ?? match?.[2] ?? match?.[3]; + const trimmed = raw?.trim(); + if (!trimmed || trimmed === "none") return null; + return trimmed; +} + +export function cssPaintAlpha(element: HTMLElement, properties: string[]): number { + for (const property of properties) { + const alpha = cssColorAlpha(resolvedStyleValue(element, property)); + if (alpha !== null) return alpha; + } + return 1; +} + +export function localElementSize(element: HTMLElement): { width: number; height: number } { + if (element.tagName === "U") { + const left = cssPxValue(resolvedStyleValue(element, "border-left-width")) ?? 0; + const right = cssPxValue(resolvedStyleValue(element, "border-right-width")) ?? 0; + const bottom = cssPxValue(resolvedStyleValue(element, "border-bottom-width")) ?? 0; + return { + width: Math.max(1, left + right), + height: Math.max(1, bottom), + }; + } + + return { + width: Math.max(1, cssPxValue(resolvedStyleValue(element, "width")) ?? element.offsetWidth), + height: Math.max(1, cssPxValue(resolvedStyleValue(element, "height")) ?? element.offsetHeight), + }; +} diff --git a/website/src/components/GalleryWorkbench/helpers/domMetrics.ts b/website/src/components/GalleryWorkbench/helpers/domMetrics.ts index ce50ad09..09761920 100644 --- a/website/src/components/GalleryWorkbench/helpers/domMetrics.ts +++ b/website/src/components/GalleryWorkbench/helpers/domMetrics.ts @@ -1,4 +1,10 @@ import type { DomMetrics } from "../../types"; +import { cssUrlValue, cssPxValue, cssNumberValue, cssPaintAlpha, localElementSize } from "./cssValues"; + +export const DOM_OVERPAINT_CACHE_EVENT = "polycss:dom-overpaint-cache"; +export const SPRITE_ALPHA_CACHE = new Map(); +export const SPRITE_ALPHA_PENDING = new Set(); +export const SPRITE_ALPHA_IMAGE_CACHE = new Map>(); export const EMPTY_METRICS: DomMetrics = { measuredAt: 0, @@ -7,8 +13,172 @@ export const EMPTY_METRICS: DomMetrics = { rects: 0, triangles: 0, irregular: 0, + overpaintPercent: 0, }; +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.min(Math.max(value, min), max); +} + +export function loadAlphaImage(url: string): Promise { + let promise = SPRITE_ALPHA_IMAGE_CACHE.get(url); + if (promise) return promise; + + promise = new Promise((resolve, reject) => { + const img = new Image(); + img.decoding = "async"; + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`alpha image load failed: ${url}`)); + img.src = url; + }); + SPRITE_ALPHA_IMAGE_CACHE.set(url, promise); + return promise; +} + +export function emitOverpaintCacheUpdate(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(DOM_OVERPAINT_CACHE_EVENT)); +} + +async function sampleSpriteAlpha( + key: string, + url: string, + cssX: number, + cssY: number, + cssW: number, + cssH: number, + cssBackgroundW: number, + cssBackgroundH: number, +): Promise { + try { + const img = await loadAlphaImage(url); + const scaleX = img.naturalWidth / cssBackgroundW; + const scaleY = img.naturalHeight / cssBackgroundH; + if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY) || scaleX <= 0 || scaleY <= 0) return; + + const sx = Math.max(0, Math.round(cssX * scaleX)); + const sy = Math.max(0, Math.round(cssY * scaleY)); + const sw = Math.max(1, Math.min(img.naturalWidth - sx, Math.round(cssW * scaleX))); + const sh = Math.max(1, Math.min(img.naturalHeight - sy, Math.round(cssH * scaleY))); + if (sw <= 0 || sh <= 0) return; + + const canvas = document.createElement("canvas"); + canvas.width = sw; + canvas.height = sh; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) return; + + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh); + const pixels = ctx.getImageData(0, 0, sw, sh).data; + let alpha = 0; + for (let i = 3; i < pixels.length; i += 4) alpha += pixels[i] / 255; + SPRITE_ALPHA_CACHE.set(key, alpha / (pixels.length / 4)); + } catch { + SPRITE_ALPHA_CACHE.set(key, 1); + } finally { + SPRITE_ALPHA_PENDING.delete(key); + emitOverpaintCacheUpdate(); + } +} + +export function spriteAtlasAlpha(element: HTMLElement): number | null { + const view = element.ownerDocument.defaultView; + if (!view || typeof Image === "undefined") return null; + + const style = view.getComputedStyle(element); + const url = cssUrlValue(style.backgroundImage) + ?? cssUrlValue(style.getPropertyValue("-webkit-mask-image")) + ?? cssUrlValue(style.getPropertyValue("mask-image")) + ?? cssUrlValue(style.background); + if (!url) return null; + + const width = cssPxValue(style.width) ?? element.offsetWidth; + const height = cssPxValue(style.height) ?? element.offsetHeight; + const positionX = cssNumberValue(style.backgroundPositionX) ?? 0; + const positionY = cssNumberValue(style.backgroundPositionY) ?? 0; + const maskPosition = style.getPropertyValue("-webkit-mask-position") || style.getPropertyValue("mask-position"); + const [maskPositionXRaw, maskPositionYRaw] = maskPosition.split(/\s+/); + const cssX = -(cssNumberValue(style.backgroundPositionX) ?? cssNumberValue(maskPositionXRaw) ?? positionX); + const cssY = -(cssNumberValue(style.backgroundPositionY) ?? cssNumberValue(maskPositionYRaw) ?? positionY); + const size = style.backgroundSize || style.getPropertyValue("-webkit-mask-size") || style.getPropertyValue("mask-size"); + const [backgroundWidthRaw, backgroundHeightRaw] = size.split(/\s+/); + const backgroundWidth = cssPxValue(backgroundWidthRaw); + const backgroundHeight = cssPxValue(backgroundHeightRaw); + if (!width || !height || !backgroundWidth || !backgroundHeight) return null; + + const key = [ + url, + cssX.toFixed(3), + cssY.toFixed(3), + width.toFixed(3), + height.toFixed(3), + backgroundWidth.toFixed(3), + backgroundHeight.toFixed(3), + ].join("|"); + + const cached = SPRITE_ALPHA_CACHE.get(key); + if (cached !== undefined) return cached; + + if (!SPRITE_ALPHA_PENDING.has(key)) { + SPRITE_ALPHA_PENDING.add(key); + void sampleSpriteAlpha(key, url, cssX, cssY, width, height, backgroundWidth, backgroundHeight); + } + + return null; +} + +export function elementPaintAlphaSample(element: HTMLElement): { alpha: number; area: number } | null { + const { width, height } = localElementSize(element); + const area = Math.max(1, width * height); + + if (element.tagName === "U") { + return { + alpha: 0.5 * cssPaintAlpha(element, ["border-bottom-color", "color", "--polycss-paint"]), + area, + }; + } + + if (element.tagName === "I") { + return { + alpha: cssPaintAlpha(element, ["border-bottom-color", "border-color", "color", "--polycss-paint"]), + area, + }; + } + + if (element.tagName === "S") { + const alpha = spriteAtlasAlpha(element) + ?? cssPaintAlpha(element, ["background-color", "background"]); + return { alpha, area }; + } + + if (element.tagName === "B") { + return { + alpha: cssPaintAlpha(element, ["background-color", "background", "color", "--polycss-paint"]), + area, + }; + } + + return null; +} + +export function measureDomOverpaintPercent(scopes: HTMLElement[]): number { + let weightedPaintAlpha = 0; + let totalArea = 0; + + for (const scope of scopes) { + const elements = scope.querySelectorAll("b, u, s, i"); + for (const element of elements) { + const sample = elementPaintAlphaSample(element); + if (!sample) continue; + weightedPaintAlpha += clamp(sample.alpha, 0, 1) * sample.area; + totalArea += sample.area; + } + } + + return totalArea > 0 ? Number(((1 - weightedPaintAlpha / totalArea) * 100).toFixed(1)) : 0; +} + export function measureDom(root: HTMLElement | null): DomMetrics { if (!root) return EMPTY_METRICS; const modelScopes = Array.from(root.querySelectorAll(".dn-model-mesh")); @@ -25,15 +195,6 @@ export function measureDom(root: HTMLElement | null): DomMetrics { rects: countInScopes("b"), triangles: countInScopes("u"), irregular: countInScopes("i"), + overpaintPercent: measureDomOverpaintPercent(scopes), }; } - -export function domMetricCountsEqual(a: DomMetrics, b: DomMetrics): boolean { - return ( - a.nodeCount === b.nodeCount && - a.sprites === b.sprites && - a.rects === b.rects && - a.triangles === b.triangles && - a.irregular === b.irregular - ); -} diff --git a/website/src/components/GalleryWorkbench/hooks/index.ts b/website/src/components/GalleryWorkbench/hooks/index.ts index 49b30efe..3767e32a 100644 --- a/website/src/components/GalleryWorkbench/hooks/index.ts +++ b/website/src/components/GalleryWorkbench/hooks/index.ts @@ -10,9 +10,6 @@ export type { UseScenePolygonsOptions, UseScenePolygonsResult } from "./useScene export { useAnimationFrames } from "./useAnimationFrames"; export type { UseAnimationFramesOptions, UseAnimationFramesResult } from "./useAnimationFrames"; -export { useFpvSpawn } from "./useFpvSpawn"; -export type { UseFpvSpawnOptions } from "./useFpvSpawn"; - export { useRouteSync, setRoutePresetId, routeInitialPresetId } from "./useRouteSync"; export type { UseRouteSyncOptions } from "./useRouteSync"; diff --git a/website/src/components/GalleryWorkbench/presets/presetFiles.ts b/website/src/components/GalleryWorkbench/presets/presetFiles.ts index afd058ae..03a21776 100644 --- a/website/src/components/GalleryWorkbench/presets/presetFiles.ts +++ b/website/src/components/GalleryWorkbench/presets/presetFiles.ts @@ -87,6 +87,146 @@ export const GLB_PRESET_FILES: GalleryPresetFile[] = [ { file: "Campfire.glb", category: "Environment" }, { file: "Drill.glb", category: "Objects" }, { file: "Globe.glb", category: "Objects" }, + + // Medieval Village Pack — used by the /builder Medieval Village scene + // preset as well as standalone models. All share the same category so + // they group cleanly in the sidebar. + { file: "medieval/Bag Open.glb", label: "Bag Open", category: "Medieval Village" }, + { file: "medieval/Bag.glb", category: "Medieval Village" }, + { file: "medieval/Bags.glb", category: "Medieval Village" }, + { file: "medieval/Barrel.glb", category: "Medieval Village" }, + { file: "medieval/Bell Tower.glb", label: "Bell Tower", category: "Medieval Village" }, + { file: "medieval/Bell.glb", category: "Medieval Village" }, + { file: "medieval/Bench.glb", category: "Medieval Village" }, + { file: "medieval/Bench-7uSlZo3n9Y.glb", label: "Bench (Tall)", category: "Medieval Village" }, + { file: "medieval/Blacksmith.glb", category: "Medieval Village" }, + { file: "medieval/Bonfire.glb", category: "Medieval Village" }, + { file: "medieval/Cart.glb", category: "Medieval Village" }, + { file: "medieval/Cauldron.glb", category: "Medieval Village" }, + { file: "medieval/Crate.glb", category: "Medieval Village" }, + { file: "medieval/Door Round.glb", label: "Door Round", category: "Medieval Village" }, + { file: "medieval/Door Straight.glb", label: "Door Straight", category: "Medieval Village" }, + { file: "medieval/Fantasy Barracks.glb", label: "Fantasy Barracks", category: "Medieval Village" }, + { file: "medieval/Fantasy House.glb", label: "Fantasy House", category: "Medieval Village" }, + { file: "medieval/Fantasy House-BH2XHWUNmF.glb", label: "Fantasy House (Stone)", category: "Medieval Village" }, + { file: "medieval/Fantasy House-dcPho4SUA3.glb", label: "Fantasy House (Tall)", category: "Medieval Village" }, + { file: "medieval/Fantasy Inn.glb", label: "Fantasy Inn", category: "Medieval Village" }, + { file: "medieval/Fantasy Sawmill.glb", label: "Fantasy Sawmill", category: "Medieval Village" }, + { file: "medieval/Fantasy Stable.glb", label: "Fantasy Stable", category: "Medieval Village" }, + { file: "medieval/Fence.glb", category: "Medieval Village" }, + { file: "medieval/Gazebo.glb", category: "Medieval Village" }, + { file: "medieval/Hay.glb", category: "Medieval Village" }, + { file: "medieval/Market Stand.glb", label: "Market Stand", category: "Medieval Village" }, + { file: "medieval/Market Stand-DGIM5HGISb.glb", label: "Market Stand (Variant)", category: "Medieval Village" }, + { file: "medieval/Mill.glb", category: "Medieval Village" }, + { file: "medieval/Package.glb", category: "Medieval Village" }, + { file: "medieval/Package-kYvD6QCQRd.glb", label: "Package (Small)", category: "Medieval Village" }, + { file: "medieval/Path Straight.glb", label: "Path Straight", category: "Medieval Village" }, + { file: "medieval/Rocks.glb", category: "Medieval Village" }, + { file: "medieval/Round Window.glb", label: "Round Window", category: "Medieval Village" }, + { file: "medieval/Sawmill Saw.glb", label: "Sawmill Saw", category: "Medieval Village" }, + { file: "medieval/Smoke.glb", category: "Medieval Village" }, + { file: "medieval/Stairs.glb", category: "Medieval Village" }, + { file: "medieval/Well.glb", category: "Medieval Village" }, + { file: "medieval/Window.glb", category: "Medieval Village" }, + { file: "medieval/Window-EY1zrFcme9.glb", label: "Window (Tall)", category: "Medieval Village" }, + + // City Kit — used by the /builder City Block scene preset and for + // standalone placement. 31 building variants across 6 archetypes + // (Skyscraper, Large/Low Wide/Low/Small Building, Sign Hospital). + { file: "city/Skyscraper.glb", category: "City Kit" }, + { file: "city/Skyscraper-BwEXdOoUSO.glb", label: "Skyscraper (A)", category: "City Kit" }, + { file: "city/Skyscraper-jIRx0AhYOR.glb", label: "Skyscraper (B)", category: "City Kit" }, + { file: "city/Skyscraper-obYD8hWLTZ.glb", label: "Skyscraper (C)", category: "City Kit" }, + { file: "city/Skyscraper-PsPe0MzK0E.glb", label: "Skyscraper (D)", category: "City Kit" }, + { file: "city/Skyscraper-XST1j6kYsL.glb", label: "Skyscraper (E)", category: "City Kit" }, + { file: "city/Large Building.glb", label: "Large Building", category: "City Kit" }, + { file: "city/Large Building-1bt4yYKmuK.glb", label: "Large Building (A)", category: "City Kit" }, + { file: "city/Large Building-3IhrYZp6tP.glb", label: "Large Building (B)", category: "City Kit" }, + { file: "city/Large Building-h7Jaq7bqMq.glb", label: "Large Building (C)", category: "City Kit" }, + { file: "city/Large Building-JgGLJH2iXj.glb", label: "Large Building (D)", category: "City Kit" }, + { file: "city/Large Building-ppwtREejXg.glb", label: "Large Building (E)", category: "City Kit" }, + { file: "city/Large Building-sxXonOmtct.glb", label: "Large Building (F)", category: "City Kit" }, + { file: "city/Low Wide.glb", label: "Low Wide", category: "City Kit" }, + { file: "city/Low Wide-DKgknsHjmr.glb", label: "Low Wide (A)", category: "City Kit" }, + { file: "city/Low Building.glb", label: "Low Building", category: "City Kit" }, + { file: "city/Low Building-4RoPd9BkSx.glb", label: "Low Building (A)", category: "City Kit" }, + { file: "city/Low Building-9fEKMpTsAi.glb", label: "Low Building (B)", category: "City Kit" }, + { file: "city/Low Building-AXFdNPAEc9.glb", label: "Low Building (C)", category: "City Kit" }, + { file: "city/Low Building-dYEbYdPfJr.glb", label: "Low Building (D)", category: "City Kit" }, + { file: "city/Low Building-sObKC8Mio2.glb", label: "Low Building (E)", category: "City Kit" }, + { file: "city/Low Building-tuieC1Pj0a.glb", label: "Low Building (F)", category: "City Kit" }, + { file: "city/Low Building-XsFOzw8E5N.glb", label: "Low Building (G)", category: "City Kit" }, + { file: "city/Low Building-zfjlejAxB7.glb", label: "Low Building (H)", category: "City Kit" }, + { file: "city/Small Building.glb", label: "Small Building", category: "City Kit" }, + { file: "city/Small Building-gyjF60t7CG.glb", label: "Small Building (A)", category: "City Kit" }, + { file: "city/Small Building-QjL4Fo9dU9.glb", label: "Small Building (B)", category: "City Kit" }, + { file: "city/Small Building-Rq572hdKEz.glb", label: "Small Building (C)", category: "City Kit" }, + { file: "city/Small Building-t9j9Lof5ul.glb", label: "Small Building (D)", category: "City Kit" }, + { file: "city/Small Building-yLvnMqC9ZG.glb", label: "Small Building (E)", category: "City Kit" }, + { file: "city/Sign Hospital.glb", label: "Sign Hospital", category: "City Kit" }, + + // Urban Pack — buildings + cars + street furniture + a few characters. + // Used by the /builder "City Street" scene plus ad-hoc placement. + { file: "urban/Big Building.glb", label: "Big Building", category: "Urban Pack" }, + { file: "urban/Brown Building.glb", label: "Brown Building", category: "Urban Pack" }, + { file: "urban/Building Green.glb", label: "Building (Green)", category: "Urban Pack" }, + { file: "urban/Building Red.glb", label: "Building (Red)", category: "Urban Pack" }, + { file: "urban/Building Red Corner.glb", label: "Building (Red Corner)", category: "Urban Pack" }, + { file: "urban/Greenhouse.glb", category: "Urban Pack" }, + { file: "urban/Pizza Corner.glb", label: "Pizza Corner", category: "Urban Pack" }, + + { file: "urban/Road Bits.glb", label: "Road", category: "Urban Pack" }, + { file: "urban/Floor Hole.glb", label: "Floor Hole", category: "Urban Pack" }, + { file: "urban/Manhole Cover.glb", label: "Manhole Cover", category: "Urban Pack" }, + + { file: "urban/Car.glb", category: "Urban Pack" }, + { file: "urban/Car-unqqkULtRU.glb", label: "Car (Variant)", category: "Urban Pack" }, + { file: "urban/SUV.glb", category: "Urban Pack" }, + { file: "urban/Van.glb", category: "Urban Pack" }, + { file: "urban/Pickup Truck.glb", label: "Pickup Truck", category: "Urban Pack" }, + { file: "urban/Bus.glb", category: "Urban Pack" }, + { file: "urban/Sports Car.glb", label: "Sports Car", category: "Urban Pack" }, + { file: "urban/Sports Car-Gzj704DXdr.glb", label: "Sports Car (Variant)", category: "Urban Pack" }, + { file: "urban/Police Car.glb", label: "Police Car", category: "Urban Pack" }, + { file: "urban/Motorcycle.glb", category: "Urban Pack" }, + { file: "urban/Bicycle.glb", category: "Urban Pack" }, + + { file: "urban/Bus Stop.glb", label: "Bus Stop", category: "Urban Pack" }, + { file: "urban/Bus stop sign.glb", label: "Bus Stop Sign", category: "Urban Pack" }, + { file: "urban/Stop sign.glb", label: "Stop Sign", category: "Urban Pack" }, + { file: "urban/Traffic Light.glb", label: "Traffic Light", category: "Urban Pack" }, + { file: "urban/Billboard.glb", category: "Urban Pack" }, + { file: "urban/Rock band poster.glb", label: "Poster", category: "Urban Pack" }, + + { file: "urban/Bench.glb", category: "Urban Pack" }, + { file: "urban/Trash Can.glb", label: "Trash Can", category: "Urban Pack" }, + { file: "urban/trah bag grey.glb", label: "Trash Bag", category: "Urban Pack" }, + { file: "urban/Dumpster.glb", category: "Urban Pack" }, + { file: "urban/Mailbox.glb", category: "Urban Pack" }, + { file: "urban/Fire hydrant.glb", label: "Fire Hydrant", category: "Urban Pack" }, + { file: "urban/Cone.glb", label: "Traffic Cone", category: "Urban Pack" }, + { file: "urban/Box.glb", category: "Urban Pack" }, + { file: "urban/Power Box.glb", label: "Power Box", category: "Urban Pack" }, + { file: "urban/Air conditioner.glb", label: "Air Conditioner", category: "Urban Pack" }, + { file: "urban/ATM.glb", category: "Urban Pack" }, + { file: "urban/Tree.glb", category: "Urban Pack" }, + { file: "urban/Planter & Bushes.glb", label: "Planter & Bushes", category: "Urban Pack" }, + { file: "urban/Flower Pot.glb", label: "Flower Pot", category: "Urban Pack" }, + { file: "urban/Flower Pot-Kgt363WkKd.glb", label: "Flower Pot (Variant)", category: "Urban Pack" }, + { file: "urban/Fence.glb", category: "Urban Pack" }, + { file: "urban/Fence Piece.glb", label: "Fence Piece", category: "Urban Pack" }, + { file: "urban/Fence End.glb", label: "Fence End", category: "Urban Pack" }, + { file: "urban/Fire Exit.glb", label: "Fire Exit", category: "Urban Pack" }, + { file: "urban/Roof Exit.glb", label: "Roof Exit", category: "Urban Pack" }, + { file: "urban/Washing Line.glb", label: "Washing Line", category: "Urban Pack" }, + { file: "urban/Debris Papers.glb", label: "Debris", category: "Urban Pack" }, + + { file: "urban/Man.glb", category: "Urban Pack" }, + { file: "urban/Adventurer.glb", category: "Urban Pack" }, + { file: "urban/Animated Woman.glb", label: "Animated Woman", category: "Urban Pack" }, + { file: "urban/Animated Woman-nIItLV9nxS.glb", label: "Animated Woman (A)", category: "Urban Pack" }, + { file: "urban/Animated Woman-qJ2gsTUBHL.glb", label: "Animated Woman (B)", category: "Urban Pack" }, ]; export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ diff --git a/website/src/components/fpv/index.ts b/website/src/components/fpv/index.ts new file mode 100644 index 00000000..20a5e8e0 --- /dev/null +++ b/website/src/components/fpv/index.ts @@ -0,0 +1,6 @@ +export { useFpvHost } from "./useFpvHost"; +export type { UseFpvHostOptions } from "./useFpvHost"; +export { useFpvSpawn } from "./useFpvSpawn"; +export type { UseFpvSpawnOptions } from "./useFpvSpawn"; +export { useFpvCull } from "./useFpvCull"; +export type { FpvCullItem, UseFpvCullOptions } from "./useFpvCull"; diff --git a/website/src/components/fpv/useFpvCull.ts b/website/src/components/fpv/useFpvCull.ts new file mode 100644 index 00000000..3f3d6f53 --- /dev/null +++ b/website/src/components/fpv/useFpvCull.ts @@ -0,0 +1,132 @@ +/** + * Distance-based mesh culling for FPV mode. + * + * In FPV the player typically only sees a small slice of a large scene, + * so mounting every mesh's polygons (one DOM node per polygon) wastes + * paint/layout work on geometry the player can't see. This hook + * subscribes to PolyFirstPersonControls' `change` event, throttles + * updates to camera moves bigger than a step threshold, and returns the + * set of item IDs within `renderDistance` world units of the camera origin. + * + * Outside FPV the hook returns `null` (no culling — render everything). + * Callers should treat null as "all items visible". + * + * The selected item is always included so the gizmo doesn't snap off + * the camera while editing a distant placement. + */ +import { useEffect, useRef, useState, type RefObject } from "react"; +import type { PolyFirstPersonControlsHandle } from "@layoutit/polycss-react"; + +export interface FpvCullItem { + id: string; + worldX: number; + worldY: number; +} + +export interface UseFpvCullOptions { + /** Imperative handle ref for the FPV controls (exposed via `ref` + * on ``). */ + controlsRef: RefObject; + /** Currently placed items — each must have a stable `id` and world + * XY coordinates (Z is irrelevant for floor-plane culling). */ + items: FpvCullItem[]; + /** Max render distance in world units. Items farther than this from + * the camera origin are excluded. */ + renderDistance: number; + /** Only cull when this is `true` — typically when `dragMode === "fpv"`. */ + enabled: boolean; + /** Always include this item in the visible set (the gizmo target, + * for example) so distant edits don't blink out from under the user. */ + alwaysIncludeId?: string | null; + /** Recompute when the camera origin has moved this many world units + * since the last update. Default 2. Larger = fewer updates, more + * flicker risk near the boundary. */ + stepThreshold?: number; +} + +export function useFpvCull({ + controlsRef, + items, + renderDistance, + enabled, + alwaysIncludeId, + stepThreshold = 2, +}: UseFpvCullOptions): Set | null { + // null sentinel = no culling (render everything). A Set means we're in + // FPV and the contents are the visible item IDs. + const [visible, setVisible] = useState | null>(null); + + // Items + alwaysIncludeId via ref so the change listener (attached + // once per FPV engagement) always reads the latest without re-binding + // and losing the throttle baseline on every render. + const itemsRef = useRef(items); + itemsRef.current = items; + const alwaysIncludeRef = useRef(alwaysIncludeId); + alwaysIncludeRef.current = alwaysIncludeId; + + useEffect(() => { + if (!enabled) { + setVisible(null); + return; + } + const ctrl = controlsRef.current; + if (!ctrl) { + setVisible(null); + return; + } + + const r2 = renderDistance * renderDistance; + const stepSq = stepThreshold * stepThreshold; + let lastOrigin = ctrl.getOrigin(); + + const compute = (): Set => { + const [ox, oy] = lastOrigin; + const next = new Set(); + for (const it of itemsRef.current) { + const dx = it.worldX - ox; + const dy = it.worldY - oy; + if (dx * dx + dy * dy <= r2) next.add(it.id); + } + const pinned = alwaysIncludeRef.current; + if (pinned) next.add(pinned); + return next; + }; + + setVisible(compute()); + + const onChange = (): void => { + const origin = ctrl.getOrigin(); + const dx = origin[0] - lastOrigin[0]; + const dy = origin[1] - lastOrigin[1]; + if (dx * dx + dy * dy < stepSq) return; + lastOrigin = origin; + setVisible(compute()); + }; + ctrl.addEventListener("change", onChange); + return () => { + ctrl.removeEventListener("change", onChange); + }; + }, [controlsRef, enabled, renderDistance, stepThreshold]); + + // Re-run compute when items change (placement add/remove/drag) so + // the visible set stays accurate without waiting for the next camera + // move. We keep the same baseline `lastOrigin` (held inside the effect + // closure above) by reading the controls' current origin directly. + useEffect(() => { + if (!enabled) return; + const ctrl = controlsRef.current; + if (!ctrl) return; + const [ox, oy] = ctrl.getOrigin(); + const r2 = renderDistance * renderDistance; + const next = new Set(); + for (const it of items) { + const dx = it.worldX - ox; + const dy = it.worldY - oy; + if (dx * dx + dy * dy <= r2) next.add(it.id); + } + if (alwaysIncludeId) next.add(alwaysIncludeId); + setVisible(next); + }, [controlsRef, enabled, items, renderDistance, alwaysIncludeId]); + + return visible; +} diff --git a/website/src/components/fpv/useFpvHost.ts b/website/src/components/fpv/useFpvHost.ts new file mode 100644 index 00000000..8974d6d9 --- /dev/null +++ b/website/src/components/fpv/useFpvHost.ts @@ -0,0 +1,40 @@ +/** + * Single-stop FPV side effect for a workbench root. + * + * Runs `useFpvSpawn`: when `dragMode` flips to `"fpv"`, places the camera + * origin one mesh-span behind the scene bbox along the current look + * direction, sets eyeHeight proportional to the model, and flips + * `autoCenter` off. Restores prior values when leaving FPV. + * + * The perspective context FPV needs is owned by the library: + * `PolyFirstPersonControls` toggles `.polycss-fpv-host` on its host + * element on attach (see `polycss/src/styles/styles.ts`), so pages + * don't need to provide perspective CSS. + */ +import type { Polygon } from "@layoutit/polycss-react"; +import type { SceneOptionsState } from "../types"; +import { useFpvSpawn } from "./useFpvSpawn"; + +export interface UseFpvHostOptions { + dragMode: SceneOptionsState["dragMode"]; + autoCenter: boolean; + perspective: number | false; + rotY: number; + /** World-space polygons used to compute the spawn bbox. Caller is + * responsible for applying per-mesh transforms (position/scale) before + * passing in — builder flattens its placed items, gallery uses the + * scene polygons directly. */ + scenePolygons: Polygon[]; + updateScene: (partial: Partial) => void; +} + +export function useFpvHost(options: UseFpvHostOptions): void { + useFpvSpawn({ + dragMode: options.dragMode, + autoCenter: options.autoCenter, + perspective: options.perspective, + rotY: options.rotY, + scenePolygons: options.scenePolygons, + updateScene: options.updateScene, + }); +} diff --git a/website/src/components/GalleryWorkbench/hooks/useFpvSpawn.ts b/website/src/components/fpv/useFpvSpawn.ts similarity index 98% rename from website/src/components/GalleryWorkbench/hooks/useFpvSpawn.ts rename to website/src/components/fpv/useFpvSpawn.ts index e586e621..9663d495 100644 --- a/website/src/components/GalleryWorkbench/hooks/useFpvSpawn.ts +++ b/website/src/components/fpv/useFpvSpawn.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; import type { Polygon } from "@layoutit/polycss-react"; -import type { SceneOptionsState } from "../../types"; +import type { SceneOptionsState } from "../types"; export interface UseFpvSpawnOptions { dragMode: SceneOptionsState["dragMode"]; diff --git a/website/src/components/types.ts b/website/src/components/types.ts index 08386079..bec4f649 100644 --- a/website/src/components/types.ts +++ b/website/src/components/types.ts @@ -21,6 +21,7 @@ export interface DomMetrics { rects: number; triangles: number; irregular: number; + overpaintPercent: number; } export interface SceneOptionsState { @@ -68,4 +69,14 @@ export interface SceneOptionsState { fpvCrouchHeight: number; fpvLookSensitivity: number; fpvInvertY: boolean; + /** Distance-based mesh culling in FPV mode. Meshes farther than this + * many world units from the camera origin are unmounted. 0 disables + * culling (render everything). */ + fpvRenderDistance: number; + /** Builder placement: snap the ghost to the floor grid before + * committing. */ + snapToGrid: boolean; + /** Grid resolution in world units (cell side length). Drives both + * the rendered grid and snap-to-grid rounding. */ + gridResolution: number; } diff --git a/website/src/pages/builder.astro b/website/src/pages/builder.astro new file mode 100644 index 00000000..74c7924d --- /dev/null +++ b/website/src/pages/builder.astro @@ -0,0 +1,51 @@ +--- +import { BuilderWorkbench } from '../components/BuilderWorkbench'; +import DocsHeader from '../components/DocsHeader.astro'; +import '../styles/custom.css'; +--- + + + + Builder + + +
+ +
+ +
+
+ + +