diff --git a/AGENTS.md b/AGENTS.md index 93ba08ac..8ff5d619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,16 @@ Monorepo layout (pnpm workspaces): Public API is **mirrored** across React and Vue. Adding a hook on one side without adding the matching composable on the other is not acceptable (see "Cross-package discipline" below). +### Three-like parity surface + +The native PolyCSS API keeps PolyCSS world and camera conventions. For agent-friendly Three.js ports, the monorepo also exposes explicit `*/three` subpaths: + +- `@layoutit/polycss-core/three` — pure Three-like math wrappers, camera conversion, lights, and transforms. +- `@layoutit/polycss/three` — the core Three-like surface plus vanilla scene helpers. +- `@layoutit/polycss-react/three` and `@layoutit/polycss-vue/three` — mirrored framework components: `PolyThreePerspectiveCamera`, `PolyThreeOrthographicCamera`, and `PolyThreeMesh`. + +These subpaths intentionally use Three-compatible public names and units: `Vector3`, `Euler`, `Object3D`, `PerspectiveCamera`, `OrthographicCamera`, `DirectionalLight`, `PointLight`, `AmbientLight`, radians for object rotations, Y-up authoring coordinates, and `camera.position` + `camera.lookAt(...)` framing. They are adapters over PolyCSS, not a Three.js runtime dependency. Geometry authored in that surface is converted to native PolyCSS coordinates with `transformPolygonsToPoly`; the Y-up → Z-up axis map is `[x, -z, y]` so winding and Lambert lighting stay right-handed. The vanilla `mountPolyThreeScene` helper defaults `textureLighting` to `"baked"` because baked Lambert is the Three-parity baseline. Dynamic lighting remains available as an explicit opt-in for live CSS light changes, but it is not the exact conformance mode. + ## Rendering model — the mental model **One visible `Polygon` → one leaf DOM element.** Leaves use canonical CSS primitives where possible and move scale into `matrix3d`; clipped solids use fixed primitives because their paint geometry becomes unstable when collapsed to 1px. Atlas-backed textured polygons pack their local-2D bounding rect (`canvasW × canvasH`) into atlas pages; source-exact textured polygons may instead carry `textureImageSource` + `texturePresentation.backend="image"` and render as direct image leaves without atlas rasterisation. The HTML tag *is* the render strategy — the renderer picks one tag per polygon based on its shape and material. @@ -57,7 +67,7 @@ The `.vox` fast path emits plain `` elements inside `.polycss-voxel-face` wra ### Lights -The scene takes one `directionalLight`, one `ambientLight`, and zero or more `pointLights` (`PolyPointLight[]`). Point lights are **direction-only** — no distance falloff. Per polygon the contribution is `color · intensity · max(0, n · L̂)`, where `L̂` is the unit direction from the surface to the light position; multiple colored lights accumulate per-channel alongside the directional + ambient terms. This deliberately omits CSS gradients: point lights shade flat-per-face (an accepted approximation vs three.js's per-fragment `PointLight(distance:0, decay:0)`; exact for small faces / distant lights). Point lights are **baked-mode only** — the dynamic mode's zero-JS light move can't express a per-face direction that varies with position, so dynamic scenes ignore `pointLights` entirely: not for surface shading, and not for shadows. (A point light casting a shadow onto a floor those same lights never lit would read as broken, so dynamic shadows are directional-only — see the lighting modes below.) +The scene takes one `directionalLight`, one `ambientLight`, and zero or more `pointLights` (`PolyPointLight[]`). Directional light `direction` is the unit source vector from the surface toward the distant light. Point lights are **direction-only** — no distance falloff. Per polygon the contribution is `color · intensity · max(0, n · L̂)`, where `L̂` is the unit direction from the surface to the light position; multiple colored lights accumulate per-channel alongside the directional + ambient terms. This deliberately omits CSS gradients: point lights shade flat-per-face (an accepted approximation vs three.js's per-fragment `PointLight(distance:0, decay:0)`; exact for small faces / distant lights). Point lights are **baked-mode only** — the dynamic mode's zero-JS light move can't express a per-face direction that varies with position, so dynamic scenes ignore `pointLights` entirely: not for surface shading, and not for shadows. (A point light casting a shadow onto a floor those same lights never lit would read as broken, so dynamic shadows are directional-only — see the lighting modes below.) ### Lighting modes (`PolyTextureLightingMode = "baked" | "dynamic"`) @@ -98,9 +108,9 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n ## Naming (three.js parity) - Brand text is **PolyCSS**. Keep lowercase `polycss` only for literal package names, import paths, CSS classes, domains, and other code identifiers. -- Every public export gets a `Poly` prefix. Exceptions are generic math types: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial` (already prefixed). +- Every public export gets a `Poly` prefix. Exceptions are generic math types (`Vec2`, `Vec3`, `Polygon`, `PolyMaterial`) and the explicit `*/three` compatibility subpaths, where Three-compatible names are the point of the API. React/Vue components in those subpaths still use the `PolyThree` prefix. - **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`. -- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`, `PolyIframe`. +- **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`, `PolyIframe`, `PolyThreePerspectiveCamera`, `PolyThreeOrthographicCamera`, `PolyThreeMesh`. - **Types:** `PolyDirectionalLight`, `PolyPointLight`, `PolyAmbientLight`, `PolyTextureLightingMode`, `PolyTextureLeafSizing`, `PolyTextureBackend`, `PolyTextureImageRendering`, `PolyTextureImageLighting`, `PolyTextureProjection`, `PolyTexturePresentation`, `PolyTextureImageSource`, `PolyCameraProjection`, `PolyCameraSnapshot`, `PolyCameraSnapshotStats`, `PolyMeshTransformInput`, `PolySceneTransformInput`, `PolyAnimationMixer`, `PolyRenderStats`. - **Functions:** `findPolyMeshHandle`, `injectPolyBaseStyles`, `collectPolyRenderStats`, `collectPolyTextureReadiness`, `queryPolyLeaves`, `resolvePolyTextureLeafGeometry`, `resolvePolyTextureImageSource`, `resolvePolyTexturePresentation`, `resolvePolyTextureImageRendering`, `buildPolyCameraSceneTransform`, `buildPolyMeshTransform`, `buildPolySceneTransform`, `capturePolyCameraSnapshot`, `polyCameraTargetToCss`, `resolvePolyCameraAppliedPerspectiveStyle`, `worldPositionToCss`, `worldPositionToPolyCss`, `cssPositionToWorld`, `polyCssPositionToWorld`, `worldDistanceToCss`, `worldDistanceToPolyCss`, `cssDistanceToWorld`, `polyCssDistanceToWorld`, `worldDirectionToCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToCss`, `worldDirectionalLightToPolyCss`, `exportPolySceneSnapshot`. - **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`). diff --git a/README.md b/README.md index a9544a45..9e379187 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,54 @@ export default function App() { } ``` +## Three.js Parity API + +When porting Three.js scenes or generating code with an agent, use the explicit +`*/three` subpaths: + +- `@layoutit/polycss-core/three` +- `@layoutit/polycss/three` +- `@layoutit/polycss-react/three` +- `@layoutit/polycss-vue/three` + +They expose Three-like `PerspectiveCamera`, `OrthographicCamera`, `Object3D`, +`Vector3`, `DirectionalLight`, `PointLight`, `AmbientLight`, radians for object +rotations, Y-up authoring coordinates, and `camera.position` + `camera.lookAt(...)` +framing. The adapters convert into native PolyCSS coordinates with a right-handed +axis map, so the apparent object size, projection, orientation, depth ordering, +and light direction line up with Three.js scene math while still rendering +through the DOM. + +```tsx +import { PolyScene } from "@layoutit/polycss-react"; +import { + DirectionalLight, + PolyThreeMesh, + PolyThreePerspectiveCamera, +} from "@layoutit/polycss-react/three"; + +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); + +export function App() { + return ( + + + + + + ); +} +``` + +Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-parity). + ## API Reference ### PolyCamera diff --git a/bench/build.mjs b/bench/build.mjs index 9aff81f6..5a497a17 100644 --- a/bench/build.mjs +++ b/bench/build.mjs @@ -44,10 +44,13 @@ const bundleDir = resolve(__dirname, ".generated"); const ALIASES = { "@layoutit/polycss-core": resolve(repoRoot, "packages/core/src/index.ts"), + "@layoutit/polycss-core/three": resolve(repoRoot, "packages/core/src/three/index.ts"), "@layoutit/polycss": resolve(repoRoot, "packages/polycss/src/index.ts"), "@layoutit/polycss/elements": resolve(repoRoot, "packages/polycss/src/elements/index.ts"), + "@layoutit/polycss/three": resolve(repoRoot, "packages/polycss/src/three.ts"), "@layoutit/polycss-react": resolve(repoRoot, "packages/react/src/index.ts"), "@layoutit/polycss-vue": resolve(repoRoot, "packages/vue/src/index.ts"), + "three": resolve(repoRoot, "node_modules/three/build/three.module.js"), // Pin React + ReactDOM to the workspace-root copies so the alias-resolved // @layoutit/polycss-react source AND the bench entry import the SAME instance. // Without this, esbuild treats two `react` imports starting from different @@ -135,6 +138,11 @@ const targets = [ entry: resolve(__dirname, "entries/atlasBackground.ts"), out: resolve(bundleDir, "polycss-atlas-background.js"), }, + { + label: "three parity dashboard", + entry: resolve(__dirname, "entries/threeParityDashboard.ts"), + out: resolve(bundleDir, "polycss-three-parity-dashboard.js"), + }, ]; const t0 = performance.now(); diff --git a/bench/entries/threeParityDashboard.ts b/bench/entries/threeParityDashboard.ts new file mode 100644 index 00000000..7ee0e6b4 --- /dev/null +++ b/bench/entries/threeParityDashboard.ts @@ -0,0 +1,936 @@ +import * as THREE from "three"; +import { + boxPolygons, + createPolyOrthographicCamera, + createPolyPerspectiveCamera, + createPolyScene, + dodecahedronPolygons, + icosahedronPolygons, + loadMesh, +} from "@layoutit/polycss"; +import type { + ParseResult, + Polygon, + Vec3, +} from "@layoutit/polycss"; +import { + AmbientLight, + DirectionalLight, + OrthographicCamera as PolyThreeOrthographicCamera, + PerspectiveCamera as PolyThreePerspectiveCamera, + Vector3, + createPolyOrthographicCameraFromThree, + createPolyPerspectiveCameraFromThree, + mountPolyThreeScene, + polyToThreePoint, + threeToPolyPoint, +} from "@layoutit/polycss/three"; + +type CameraMode = "perspective" | "orthographic" | "fpv"; +type ObjectKey = "cube" | "icosahedron" | "dodecahedron" | "cactus" | "box" | "car"; +type SceneKey = "single" | "lineup" | "occlusion"; +type ViewKey = "iso" | "front" | "side" | "top"; +type LightingMode = "baked" | "dynamic"; +type ThreePolygon = Omit & { vertices: Vec3[] }; +type BBox2 = { x: number; y: number; width: number; height: number; count: number }; + +const state = { + object: "cube" as ObjectKey, + scene: "single" as SceneKey, + camera: "perspective" as CameraMode, + view: "iso" as ViewKey, + fov: 42, + orthoSize: 7, + zoom: 1, + lighting: "baked" as LightingMode, + yaw: 35, + pitch: 24, +}; + +const objectCache = new Map>(); +let renderToken = 0; +let renderer: THREE.WebGLRenderer | null = null; +let polyScenes: Array<{ destroy(): void }> = []; +let activeCodeTab: "three" | "adapter" | "native" = "three"; +let lastCode = { three: "", adapter: "", native: "" }; + +const els = { + object: byId("object"), + scene: byId("scene"), + camera: byId("camera"), + view: byId("view"), + lighting: byId("lighting"), + fov: byId("fov"), + orthoSize: byId("ortho-size"), + zoom: byId("zoom"), + fovValue: byId("fov-value"), + sizeValue: byId("size-value"), + zoomValue: byId("zoom-value"), + reset: byId("reset"), + threeSurface: byId("three-surface"), + adapterHost: byId("adapter-host"), + nativeHost: byId("native-host"), + metricThree: byId("metric-three"), + metricAdapter: byId("metric-adapter"), + metricNative: byId("metric-native"), + metricDelta: byId("metric-delta"), + code: byId("code"), +}; + +const objectLabels: Record = { + cube: "colored cube", + icosahedron: "icosahedron", + dodecahedron: "dodecahedron", + cactus: "gallery cactus", + box: "gallery box", + car: "gallery car", +}; + +function byId(id: string): T { + const el = document.getElementById(id); + if (!el) throw new Error(`Missing #${id}`); + return el as T; +} + +function parseResult(polygons: Polygon[]): ParseResult { + return { polygons, objectUrls: [], warnings: [], dispose() {} }; +} + +function polyPolygonsToThree(polygons: Polygon[]): ThreePolygon[] { + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map((vertex) => polyToThreePoint(vertex).toArray() as Vec3), + textureTriangles: undefined, + uvs: undefined, + texture: undefined, + material: undefined, + })); +} + +function threePolygonsToPoly(polygons: ThreePolygon[]): Polygon[] { + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map((vertex) => threeToPolyPoint(vertex as Vec3)), + textureTriangles: undefined, + uvs: undefined, + texture: undefined, + material: undefined, + })); +} + +function normalizeThreePolygons(polygons: ThreePolygon[], targetSize = 2.4): ThreePolygon[] { + const bounds = bounds3(polygons); + const span = [ + bounds.max[0] - bounds.min[0], + bounds.max[1] - bounds.min[1], + bounds.max[2] - bounds.min[2], + ]; + const maxSpan = Math.max(span[0], span[1], span[2], 0.0001); + const scale = targetSize / maxSpan; + const centerX = (bounds.min[0] + bounds.max[0]) / 2; + const centerZ = (bounds.min[2] + bounds.max[2]) / 2; + const minY = bounds.min[1]; + + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map((vertex) => [ + (vertex[0] - centerX) * scale, + (vertex[1] - minY) * scale, + (vertex[2] - centerZ) * scale, + ]), + })); +} + +function bounds3(polygons: Pick[]) { + const min: Vec3 = [Infinity, Infinity, Infinity]; + const max: Vec3 = [-Infinity, -Infinity, -Infinity]; + for (const polygon of polygons) { + for (const vertex of polygon.vertices) { + min[0] = Math.min(min[0], vertex[0]); + min[1] = Math.min(min[1], vertex[1]); + min[2] = Math.min(min[2], vertex[2]); + max[0] = Math.max(max[0], vertex[0]); + max[1] = Math.max(max[1], vertex[1]); + max[2] = Math.max(max[2], vertex[2]); + } + } + return { min, max }; +} + +function transformThreePolygons( + polygons: ThreePolygon[], + transform: { position?: Vec3; scale?: number; rotationY?: number }, +): ThreePolygon[] { + const matrix = new THREE.Matrix4(); + matrix.compose( + new THREE.Vector3(...(transform.position ?? [0, 0, 0])), + new THREE.Quaternion().setFromEuler(new THREE.Euler(0, transform.rotationY ?? 0, 0)), + new THREE.Vector3(transform.scale ?? 1, transform.scale ?? 1, transform.scale ?? 1), + ); + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map((vertex) => { + const v = new THREE.Vector3(vertex[0], vertex[1], vertex[2]).applyMatrix4(matrix); + return [v.x, v.y, v.z] as Vec3; + }), + })); +} + +function floorPolygon(): ThreePolygon { + return { + color: "#d8dee8", + vertices: [ + [-4.2, 0, -4.2], + [-4.2, 0, 4.2], + [4.2, 0, 4.2], + [4.2, 0, -4.2], + ], + }; +} + +async function objectPolygons(key: ObjectKey): Promise { + if (!objectCache.has(key)) { + objectCache.set(key, loadObjectPolygons(key)); + } + return objectCache.get(key)!; +} + +async function loadObjectPolygons(key: ObjectKey): Promise { + if (key === "cube") { + return normalizeThreePolygons(polyPolygonsToThree(boxPolygons({ + size: 2, + faces: { + right: { color: "#ef4444" }, + left: { color: "#b91c1c" }, + front: { color: "#f59e0b" }, + back: { color: "#2563eb" }, + top: { color: "#f8fafc" }, + bottom: { color: "#64748b" }, + }, + })), 2.15); + } + if (key === "icosahedron") { + return normalizeThreePolygons(polyPolygonsToThree(icosahedronPolygons({ size: 1, color: "#38bdf8" })), 2.25); + } + if (key === "dodecahedron") { + return normalizeThreePolygons(polyPolygonsToThree(dodecahedronPolygons({ size: 1, color: "#f97316" })), 2.25); + } + + const url = { + cactus: "/gallery/glb/poly-pizza/cactus-a.glb", + box: "/gallery/glb/urban/Box.glb", + car: "/gallery/glb/urban/Car.glb", + }[key]; + const result = await loadMesh(url, { + meshResolution: "lossless", + gltfOptions: { center: true }, + solidTextureSamples: true, + }); + return normalizeThreePolygons(polyPolygonsToThree(result.polygons), key === "car" ? 3.0 : 2.45); +} + +async function scenePolygons(): Promise { + const base = await objectPolygons(state.object); + const floor = floorPolygon(); + if (state.scene === "lineup") { + return [ + floor, + ...transformThreePolygons(base, { position: [-2.25, 0, 0.2], scale: 0.72, rotationY: -0.45 }), + ...transformThreePolygons(base, { position: [0, 0, 0], scale: 0.86, rotationY: 0.1 }), + ...transformThreePolygons(base, { position: [2.25, 0, -0.15], scale: 0.72, rotationY: 0.55 }), + ]; + } + if (state.scene === "occlusion") { + return [ + floor, + ...transformThreePolygons(base, { position: [-0.72, 0, 0.78], scale: 0.95, rotationY: 0.5 }), + ...transformThreePolygons(base, { position: [0.82, 0, -0.42], scale: 0.95, rotationY: -0.35 }), + ]; + } + return [floor, ...base]; +} + +function panelSize(el: HTMLElement) { + const rect = el.getBoundingClientRect(); + return { + width: Math.max(300, Math.round(rect.width)), + height: Math.max(260, Math.round(rect.height)), + }; +} + +function cameraPosition(mode: CameraMode): { position: THREE.Vector3; target: THREE.Vector3 } { + if (mode === "fpv") { + return { + position: new THREE.Vector3(-0.4, 1.35, 5.1 / state.zoom), + target: new THREE.Vector3(0, 1.05, 0), + }; + } + const target = new THREE.Vector3(0, 1.05, 0); + const radius = 7.2 / state.zoom; + const yaw = state.yaw * Math.PI / 180; + const pitch = state.pitch * Math.PI / 180; + return { + position: new THREE.Vector3( + Math.sin(yaw) * Math.cos(pitch) * radius, + target.y + Math.sin(pitch) * radius, + Math.cos(yaw) * Math.cos(pitch) * radius, + ), + target, + }; +} + +function applyPresetView() { + const view = state.view; + if (view === "iso") { + state.yaw = 35; + state.pitch = 24; + } else if (view === "front") { + state.yaw = 0; + state.pitch = 0; + } else if (view === "side") { + state.yaw = 90; + state.pitch = 0; + } else { + state.yaw = 0; + state.pitch = 82; + } +} + +function makeThreeCamera(size: { width: number; height: number }) { + const mode = state.camera; + const { position, target } = cameraPosition(mode); + const aspect = size.width / size.height; + const camera = mode === "orthographic" + ? new THREE.OrthographicCamera( + -state.orthoSize * aspect / 2, + state.orthoSize * aspect / 2, + state.orthoSize / 2, + -state.orthoSize / 2, + 0.01, + 200, + ) + : new THREE.PerspectiveCamera(state.fov, aspect, 0.01, 200); + camera.position.copy(position); + camera.lookAt(target); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(); + return camera; +} + +function makeCompatCamera(size: { width: number; height: number }) { + const mode = state.camera; + const { position, target } = cameraPosition(mode); + const aspect = size.width / size.height; + if (mode === "orthographic") { + const camera = new PolyThreeOrthographicCamera( + -state.orthoSize * aspect / 2, + state.orthoSize * aspect / 2, + state.orthoSize / 2, + -state.orthoSize / 2, + 0.01, + 200, + ); + camera.position.set(position.x, position.y, position.z); + camera.lookAt(new Vector3(target.x, target.y, target.z)); + return camera; + } + const camera = new PolyThreePerspectiveCamera(state.fov, aspect, 0.01, 200); + camera.position.set(position.x, position.y, position.z); + camera.lookAt(new Vector3(target.x, target.y, target.z)); + return camera; +} + +function makeLights() { + const ambient = new AmbientLight("#ffffff", 0.48); + const directional = new DirectionalLight("#ffffff", 0.82); + directional.position.set(3.4, 5.2, 4.1); + directional.target.position.set(0, 0.8, 0); + return { ambient, directional }; +} + +function renderThree(polygons: ThreePolygon[], camera: THREE.Camera, size: { width: number; height: number }) { + renderer?.dispose(); + els.threeSurface.textContent = ""; + const canvas = document.createElement("canvas"); + els.threeSurface.appendChild(canvas); + try { + renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setSize(size.width, size.height, false); + renderer.setClearColor(0xf8fafc, 1); + + const scene = new THREE.Scene(); + scene.add(new THREE.AmbientLight("#ffffff", 0.48)); + const light = new THREE.DirectionalLight("#ffffff", 0.82); + light.position.set(3.4, 5.2, 4.1); + light.target.position.set(0, 0.8, 0); + scene.add(light); + scene.add(light.target); + + scene.add(meshFromPolygons(polygons)); + renderer.render(scene, camera); + } catch (err) { + renderer = null; + renderThreeCanvasFallback(canvas, polygons, camera, size); + console.warn("Three.js WebGL unavailable; dashboard used a 2D projected fallback for this panel.", err); + } +} + +function meshFromPolygons(polygons: ThreePolygon[]) { + const positions: number[] = []; + const colors: number[] = []; + for (const polygon of polygons) { + if (polygon.vertices.length < 3) continue; + const color = new THREE.Color(polygon.color ?? "#cbd5e1"); + for (let i = 1; i < polygon.vertices.length - 1; i++) { + for (const vertex of [polygon.vertices[0], polygon.vertices[i], polygon.vertices[i + 1]]) { + positions.push(vertex[0], vertex[1], vertex[2]); + colors.push(color.r, color.g, color.b); + } + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); + geometry.computeVertexNormals(); + return new THREE.Mesh( + geometry, + new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.FrontSide }), + ); +} + +function renderThreeCanvasFallback( + canvas: HTMLCanvasElement, + polygons: ThreePolygon[], + camera: THREE.Camera, + size: { width: number; height: number }, +) { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(size.width * dpr); + canvas.height = Math.round(size.height * dpr); + canvas.style.width = "100%"; + canvas.style.height = "100%"; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.scale(dpr, dpr); + ctx.fillStyle = "#f8fafc"; + ctx.fillRect(0, 0, size.width, size.height); + + const light = new THREE.Vector3(3.4, 5.2, 4.1).sub(new THREE.Vector3(0, 0.8, 0)).normalize(); + const drawables = polygons + .filter((polygon) => polygon.vertices.length >= 3) + .map((polygon) => { + const pts = polygon.vertices.map((vertex) => { + const world = new THREE.Vector3(vertex[0], vertex[1], vertex[2]); + const ndc = world.clone().project(camera); + const view = world.clone().applyMatrix4(camera.matrixWorldInverse); + return { + x: (ndc.x * 0.5 + 0.5) * size.width, + y: (-ndc.y * 0.5 + 0.5) * size.height, + z: view.z, + }; + }); + return { + polygon, + pts, + z: pts.reduce((sum, pt) => sum + pt.z, 0) / pts.length, + }; + }) + .sort((a, b) => a.z - b.z); + + for (const item of drawables) { + const normal = polygonNormal(item.polygon.vertices); + const direct = 0.82 * Math.max(0, normal.dot(light)); + const color = lambertSrgbColor(item.polygon.color ?? "#cbd5e1", direct, 0.48); + ctx.beginPath(); + ctx.moveTo(item.pts[0].x, item.pts[0].y); + for (let i = 1; i < item.pts.length; i++) ctx.lineTo(item.pts[i].x, item.pts[i].y); + ctx.closePath(); + ctx.fillStyle = `#${color.getHexString()}`; + ctx.fill(); + } + + ctx.fillStyle = "rgba(82, 96, 114, 0.82)"; + ctx.font = "11px ui-monospace, SFMono-Regular, Menlo, monospace"; + ctx.fillText("2D fallback: WebGL unavailable in this browser", 12, size.height - 14); +} + +function lambertSrgbColor(baseHex: string, direct: number, ambient: number) { + const base = new THREE.Color(baseHex); + const lit = new THREE.Color( + base.r * (direct + ambient) / Math.PI, + base.g * (direct + ambient) / Math.PI, + base.b * (direct + ambient) / Math.PI, + ); + return lit; +} + +function polygonNormal(vertices: Vec3[]) { + if (vertices.length < 3) return new THREE.Vector3(0, 1, 0); + const a = new THREE.Vector3(...vertices[0]); + const b = new THREE.Vector3(...vertices[1]); + const c = new THREE.Vector3(...vertices[2]); + return b.sub(a).cross(c.sub(a)).normalize(); +} + +function renderAdapter(host: HTMLElement, polygons: Polygon[], camera: ReturnType, size: { height: number }) { + const { ambient, directional } = makeLights(); + const scene = mountPolyThreeScene(host, { + camera, + cameraOptions: { viewportHeight: size.height }, + polygons, + autoCenter: false, + directionalLight: directional.toPolyDirectionalLight(), + ambientLight: ambient.toPolyAmbientLight(), + textureLighting: state.lighting, + textureQuality: 1, + }); + polyScenes.push(scene); +} + +function renderNative(host: HTMLElement, polygons: Polygon[], camera: ReturnType, size: { height: number }) { + const { ambient, directional } = makeLights(); + const nativeCamera = camera instanceof PolyThreePerspectiveCamera + ? createPolyPerspectiveCameraFromThree(camera, { viewportHeight: size.height }) + : createPolyOrthographicCameraFromThree(camera, { viewportHeight: size.height }); + const scene = createPolyScene(host, { + camera: nativeCamera, + autoCenter: false, + directionalLight: directional.toPolyDirectionalLight(), + ambientLight: ambient.toPolyAmbientLight(), + textureLighting: state.lighting, + textureQuality: 1, + }); + scene.add(parseResult(polygons), { merge: false, meshResolution: "lossless" }); + polyScenes.push(scene); +} + +function clearPolyScenes() { + for (const scene of polyScenes) scene.destroy(); + polyScenes = []; + els.adapterHost.textContent = ""; + els.nativeHost.textContent = ""; +} + +async function render() { + const token = ++renderToken; + setLoading(true); + updateLabels(); + try { + const polygons = await scenePolygons(); + if (token !== renderToken) return; + + clearPolyScenes(); + const size = panelSize(els.threeSurface); + const threeCamera = makeThreeCamera(size); + const compatCamera = makeCompatCamera(size); + const polyPolygons = threePolygonsToPoly(polygons); + + renderThree(polygons, threeCamera, size); + renderAdapter(els.adapterHost, polyPolygons, compatCamera, size); + renderNative(els.nativeHost, polyPolygons, compatCamera, size); + updateCode(); + + requestAnimationFrame(() => { + const threeBox = projectedBBox(polygons, threeCamera, size); + const adapterBox = domBBox(els.adapterHost); + const nativeBox = domBBox(els.nativeHost); + updateMetrics(threeBox, adapterBox, nativeBox); + setLoading(false); + (window as unknown as { __polyThreeParityReady?: boolean; __polyThreeParitySnapshot?: unknown }).__polyThreeParityReady = true; + (window as unknown as { __polyThreeParitySnapshot?: unknown }).__polyThreeParitySnapshot = { + state: { ...state }, + threeBox, + adapterBox, + nativeBox, + }; + }); + } catch (err) { + setLoading(false); + const message = err instanceof Error ? err.message : String(err); + els.metricDelta.textContent = `error: ${message}`; + throw err; + } +} + +function projectedBBox(polygons: ThreePolygon[], camera: THREE.Camera, size: { width: number; height: number }): BBox2 { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let count = 0; + for (const polygon of polygons) { + for (const vertex of polygon.vertices) { + const v = new THREE.Vector3(vertex[0], vertex[1], vertex[2]).project(camera); + if (!Number.isFinite(v.x) || !Number.isFinite(v.y) || !Number.isFinite(v.z)) continue; + const x = (v.x * 0.5 + 0.5) * size.width; + const y = (-v.y * 0.5 + 0.5) * size.height; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + count++; + } + } + return bboxOrEmpty(minX, minY, maxX, maxY, count); +} + +function domBBox(host: HTMLElement): BBox2 { + const root = host.getBoundingClientRect(); + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + let count = 0; + for (const el of host.querySelectorAll(".polycss-mesh *")) { + const rect = el.getBoundingClientRect(); + if (rect.width < 0.5 || rect.height < 0.5) continue; + minX = Math.min(minX, rect.left - root.left); + minY = Math.min(minY, rect.top - root.top); + maxX = Math.max(maxX, rect.right - root.left); + maxY = Math.max(maxY, rect.bottom - root.top); + count++; + } + return bboxOrEmpty(minX, minY, maxX, maxY, count); +} + +function bboxOrEmpty(minX: number, minY: number, maxX: number, maxY: number, count: number): BBox2 { + if (!count) return { x: 0, y: 0, width: 0, height: 0, count: 0 }; + return { + x: minX, + y: minY, + width: Math.max(0, maxX - minX), + height: Math.max(0, maxY - minY), + count, + }; +} + +function updateMetrics(three: BBox2, adapter: BBox2, native: BBox2) { + els.metricThree.textContent = fmtBox(three); + els.metricAdapter.textContent = fmtBox(adapter); + els.metricNative.textContent = fmtBox(native); + els.metricDelta.textContent = [ + `PolyCSS lighting: ${state.lighting}`, + `adapter vs three: ${fmtDelta(three, adapter)}`, + `native vs three: ${fmtDelta(three, native)}`, + `native vs adapter:${fmtDelta(adapter, native)}`, + ].join("\n"); +} + +function fmtBox(box: BBox2): string { + return `x ${box.x.toFixed(1)} +y ${box.y.toFixed(1)} +w ${box.width.toFixed(1)} +h ${box.height.toFixed(1)} +leaves/verts ${box.count}`; +} + +function fmtDelta(a: BBox2, b: BBox2): string { + const acx = a.x + a.width / 2; + const acy = a.y + a.height / 2; + const bcx = b.x + b.width / 2; + const bcy = b.y + b.height / 2; + return `center ${Math.hypot(acx - bcx, acy - bcy).toFixed(1)}px, size ${Math.hypot(a.width - b.width, a.height - b.height).toFixed(1)}px`; +} + +function setLoading(active: boolean) { + for (const surface of [els.threeSurface, els.adapterHost.parentElement, els.nativeHost.parentElement]) { + if (!surface) continue; + const existing = surface.querySelector(".loading"); + if (!active) { + existing?.remove(); + continue; + } + if (!existing) { + const loading = document.createElement("div"); + loading.className = "loading"; + loading.textContent = "rendering"; + surface.appendChild(loading); + } + } +} + +function updateLabels() { + els.fovValue.textContent = `${state.fov}deg`; + els.sizeValue.textContent = state.orthoSize.toFixed(2); + els.zoomValue.textContent = `${state.zoom.toFixed(2)}x`; +} + +function codeNumber(value: number): string { + return Number(value.toFixed(4)).toString(); +} + +function codeVector(v: THREE.Vector3): string { + return `${codeNumber(v.x)}, ${codeNumber(v.y)}, ${codeNumber(v.z)}`; +} + +function codeSize() { + const size = panelSize(els.threeSurface); + return { ...size, aspect: size.width / size.height }; +} + +function threeCameraCode(size = codeSize()): string { + const { position, target } = cameraPosition(state.camera); + if (state.camera === "orthographic") { + return `const camera = new THREE.OrthographicCamera( + ${codeNumber(-state.orthoSize * size.aspect / 2)}, + ${codeNumber(state.orthoSize * size.aspect / 2)}, + ${codeNumber(state.orthoSize / 2)}, + ${codeNumber(-state.orthoSize / 2)}, + 0.01, + 200, +); +camera.position.set(${codeVector(position)}); +camera.lookAt(${codeVector(target)}); +camera.updateProjectionMatrix(); +camera.updateMatrixWorld();`; + } + return `const camera = new THREE.PerspectiveCamera(${state.fov}, ${codeNumber(size.aspect)}, 0.01, 200); +camera.position.set(${codeVector(position)}); +camera.lookAt(${codeVector(target)}); +camera.updateProjectionMatrix(); +camera.updateMatrixWorld();`; +} + +function compatCameraCode(variableName: string, size = codeSize()): string { + const { position, target } = cameraPosition(state.camera); + if (state.camera === "orthographic") { + return `const ${variableName} = new OrthographicCamera( + ${codeNumber(-state.orthoSize * size.aspect / 2)}, + ${codeNumber(state.orthoSize * size.aspect / 2)}, + ${codeNumber(state.orthoSize / 2)}, + ${codeNumber(-state.orthoSize / 2)}, + 0.01, + 200, +); +${variableName}.position.set(${codeVector(position)}); +${variableName}.lookAt(${codeVector(target)});`; + } + return `const ${variableName} = new PerspectiveCamera(${state.fov}, ${codeNumber(size.aspect)}, 0.01, 200); +${variableName}.position.set(${codeVector(position)}); +${variableName}.lookAt(${codeVector(target)});`; +} + +function lightsCode(prefix: "THREE" | "poly") { + if (prefix === "THREE") { + return `scene.add(new THREE.AmbientLight("#ffffff", 0.48)); +const directional = new THREE.DirectionalLight("#ffffff", 0.82); +directional.position.set(3.4, 5.2, 4.1); +directional.target.position.set(0, 0.8, 0); +scene.add(directional); +scene.add(directional.target);`; + } + return `const ambient = new AmbientLight("#ffffff", 0.48); +const directional = new DirectionalLight("#ffffff", 0.82); +directional.position.set(3.4, 5.2, 4.1); +directional.target.position.set(0, 0.8, 0);`; +} + +function currentInputComment() { + return `// Input geometry: ${objectLabels[state.object]}, ${state.scene} scene, authored in Three/Y-up coordinates. +// The bench loader produces this from primitives or gallery GLBs, then applies the same floor/lineup/occlusion transforms. +const threePolygons = await loadSelectedScenePolygons();`; +} + +function updateCode() { + const size = codeSize(); + lastCode = { + three: `import * as THREE from "three"; + +const host = document.querySelector("#three")!; +const size = { width: ${size.width}, height: ${size.height} }; +${currentInputComment()} + +const canvas = document.createElement("canvas"); +host.appendChild(canvas); +const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); +renderer.setSize(size.width, size.height, false); +renderer.setClearColor(0xf8fafc, 1); + +${threeCameraCode(size)} + +const scene = new THREE.Scene(); +${lightsCode("THREE")} +scene.add(meshFromPolygons(threePolygons)); +renderer.render(scene, camera); + +function meshFromPolygons(polygons) { + const positions = []; + const colors = []; + for (const polygon of polygons) { + const color = new THREE.Color(polygon.color ?? "#cbd5e1"); + for (let i = 1; i < polygon.vertices.length - 1; i++) { + for (const vertex of [polygon.vertices[0], polygon.vertices[i], polygon.vertices[i + 1]]) { + positions.push(vertex[0], vertex[1], vertex[2]); + colors.push(color.r, color.g, color.b); + } + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); + geometry.computeVertexNormals(); + return new THREE.Mesh( + geometry, + new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.FrontSide }), + ); +}`, + adapter: `import { + AmbientLight, + DirectionalLight, + ${state.camera === "orthographic" ? "OrthographicCamera" : "PerspectiveCamera"}, + mountPolyThreeScene, + threeToPolyPoint, +} from "@layoutit/polycss/three"; + +const host = document.querySelector("#poly-three")!; +${currentInputComment()} + +const polyPolygons = threePolygons.map((polygon) => ({ + color: polygon.color, + vertices: polygon.vertices.map((vertex) => threeToPolyPoint(vertex)), +})); + +${compatCameraCode("camera", size)} +${lightsCode("poly")} + +mountPolyThreeScene(host, { + camera, + cameraOptions: { viewportHeight: ${size.height} }, + polygons: polyPolygons, + autoCenter: false, + directionalLight: directional.toPolyDirectionalLight(), + ambientLight: ambient.toPolyAmbientLight(), + textureLighting: "${state.lighting}", + textureQuality: 1, +});`, + native: `import { createPolyScene } from "@layoutit/polycss"; +import { + AmbientLight, + DirectionalLight, + ${state.camera === "orthographic" ? "OrthographicCamera, createPolyOrthographicCameraFromThree" : "PerspectiveCamera, createPolyPerspectiveCameraFromThree"}, + threeToPolyPoint, +} from "@layoutit/polycss/three"; + +const host = document.querySelector("#poly-native")!; +${currentInputComment()} + +const polyPolygons = threePolygons.map((polygon) => ({ + color: polygon.color, + vertices: polygon.vertices.map((vertex) => threeToPolyPoint(vertex)), +})); + +${compatCameraCode("threeLikeCamera", size)} +${lightsCode("poly")} + +const camera = ${state.camera === "orthographic" ? "createPolyOrthographicCameraFromThree" : "createPolyPerspectiveCameraFromThree"}(threeLikeCamera, { + viewportHeight: ${size.height}, +}); + +const scene = createPolyScene(host, { + camera, + autoCenter: false, + directionalLight: directional.toPolyDirectionalLight(), + ambientLight: ambient.toPolyAmbientLight(), + textureLighting: "${state.lighting}", + textureQuality: 1, +}); + +scene.add({ polygons: polyPolygons, objectUrls: [], warnings: [], dispose() {} }, { + merge: false, + meshResolution: "lossless", +});`, + }; + els.code.textContent = lastCode[activeCodeTab]; +} + +function bindControls() { + els.object.value = state.object; + els.scene.value = state.scene; + els.camera.value = state.camera; + els.view.value = state.view; + els.lighting.value = state.lighting; + els.fov.value = String(state.fov); + els.orthoSize.value = String(state.orthoSize); + els.zoom.value = String(state.zoom); + + els.object.addEventListener("change", () => { + state.object = els.object.value as ObjectKey; + render(); + }); + els.scene.addEventListener("change", () => { + state.scene = els.scene.value as SceneKey; + render(); + }); + els.camera.addEventListener("change", () => { + state.camera = els.camera.value as CameraMode; + render(); + }); + els.view.addEventListener("change", () => { + state.view = els.view.value as ViewKey; + applyPresetView(); + render(); + }); + els.lighting.addEventListener("change", () => { + state.lighting = els.lighting.value as LightingMode; + render(); + }); + els.fov.addEventListener("input", () => { + state.fov = Number(els.fov.value); + render(); + }); + els.orthoSize.addEventListener("input", () => { + state.orthoSize = Number(els.orthoSize.value); + render(); + }); + els.zoom.addEventListener("input", () => { + state.zoom = Number(els.zoom.value); + render(); + }); + els.reset.addEventListener("click", () => { + state.zoom = 1; + state.view = "iso"; + els.zoom.value = "1"; + els.view.value = "iso"; + applyPresetView(); + render(); + }); + + for (const button of document.querySelectorAll(".tabs button")) { + button.addEventListener("click", () => { + activeCodeTab = button.dataset.tab as typeof activeCodeTab; + for (const b of document.querySelectorAll(".tabs button")) b.classList.remove("active"); + button.classList.add("active"); + els.code.textContent = lastCode[activeCodeTab]; + }); + } + + let dragging = false; + let lastX = 0; + let lastY = 0; + for (const surface of document.querySelectorAll(".surface")) { + surface.addEventListener("pointerdown", (event) => { + dragging = true; + lastX = event.clientX; + lastY = event.clientY; + surface.setPointerCapture(event.pointerId); + }); + surface.addEventListener("pointermove", (event) => { + if (!dragging || state.camera === "fpv") return; + state.yaw += (event.clientX - lastX) * 0.35; + state.pitch = Math.max(-82, Math.min(82, state.pitch - (event.clientY - lastY) * 0.25)); + lastX = event.clientX; + lastY = event.clientY; + render(); + }); + surface.addEventListener("pointerup", () => { + dragging = false; + }); + } + + window.addEventListener("resize", () => render()); +} + +bindControls(); +applyPresetView(); +render(); diff --git a/bench/perf-serve.mjs b/bench/perf-serve.mjs index 6de1d1fa..e18537ad 100644 --- a/bench/perf-serve.mjs +++ b/bench/perf-serve.mjs @@ -80,6 +80,7 @@ const INDEX_HTML = `
  • /async-scene-mount.html  internal async scene mount
  • /sync-scene-add.html  synchronous scene.add render
  • /atlas-background.html  atlas background reveal style application
  • +
  • /three-parity.html  three.js vs polycss/three vs native PolyCSS
  • `; diff --git a/bench/three-parity.html b/bench/three-parity.html index 996b941a..988bcd29 100644 --- a/bench/three-parity.html +++ b/bench/three-parity.html @@ -2,1392 +2,142 @@ - polycss vs three.js — parity check (cube) + + PolyCSS Three Parity Dashboard -

    PolyCSS

    no bridging — values from S go in raw
    -

    Three.js

    no bridging — values from S go in raw
    -
    - - - - +
    +
    +
    +

    PolyCSS Three Parity Dashboard

    +

    Real Three.js reference, PolyCSS parity adapter, and native PolyCSS conversion rendered from the same scene state.

    +
    +
    + +
    + + + + + + + + + +
    + +
    +
    +

    Three.js reference

    real WebGL
    +
    +
    +
    +

    polycss/three

    adapter
    +
    +
    +
    +

    PolyCSS native

    converted params
    +
    +
    +
    + +
    +
    Three bboxpending
    +
    Adapter bboxpending
    +
    Native bboxpending
    +
    Deltapending
    +
    + +
    +
    + + + +
    +
    
    +    
    +
    + + diff --git a/package.json b/package.json index 3de7879e..8e437054 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "playwright": "^1.58.2", "react": "^19.2.6", "react-dom": "^19.2.6", + "three": "^0.185.1", "vue": "^3.5.30" }, "main": "index.js", diff --git a/packages/core/README.md b/packages/core/README.md index f4d3bc19..9e379187 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,26 +1,24 @@ -

    - PolyCSS -

    - # PolyCSS -A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, STL, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. +A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript. Visit [polycss.com](https://polycss.com) for docs and model examples. -PolyCSS scene +PolyCSS primitives banner ## Installation ```bash + +# Vanilla +npm install @layoutit/polycss + # React npm install @layoutit/polycss-react # Vue npm install @layoutit/polycss-vue -# Vanilla / custom elements -npm install @layoutit/polycss ``` You can also load PolyCSS directly from a CDN. Here is a minimal custom-element scene: @@ -36,9 +34,11 @@ You can also load PolyCSS directly from a CDN. Here is a minimal custom-element ``` +PolyCSS intro + ## Framework Components -React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and atlas options, and `` loads or receives polygon data. +React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and options, and `` loads or receives polygon data. ```tsx import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; @@ -55,23 +55,54 @@ export default function App() { } ``` -The Vue package mirrors the same names and props with Vue casing: - -```vue - - - +## Three.js Parity API + +When porting Three.js scenes or generating code with an agent, use the explicit +`*/three` subpaths: + +- `@layoutit/polycss-core/three` +- `@layoutit/polycss/three` +- `@layoutit/polycss-react/three` +- `@layoutit/polycss-vue/three` + +They expose Three-like `PerspectiveCamera`, `OrthographicCamera`, `Object3D`, +`Vector3`, `DirectionalLight`, `PointLight`, `AmbientLight`, radians for object +rotations, Y-up authoring coordinates, and `camera.position` + `camera.lookAt(...)` +framing. The adapters convert into native PolyCSS coordinates with a right-handed +axis map, so the apparent object size, projection, orientation, depth ordering, +and light direction line up with Three.js scene math while still rendering +through the DOM. + +```tsx +import { PolyScene } from "@layoutit/polycss-react"; +import { + DirectionalLight, + PolyThreeMesh, + PolyThreePerspectiveCamera, +} from "@layoutit/polycss-react/three"; + +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); + +export function App() { + return ( + + + + + + ); +} ``` +Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-parity). + ## API Reference ### PolyCamera @@ -88,7 +119,6 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. -- Solid seam bleed is automatic on detected shared solid edges. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -99,9 +129,8 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `polygons` accepts pre-parsed geometry. - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair; STL imports use the conservative lossless path in both modes. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. - `castShadow` emits CSS-projected shadows in dynamic lighting mode. -- Tooling can reuse `buildPolyMeshTransform`, `buildPolySceneTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions. ### Controls @@ -110,6 +139,18 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `` provides keyboard and pointer-look navigation. - `` adds translate/rotate gizmos for selected mesh handles. +### Snapshot Export + +The vanilla package exports `exportPolySceneSnapshot(target)`. It clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React/Vue scenes too; import it from `@layoutit/polycss` and pass the rendered camera or scene element. + +```ts +import { exportPolySceneSnapshot } from "@layoutit/polycss"; + +const html = await exportPolySceneSnapshot(scene.host); +``` + +If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`. + ### Polygon Data Model Each polygon describes one renderable face: @@ -147,7 +188,7 @@ Render polygons directly when you need per-face DOM events or custom styling: ## Loading Mesh Files -Use `loadMesh()` from `@layoutit/polycss`, `@layoutit/polycss-react`, or `@layoutit/polycss-vue` to parse supported model formats: +Use `loadMesh()` to parse supported model formats: ```ts import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; @@ -173,16 +214,7 @@ Supported formats: ## Performance -PolyCSS renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. - -- One visible polygon becomes one leaf DOM element. -- Flat rectangles and stable quads use solid CSS leaves. -- Textured polygons are packed into generated texture atlases. -- Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. -- Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. -- `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. - -Renderer internals: +PolyCSS renders through the DOM, so performance is mostly shaped by two things: the number of mounted leaves, and the amount of texture atlas area the browser has to paint. The renderer tries to keep the common cases cheap. Simple surfaces stay as solid CSS elements, while textured, irregular, or high-detail geometry falls back to atlas-backed slices only when needed. Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. @@ -191,8 +223,6 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le - `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. - `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. -For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. - ## Packages | Package | Description | @@ -204,10 +234,11 @@ For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, wh ## Made with PolyCSS -[Layoutit Voxels](https://voxels.layoutit.com) --> A CSS Voxel editor +[cssQuake](https://cssquake.com) +-> A CSS port of Quake (1996) + +quake -layoutit-voxels [Layoutit Terra](https://terra.layoutit.com) -> A CSS Terrain Generator diff --git a/packages/core/package.json b/packages/core/package.json index 350cebf1..f25dde25 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,16 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./three": { + "types": "./dist/three.d.ts", + "import": "./dist/three.js", + "require": "./dist/three.cjs" + } + }, + "typesVersions": { + "*": { + "three": ["dist/three.d.ts"] } }, "scripts": { diff --git a/packages/core/src/color/lighting.test.ts b/packages/core/src/color/lighting.test.ts index 23c84ad4..b8f8c0cd 100644 --- a/packages/core/src/color/lighting.test.ts +++ b/packages/core/src/color/lighting.test.ts @@ -71,13 +71,13 @@ describe("shadeColor", () => { describe("computeShapeLighting", () => { it("face pointing toward light is fully lit (lambert = 1)", () => { - // Light shines toward [0,0,-1] (down). A face whose normal is [0,0,1] (up) + // Light source is above along [0,0,1]. A face whose normal is [0,0,1] (up) // catches the light fully. With ambient intensity 0, only the // directional contribution applies. const result = computeShapeLighting( [0, 0, 1], "#808080", - { direction: [0, 0, -1], color: "#ffffff", intensity: 1 }, + { direction: [0, 0, 1], color: "#ffffff", intensity: 1 }, { color: "#ffffff", intensity: 0 }, ); expect(result).toBe("rgb(128, 128, 128)"); @@ -87,7 +87,7 @@ describe("computeShapeLighting", () => { const result = computeShapeLighting( [0, 0, -1], "#808080", - { direction: [0, 0, -1], color: "#ffffff", intensity: 1 }, + { direction: [0, 0, 1], color: "#ffffff", intensity: 1 }, { color: "#ffffff", intensity: 0.5 }, ); // ambient * base = 0.5 * 128 = 64 @@ -98,7 +98,7 @@ describe("computeShapeLighting", () => { const result = computeShapeLighting( [1, 0, 0], "#808080", - { direction: [0, 0, -1], color: "#ffffff", intensity: 1 }, + { direction: [0, 0, 1], color: "#ffffff", intensity: 1 }, { color: "#ffffff", intensity: 0.4 }, ); // perpendicular: lambert = 0; only ambient applies → 0.4 * 128 = 51 @@ -117,7 +117,7 @@ describe("computeShapeLighting", () => { const half = computeShapeLighting( [0, 0, 1], "#808080", - { direction: [0, 0, -1], color: "#ffffff", intensity: 0.5 }, + { direction: [0, 0, 1], color: "#ffffff", intensity: 0.5 }, { color: "#ffffff", intensity: 0 }, ); // tint = 0 + 0.5*1 = 0.5 → 128 * 0.5 = 64 diff --git a/packages/core/src/color/lighting.ts b/packages/core/src/color/lighting.ts index 52f9287b..85773f85 100644 --- a/packages/core/src/color/lighting.ts +++ b/packages/core/src/color/lighting.ts @@ -50,7 +50,7 @@ export function shadeColor(base: string, delta: number): string { } const DEFAULT_DIRECTIONAL: Required = { - direction: [0, 0, -1], + direction: [0, 0, 1], color: "#ffffff", intensity: 1, }; @@ -78,7 +78,7 @@ function tintChannel(base: number, tintHex: string, channel: 0 | 1 | 2): number * * Math (decoupled, three.js convention): * tint = ambient.color · ambient.intensity - * + directional.color · directional.intensity · max(0, n · (−L)) + * + directional.color · directional.intensity · max(0, n · L) * final = baseColor × tint * * Pass `directional` and/or `ambient` undefined to fall back to defaults @@ -99,9 +99,7 @@ export function computeShapeLighting( const ambientIntensity = Math.max(0, ambient?.intensity ?? DEFAULT_AMBIENT.intensity); const n = normalizeVec3(normal); - // Light shines TOWARD `dir`; surface receives light when its outward - // normal points back toward the source (-dir). - const lambert = Math.max(0, -(n[0] * dir[0] + n[1] * dir[1] + n[2] * dir[2])); + const lambert = Math.max(0, n[0] * dir[0] + n[1] * dir[1] + n[2] * dir[2]); const directionalScale = lightIntensity * lambert; const out: [number, number, number] = [0, 0, 0]; diff --git a/packages/core/src/three/index.ts b/packages/core/src/three/index.ts new file mode 100644 index 00000000..5264ee58 --- /dev/null +++ b/packages/core/src/three/index.ts @@ -0,0 +1,391 @@ +import { BASE_TILE } from "../camera/camera"; +import type { + CameraState, +} from "../camera/camera"; +import type { + Polygon, + PolyAmbientLight, + PolyDirectionalLight, + PolyPointLight, + TextureTriangle, + Vec3, +} from "../types"; + +const DEG = Math.PI / 180; +const EPS = 1e-12; +const DEFAULT_VIEWPORT_HEIGHT = 420; + +export type Vector3Tuple = [number, number, number]; + +export interface PolyCameraFromThreeOptions { + /** PolyCSS CSS-pixel scale per world unit. Defaults to BASE_TILE. */ + zoom?: number; + /** Viewport height in CSS px for fov/frustum conversion. Defaults to 420. */ + viewportHeight?: number; +} + +export interface PolyPerspectiveCameraFromThreeOptions extends PolyCameraFromThreeOptions { + /** CSS perspective distance in px. Defaults to the value derived from fov. */ + perspective?: number; +} + +export type PolyOrthographicCameraFromThreeOptions = PolyCameraFromThreeOptions; + +export interface PolyPerspectiveCameraOptionsFromThree extends CameraState { + perspective: number; +} + +export type PolyOrthographicCameraOptionsFromThree = CameraState; + +export class Vector3 { + x: number; + y: number; + z: number; + + constructor(x = 0, y = 0, z = 0) { + this.x = x; + this.y = y; + this.z = z; + } + + set(x: number, y: number, z: number): this { + this.x = x; + this.y = y; + this.z = z; + return this; + } + + copy(v: Vector3): this { + this.x = v.x; + this.y = v.y; + this.z = v.z; + return this; + } + + clone(): Vector3 { + return new Vector3(this.x, this.y, this.z); + } + + add(v: Vector3): this { + this.x += v.x; + this.y += v.y; + this.z += v.z; + return this; + } + + sub(v: Vector3): this { + this.x -= v.x; + this.y -= v.y; + this.z -= v.z; + return this; + } + + multiplyScalar(s: number): this { + this.x *= s; + this.y *= s; + this.z *= s; + return this; + } + + dot(v: Vector3): number { + return this.x * v.x + this.y * v.y + this.z * v.z; + } + + cross(v: Vector3): this { + const x = this.y * v.z - this.z * v.y; + const y = this.z * v.x - this.x * v.z; + const z = this.x * v.y - this.y * v.x; + this.x = x; + this.y = y; + this.z = z; + return this; + } + + length(): number { + return Math.hypot(this.x, this.y, this.z); + } + + normalize(): this { + const len = this.length(); + if (len > EPS) this.multiplyScalar(1 / len); + return this; + } + + toArray(): Vector3Tuple { + return [this.x, this.y, this.z]; + } +} + +export class Euler { + x: number; + y: number; + z: number; + readonly order = "XYZ"; + #onChange: () => void; + + constructor(x = 0, y = 0, z = 0, onChange: () => void = () => {}) { + this.x = x; + this.y = y; + this.z = z; + this.#onChange = onChange; + } + + set(x: number, y: number, z: number): this { + this.x = x; + this.y = y; + this.z = z; + this.#onChange(); + return this; + } +} + +export class Object3D { + readonly position = new Vector3(); + readonly rotation: Euler; + readonly scale = new Vector3(1, 1, 1); + up = new Vector3(0, 1, 0); + #lookAtTarget: Vector3 | null = null; + + constructor() { + this.rotation = new Euler(0, 0, 0, () => { + this.#lookAtTarget = null; + }); + } + + lookAt(x: number | Vector3, y?: number, z?: number): this { + this.#lookAtTarget = x instanceof Vector3 + ? x.clone() + : new Vector3(x, y ?? 0, z ?? 0); + return this; + } + + get lookAtTarget(): Vector3 | null { + return this.#lookAtTarget?.clone() ?? null; + } + + localToWorld(v: Vector3): Vector3 { + const out = rotateByEuler( + new Vector3(v.x * this.scale.x, v.y * this.scale.y, v.z * this.scale.z), + this.rotation, + ); + return out.add(this.position); + } + + worldForward(): Vector3 { + if (this.#lookAtTarget) { + return this.#lookAtTarget.clone().sub(this.position).normalize(); + } + return rotateByEuler(new Vector3(0, 0, -1), this.rotation).normalize(); + } +} + +export class PerspectiveCamera extends Object3D { + readonly type = "PerspectiveCamera"; + fov: number; + aspect: number; + near: number; + far: number; + zoom = 1; + + constructor(fov = 50, aspect = 1, near = 0.1, far = 2000) { + super(); + this.fov = fov; + this.aspect = aspect; + this.near = near; + this.far = far; + } + + toPolyCameraState(options: PolyPerspectiveCameraFromThreeOptions = {}): PolyPerspectiveCameraOptionsFromThree { + const perspective = options.perspective ?? perspectiveFromFov( + this.fov, + options.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT, + this.zoom, + ); + const state = cameraStateFromObject(this, options.zoom ?? BASE_TILE); + return { + ...state, + distance: state.distance - perspective, + perspective, + }; + } +} + +export class OrthographicCamera extends Object3D { + readonly type = "OrthographicCamera"; + left: number; + right: number; + top: number; + bottom: number; + near: number; + far: number; + zoom = 1; + + constructor(left = -1, right = 1, top = 1, bottom = -1, near = 0.1, far = 2000) { + super(); + this.left = left; + this.right = right; + this.top = top; + this.bottom = bottom; + this.near = near; + this.far = far; + } + + toPolyCameraState(options: PolyOrthographicCameraFromThreeOptions = {}): PolyOrthographicCameraOptionsFromThree { + const frustumHeight = Math.abs(this.top - this.bottom) || 1; + const zoom = options.zoom ?? (((options.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT) / frustumHeight) * this.zoom); + return cameraStateFromObject(this, zoom); + } +} + +export class DirectionalLight extends Object3D { + readonly target = new Object3D(); + color: string; + intensity: number; + + constructor(color = "#ffffff", intensity = 1) { + super(); + this.color = color; + this.intensity = intensity; + this.position.set(0, 1, 0); + } + + toPolyDirectionalLight(): PolyDirectionalLight { + const direction = this.position.clone().sub(this.target.position).normalize(); + return { + direction: threeToPolyDirection(direction), + color: this.color, + intensity: this.intensity, + }; + } +} + +export class PointLight extends Object3D { + color: string; + intensity: number; + distance: number; + decay: number; + castShadow = false; + + constructor(color = "#ffffff", intensity = 1, distance = 0, decay = 0) { + super(); + this.color = color; + this.intensity = intensity; + this.distance = distance; + this.decay = decay; + } + + toPolyPointLight(): PolyPointLight { + return { + position: threeToPolyPoint(this.position), + color: this.color, + intensity: this.intensity, + castShadow: this.castShadow, + }; + } +} + +export class AmbientLight { + color: string; + intensity: number; + + constructor(color = "#ffffff", intensity = 1) { + this.color = color; + this.intensity = intensity; + } + + toPolyAmbientLight(): PolyAmbientLight { + return { + color: this.color, + intensity: this.intensity, + }; + } +} + +// Y-up (three) -> Z-up (polycss) must be a ROTATION (det +1), not an axis +// swap. [x, z, y] is a reflection and flips winding; [x, -z, y] is +90deg +// about X and keeps polygon normals/light response right-handed. +export function threeToPolyPoint(v: Vector3 | Vector3Tuple): Vec3 { + const [x, y, z] = vectorTuple(v); + return [x, -z, y]; +} + +export function polyToThreePoint(v: Vec3): Vector3 { + return new Vector3(v[0], v[2], -v[1]); +} + +export function threeToPolyDirection(v: Vector3 | Vector3Tuple): Vec3 { + const [x, y, z] = vectorTuple(v); + return [x, -z, y]; +} + +export function polyToThreeDirection(v: Vec3): Vector3 { + return new Vector3(v[0], v[2], -v[1]); +} + +export function transformPointToPoly(point: Vector3 | Vector3Tuple, object: Object3D): Vec3 { + return threeToPolyPoint(object.localToWorld(asVector3(point))); +} + +export function transformPolygonsToPoly(polygons: Polygon[], object: Object3D): Polygon[] { + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map((v) => transformPointToPoly(v, object)), + ...(polygon.textureTriangles?.length + ? { + textureTriangles: polygon.textureTriangles.map((triangle) => ({ + ...triangle, + vertices: triangle.vertices.map((v) => transformPointToPoly(v, object)) as TextureTriangle["vertices"], + })), + } + : null), + })); +} + +function cameraStateFromObject(object: Object3D, zoom: number): CameraState { + const target = object.lookAtTarget ?? object.position.clone().add(object.worldForward()); + const eye = threeToPolyPoint(object.position); + const aim = threeToPolyPoint(target); + const dx = eye[0] - aim[0]; + const dy = eye[1] - aim[1]; + const dz = eye[2] - aim[2]; + const horizontal = Math.hypot(dx, dy) || EPS; + + return { + target: aim, + rotX: Math.atan2(horizontal, dz) / DEG, + rotY: Math.atan2(dy, dx) / DEG, + zoom, + distance: Math.hypot(dx, dy, dz) * zoom, + }; +} + +function rotateByEuler(v: Vector3, euler: Euler): Vector3 { + const a = Math.cos(euler.x); + const b = Math.sin(euler.x); + const c = Math.cos(euler.y); + const d = Math.sin(euler.y); + const e = Math.cos(euler.z); + const f = Math.sin(euler.z); + + const x = v.x; + const y = v.y; + const z = v.z; + + return new Vector3( + c * e * x - c * f * y + d * z, + (a * f + b * e * d) * x + (a * e - b * f * d) * y - b * c * z, + (b * f - a * e * d) * x + (b * e + a * f * d) * y + a * c * z, + ); +} + +function perspectiveFromFov(fov: number, viewportHeight: number, zoom = 1): number { + return (viewportHeight * zoom) / (2 * Math.tan((fov * DEG) / 2)); +} + +function asVector3(v: Vector3 | Vector3Tuple): Vector3 { + return v instanceof Vector3 ? v.clone() : new Vector3(v[0], v[1], v[2]); +} + +function vectorTuple(v: Vector3 | Vector3Tuple): Vector3Tuple { + return v instanceof Vector3 ? [v.x, v.y, v.z] : v; +} diff --git a/packages/core/src/three/three.test.ts b/packages/core/src/three/three.test.ts new file mode 100644 index 00000000..940280ef --- /dev/null +++ b/packages/core/src/three/three.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { worldDirectionalLightToCss } from "../shadow/receiverFaceGroups"; +import { + AmbientLight, + DirectionalLight, + Object3D, + PerspectiveCamera, + PointLight, + Vector3, + threeToPolyPoint, + transformPolygonsToPoly, +} from "./index"; +import type { Polygon } from "../types"; + +function normalOf(polygon: Polygon): [number, number, number] { + const [a, b, c] = polygon.vertices; + const ab = [b![0] - a![0], b![1] - a![1], b![2] - a![2]]; + const ac = [c![0] - a![0], c![1] - a![1], c![2] - a![2]]; + const n: [number, number, number] = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + const len = Math.hypot(n[0], n[1], n[2]) || 1; + return [n[0] / len, n[1] / len, n[2] / len]; +} + +describe("@layoutit/polycss-core/three", () => { + it("maps Three Y-up points into PolyCSS Z-up space with a right-handed rotation", () => { + expect(threeToPolyPoint(new Vector3(1, 2, 3))).toEqual([1, -3, 2]); + + const topFacingThreeQuad: Polygon = { + vertices: [ + [-1, 0, -1], + [-1, 0, 1], + [1, 0, 1], + [1, 0, -1], + ], + }; + const [converted] = transformPolygonsToPoly([topFacingThreeQuad], new Object3D()); + expect(normalOf(converted!)[2]).toBeGreaterThan(0.999); + }); + + it("converts a Three perspective camera into PolyCSS camera state", () => { + const camera = new PerspectiveCamera(50, 1, 0.1, 100); + camera.position.set(3, 2, 5); + camera.lookAt(0, 0, 0); + + const state = camera.toPolyCameraState({ viewportHeight: 420, zoom: 50 }); + + expect(state.target.map((v) => Math.abs(v))).toEqual([0, 0, 0]); + expect(state.zoom).toBe(50); + expect(state.perspective).toBeCloseTo(420 / (2 * Math.tan((50 * Math.PI / 180) / 2)), 6); + expect(state.rotX).toBeGreaterThan(0); + expect(state.rotY).toBeLessThan(0); + }); + + it("converts Three directional lights to PolyCSS source-direction lights", () => { + const light = new DirectionalLight("#ffffff", 1); + light.position.set(0, 5, 0); + light.target.position.set(0, 0, 0); + + const polyLight = light.toPolyDirectionalLight(); + expect(polyLight.direction[0]).toBeCloseTo(0); + expect(polyLight.direction[1]).toBeCloseTo(0); + expect(polyLight.direction[2]).toBeCloseTo(1); + expect(polyLight.intensity).toBe(1); + + const cssLight = worldDirectionalLightToCss(polyLight); + expect(cssLight.direction[0]).toBeCloseTo(0); + expect(cssLight.direction[1]).toBeCloseTo(0); + expect(cssLight.direction[2]).toBeCloseTo(1); + }); + + it("preserves Three light intensities because both shaders use Lambert / pi", () => { + const directional = new DirectionalLight("#ffffff", 0.82); + const point = new PointLight("#ffffff", 0.5); + const ambient = new AmbientLight("#ffffff", 0.48); + + expect(directional.toPolyDirectionalLight().intensity).toBe(0.82); + expect(point.toPolyPointLight().intensity).toBe(0.5); + expect(ambient.toPolyAmbientLight().intensity).toBe(0.48); + }); +}); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c09e65a9..7c995aa3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -87,7 +87,7 @@ export type PolyTextureAlphaMode = "opaque" | "mask" | "blend"; * Mirrors three.js's `DirectionalLight`. */ export interface PolyDirectionalLight { - /** Direction the light shines TOWARD (typical convention). */ + /** Unit direction from the surface toward the distant light source. */ direction: Vec3; /** Light tint, hex string. White by default. */ color?: string; diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 78187e05..12894ac4 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: { index: "src/index.ts" }, + entry: { + index: "src/index.ts", + three: "src/three/index.ts", + }, format: ["esm", "cjs"], dts: true, splitting: false, diff --git a/packages/polycss/README.md b/packages/polycss/README.md index 178f4e1d..9e379187 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -1,26 +1,24 @@ -

    - PolyCSS -

    - # PolyCSS -A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, STL, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. +A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript. Visit [polycss.com](https://polycss.com) for docs and model examples. -PolyCSS scene +PolyCSS primitives banner ## Installation ```bash + +# Vanilla +npm install @layoutit/polycss + # React npm install @layoutit/polycss-react # Vue npm install @layoutit/polycss-vue -# Vanilla / custom elements -npm install @layoutit/polycss ``` You can also load PolyCSS directly from a CDN. Here is a minimal custom-element scene: @@ -36,9 +34,11 @@ You can also load PolyCSS directly from a CDN. Here is a minimal custom-element ``` +PolyCSS intro + ## Framework Components -React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and atlas options, and `` loads or receives polygon data. +React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and options, and `` loads or receives polygon data. ```tsx import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; @@ -55,23 +55,54 @@ export default function App() { } ``` -The Vue package mirrors the same names and props with Vue casing: - -```vue - - - +## Three.js Parity API + +When porting Three.js scenes or generating code with an agent, use the explicit +`*/three` subpaths: + +- `@layoutit/polycss-core/three` +- `@layoutit/polycss/three` +- `@layoutit/polycss-react/three` +- `@layoutit/polycss-vue/three` + +They expose Three-like `PerspectiveCamera`, `OrthographicCamera`, `Object3D`, +`Vector3`, `DirectionalLight`, `PointLight`, `AmbientLight`, radians for object +rotations, Y-up authoring coordinates, and `camera.position` + `camera.lookAt(...)` +framing. The adapters convert into native PolyCSS coordinates with a right-handed +axis map, so the apparent object size, projection, orientation, depth ordering, +and light direction line up with Three.js scene math while still rendering +through the DOM. + +```tsx +import { PolyScene } from "@layoutit/polycss-react"; +import { + DirectionalLight, + PolyThreeMesh, + PolyThreePerspectiveCamera, +} from "@layoutit/polycss-react/three"; + +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); + +export function App() { + return ( + + + + + + ); +} ``` +Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-parity). + ## API Reference ### PolyCamera @@ -88,7 +119,6 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. -- Solid seam bleed is automatic on detected shared solid edges. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -99,9 +129,8 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `polygons` accepts pre-parsed geometry. - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair; STL imports use the conservative lossless path in both modes. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. - `castShadow` emits CSS-projected shadows in dynamic lighting mode. -- Tooling can reuse `buildPolyMeshTransform`, `buildPolySceneTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions. ### Controls @@ -159,7 +188,7 @@ Render polygons directly when you need per-face DOM events or custom styling: ## Loading Mesh Files -Use `loadMesh()` from `@layoutit/polycss`, `@layoutit/polycss-react`, or `@layoutit/polycss-vue` to parse supported model formats: +Use `loadMesh()` to parse supported model formats: ```ts import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; @@ -185,16 +214,7 @@ Supported formats: ## Performance -PolyCSS renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. - -- One visible polygon becomes one leaf DOM element. -- Flat rectangles and stable quads use solid CSS leaves. -- Textured polygons are packed into generated texture atlases. -- Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. -- Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. -- `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. - -Renderer internals: +PolyCSS renders through the DOM, so performance is mostly shaped by two things: the number of mounted leaves, and the amount of texture atlas area the browser has to paint. The renderer tries to keep the common cases cheap. Simple surfaces stay as solid CSS elements, while textured, irregular, or high-detail geometry falls back to atlas-backed slices only when needed. Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. @@ -203,8 +223,6 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le - `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. - `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. -For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. - ## Packages | Package | Description | @@ -216,10 +234,11 @@ For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, wh ## Made with PolyCSS -[Layoutit Voxels](https://voxels.layoutit.com) --> A CSS Voxel editor +[cssQuake](https://cssquake.com) +-> A CSS port of Quake (1996) + +quake -layoutit-voxels [Layoutit Terra](https://terra.layoutit.com) -> A CSS Terrain Generator diff --git a/packages/polycss/package.json b/packages/polycss/package.json index 60d2a845..4c44977c 100644 --- a/packages/polycss/package.json +++ b/packages/polycss/package.json @@ -30,6 +30,17 @@ "types": "./dist/elements.d.ts", "import": "./dist/elements.js", "require": "./dist/elements.cjs" + }, + "./three": { + "types": "./dist/three.d.ts", + "import": "./dist/three.js", + "require": "./dist/three.cjs" + } + }, + "typesVersions": { + "*": { + "three": ["dist/three.d.ts"], + "elements": ["dist/elements.d.ts"] } }, "scripts": { diff --git a/packages/polycss/src/three.test.ts b/packages/polycss/src/three.test.ts new file mode 100644 index 00000000..8d0a1178 --- /dev/null +++ b/packages/polycss/src/three.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + PerspectiveCamera, + Object3D, + boxPolygons, + mountPolyThreeScene, + transformPolygonsToPoly, +} from "./three"; + +describe("polycss/three", () => { + it("mounts Three-authored polygons through the vanilla scene API", () => { + const host = document.createElement("div"); + const camera = new PerspectiveCamera(50, 1, 0.1, 100); + camera.position.set(3, 2, 5); + camera.lookAt(0, 0, 0); + const object = new Object3D(); + + const scene = mountPolyThreeScene(host, { + camera, + cameraOptions: { viewportHeight: 420 }, + polygons: transformPolygonsToPoly( + boxPolygons({ size: 1, color: "#66aaff" }), + object, + ), + }); + + expect(host.querySelector(".polycss-scene")).toBeTruthy(); + expect(host.querySelector(".polycss-mesh")).toBeTruthy(); + expect(scene.camera.state.zoom).toBe(50); + scene.destroy(); + }); + + it("defaults the Three parity scene helper to baked lighting", () => { + const host = document.createElement("div"); + const camera = new PerspectiveCamera(50, 1, 0.1, 100); + camera.position.set(3, 2, 5); + camera.lookAt(0, 0, 0); + + const scene = mountPolyThreeScene(host, { + camera, + cameraOptions: { viewportHeight: 420 }, + polygons: [], + }); + + expect(host.querySelector(".polycss-scene")?.dataset.polycssLighting).toBe("baked"); + scene.destroy(); + }); + + it("allows the Three parity scene helper to opt into dynamic lighting", () => { + const host = document.createElement("div"); + const camera = new PerspectiveCamera(50, 1, 0.1, 100); + camera.position.set(3, 2, 5); + camera.lookAt(0, 0, 0); + + const scene = mountPolyThreeScene(host, { + camera, + cameraOptions: { viewportHeight: 420 }, + polygons: [], + textureLighting: "dynamic", + }); + + expect(host.querySelector(".polycss-scene")?.dataset.polycssLighting).toBe("dynamic"); + scene.destroy(); + }); +}); diff --git a/packages/polycss/src/three.ts b/packages/polycss/src/three.ts new file mode 100644 index 00000000..45be42be --- /dev/null +++ b/packages/polycss/src/three.ts @@ -0,0 +1,152 @@ +import type { + ParseResult, + Polygon, +} from "@layoutit/polycss-core"; +import { + OrthographicCamera, + PerspectiveCamera, +} from "@layoutit/polycss-core/three"; +import type { + PolyOrthographicCameraFromThreeOptions, + PolyPerspectiveCameraFromThreeOptions, +} from "@layoutit/polycss-core/three"; +import { + createPolyOrthographicCamera, + createPolyPerspectiveCamera, + createPolyScene, +} from "./index"; +import type { + PolyMeshTransform, + PolySceneHandle, + PolySceneOptions, +} from "./index"; + +export { + AmbientLight, + DirectionalLight, + Euler, + Object3D, + OrthographicCamera, + PerspectiveCamera, + PointLight, + Vector3, + polyToThreeDirection, + polyToThreePoint, + threeToPolyDirection, + threeToPolyPoint, + transformPointToPoly, + transformPolygonsToPoly, +} from "@layoutit/polycss-core/three"; +export type { + PolyCameraFromThreeOptions, + PolyOrthographicCameraFromThreeOptions, + PolyOrthographicCameraOptionsFromThree, + PolyPerspectiveCameraFromThreeOptions, + PolyPerspectiveCameraOptionsFromThree, + Vector3Tuple, +} from "@layoutit/polycss-core/three"; + +export { + boxPolygons, + loadMesh, + octahedronPolygons, + planePolygons, + spherePolygons, + tetrahedronPolygons, + icosahedronPolygons, + dodecahedronPolygons, + cylinderPolygons, + conePolygons, + torusPolygons, +} from "@layoutit/polycss-core"; +export type { + LoadMeshOptions, + ParseResult, + Polygon, + PolyAmbientLight, + PolyDirectionalLight, + PolyPointLight, + Vec3, +} from "@layoutit/polycss-core"; + +export { + createPolyOrthographicCamera, + createPolyPerspectiveCamera, + createPolyScene, +}; + +export function createPolyPerspectiveCameraFromThree( + camera: PerspectiveCamera, + options: PolyPerspectiveCameraFromThreeOptions = {}, +) { + return createPolyPerspectiveCamera(camera.toPolyCameraState(options)); +} + +export function createPolyOrthographicCameraFromThree( + camera: OrthographicCamera, + options: PolyOrthographicCameraFromThreeOptions = {}, +) { + return createPolyOrthographicCamera(camera.toPolyCameraState(options)); +} + +export interface PolyThreeMeshMount { + polygons: Polygon[]; + transform?: PolyMeshTransform; +} + +export interface MountPolyThreeSceneOptions extends Omit { + camera: PerspectiveCamera | OrthographicCamera; + cameraOptions?: PolyPerspectiveCameraFromThreeOptions | PolyOrthographicCameraFromThreeOptions; + polygons?: Polygon[]; + meshes?: PolyThreeMeshMount[]; +} + +export function mountPolyThreeScene( + host: HTMLElement, + options: MountPolyThreeSceneOptions, +): PolySceneHandle { + const camera = options.camera instanceof PerspectiveCamera + ? createPolyPerspectiveCameraFromThree( + options.camera, + options.cameraOptions as PolyPerspectiveCameraFromThreeOptions | undefined, + ) + : createPolyOrthographicCameraFromThree( + options.camera, + options.cameraOptions as PolyOrthographicCameraFromThreeOptions | undefined, + ); + + const { + camera: _camera, + cameraOptions: _cameraOptions, + polygons, + meshes, + ...sceneOptions + } = options; + const scene = createPolyScene(host, { + ...sceneOptions, + textureLighting: sceneOptions.textureLighting ?? "baked", + camera, + }); + + if (meshes) { + for (const mesh of meshes) { + scene.add(parseResultFromPolygons(mesh.polygons), { + merge: false, + ...mesh.transform, + }); + } + } else { + scene.add(parseResultFromPolygons(polygons ?? []), { merge: false }); + } + + return scene; +} + +function parseResultFromPolygons(polygons: Polygon[]): ParseResult { + return { + polygons, + objectUrls: [], + warnings: [], + dispose() {}, + }; +} diff --git a/packages/polycss/tsup.config.ts b/packages/polycss/tsup.config.ts index a5211f0a..f34f6bcc 100644 --- a/packages/polycss/tsup.config.ts +++ b/packages/polycss/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: "src/index.ts", elements: "src/elements/index.ts", + three: "src/three.ts", }, format: ["esm", "cjs"], dts: true, @@ -13,5 +14,5 @@ export default defineConfig({ minify: true, target: "es2020", tsconfig: "tsconfig.build.json", - external: ["@layoutit/polycss-core"], + external: ["@layoutit/polycss-core", "@layoutit/polycss-core/three"], }); diff --git a/packages/polycss/vitest.config.ts b/packages/polycss/vitest.config.ts index a78ffda8..8ff344b4 100644 --- a/packages/polycss/vitest.config.ts +++ b/packages/polycss/vitest.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ }, resolve: { alias: { + "@layoutit/polycss-core/three": resolve(__dirname, "../core/src/three/index.ts"), "@layoutit/polycss-core": resolve(__dirname, "../core/src"), }, }, diff --git a/packages/react/README.md b/packages/react/README.md index f4d3bc19..9e379187 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,26 +1,24 @@ -

    - PolyCSS -

    - # PolyCSS -A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, STL, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. +A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript. Visit [polycss.com](https://polycss.com) for docs and model examples. -PolyCSS scene +PolyCSS primitives banner ## Installation ```bash + +# Vanilla +npm install @layoutit/polycss + # React npm install @layoutit/polycss-react # Vue npm install @layoutit/polycss-vue -# Vanilla / custom elements -npm install @layoutit/polycss ``` You can also load PolyCSS directly from a CDN. Here is a minimal custom-element scene: @@ -36,9 +34,11 @@ You can also load PolyCSS directly from a CDN. Here is a minimal custom-element ``` +PolyCSS intro + ## Framework Components -React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and atlas options, and `` loads or receives polygon data. +React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and options, and `` loads or receives polygon data. ```tsx import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; @@ -55,23 +55,54 @@ export default function App() { } ``` -The Vue package mirrors the same names and props with Vue casing: - -```vue - - - +## Three.js Parity API + +When porting Three.js scenes or generating code with an agent, use the explicit +`*/three` subpaths: + +- `@layoutit/polycss-core/three` +- `@layoutit/polycss/three` +- `@layoutit/polycss-react/three` +- `@layoutit/polycss-vue/three` + +They expose Three-like `PerspectiveCamera`, `OrthographicCamera`, `Object3D`, +`Vector3`, `DirectionalLight`, `PointLight`, `AmbientLight`, radians for object +rotations, Y-up authoring coordinates, and `camera.position` + `camera.lookAt(...)` +framing. The adapters convert into native PolyCSS coordinates with a right-handed +axis map, so the apparent object size, projection, orientation, depth ordering, +and light direction line up with Three.js scene math while still rendering +through the DOM. + +```tsx +import { PolyScene } from "@layoutit/polycss-react"; +import { + DirectionalLight, + PolyThreeMesh, + PolyThreePerspectiveCamera, +} from "@layoutit/polycss-react/three"; + +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); + +export function App() { + return ( + + + + + + ); +} ``` +Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-parity). + ## API Reference ### PolyCamera @@ -88,7 +119,6 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. -- Solid seam bleed is automatic on detected shared solid edges. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -99,9 +129,8 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `polygons` accepts pre-parsed geometry. - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair; STL imports use the conservative lossless path in both modes. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. - `castShadow` emits CSS-projected shadows in dynamic lighting mode. -- Tooling can reuse `buildPolyMeshTransform`, `buildPolySceneTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions. ### Controls @@ -110,6 +139,18 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `` provides keyboard and pointer-look navigation. - `` adds translate/rotate gizmos for selected mesh handles. +### Snapshot Export + +The vanilla package exports `exportPolySceneSnapshot(target)`. It clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React/Vue scenes too; import it from `@layoutit/polycss` and pass the rendered camera or scene element. + +```ts +import { exportPolySceneSnapshot } from "@layoutit/polycss"; + +const html = await exportPolySceneSnapshot(scene.host); +``` + +If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`. + ### Polygon Data Model Each polygon describes one renderable face: @@ -147,7 +188,7 @@ Render polygons directly when you need per-face DOM events or custom styling: ## Loading Mesh Files -Use `loadMesh()` from `@layoutit/polycss`, `@layoutit/polycss-react`, or `@layoutit/polycss-vue` to parse supported model formats: +Use `loadMesh()` to parse supported model formats: ```ts import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; @@ -173,16 +214,7 @@ Supported formats: ## Performance -PolyCSS renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. - -- One visible polygon becomes one leaf DOM element. -- Flat rectangles and stable quads use solid CSS leaves. -- Textured polygons are packed into generated texture atlases. -- Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. -- Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. -- `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. - -Renderer internals: +PolyCSS renders through the DOM, so performance is mostly shaped by two things: the number of mounted leaves, and the amount of texture atlas area the browser has to paint. The renderer tries to keep the common cases cheap. Simple surfaces stay as solid CSS elements, while textured, irregular, or high-detail geometry falls back to atlas-backed slices only when needed. Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. @@ -191,8 +223,6 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le - `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. - `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. -For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. - ## Packages | Package | Description | @@ -204,10 +234,11 @@ For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, wh ## Made with PolyCSS -[Layoutit Voxels](https://voxels.layoutit.com) --> A CSS Voxel editor +[cssQuake](https://cssquake.com) +-> A CSS port of Quake (1996) + +quake -layoutit-voxels [Layoutit Terra](https://terra.layoutit.com) -> A CSS Terrain Generator diff --git a/packages/react/package.json b/packages/react/package.json index 8d0bfcc0..ac3dd665 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -25,6 +25,16 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./three": { + "types": "./dist/three.d.ts", + "import": "./dist/three.js", + "require": "./dist/three.cjs" + } + }, + "typesVersions": { + "*": { + "three": ["dist/three.d.ts"] } }, "scripts": { diff --git a/packages/react/src/three/PolyThreeMesh.test.tsx b/packages/react/src/three/PolyThreeMesh.test.tsx new file mode 100644 index 00000000..96dca777 --- /dev/null +++ b/packages/react/src/three/PolyThreeMesh.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { boxPolygons } from "@layoutit/polycss-core"; +import { PolyScene } from "../scene/PolyScene"; +import { PolyThreeMesh } from "./PolyThreeMesh"; +import { PolyThreePerspectiveCamera } from "./PolyThreePerspectiveCamera"; + +function renderToDiv(element: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + const root = createRoot(container); + act(() => root.render(element)); + return container; +} + +describe("PolyThreeMesh", () => { + it("renders Three-authored polygons inside a PolyCSS scene", async () => { + const container = renderToDiv( + + + + + , + ); + + expect(container.querySelector(".polycss-camera")).toBeTruthy(); + expect(container.querySelector(".polycss-scene")).toBeTruthy(); + expect(container.querySelector(".polycss-mesh")).toBeTruthy(); + }); +}); diff --git a/packages/react/src/three/PolyThreeMesh.tsx b/packages/react/src/three/PolyThreeMesh.tsx new file mode 100644 index 00000000..6a84763e --- /dev/null +++ b/packages/react/src/three/PolyThreeMesh.tsx @@ -0,0 +1,130 @@ +import { memo, useMemo, useRef } from "react"; +import { + computeSceneBbox, +} from "@layoutit/polycss-core"; +import type { + LoadMeshOptions, + Polygon, + Vec3, +} from "@layoutit/polycss-core"; +import { + Object3D, + transformPolygonsToPoly, +} from "@layoutit/polycss-core/three"; +import type { Vector3Tuple } from "@layoutit/polycss-core/three"; +import { PolyMesh } from "../scene/PolyMesh"; +import type { PolyMeshProps } from "../scene/PolyMesh"; +import { usePolyMesh } from "../scene/useMesh"; + +export interface PolyThreeMeshProps extends Omit< + PolyMeshProps, + "polygons" | "src" | "mtl" | "position" | "rotation" | "scale" | "autoCenter" | "parseOptions" | "meshResolution" +> { + object?: Object3D; + polygons?: Polygon[]; + src?: string; + mtl?: string; + position?: Vector3Tuple; + rotation?: Vector3Tuple; + scale?: number | Vector3Tuple; + autoCenter?: boolean; + parseOptions?: LoadMeshOptions; + meshResolution?: PolyMeshProps["meshResolution"]; +} + +function applyObjectProps( + object: Object3D, + { + position, + rotation, + scale, + }: Pick, +): Object3D { + if (position) object.position.set(position[0], position[1], position[2]); + if (rotation) object.rotation.set(rotation[0], rotation[1], rotation[2]); + if (typeof scale === "number") object.scale.set(scale, scale, scale); + else if (scale) object.scale.set(scale[0], scale[1], scale[2]); + return object; +} + +function recenterPolygons(polygons: Polygon[]): Polygon[] { + if (polygons.length === 0) return polygons; + const bbox = computeSceneBbox(polygons); + const center: Vec3 = [ + (bbox.min[0] + bbox.max[0]) / 2, + (bbox.min[1] + bbox.max[1]) / 2, + (bbox.min[2] + bbox.max[2]) / 2, + ]; + if (center[0] === 0 && center[1] === 0 && center[2] === 0) return polygons; + const shift = (v: Vec3): Vec3 => [v[0] - center[0], v[1] - center[1], v[2] - center[2]]; + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map(shift), + ...(polygon.textureTriangles?.length + ? { + textureTriangles: polygon.textureTriangles.map((triangle) => ({ + ...triangle, + vertices: triangle.vertices.map(shift) as typeof triangle.vertices, + })), + } + : null), + })); +} + +function PolyThreeMeshInner({ + object: objectProp, + polygons: polygonsProp, + src, + mtl, + position, + rotation, + scale, + autoCenter = false, + parseOptions, + meshResolution, + fallback, + errorFallback, + ...meshProps +}: PolyThreeMeshProps) { + const objectRef = useRef(objectProp ?? new Object3D()); + const mergedOptions = useMemo(() => { + if (!mtl && !parseOptions && meshResolution === undefined) return undefined; + return { + ...(parseOptions ?? {}), + ...(mtl ? { mtlUrl: mtl } : {}), + ...(meshResolution !== undefined ? { meshResolution } : {}), + }; + }, [mtl, parseOptions, meshResolution]); + const fetched = usePolyMesh(src ?? "", mergedOptions); + const rawPolygons = polygonsProp ?? (src ? fetched.polygons : []); + + const sourcePolygons = useMemo( + () => (autoCenter ? recenterPolygons(rawPolygons) : rawPolygons), + [rawPolygons, autoCenter], + ); + const polyPolygons = useMemo(() => { + const object = objectProp ?? objectRef.current; + return transformPolygonsToPoly( + sourcePolygons, + applyObjectProps(object, { position, rotation, scale }), + ); + }, [sourcePolygons, objectProp, position, rotation, scale]); + + if (src && fetched.loading && polyPolygons.length === 0 && fallback !== undefined) { + return <>{fallback}; + } + if (src && fetched.error && errorFallback) { + return <>{errorFallback(fetched.error)}; + } + + return ( + + ); +} + +export const PolyThreeMesh = memo(PolyThreeMeshInner); diff --git a/packages/react/src/three/PolyThreeOrthographicCamera.tsx b/packages/react/src/three/PolyThreeOrthographicCamera.tsx new file mode 100644 index 00000000..81c7974f --- /dev/null +++ b/packages/react/src/three/PolyThreeOrthographicCamera.tsx @@ -0,0 +1,142 @@ +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { OrthographicCamera } from "@layoutit/polycss-core/three"; +import type { Vector3Tuple } from "@layoutit/polycss-core/three"; +import { PolyCameraContext } from "../camera/context"; +import { usePolyCamera } from "../camera/useCamera"; + +export interface PolyThreeOrthographicCameraProps { + camera?: OrthographicCamera; + left?: number; + right?: number; + top?: number; + bottom?: number; + near?: number; + far?: number; + zoom?: number; + polyZoom?: number; + viewportHeight?: number; + position?: Vector3Tuple; + lookAt?: Vector3Tuple; + up?: Vector3Tuple; + className?: string; + style?: CSSProperties; + children?: ReactNode; +} + +function applyCameraProps( + camera: OrthographicCamera, + props: Pick, +): OrthographicCamera { + if (props.left !== undefined) camera.left = props.left; + if (props.right !== undefined) camera.right = props.right; + if (props.top !== undefined) camera.top = props.top; + if (props.bottom !== undefined) camera.bottom = props.bottom; + if (props.near !== undefined) camera.near = props.near; + if (props.far !== undefined) camera.far = props.far; + if (props.zoom !== undefined) camera.zoom = props.zoom; + if (props.position !== undefined) camera.position.set(props.position[0], props.position[1], props.position[2]); + if (props.up !== undefined) camera.up.set(props.up[0], props.up[1], props.up[2]); + if (props.lookAt !== undefined) camera.lookAt(props.lookAt[0], props.lookAt[1], props.lookAt[2]); + return camera; +} + +function PolyThreeOrthographicCameraInner({ + camera: cameraProp, + left = -1, + right = 1, + top = 1, + bottom = -1, + near = 0.1, + far = 2000, + zoom, + polyZoom, + viewportHeight, + position, + lookAt, + up, + className, + style, + children, +}: PolyThreeOrthographicCameraProps) { + const localCameraRef = useRef(null); + if (!localCameraRef.current) { + localCameraRef.current = new OrthographicCamera(left, right, top, bottom, near, far); + } + + const [measuredHeight, setMeasuredHeight] = useState(viewportHeight ?? 420); + const camera = applyCameraProps(cameraProp ?? localCameraRef.current, { + left, + right, + top, + bottom, + near, + far, + zoom, + position, + lookAt, + up, + }); + const polyCamera = camera.toPolyCameraState({ + zoom: polyZoom, + viewportHeight: viewportHeight ?? measuredHeight, + }); + + const { + store, + cameraRef, + sceneElRef, + cameraElRef, + applyTransformDirect, + } = usePolyCamera({ + zoom: polyCamera.zoom, + target: polyCamera.target, + rotX: polyCamera.rotX, + rotY: polyCamera.rotY, + distance: polyCamera.distance, + projection: "orthographic", + perspectiveStyle: "none", + }); + + useEffect(() => { + if (viewportHeight !== undefined) return; + const el = cameraElRef.current; + if (!el) return; + const measure = () => { + const next = el.getBoundingClientRect().height || el.clientHeight || 420; + setMeasuredHeight(next); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [cameraElRef, viewportHeight]); + + const contextValue = useMemo( + () => ({ store, cameraRef, sceneElRef, cameraElRef, applyTransformDirect }), + [store, cameraRef, sceneElRef, cameraElRef, applyTransformDirect], + ); + + return ( + +
    + {children} +
    +
    + ); +} + +export const PolyThreeOrthographicCamera = memo(PolyThreeOrthographicCameraInner); diff --git a/packages/react/src/three/PolyThreePerspectiveCamera.tsx b/packages/react/src/three/PolyThreePerspectiveCamera.tsx new file mode 100644 index 00000000..fcb577f3 --- /dev/null +++ b/packages/react/src/three/PolyThreePerspectiveCamera.tsx @@ -0,0 +1,138 @@ +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import type { CSSProperties, ReactNode } from "react"; +import { PerspectiveCamera } from "@layoutit/polycss-core/three"; +import type { Vector3Tuple } from "@layoutit/polycss-core/three"; +import { PolyCameraContext } from "../camera/context"; +import { usePolyCamera } from "../camera/useCamera"; + +export interface PolyThreePerspectiveCameraProps { + camera?: PerspectiveCamera; + fov?: number; + aspect?: number; + near?: number; + far?: number; + zoom?: number; + polyZoom?: number; + perspective?: number; + viewportHeight?: number; + position?: Vector3Tuple; + lookAt?: Vector3Tuple; + up?: Vector3Tuple; + className?: string; + style?: CSSProperties; + children?: ReactNode; +} + +function applyCameraProps( + camera: PerspectiveCamera, + props: Pick, +): PerspectiveCamera { + if (props.fov !== undefined) camera.fov = props.fov; + if (props.aspect !== undefined) camera.aspect = props.aspect; + if (props.near !== undefined) camera.near = props.near; + if (props.far !== undefined) camera.far = props.far; + if (props.zoom !== undefined) camera.zoom = props.zoom; + if (props.position !== undefined) camera.position.set(props.position[0], props.position[1], props.position[2]); + if (props.up !== undefined) camera.up.set(props.up[0], props.up[1], props.up[2]); + if (props.lookAt !== undefined) camera.lookAt(props.lookAt[0], props.lookAt[1], props.lookAt[2]); + return camera; +} + +function PolyThreePerspectiveCameraInner({ + camera: cameraProp, + fov = 50, + aspect = 1, + near = 0.1, + far = 2000, + zoom, + polyZoom, + perspective, + viewportHeight, + position, + lookAt, + up, + className, + style, + children, +}: PolyThreePerspectiveCameraProps) { + const localCameraRef = useRef(null); + if (!localCameraRef.current) { + localCameraRef.current = new PerspectiveCamera(fov, aspect, near, far); + } + + const [measuredHeight, setMeasuredHeight] = useState(viewportHeight ?? 420); + const camera = applyCameraProps(cameraProp ?? localCameraRef.current, { + fov, + aspect, + near, + far, + zoom, + position, + lookAt, + up, + }); + const polyCamera = camera.toPolyCameraState({ + zoom: polyZoom, + perspective, + viewportHeight: viewportHeight ?? measuredHeight, + }); + const perspectiveStyle = `${polyCamera.perspective}px`; + + const { + store, + cameraRef, + sceneElRef, + cameraElRef, + applyTransformDirect, + } = usePolyCamera({ + zoom: polyCamera.zoom, + target: polyCamera.target, + rotX: polyCamera.rotX, + rotY: polyCamera.rotY, + distance: polyCamera.distance, + projection: "perspective", + perspectiveStyle, + }); + + useEffect(() => { + if (viewportHeight !== undefined) return; + const el = cameraElRef.current; + if (!el) return; + const measure = () => { + const next = el.getBoundingClientRect().height || el.clientHeight || 420; + setMeasuredHeight(next); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [cameraElRef, viewportHeight]); + + const contextValue = useMemo( + () => ({ store, cameraRef, sceneElRef, cameraElRef, applyTransformDirect }), + [store, cameraRef, sceneElRef, cameraElRef, applyTransformDirect], + ); + + return ( + +
    + {children} +
    +
    + ); +} + +export const PolyThreePerspectiveCamera = memo(PolyThreePerspectiveCameraInner); diff --git a/packages/react/src/three/index.ts b/packages/react/src/three/index.ts new file mode 100644 index 00000000..223cef5f --- /dev/null +++ b/packages/react/src/three/index.ts @@ -0,0 +1,31 @@ +export { + AmbientLight, + DirectionalLight, + Euler, + Object3D, + OrthographicCamera, + PerspectiveCamera, + PointLight, + Vector3, + polyToThreeDirection, + polyToThreePoint, + threeToPolyDirection, + threeToPolyPoint, + transformPointToPoly, + transformPolygonsToPoly, +} from "@layoutit/polycss-core/three"; +export type { + PolyCameraFromThreeOptions, + PolyOrthographicCameraFromThreeOptions, + PolyOrthographicCameraOptionsFromThree, + PolyPerspectiveCameraFromThreeOptions, + PolyPerspectiveCameraOptionsFromThree, + Vector3Tuple, +} from "@layoutit/polycss-core/three"; + +export { PolyThreePerspectiveCamera } from "./PolyThreePerspectiveCamera"; +export type { PolyThreePerspectiveCameraProps } from "./PolyThreePerspectiveCamera"; +export { PolyThreeOrthographicCamera } from "./PolyThreeOrthographicCamera"; +export type { PolyThreeOrthographicCameraProps } from "./PolyThreeOrthographicCamera"; +export { PolyThreeMesh } from "./PolyThreeMesh"; +export type { PolyThreeMeshProps } from "./PolyThreeMesh"; diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index 9f9394d3..43007ba2 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: { index: "src/index.ts", + three: "src/three/index.ts", }, format: ["esm", "cjs"], dts: true, diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 2346f9c6..d45d9f91 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -26,7 +26,9 @@ export default defineConfig({ }, resolve: { alias: { + "@layoutit/polycss-core/three": path.resolve(__dirname, "../core/src/three/index.ts"), "@layoutit/polycss": path.resolve(__dirname, "../polycss/src/index.ts"), + "@layoutit/polycss/three": path.resolve(__dirname, "../polycss/src/three.ts"), "@layoutit/polycss-core": path.resolve(__dirname, "../core/src/index.ts"), }, }, diff --git a/packages/vue/README.md b/packages/vue/README.md index f4d3bc19..9e379187 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -1,26 +1,24 @@ -

    - PolyCSS -

    - # PolyCSS -A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, STL, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. +A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, STL, glTF/GLB, and VOX as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, shapes and animations. Works with React, Vue or plain JavaScript. Visit [polycss.com](https://polycss.com) for docs and model examples. -PolyCSS scene +PolyCSS primitives banner ## Installation ```bash + +# Vanilla +npm install @layoutit/polycss + # React npm install @layoutit/polycss-react # Vue npm install @layoutit/polycss-vue -# Vanilla / custom elements -npm install @layoutit/polycss ``` You can also load PolyCSS directly from a CDN. Here is a minimal custom-element scene: @@ -36,9 +34,11 @@ You can also load PolyCSS directly from a CDN. Here is a minimal custom-element ``` +PolyCSS intro + ## Framework Components -React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and atlas options, and `` loads or receives polygon data. +React and Vue expose the same component model. `` owns the viewpoint, `` owns lighting and options, and `` loads or receives polygon data. ```tsx import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/polycss-react"; @@ -55,23 +55,54 @@ export default function App() { } ``` -The Vue package mirrors the same names and props with Vue casing: - -```vue - - - +## Three.js Parity API + +When porting Three.js scenes or generating code with an agent, use the explicit +`*/three` subpaths: + +- `@layoutit/polycss-core/three` +- `@layoutit/polycss/three` +- `@layoutit/polycss-react/three` +- `@layoutit/polycss-vue/three` + +They expose Three-like `PerspectiveCamera`, `OrthographicCamera`, `Object3D`, +`Vector3`, `DirectionalLight`, `PointLight`, `AmbientLight`, radians for object +rotations, Y-up authoring coordinates, and `camera.position` + `camera.lookAt(...)` +framing. The adapters convert into native PolyCSS coordinates with a right-handed +axis map, so the apparent object size, projection, orientation, depth ordering, +and light direction line up with Three.js scene math while still rendering +through the DOM. + +```tsx +import { PolyScene } from "@layoutit/polycss-react"; +import { + DirectionalLight, + PolyThreeMesh, + PolyThreePerspectiveCamera, +} from "@layoutit/polycss-react/three"; + +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); + +export function App() { + return ( + + + + + + ); +} ``` +Full reference: [polycss.com/api/three-parity](https://polycss.com/api/three-parity). + ## API Reference ### PolyCamera @@ -88,7 +119,6 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `directionalLight`, `pointLights` (direction-only, baked mode; optional per-light `castShadow`), and `ambientLight` control scene lighting. - `textureLighting` chooses `"baked"` or `"dynamic"`. - `textureQuality` controls atlas raster budget. -- Solid seam bleed is automatic on detected shared solid edges. - `strategies` can disable selected render strategies for diagnostics. - `autoCenter` rotates around the rendered mesh bounds instead of world origin. @@ -99,9 +129,8 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `polygons` accepts pre-parsed geometry. - `position`, `scale`, and `rotation` transform the mesh wrapper. - `autoCenter` shifts the mesh bbox center to local origin. -- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. Lossy also applies bounded seam repair; STL imports use the conservative lossless path in both modes. +- `meshResolution` chooses `"lossy"` (default) or `"lossless"` optimization. STL imports use the conservative lossless path in both modes. - `castShadow` emits CSS-projected shadows in dynamic lighting mode. -- Tooling can reuse `buildPolyMeshTransform`, `buildPolySceneTransform`, `worldPositionToPolyCss`, `worldDirectionToPolyCss`, `worldDirectionalLightToPolyCss`, `worldDistanceToPolyCss`, `polyCssDistanceToWorld`, and `polyCssPositionToWorld` for renderer-compatible transforms and world/CSS conversions. ### Controls @@ -110,6 +139,18 @@ import { PolyCamera, PolyScene, PolyOrbitControls, PolyMesh } from "@layoutit/po - `` provides keyboard and pointer-look navigation. - `` adds translate/rotate gizmos for selected mesh handles. +### Snapshot Export + +The vanilla package exports `exportPolySceneSnapshot(target)`. It clones the current rendered `.polycss-camera` / `.polycss-scene` DOM, injects only the PolyCSS CSS needed by that snapshot, inlines CSS `url(...)` image assets as `data:image/...;base64,...`, strips scripts and inline event handlers, and returns a standalone HTML document string with no PolyCSS runtime import. It works with rendered React/Vue scenes too; import it from `@layoutit/polycss` and pass the rendered camera or scene element. + +```ts +import { exportPolySceneSnapshot } from "@layoutit/polycss"; + +const html = await exportPolySceneSnapshot(scene.host); +``` + +If any referenced asset cannot be inlined, the function throws `PolySceneSnapshotError` with `code: "ASSET_INLINE_FAILED"`. + ### Polygon Data Model Each polygon describes one renderable face: @@ -147,7 +188,7 @@ Render polygons directly when you need per-face DOM events or custom styling: ## Loading Mesh Files -Use `loadMesh()` from `@layoutit/polycss`, `@layoutit/polycss-react`, or `@layoutit/polycss-vue` to parse supported model formats: +Use `loadMesh()` to parse supported model formats: ```ts import { createPolyCamera, createPolyScene, loadMesh } from "@layoutit/polycss"; @@ -173,16 +214,7 @@ Supported formats: ## Performance -PolyCSS renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. - -- One visible polygon becomes one leaf DOM element. -- Flat rectangles and stable quads use solid CSS leaves. -- Textured polygons are packed into generated texture atlases. -- Dynamic lighting runs through CSS custom properties instead of per-frame JavaScript. -- Voxel-shaped meshes mount only camera-facing leaves when the mesh is eligible. -- `meshResolution: "lossy"` merges compatible polygons, then may spend a small split budget to repair high-risk seams. - -Renderer internals: +PolyCSS renders through the DOM, so performance is mostly shaped by two things: the number of mounted leaves, and the amount of texture atlas area the browser has to paint. The renderer tries to keep the common cases cheap. Simple surfaces stay as solid CSS elements, while textured, irregular, or high-detail geometry falls back to atlas-backed slices only when needed. Each visible polygon is emitted as one leaf element; the renderer chooses the least expensive CSS primitive that can represent the polygon, then uses `matrix3d(...)` to place that primitive in 3D space. @@ -191,8 +223,6 @@ Each visible polygon is emitted as one leaf element; the renderer chooses the le - `` clips solid polygons with `border-shape: polygon(...)` when the browser supports it. - `` maps a packed texture-atlas slice with `background-image`, and is the fallback for textured or unsupported shapes. -For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, which returns mounted polygon leaf counts, shadow counts, surface categories, and bucket counts for an already-rendered scene. - ## Packages | Package | Description | @@ -204,10 +234,11 @@ For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, wh ## Made with PolyCSS -[Layoutit Voxels](https://voxels.layoutit.com) --> A CSS Voxel editor +[cssQuake](https://cssquake.com) +-> A CSS port of Quake (1996) + +quake -layoutit-voxels [Layoutit Terra](https://terra.layoutit.com) -> A CSS Terrain Generator diff --git a/packages/vue/package.json b/packages/vue/package.json index 803c56be..75f827b6 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -25,6 +25,16 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./three": { + "types": "./dist/three.d.ts", + "import": "./dist/three.js", + "require": "./dist/three.cjs" + } + }, + "typesVersions": { + "*": { + "three": ["dist/three.d.ts"] } }, "scripts": { diff --git a/packages/vue/src/three/PolyThreeMesh.test.ts b/packages/vue/src/three/PolyThreeMesh.test.ts new file mode 100644 index 00000000..40ea71f5 --- /dev/null +++ b/packages/vue/src/three/PolyThreeMesh.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { createApp, h } from "vue"; +import { boxPolygons } from "@layoutit/polycss-core"; +import { PolyScene } from "../scene/PolyScene"; +import { PolyThreeMesh } from "./PolyThreeMesh"; +import { PolyThreePerspectiveCamera } from "./PolyThreePerspectiveCamera"; + +describe("PolyThreeMesh", () => { + it("renders Three-authored polygons inside a PolyCSS scene", () => { + const container = document.createElement("div"); + const app = createApp({ + render() { + return h(PolyThreePerspectiveCamera, { + fov: 50, + aspect: 1, + position: [3, 2, 5], + lookAt: [0, 0, 0], + viewportHeight: 420, + }, { + default: () => h(PolyScene, {}, { + default: () => h(PolyThreeMesh, { + polygons: boxPolygons({ size: 1, color: "#66aaff" }), + rotation: [0, Math.PI / 4, 0], + }), + }), + }); + }, + }); + app.mount(container); + + expect(container.querySelector(".polycss-camera")).toBeTruthy(); + expect(container.querySelector(".polycss-scene")).toBeTruthy(); + expect(container.querySelector(".polycss-mesh")).toBeTruthy(); + }); +}); diff --git a/packages/vue/src/three/PolyThreeMesh.ts b/packages/vue/src/three/PolyThreeMesh.ts new file mode 100644 index 00000000..060a086c --- /dev/null +++ b/packages/vue/src/three/PolyThreeMesh.ts @@ -0,0 +1,166 @@ +import { computed, defineComponent, h, shallowRef } from "vue"; +import type { CSSProperties, PropType } from "vue"; +import { + computeSceneBbox, +} from "@layoutit/polycss-core"; +import type { + LoadMeshOptions, + MeshResolution, + Polygon, + PolyTextureBackend, + PolyTextureImageRendering, + PolyTextureLeafSizing, + PolyTextureLightingMode, + PolyTextureProjection, + Vec3, +} from "@layoutit/polycss-core"; +import { + Object3D, + transformPolygonsToPoly, +} from "@layoutit/polycss-core/three"; +import type { Vector3Tuple } from "@layoutit/polycss-core/three"; +import { PolyMesh } from "../scene/PolyMesh"; +import { usePolyMesh } from "../scene/useMesh"; + +export interface PolyThreeMeshProps { + id?: string; + object?: Object3D; + polygons?: Polygon[]; + src?: string; + mtl?: string; + position?: Vector3Tuple; + rotation?: Vector3Tuple; + scale?: number | Vector3Tuple; + autoCenter?: boolean; + textureLighting?: PolyTextureLightingMode; + textureQuality?: number | "auto"; + textureLeafSizing?: PolyTextureLeafSizing; + textureImageRendering?: PolyTextureImageRendering; + textureBackend?: PolyTextureBackend; + textureProjection?: PolyTextureProjection; + castShadow?: boolean; + receiveShadow?: boolean; + merge?: boolean; + meshResolution?: MeshResolution; + parseOptions?: LoadMeshOptions; + class?: string; + style?: CSSProperties | string; +} + +function applyObjectProps( + object: Object3D, + props: Pick, +): Object3D { + if (props.position) object.position.set(props.position[0], props.position[1], props.position[2]); + if (props.rotation) object.rotation.set(props.rotation[0], props.rotation[1], props.rotation[2]); + if (typeof props.scale === "number") object.scale.set(props.scale, props.scale, props.scale); + else if (props.scale) object.scale.set(props.scale[0], props.scale[1], props.scale[2]); + return object; +} + +function recenterPolygons(polygons: Polygon[]): Polygon[] { + if (polygons.length === 0) return polygons; + const bbox = computeSceneBbox(polygons); + const center: Vec3 = [ + (bbox.min[0] + bbox.max[0]) / 2, + (bbox.min[1] + bbox.max[1]) / 2, + (bbox.min[2] + bbox.max[2]) / 2, + ]; + if (center[0] === 0 && center[1] === 0 && center[2] === 0) return polygons; + const shift = (v: Vec3): Vec3 => [v[0] - center[0], v[1] - center[1], v[2] - center[2]]; + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map(shift), + ...(polygon.textureTriangles?.length + ? { + textureTriangles: polygon.textureTriangles.map((triangle) => ({ + ...triangle, + vertices: triangle.vertices.map(shift) as typeof triangle.vertices, + })), + } + : null), + })); +} + +export const PolyThreeMesh = defineComponent({ + name: "PolyThreeMesh", + inheritAttrs: false, + props: { + id: { type: String, default: undefined }, + object: { type: Object as PropType, default: undefined }, + polygons: { type: Array as PropType, default: undefined }, + src: { type: String, default: undefined }, + mtl: { type: String, default: undefined }, + position: { type: Array as unknown as PropType, default: undefined }, + rotation: { type: Array as unknown as PropType, default: undefined }, + scale: { type: [Number, Array] as unknown as PropType, default: undefined }, + autoCenter: { type: Boolean, default: false }, + textureLighting: { type: String as PropType, default: undefined }, + textureQuality: { type: [Number, String] as PropType, default: undefined }, + textureLeafSizing: { type: String as PropType, default: undefined }, + textureImageRendering: { type: String as PropType, default: undefined }, + textureBackend: { type: String as PropType, default: undefined }, + textureProjection: { type: String as PropType, default: undefined }, + castShadow: { type: Boolean, default: false }, + receiveShadow: { type: Boolean, default: false }, + merge: { type: Boolean, default: true }, + meshResolution: { type: String as PropType, default: undefined }, + parseOptions: { type: Object as PropType, default: undefined }, + class: { type: String, default: undefined }, + style: { type: [Object, String] as PropType, default: undefined }, + }, + setup(props, { attrs, slots }) { + const objectRef = shallowRef(props.object ?? new Object3D()); + const srcRef = computed(() => props.src ?? ""); + const meshOptions = computed(() => { + if (!props.mtl && !props.parseOptions && props.meshResolution === undefined) return undefined; + return { + ...(props.parseOptions ?? {}), + ...(props.mtl ? { mtlUrl: props.mtl } : {}), + ...(props.meshResolution !== undefined ? { meshResolution: props.meshResolution } : {}), + }; + }); + const fetched = usePolyMesh(srcRef, meshOptions.value); + + const sourcePolygons = computed(() => { + const raw = props.polygons ?? (props.src ? fetched.polygons.value : []); + return props.autoCenter ? recenterPolygons(raw) : raw; + }); + const polyPolygons = computed(() => transformPolygonsToPoly( + sourcePolygons.value, + applyObjectProps(props.object ?? objectRef.value, { + position: props.position, + rotation: props.rotation, + scale: props.scale, + }), + )); + + return () => { + if (props.src && fetched.loading.value && polyPolygons.value.length === 0 && slots.fallback) { + return slots.fallback(); + } + if (props.src && fetched.error.value && slots.error) { + return slots.error({ error: fetched.error.value }); + } + + return h(PolyMesh, { + ...attrs, + id: props.id, + polygons: polyPolygons.value, + autoCenter: false, + textureLighting: props.textureLighting, + textureQuality: props.textureQuality, + textureLeafSizing: props.textureLeafSizing, + textureImageRendering: props.textureImageRendering, + textureBackend: props.textureBackend, + textureProjection: props.textureProjection, + castShadow: props.castShadow, + receiveShadow: props.receiveShadow, + merge: props.merge, + meshResolution: props.meshResolution, + class: props.class, + style: props.style, + }, slots); + }; + }, +}); diff --git a/packages/vue/src/three/PolyThreeOrthographicCamera.ts b/packages/vue/src/three/PolyThreeOrthographicCamera.ts new file mode 100644 index 00000000..a2d925ef --- /dev/null +++ b/packages/vue/src/three/PolyThreeOrthographicCamera.ts @@ -0,0 +1,119 @@ +import { computed, defineComponent, h, provide, ref, watch } from "vue"; +import type { PropType } from "vue"; +import { OrthographicCamera } from "@layoutit/polycss-core/three"; +import type { Vector3Tuple } from "@layoutit/polycss-core/three"; +import { usePolyCamera } from "../camera/useCamera"; +import { PolyCameraContextKey } from "../camera/context"; + +export interface PolyThreeOrthographicCameraProps { + camera?: OrthographicCamera; + left?: number; + right?: number; + top?: number; + bottom?: number; + near?: number; + far?: number; + zoom?: number; + polyZoom?: number; + viewportHeight?: number; + position?: Vector3Tuple; + lookAt?: Vector3Tuple; + up?: Vector3Tuple; + class?: string; +} + +function applyCameraProps( + camera: OrthographicCamera, + props: PolyThreeOrthographicCameraProps, +): OrthographicCamera { + if (props.left !== undefined) camera.left = props.left; + if (props.right !== undefined) camera.right = props.right; + if (props.top !== undefined) camera.top = props.top; + if (props.bottom !== undefined) camera.bottom = props.bottom; + if (props.near !== undefined) camera.near = props.near; + if (props.far !== undefined) camera.far = props.far; + if (props.zoom !== undefined) camera.zoom = props.zoom; + if (props.position !== undefined) camera.position.set(props.position[0], props.position[1], props.position[2]); + if (props.up !== undefined) camera.up.set(props.up[0], props.up[1], props.up[2]); + if (props.lookAt !== undefined) camera.lookAt(props.lookAt[0], props.lookAt[1], props.lookAt[2]); + return camera; +} + +export const PolyThreeOrthographicCamera = defineComponent({ + name: "PolyThreeOrthographicCamera", + props: { + camera: { type: Object as PropType, default: undefined }, + left: { type: Number, default: -1 }, + right: { type: Number, default: 1 }, + top: { type: Number, default: 1 }, + bottom: { type: Number, default: -1 }, + near: { type: Number, default: 0.1 }, + far: { type: Number, default: 2000 }, + zoom: { type: Number, default: undefined }, + polyZoom: { type: Number, default: undefined }, + viewportHeight: { type: Number, default: undefined }, + position: { type: Array as unknown as PropType, default: undefined }, + lookAt: { type: Array as unknown as PropType, default: undefined }, + up: { type: Array as unknown as PropType, default: undefined }, + class: { type: String, default: undefined }, + }, + setup(props, { slots }) { + const localCamera = new OrthographicCamera(props.left, props.right, props.top, props.bottom, props.near, props.far); + const measuredHeight = ref(props.viewportHeight ?? 420); + + const polyCamera = computed(() => applyCameraProps(props.camera ?? localCamera, props).toPolyCameraState({ + zoom: props.polyZoom, + viewportHeight: props.viewportHeight ?? measuredHeight.value, + })); + const cameraOptions = computed(() => ({ + zoom: polyCamera.value.zoom, + target: polyCamera.value.target, + rotX: polyCamera.value.rotX, + rotY: polyCamera.value.rotY, + distance: polyCamera.value.distance, + projection: "orthographic" as const, + perspectiveStyle: "none", + })); + + const { + store, + cameraRef, + sceneElRef, + cameraElRef, + autoCenterOffset, + applyTransformDirect, + } = usePolyCamera(cameraOptions); + + provide(PolyCameraContextKey, { store, cameraRef, sceneElRef, cameraElRef, autoCenterOffset, applyTransformDirect }); + + watch(cameraElRef, (el, _prev, onCleanup) => { + if (props.viewportHeight !== undefined || !el) return; + const measure = () => { + measuredHeight.value = el.getBoundingClientRect().height || el.clientHeight || 420; + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(el); + onCleanup(() => observer.disconnect()); + }, { flush: "post" }); + + return () => h( + "div", + { + ref: cameraElRef, + class: `polycss-camera${props.class ? ` ${props.class}` : ""}`, + style: { perspective: "none" }, + "data-polycss-camera-projection": "orthographic", + "data-polycss-camera-perspective": "none", + "data-polycss-camera-applied-perspective": "1000000px", + "data-polycss-camera-zoom": cameraRef.value.state.zoom, + "data-polycss-camera-distance": cameraRef.value.state.distance, + "data-polycss-camera-rot-x": cameraRef.value.state.rotX, + "data-polycss-camera-rot-y": cameraRef.value.state.rotY, + "data-polycss-camera-target": cameraRef.value.state.target.join(","), + }, + slots.default?.(), + ); + }, +}); diff --git a/packages/vue/src/three/PolyThreePerspectiveCamera.ts b/packages/vue/src/three/PolyThreePerspectiveCamera.ts new file mode 100644 index 00000000..a8d7304e --- /dev/null +++ b/packages/vue/src/three/PolyThreePerspectiveCamera.ts @@ -0,0 +1,119 @@ +import { computed, defineComponent, h, provide, ref, watch } from "vue"; +import type { PropType } from "vue"; +import { PerspectiveCamera } from "@layoutit/polycss-core/three"; +import type { Vector3Tuple } from "@layoutit/polycss-core/three"; +import { usePolyCamera } from "../camera/useCamera"; +import { PolyCameraContextKey } from "../camera/context"; + +export interface PolyThreePerspectiveCameraProps { + camera?: PerspectiveCamera; + fov?: number; + aspect?: number; + near?: number; + far?: number; + zoom?: number; + polyZoom?: number; + perspective?: number; + viewportHeight?: number; + position?: Vector3Tuple; + lookAt?: Vector3Tuple; + up?: Vector3Tuple; + class?: string; +} + +function applyCameraProps( + camera: PerspectiveCamera, + props: PolyThreePerspectiveCameraProps, +): PerspectiveCamera { + if (props.fov !== undefined) camera.fov = props.fov; + if (props.aspect !== undefined) camera.aspect = props.aspect; + if (props.near !== undefined) camera.near = props.near; + if (props.far !== undefined) camera.far = props.far; + if (props.zoom !== undefined) camera.zoom = props.zoom; + if (props.position !== undefined) camera.position.set(props.position[0], props.position[1], props.position[2]); + if (props.up !== undefined) camera.up.set(props.up[0], props.up[1], props.up[2]); + if (props.lookAt !== undefined) camera.lookAt(props.lookAt[0], props.lookAt[1], props.lookAt[2]); + return camera; +} + +export const PolyThreePerspectiveCamera = defineComponent({ + name: "PolyThreePerspectiveCamera", + props: { + camera: { type: Object as PropType, default: undefined }, + fov: { type: Number, default: 50 }, + aspect: { type: Number, default: 1 }, + near: { type: Number, default: 0.1 }, + far: { type: Number, default: 2000 }, + zoom: { type: Number, default: undefined }, + polyZoom: { type: Number, default: undefined }, + perspective: { type: Number, default: undefined }, + viewportHeight: { type: Number, default: undefined }, + position: { type: Array as unknown as PropType, default: undefined }, + lookAt: { type: Array as unknown as PropType, default: undefined }, + up: { type: Array as unknown as PropType, default: undefined }, + class: { type: String, default: undefined }, + }, + setup(props, { slots }) { + const localCamera = new PerspectiveCamera(props.fov, props.aspect, props.near, props.far); + const measuredHeight = ref(props.viewportHeight ?? 420); + + const polyCamera = computed(() => applyCameraProps(props.camera ?? localCamera, props).toPolyCameraState({ + zoom: props.polyZoom, + perspective: props.perspective, + viewportHeight: props.viewportHeight ?? measuredHeight.value, + })); + const cameraOptions = computed(() => ({ + zoom: polyCamera.value.zoom, + target: polyCamera.value.target, + rotX: polyCamera.value.rotX, + rotY: polyCamera.value.rotY, + distance: polyCamera.value.distance, + projection: "perspective" as const, + perspectiveStyle: `${polyCamera.value.perspective}px`, + })); + + const { + store, + cameraRef, + sceneElRef, + cameraElRef, + autoCenterOffset, + applyTransformDirect, + } = usePolyCamera(cameraOptions); + + provide(PolyCameraContextKey, { store, cameraRef, sceneElRef, cameraElRef, autoCenterOffset, applyTransformDirect }); + + watch(cameraElRef, (el, _prev, onCleanup) => { + if (props.viewportHeight !== undefined || !el) return; + const measure = () => { + measuredHeight.value = el.getBoundingClientRect().height || el.clientHeight || 420; + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(el); + onCleanup(() => observer.disconnect()); + }, { flush: "post" }); + + return () => { + const perspectiveStyle = `${polyCamera.value.perspective}px`; + return h( + "div", + { + ref: cameraElRef, + class: `polycss-camera${props.class ? ` ${props.class}` : ""}`, + style: { perspective: perspectiveStyle }, + "data-polycss-camera-projection": "perspective", + "data-polycss-camera-perspective": perspectiveStyle, + "data-polycss-camera-applied-perspective": perspectiveStyle, + "data-polycss-camera-zoom": cameraRef.value.state.zoom, + "data-polycss-camera-distance": cameraRef.value.state.distance, + "data-polycss-camera-rot-x": cameraRef.value.state.rotX, + "data-polycss-camera-rot-y": cameraRef.value.state.rotY, + "data-polycss-camera-target": cameraRef.value.state.target.join(","), + }, + slots.default?.(), + ); + }; + }, +}); diff --git a/packages/vue/src/three/index.ts b/packages/vue/src/three/index.ts new file mode 100644 index 00000000..223cef5f --- /dev/null +++ b/packages/vue/src/three/index.ts @@ -0,0 +1,31 @@ +export { + AmbientLight, + DirectionalLight, + Euler, + Object3D, + OrthographicCamera, + PerspectiveCamera, + PointLight, + Vector3, + polyToThreeDirection, + polyToThreePoint, + threeToPolyDirection, + threeToPolyPoint, + transformPointToPoly, + transformPolygonsToPoly, +} from "@layoutit/polycss-core/three"; +export type { + PolyCameraFromThreeOptions, + PolyOrthographicCameraFromThreeOptions, + PolyOrthographicCameraOptionsFromThree, + PolyPerspectiveCameraFromThreeOptions, + PolyPerspectiveCameraOptionsFromThree, + Vector3Tuple, +} from "@layoutit/polycss-core/three"; + +export { PolyThreePerspectiveCamera } from "./PolyThreePerspectiveCamera"; +export type { PolyThreePerspectiveCameraProps } from "./PolyThreePerspectiveCamera"; +export { PolyThreeOrthographicCamera } from "./PolyThreeOrthographicCamera"; +export type { PolyThreeOrthographicCameraProps } from "./PolyThreeOrthographicCamera"; +export { PolyThreeMesh } from "./PolyThreeMesh"; +export type { PolyThreeMeshProps } from "./PolyThreeMesh"; diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts index 0eda2ece..c28f9c0d 100644 --- a/packages/vue/tsup.config.ts +++ b/packages/vue/tsup.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: { index: "src/index.ts" }, + entry: { + index: "src/index.ts", + three: "src/three/index.ts", + }, format: ["esm", "cjs"], dts: true, splitting: false, @@ -10,5 +13,5 @@ export default defineConfig({ minify: true, target: "es2020", tsconfig: "tsconfig.build.json", - external: ["vue", "@layoutit/polycss-core"], + external: ["vue", "@layoutit/polycss-core", "@layoutit/polycss-core/three"], }); diff --git a/packages/vue/vitest.config.ts b/packages/vue/vitest.config.ts index 4c26be8c..b79c5d6b 100644 --- a/packages/vue/vitest.config.ts +++ b/packages/vue/vitest.config.ts @@ -24,7 +24,9 @@ export default defineConfig({ }, resolve: { alias: { + "@layoutit/polycss-core/three": path.resolve(__dirname, "../core/src/three/index.ts"), "@layoutit/polycss": path.resolve(__dirname, "../polycss/src/index.ts"), + "@layoutit/polycss/three": path.resolve(__dirname, "../polycss/src/three.ts"), "@layoutit/polycss-core": path.resolve(__dirname, "../core/src/index.ts"), }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d71c8c7f..ebfe69fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: react-dom: specifier: ^19.2.6 version: 19.2.6(react@19.2.6) + three: + specifier: ^0.185.1 + version: 0.185.1 vue: specifier: ^3.5.30 version: 3.5.30(typescript@5.9.3) @@ -2797,6 +2800,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -6264,6 +6270,8 @@ snapshots: dependencies: any-promise: 1.3.0 + three@0.185.1: {} + tiny-inflate@1.0.3: {} tinybench@2.9.0: {} diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 61f11269..efee6807 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -103,6 +103,7 @@ export default defineConfig({ label: 'API Reference', items: [ { label: 'Headless API', slug: 'api/headless' }, + { label: 'Three.js Parity API', slug: 'api/three-parity' }, { label: 'Core Types', slug: 'api/types' }, { label: 'Fonts API', slug: 'api/fonts' }, ], diff --git a/website/public/skill.md b/website/public/skill.md new file mode 100644 index 00000000..450c62b1 --- /dev/null +++ b/website/public/skill.md @@ -0,0 +1,173 @@ +--- +name: polycss +description: Build PolyCSS scenes that render 3D meshes, primitive shapes, or custom polygons as DOM/CSS polygon elements. Use when asked to create, port, debug, or explain PolyCSS code in vanilla JavaScript, React, or Vue. +--- + +# PolyCSS — DOM 3D Rendering + +PolyCSS renders 3D polygon meshes as real DOM elements transformed with CSS +`matrix3d(...)`. It supports OBJ/MTL, STL, glTF/GLB, VOX, generated primitives, +colors, textures, dynamic lighting, shadows, controls, selection, animation, and +per-polygon interaction. + +Use native PolyCSS when authoring PolyCSS-first scenes. Use the Three.js parity +API when porting Three.js code or generating code from Three-shaped examples. + +## Native Imports + +```ts +import { + createPolyCamera, + createPolyPerspectiveCamera, + createPolyScene, + loadMesh, +} from "@layoutit/polycss"; +``` + +```tsx +import { + PolyCamera, + PolyPerspectiveCamera, + PolyScene, + PolyMesh, + PolyOrbitControls, +} from "@layoutit/polycss-react"; +``` + +```ts +import { + PolyCamera, + PolyPerspectiveCamera, + PolyScene, + PolyMesh, + PolyOrbitControls, +} from "@layoutit/polycss-vue"; +``` + +## Three.js Parity Imports + +Use these when the scene is described in Three.js terms: + +```ts +import { + PerspectiveCamera, + OrthographicCamera, + Object3D, + Vector3, + DirectionalLight, + PointLight, + AmbientLight, + transformPolygonsToPoly, +} from "@layoutit/polycss/three"; +``` + +React: + +```tsx +import { + PolyThreePerspectiveCamera, + PolyThreeOrthographicCamera, + PolyThreeMesh, + DirectionalLight, +} from "@layoutit/polycss-react/three"; +``` + +Vue: + +```ts +import { + PolyThreePerspectiveCamera, + PolyThreeOrthographicCamera, + PolyThreeMesh, + DirectionalLight, +} from "@layoutit/polycss-vue/three"; +``` + +## Native Conventions + +- Coordinates are PolyCSS world space: `[x, y, z]`, with Z up. +- Camera rotations are degrees: `rotX`, `rotY`. +- `zoom` is CSS pixels per world unit. +- `PolyCamera` / `createPolyCamera` are orthographic by default. +- Use `PolyPerspectiveCamera` / `createPolyPerspectiveCamera` for perspective. + +## Three.js Parity Conventions + +- Coordinates are Three/Y-up authoring space. +- Object rotations are radians, XYZ Euler. +- Cameras are `PerspectiveCamera(fov, aspect, near, far)` or + `OrthographicCamera(left, right, top, bottom, near, far)`. +- Frame with `camera.position.set(...)` and `camera.lookAt(...)`. +- Directional lights use the Three.js source vector, `light.target.position` → `light.position`. +- Geometry converts internally with the right-handed axis map `[x, -z, y]`, so + winding and Lambert lighting stay correct. +- `mountPolyThreeScene(...)` defaults to baked lighting for Three parity. + Use `textureLighting: "dynamic"` only when live CSS light changes matter more + than strict conformance. + +## React Parity Example + +```tsx +import { PolyScene } from "@layoutit/polycss-react"; +import { + DirectionalLight, + PolyThreeMesh, + PolyThreePerspectiveCamera, +} from "@layoutit/polycss-react/three"; + +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); + +export function App() { + return ( + + + + + + ); +} +``` + +## Vanilla Parity Example + +```ts +import { + Object3D, + PerspectiveCamera, + boxPolygons, + mountPolyThreeScene, + transformPolygonsToPoly, +} from "@layoutit/polycss/three"; + +const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 100); +camera.position.set(3, 2, 5); +camera.lookAt(0, 0, 0); + +const object = new Object3D(); +object.rotation.set(0, Math.PI / 4, 0); + +mountPolyThreeScene(document.querySelector("#scene")!, { + camera, + cameraOptions: { viewportHeight: 420 }, + polygons: transformPolygonsToPoly( + boxPolygons({ size: 1, color: "#66aaff" }), + object, + ), +}); +``` + +Full docs: https://polycss.com/api/three-parity diff --git a/website/src/content/docs/api/three-parity.mdx b/website/src/content/docs/api/three-parity.mdx new file mode 100644 index 00000000..36c258ea --- /dev/null +++ b/website/src/content/docs/api/three-parity.mdx @@ -0,0 +1,214 @@ +--- +title: Three.js Parity API +description: Three-like cameras, transforms, and framework components for porting Three.js scenes to PolyCSS. +--- + +The `*/three` subpaths expose a Three-like authoring surface on top of PolyCSS. +Use them when you are porting a Three.js scene, writing docs for coding agents, +or want familiar `PerspectiveCamera`, `Object3D`, `Vector3`, `lookAt`, and +radian-based mesh transforms. + +This is still PolyCSS: it renders DOM polygon leaves with CSS transforms. It does +not run Three.js at runtime, and it does not use WebGL. The parity API matches +Three's parameter surface closely enough that the same scene math frames the same +object with the same projection, orientation, depth ordering, and light +direction. + +## Imports + +| Package | Import | Use | +|---|---|---| +| Core math | `@layoutit/polycss-core/three` | Three-like classes and coordinate transforms | +| Vanilla | `@layoutit/polycss/three` | Core parity surface plus scene mounting, mesh loading, and geometry helpers | +| React | `@layoutit/polycss-react/three` | React components and parity classes | +| Vue | `@layoutit/polycss-vue/three` | Vue components and parity classes | + +## Conventions + +The native PolyCSS API uses PolyCSS conventions: Z-up scene math, camera rotations +in degrees, and `zoom` as CSS pixels per world unit. + +The `*/three` subpaths intentionally use Three-style conventions: + +| Value | `*/three` convention | +|---|---| +| Coordinates | Y-up authoring space | +| Mesh rotations | Radians, XYZ Euler | +| Cameras | Three-like `PerspectiveCamera(fov, aspect, near, far)` and `OrthographicCamera(left, right, top, bottom, near, far)` | +| Camera targeting | `camera.position.set(...)` + `camera.lookAt(...)` | +| Directional lights | Three.js source vector: `light.target.position` → `light.position` | +| Point lights | `light.position` in Three/Y-up space | +| Vanilla lighting default | `mountPolyThreeScene(...)` defaults to `textureLighting: "baked"` | + +Internally, geometry is converted into PolyCSS space with +`transformPolygonsToPoly`. The axis conversion is right-handed, so polygon +winding and Lambert lighting stay correct. + +Polygon arrays passed to `transformPolygonsToPoly` or `` are +interpreted as Three/Y-up coordinates. If you already have native PolyCSS +polygons with their own native transform, render them with the native API instead +of converting them again. + +For Three parity, use baked lighting as the baseline. It computes the final +Lambert face color through the same directional/ambient intensity surface used +by the Three-like light adapters. Dynamic lighting is still available by passing +`textureLighting: "dynamic"`, but it is a live CSS-lighting path for interactive +light changes, not the strict conformance mode. + +## Vanilla example + +`@layoutit/polycss/three` is the smallest path for plain JavaScript demos, tests, +CLIs, and agent-generated snippets. The scene below is authored with Three-like +camera and transform objects, then mounted into a DOM host by PolyCSS. + +```ts +import { + AmbientLight, + DirectionalLight, + Object3D, + PerspectiveCamera, + boxPolygons, + mountPolyThreeScene, + transformPolygonsToPoly, +} from "@layoutit/polycss/three"; + +const host = document.querySelector("#scene")!; + +const camera = new PerspectiveCamera(50, 16 / 9, 0.1, 100); +camera.position.set(3, 2, 5); +camera.lookAt(0, 0, 0); + +const object = new Object3D(); +object.position.set(0, 0.5, 0); +object.rotation.set(0, Math.PI / 4, 0); +object.scale.set(1, 1, 1); + +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); + +const polygons = transformPolygonsToPoly( + boxPolygons({ size: 1, color: "#66aaff" }), + object, +); + +mountPolyThreeScene(host, { + camera, + cameraOptions: { viewportHeight: 420 }, + polygons, + ambientLight: new AmbientLight("#ffffff", 0.35).toPolyAmbientLight(), + directionalLight: sun.toPolyDirectionalLight(), +}); +``` + +## React example + +The React parity components wrap the normal ``. Keep the scene and +controls from `@layoutit/polycss-react`, and import the Three-like cameras / +meshes from `@layoutit/polycss-react/three`. + +```tsx +import { PolyOrbitControls, PolyScene } from "@layoutit/polycss-react"; +import { + DirectionalLight, + PolyThreeMesh, + PolyThreePerspectiveCamera, +} from "@layoutit/polycss-react/three"; + +const sun = new DirectionalLight("#ffffff", 1); +sun.position.set(3, 5, 4); +sun.target.position.set(0, 0, 0); + +export function App() { + return ( + + + + + + + ); +} +``` + +`` accepts `polygons` or `src` like the native ``. +It also supports scene mesh options such as `castShadow`, `receiveShadow`, +texture options, `meshResolution`, and `merge`. + +## Vue example + +Vue mirrors the React parity surface. + +```vue + + + +``` + +## Core exports + +All `*/three` subpaths share these core exports: + +| Export | Purpose | +|---|---| +| `Vector3` | Minimal Three-like vector class used by cameras, objects, and lights | +| `Euler` | XYZ Euler rotation in radians | +| `Object3D` | `position`, `rotation`, `scale`, `up`, `lookAt`, and `localToWorld` | +| `PerspectiveCamera` | Three-like perspective camera that PolyCSS can render with | +| `OrthographicCamera` | Three-like orthographic camera that PolyCSS can render with | +| `DirectionalLight` | Three-like positioned light with `target`, convertible to PolyCSS light options | +| `PointLight` | Three-like point light, convertible to PolyCSS point-light options | +| `AmbientLight` | Ambient light helper, convertible to PolyCSS light options | +| `threeToPolyPoint` / `polyToThreePoint` | Convert individual points between coordinate spaces | +| `threeToPolyDirection` / `polyToThreeDirection` | Convert direction vectors between coordinate spaces | +| `transformPointToPoly` | Apply an `Object3D` transform to one point and convert it | +| `transformPolygonsToPoly` | Apply an `Object3D` transform to a `Polygon[]` and convert it | + +## When not to use it + +Use the native API when you are building PolyCSS-first scenes, DOM inspection +demos, builder output, or examples where degree-based `rotX` / `rotY` camera +controls are more direct. The parity API is for Three-shaped scene authoring and +ports; it is not a replacement for native PolyCSS controls or custom elements. diff --git a/website/src/content/docs/api/types.mdx b/website/src/content/docs/api/types.mdx index 3fa0d63f..666bd48c 100644 --- a/website/src/content/docs/api/types.mdx +++ b/website/src/content/docs/api/types.mdx @@ -82,7 +82,7 @@ Controls the directional light for the scene. ```ts interface PolyDirectionalLight { - /** Direction the light shines toward. Normalization is handled internally. */ + /** Unit direction from the surface toward the distant light source. */ direction: [number, number, number]; /** Directional light color hex (default: "#ffffff"). */ color?: string; diff --git a/website/src/content/docs/components/poly-scene.mdx b/website/src/content/docs/components/poly-scene.mdx index 6059ec6e..e1191dbc 100644 --- a/website/src/content/docs/components/poly-scene.mdx +++ b/website/src/content/docs/components/poly-scene.mdx @@ -75,7 +75,7 @@ React/Vue `` supports the full table. The `` custom element ```ts interface PolyDirectionalLight { - direction: [number, number, number]; // Direction the light shines toward + direction: [number, number, number]; // Surface-to-light source direction color?: string; // Light color (default: "#ffffff") intensity?: number; // Directional intensity (default: 1) }