Skip to content
This repository was archived by the owner on May 19, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
69923c2
refactor(website): split DebugWorkbench into GalleryWorkbench tree + …
apresmoi May 16, 2026
2e8f8e7
feat(website): make FPV depth motion visible via host perspective con…
apresmoi May 16, 2026
79646aa
feat(polycss): three.js-style FPV with separate cameraOrigin
apresmoi May 16, 2026
bb627b4
fix(website): host perspective tracks scene perspective in FPV mode
apresmoi May 16, 2026
a0cf671
feat(react,vue): mirror PolyFirstPersonControls
apresmoi May 16, 2026
191d332
test: pin FPV origin/target identity across vanilla, react, vue
apresmoi May 16, 2026
f88372c
feat(website): add /builder page — multi-mesh composer
apresmoi May 16, 2026
4b9473f
fix(builder): normalize mesh bbox + world-XY grid placement
apresmoi May 16, 2026
d373b6c
feat(builder): split panels, gizmo toggle, scale slider, camera modes
apresmoi May 16, 2026
5dca38e
fix(builder): scale gizmo to match the selected mesh's scale
apresmoi May 16, 2026
2025f86
fix(builder): textures were loading but rendered too small to see
apresmoi May 16, 2026
4340344
fix(builder): hide Starlight global search on the builder page
apresmoi May 16, 2026
b21c565
feat(builder): adopt full gallery Dock + scene outliner on top
apresmoi May 16, 2026
7f7b11b
feat(builder): merge Scene outliner into Dock as a topSlot
apresmoi May 16, 2026
40cfe6c
feat(controls): planar handles, donut rings, quaternion rotation, bbo…
apresmoi May 16, 2026
66ec731
feat(builder): scene folder + per-mesh ground/shadow/scale/list wiring
apresmoi May 16, 2026
7d25c68
fix(builder): adopt renamed interiorFillPolygons API from main
apresmoi May 16, 2026
851dbdd
fix(builder): unwrap PolyOrbitControls/PolyMapControls camera-snapsho…
apresmoi May 16, 2026
ca3e130
feat(controls): place plane handles in the camera-facing octant
apresmoi May 16, 2026
a2973dd
feat(dock): lil-gui hook primitives (useGui/useFolder/useToggle/useSl…
apresmoi May 16, 2026
216b49a
feat(dock): lil-gui hook primitives + per-folder hooks (Model/Renderi…
apresmoi May 16, 2026
e43b43c
refactor(dock): collapse Dock.tsx into thin shell composing folder hooks
apresmoi May 16, 2026
24e6516
Merge remote-tracking branch 'origin/main' into refactor/gallery-work…
apresmoi May 16, 2026
aa3dc3a
Merge branch 'feat/builder-page' into refactor/gallery-workbench-and-…
apresmoi May 17, 2026
49e0689
refactor(dock): slot components — Dock as container + DockModel/Rende…
apresmoi May 17, 2026
a49accc
feat(fpv): useFpvHost — shared spawn + perspective glue for gallery a…
apresmoi May 17, 2026
72a0bbb
fix(fpv): force perspective camera on FPV entry — orthographic has no…
apresmoi May 17, 2026
357c56e
revert(fpv): don't auto-flip perspective on FPV entry — keep user's c…
apresmoi May 17, 2026
54144d2
fix(fpv): extend perspective override to .polycss-camera so React/bui…
apresmoi May 17, 2026
d5b97b9
feat(fpv): library owns the perspective host context via .polycss-fpv…
apresmoi May 17, 2026
db6f955
fix(fpv): add .polycss-fpv-host CSS rule to react + vue style mirrors
apresmoi May 17, 2026
b6beaf2
chore(fpv): drop dead gallery CSS comment + data-camera-mode binding
apresmoi May 17, 2026
011f21a
feat(builder): Medieval Village scene preset + 39 medieval model presets
apresmoi May 17, 2026
20ce95f
feat(builder): distance-based FPV mesh culling — unmount meshes beyon…
apresmoi May 17, 2026
837e448
feat(fpv): expose render distance as a dock slider in the Camera/FPV …
apresmoi May 17, 2026
7cfb0a8
feat(builder): lazy-load scene-preset items by proximity (render dist…
apresmoi May 17, 2026
9b0cb73
feat(builder): City Block scene preset + 31 city kit model presets
apresmoi May 17, 2026
cc8c176
feat(builder): City Street scene + 57 urban-pack model presets (roads…
apresmoi May 17, 2026
74ad0ab
feat(builder): City Roads scene — drop buildings, road grid + trees o…
apresmoi May 17, 2026
9b1b552
feat(builder): wireframe floor grid (5-unit spacing, gated on showGro…
apresmoi May 17, 2026
10e0a9f
refactor(builder): sidebar shows only kits — City Kit / Urban Pack / …
apresmoi May 17, 2026
fa1ebcf
feat(builder): click-to-place — cursor-tracking ghost wireframe on th…
apresmoi May 17, 2026
20c997a
Merge branch 'worktree-agent-a6c025502d56453ef' into refactor/gallery…
apresmoi May 17, 2026
334285c
feat(builder): ground + grid visible by default
apresmoi May 17, 2026
2ac0b3d
fix(builder): placement uses viewport DOM events — drop opaque-white …
apresmoi May 17, 2026
f196480
feat(builder): wireframe floor + visible ghost bbox + snap-to-grid do…
apresmoi May 17, 2026
01e45ae
fix(builder): ghost wireframe — all 12 bbox edges as axis-aligned sla…
apresmoi May 17, 2026
befe240
fix(builder): ghost wireframe — !important to beat polycss's runtime-…
apresmoi May 17, 2026
3b7b794
fix(builder): ghost is 6 translucent bbox faces — polycss rejects thi…
apresmoi May 17, 2026
d8df791
fix(builder): ghost bbox uses planePolygons vertex ordering (the gizm…
apresmoi May 17, 2026
51faad9
fix(builder): ghost bbox in world coords — no position/scale wrapping…
apresmoi May 17, 2026
a60875b
fix(builder): ghost bbox as 12 triangles, opaque cyan — triangles ren…
apresmoi May 17, 2026
98dd747
fix(builder): ghost bbox uses axisBox winding — outward normals so ba…
apresmoi May 17, 2026
0205cd4
fix(builder): wireframe ghost (12 edge sticks); drop CSS opacity that…
apresmoi May 17, 2026
da07eb1
feat(builder): dotted ghost wireframe — each bbox edge is a run of sh…
apresmoi May 17, 2026
8a3ba22
feat(builder): floating Orbit/Pan camera-mode pill + Cmd-hold to temp…
apresmoi May 17, 2026
c5fcf6c
chore(website): disable Astro dev toolbar
apresmoi May 17, 2026
8ea2bae
refactor(builder): decompose BuilderWorkbench into hooks + components…
apresmoi May 17, 2026
c93b201
refactor(builder): remove top-level ghost/grid/screenToWorld/DockGrid…
apresmoi May 17, 2026
230410b
Merge branch 'worktree-agent-aa366652fcc7e0e49' into refactor/gallery…
apresmoi May 17, 2026
d74a0f3
chore(builder): remove stale top-level files duplicated by the refact…
apresmoi May 17, 2026
1f3cb0b
feat(builder): terrain editor — Raise/Lower/Smooth with hover ghost a…
apresmoi May 17, 2026
f66ee16
chore: untrack local log + baseline-shots dirs (added by mistake in p…
apresmoi May 17, 2026
bdb81f2
feat(builder): terrain editor warps the grid — vertex-based heightmap…
apresmoi May 17, 2026
fb90680
fix(builder): capture-phase listeners skip clicks on tool palette / c…
apresmoi May 17, 2026
2629193
fix(builder): terrain — drag-vs-click guard + triangulate cells (plan…
apresmoi May 17, 2026
86dab25
fix(builder): keep terrain pointerdown coords in a useRef so effect r…
apresmoi May 17, 2026
9bb1416
feat(builder): placements snap to terrain surface + tilt to local slope
apresmoi May 17, 2026
3d43c52
fix(builder): placeMeshOnFloor surface lift sign — meshes land ON the…
apresmoi May 17, 2026
7a8f5be
feat(builder): unify floor grid with terrain — gridlines bend at rais…
apresmoi May 17, 2026
0aec1e4
feat(builder): vertex vs face target toggle — default face; raise/low…
apresmoi May 17, 2026
00a7cb7
feat(builder): ghost wireframe tilts to match slope — bake rotation a…
apresmoi May 17, 2026
bb4bd81
feat(builder): items follow terrain edits with stable slope tilt
apresmoi May 17, 2026
0a26929
Merge origin/main: voxel backface culling
apresmoi May 17, 2026
81dfc46
feat(builder): items stay anchored to floor on scale + gizmo translate
apresmoi May 17, 2026
0e35c6a
feat(builder): drop Model folder from dock
apresmoi May 17, 2026
f66e662
feat(docs): add Builder link to top nav
apresmoi May 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
10 changes: 9 additions & 1 deletion packages/core/src/helpers/arrowPolygons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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,
];
}
4 changes: 4 additions & 0 deletions packages/core/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
64 changes: 64 additions & 0 deletions packages/core/src/helpers/planePolygons.ts
Original file line number Diff line number Diff line change
@@ -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
* `<PolyTransformControls>` — 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,
},
];
}
49 changes: 49 additions & 0 deletions packages/core/src/helpers/ringQuadPolygons.ts
Original file line number Diff line number Diff line change
@@ -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 `<PolyTransformControls
* mode="rotate">` 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,
},
];
}
12 changes: 10 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
99 changes: 99 additions & 0 deletions packages/core/src/math/quaternion.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading
Loading