diff --git a/packages/core/src/atlas/strategy.test.ts b/packages/core/src/atlas/strategy.test.ts index fb8bd112..caae531e 100644 --- a/packages/core/src/atlas/strategy.test.ts +++ b/packages/core/src/atlas/strategy.test.ts @@ -163,8 +163,8 @@ describe("isProjectiveQuadPlan — projective quad detection", () => { // --------------------------------------------------------------------------- const noDisable = new Set<"b" | "i" | "u">(); -const desktopEnv = { solidTriangleSupported: true, borderShapeSupported: false }; -const borderShapeEnv = { solidTriangleSupported: true, borderShapeSupported: true }; +const desktopEnv = { solidTriangleSupported: true, projectiveQuadSupported: true, borderShapeSupported: false }; +const borderShapeEnv = { solidTriangleSupported: true, projectiveQuadSupported: true, borderShapeSupported: true }; describe("filterAtlasPlans — full-rect solid exclusion", () => { it("full-rect plan is excluded from atlas when b is enabled", () => { @@ -177,7 +177,11 @@ describe("filterAtlasPlans — full-rect solid exclusion", () => { const plan = computeTextureAtlasPlanPublic(FLAT_RECT, 0)!; const disabled = new Set<"b" | "i" | "u">(["b"]); // When b disabled and no border-shape, rect falls through to atlas - const result = filterAtlasPlans([plan], "baked", disabled, { solidTriangleSupported: true, borderShapeSupported: false }); + const result = filterAtlasPlans([plan], "baked", disabled, { + solidTriangleSupported: true, + projectiveQuadSupported: true, + borderShapeSupported: false, + }); expect(result[0]).not.toBeNull(); }); }); @@ -193,13 +197,21 @@ describe("filterAtlasPlans — triangle exclusion", () => { const plan = computeTextureAtlasPlanPublic(FLAT_TRIANGLE, 0)!; const disabled = new Set<"b" | "i" | "u">(["u"]); // u disabled and no border-shape → triangle goes to atlas - const result = filterAtlasPlans([plan], "baked", disabled, { solidTriangleSupported: false, borderShapeSupported: false }); + const result = filterAtlasPlans([plan], "baked", disabled, { + solidTriangleSupported: false, + projectiveQuadSupported: true, + borderShapeSupported: false, + }); expect(result[0]).not.toBeNull(); }); it("triangle plan stays in atlas when solidTriangleSupported is false", () => { const plan = computeTextureAtlasPlanPublic(FLAT_TRIANGLE, 0)!; - const result = filterAtlasPlans([plan], "baked", noDisable, { solidTriangleSupported: false, borderShapeSupported: false }); + const result = filterAtlasPlans([plan], "baked", noDisable, { + solidTriangleSupported: false, + projectiveQuadSupported: true, + borderShapeSupported: false, + }); expect(result[0]).not.toBeNull(); }); }); @@ -250,6 +262,30 @@ describe("filterAtlasPlans — border-shape exclusion", () => { }); }); +describe("filterAtlasPlans — projective quad exclusion", () => { + it("non-rect projective quads are excluded when projective b is supported", () => { + const plan = computeTextureAtlasPlanPublic(NON_RECT_QUAD, 0)!; + expect(isProjectiveQuadPlan(plan)).toBe(true); + const result = filterAtlasPlans([plan], "baked", noDisable, { + solidTriangleSupported: true, + projectiveQuadSupported: true, + borderShapeSupported: false, + }); + expect(result[0]).toBeNull(); + }); + + it("non-rect projective quads stay in atlas when projective b is unsupported", () => { + const plan = computeTextureAtlasPlanPublic(NON_RECT_QUAD, 0)!; + expect(isProjectiveQuadPlan(plan)).toBe(true); + const result = filterAtlasPlans([plan], "baked", noDisable, { + solidTriangleSupported: true, + projectiveQuadSupported: false, + borderShapeSupported: false, + }); + expect(result[0]).toBe(plan); + }); +}); + describe("filterAtlasPlans — output array length matches input", () => { it("length is preserved for mixed null/non-null arrays", () => { const plans = [ diff --git a/packages/core/src/atlas/strategy.ts b/packages/core/src/atlas/strategy.ts index fa4bb8bb..fa6f3205 100644 --- a/packages/core/src/atlas/strategy.ts +++ b/packages/core/src/atlas/strategy.ts @@ -87,6 +87,7 @@ export function dominantCountKey(map: Map): string | undefined { export interface FilterAtlasPlansEnv { solidTriangleSupported: boolean; + projectiveQuadSupported: boolean; borderShapeSupported: boolean; } @@ -102,7 +103,7 @@ export function filterAtlasPlans( env: FilterAtlasPlansEnv, ): Array { const useFullRectSolid = !disabled.has("b"); - const useProjectiveQuad = useFullRectSolid; + const useProjectiveQuad = useFullRectSolid && env.projectiveQuadSupported; const useStableTriangle = !disabled.has("u") && env.solidTriangleSupported; const useBorderShape = !disabled.has("i") && textureLighting !== "dynamic" && env.borderShapeSupported; const disableB = disabled.has("b"); diff --git a/packages/polycss/src/render/atlas/strategy.ts b/packages/polycss/src/render/atlas/strategy.ts index 0bbc1010..12a4c72d 100644 --- a/packages/polycss/src/render/atlas/strategy.ts +++ b/packages/polycss/src/render/atlas/strategy.ts @@ -194,8 +194,10 @@ export function filterAtlasPlans( disabled: ReadonlySet, doc?: Document | null, ): Array { + const resolvedDoc = doc ?? (typeof document !== "undefined" ? document : null); return filterAtlasPlansCore(plans, textureLighting, disabled, { solidTriangleSupported: isSolidTriangleSupported(doc), + projectiveQuadSupported: resolvedDoc ? projectiveQuadSupported(resolvedDoc) : true, borderShapeSupported: isBorderShapeSupported(doc), }); } diff --git a/packages/react/src/scene/PolyMesh.test.tsx b/packages/react/src/scene/PolyMesh.test.tsx index a93693c1..5b0093d6 100644 --- a/packages/react/src/scene/PolyMesh.test.tsx +++ b/packages/react/src/scene/PolyMesh.test.tsx @@ -106,7 +106,10 @@ const OFFSET_TEXTURED_TRIANGLE: Polygon = { ], }; -function renderMesh(props: React.ComponentProps): HTMLElement { +function renderMesh( + props: React.ComponentProps, + sceneProps: React.ComponentProps = {}, +): HTMLElement { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); @@ -117,7 +120,7 @@ function renderMesh(props: React.ComponentProps): HTMLElement { {}, React.createElement( PolyScene, - {}, + sceneProps, React.createElement(PolyMesh, props) ) ) @@ -168,6 +171,20 @@ describe("PolyMesh — with polygons prop", () => { expect(polys.length).toBe(2); }); + it("inherits scene strategies.disable b for auto-rendered rects", () => { + vi.stubGlobal("CSS", { + supports: vi.fn((property: string) => property === "border-shape"), + }); + const container = renderMesh( + { polygons: [QUAD] }, + { strategies: { disable: ["b"] } }, + ); + const poly = container.querySelector("i") as HTMLElement | null; + expect(container.querySelector("b")).toBeNull(); + expect(poly).toBeTruthy(); + expect(poly!.style.getPropertyValue("border-shape")).toContain("polygon("); + }); + it("hoists repeated baked solid paint to the mesh wrapper", () => { const container = renderMesh({ polygons: [TRIANGLE, TRIANGLE] }); const mesh = container.querySelector(".polycss-mesh") as HTMLElement; diff --git a/packages/react/src/scene/PolyMesh.tsx b/packages/react/src/scene/PolyMesh.tsx index 63db5a71..a551d7e4 100644 --- a/packages/react/src/scene/PolyMesh.tsx +++ b/packages/react/src/scene/PolyMesh.tsx @@ -520,6 +520,10 @@ export const PolyMesh = forwardRef(function PolyM const sceneCtx = usePolySceneContext(); const effectiveTextureLighting = textureLighting ?? sceneCtx?.textureLighting ?? "baked"; const effectiveStrategies = sceneCtx?.strategies; + const disabledStrategies = useMemo( + () => effectiveStrategies?.disable?.length ? new Set(effectiveStrategies.disable) : undefined, + [effectiveStrategies], + ); const effectiveSeamBleed = seamBleed ?? sceneCtx?.seamBleed ?? DEFAULT_SEAM_BLEED; const effectiveDirectional = effectiveTextureLighting === "dynamic" ? undefined : sceneCtx?.directionalLight; @@ -884,6 +888,7 @@ export const PolyMesh = forwardRef(function PolyM key={plan.index} entry={plan} solidPaintDefaults={solidPaintDefaults} + disabledStrategies={disabledStrategies} /> ); }); diff --git a/packages/react/src/scene/PolyScene.test.tsx b/packages/react/src/scene/PolyScene.test.tsx index 8a5fa52c..5e9064f5 100644 --- a/packages/react/src/scene/PolyScene.test.tsx +++ b/packages/react/src/scene/PolyScene.test.tsx @@ -163,6 +163,25 @@ describe("PolyScene — polygon rendering", () => { expect(style).not.toContain("border-shape"); }); + it("falls back to atlas for projective solid quads on Safari", () => { + const nav = document.defaultView?.navigator ?? window.navigator; + const userAgent = vi.spyOn(nav, "userAgent", "get").mockReturnValue( + "Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + ); + vi.stubGlobal("CSS", { supports: () => false }); + + try { + const container = renderScene({ + polygons: [NON_RECT_QUAD], + }); + expect(container.querySelector("b")).toBeNull(); + expect(container.querySelector("s")).toBeTruthy(); + } finally { + userAgent.mockRestore(); + vi.unstubAllGlobals(); + } + }); + it("renders multiple polygons", () => { const container = renderScene({ polygons: [TRIANGLE, QUAD] }); const polys = container.querySelectorAll("i,b,s,u"); @@ -235,6 +254,18 @@ describe("PolyScene — autoCenter", () => { expect(transformOn).toContain("translate3d(-50px, -50px, -50px)"); }); + it("uses centerPolygons as the autoCenter bbox source without rendering them", async () => { + const container = renderScene({ + polygons: [], + centerPolygons: [QUAD], + autoCenter: true, + }); + await flushReactWork(); + const scene = container.querySelector(".polycss-scene") as HTMLElement; + expect(container.querySelectorAll("i,b,s,u")).toHaveLength(0); + expect(scene.style.transform).toContain("translate3d(-50px, -50px, -50px)"); + }); + it("target and autoCenterOffset are independent: pan survives mesh bbox change", async () => { // Render with TRIANGLE (centroid ~[0.33, 0.33, 0]) centered. // Then switch to QUAD (centroid [1, 1, 1]) — the centering offset updates diff --git a/packages/react/src/scene/atlas/filterPlans.test.ts b/packages/react/src/scene/atlas/filterPlans.test.ts index 4ba4d8cf..e95d1e4a 100644 --- a/packages/react/src/scene/atlas/filterPlans.test.ts +++ b/packages/react/src/scene/atlas/filterPlans.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect } from "vitest"; import type { Polygon } from "@layoutit/polycss-core"; -import { computeTextureAtlasPlanPublic } from "@layoutit/polycss-core"; +import { computeTextureAtlasPlanPublic, isProjectiveQuadPlan } from "@layoutit/polycss-core"; import { filterAtlasPlans } from "./filterPlans"; // --------------------------------------------------------------------------- @@ -55,6 +55,11 @@ const FLAT_TRIANGLE: Polygon = { color: "#ff0000", }; +const NON_RECT_QUAD: Polygon = { + vertices: [[0, 0, 0], [2, 0, 0], [2, 1, 0], [0, 2, 0]], + color: "#00ffff", +}; + const TEXTURED_QUAD: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], texture: "https://example.com/tex.png", @@ -139,6 +144,14 @@ describe("filterAtlasPlans — strategy filter contracts", () => { expect(filtered[0]).not.toBeNull(); }); + it("projective solid quads stay in atlas on Safari", () => { + const plan = computeTextureAtlasPlanPublic(NON_RECT_QUAD, 0); + expect(plan && isProjectiveQuadPlan(plan)).toBe(true); + const doc = makeDoc({ userAgent: SAFARI_UA, borderShape: false }); + const filtered = filterAtlasPlans([plan], "baked", noDisable, doc); + expect(filtered[0]).toBe(plan); + }); + it("output length matches input length", () => { const plans = [ computeTextureAtlasPlanPublic(FLAT_RECT, 0), diff --git a/packages/react/src/scene/atlas/filterPlans.ts b/packages/react/src/scene/atlas/filterPlans.ts index 2a826cc8..95c4d41a 100644 --- a/packages/react/src/scene/atlas/filterPlans.ts +++ b/packages/react/src/scene/atlas/filterPlans.ts @@ -7,6 +7,7 @@ import type { } from "@layoutit/polycss-core"; import type { PolyTextureLightingMode } from "@layoutit/polycss-core"; import { isBorderShapeSupported, isSolidTriangleSupported } from "./detection"; +import { projectiveQuadSupported } from "./detection"; /** * Filter a plan array to the subset that needs atlas packing, given the active @@ -21,6 +22,7 @@ export function filterAtlasPlans( ): Array { return filterAtlasPlansCore(plans, textureLighting, disabled, { solidTriangleSupported: isSolidTriangleSupported(doc), + projectiveQuadSupported: doc ? projectiveQuadSupported(doc) : true, borderShapeSupported: isBorderShapeSupported(doc), }); } diff --git a/packages/react/src/scene/atlas/useTextureAtlas.ts b/packages/react/src/scene/atlas/useTextureAtlas.ts index 5e1ce83c..0d8949b5 100644 --- a/packages/react/src/scene/atlas/useTextureAtlas.ts +++ b/packages/react/src/scene/atlas/useTextureAtlas.ts @@ -47,7 +47,12 @@ export function useTextureAtlas( ); const atlasPlans = useMemo( - () => filterAtlasPlans(plans, textureLighting, disabled), + () => filterAtlasPlans( + plans, + textureLighting, + disabled, + typeof document !== "undefined" ? document : null, + ), [plans, textureLighting, disabled], ); diff --git a/packages/react/src/shapes/Poly.test.tsx b/packages/react/src/shapes/Poly.test.tsx index 1a768a7a..f78114d5 100644 --- a/packages/react/src/shapes/Poly.test.tsx +++ b/packages/react/src/shapes/Poly.test.tsx @@ -153,8 +153,9 @@ describe("Poly — non-horizontal geometry", () => { expect(poly.style.height).toBe(""); }); - it("renders solid non-rect quads as projective b on Safari", () => { - const userAgent = vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue( + it("falls back to atlas for projective solid quads on Safari", () => { + const nav = document.defaultView?.navigator ?? window.navigator; + const userAgent = vi.spyOn(nav, "userAgent", "get").mockReturnValue( "Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", ); vi.stubGlobal("CSS", { supports: () => false }); @@ -162,9 +163,7 @@ describe("Poly — non-horizontal geometry", () => { try { const container = renderPoly({ vertices: NON_RECT_QUAD_VERTS }); const poly = getPoly(container); - // Non-rect untextured quads are rendered as projective regardless of - // browser — the projective matrix path doesn't depend on CSS.supports. - expect(poly.tagName.toLowerCase()).toBe("b"); + expect(poly.tagName.toLowerCase()).toBe("s"); expect(poly.style.getPropertyValue("border-shape")).toBe(""); } finally { userAgent.mockRestore(); diff --git a/packages/react/src/styles/styles.test.ts b/packages/react/src/styles/styles.test.ts index 3d0b6238..72be111f 100644 --- a/packages/react/src/styles/styles.test.ts +++ b/packages/react/src/styles/styles.test.ts @@ -65,6 +65,15 @@ describe("injectPolyBaseStyles", () => { expect(el.textContent).not.toContain("polycss-solid-triangle"); }); + it("includes transform-controls and shadow performance rules", () => { + injectPolyBaseStyles(document); + const el = document.getElementById("polycss-styles")!; + expect(el.textContent).toContain(".polycss-transform-controls i"); + expect(el.textContent).toContain("transition: color 150ms ease-out"); + expect(el.textContent).toContain(".polycss-scene q"); + expect(el.textContent).toContain("will-change: transform"); + }); + it("does nothing when doc is null-ish", () => { // Should not throw expect(() => diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 62df33a9..7e676fd5 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -39,6 +39,19 @@ export { PolyCone, PolyTorus, } from "./shapes"; +export type { + PolyBoxProps, + PolyPlaneProps, + PolyRingProps, + PolyOctahedronProps, + PolySphereProps, + PolyTetrahedronProps, + PolyIcosahedronProps, + PolyDodecahedronProps, + PolyCylinderProps, + PolyConeProps, + PolyTorusProps, +} from "./shapes"; export { PolyOrbitControls, PolyMapControls, PolyTransformControls, PolyFirstPersonControls } from "./controls"; export type { diff --git a/packages/vue/src/scene/PolyMesh.test.ts b/packages/vue/src/scene/PolyMesh.test.ts index ec1b791f..139fd083 100644 --- a/packages/vue/src/scene/PolyMesh.test.ts +++ b/packages/vue/src/scene/PolyMesh.test.ts @@ -113,7 +113,8 @@ function mockFetchVox(): void { function renderMesh( meshProps: Record = {}, - slots: Record VNode | VNode[]> = {} + slots: Record VNode | VNode[]> = {}, + sceneProps: Record = {}, ): { container: HTMLElement; app: ReturnType } { const container = document.createElement("div"); document.body.appendChild(container); @@ -122,7 +123,7 @@ function renderMesh( return () => h(PolyCamera, {}, { default: () => - h(PolyScene, {}, { + h(PolyScene, sceneProps, { default: () => h(PolyMesh, meshProps, slots), }), }); @@ -151,6 +152,21 @@ describe("PolyMesh (Vue) — with polygons prop", () => { expect(polys.length).toBe(2); }); + it("inherits scene strategies.disable b for auto-rendered rects", () => { + vi.stubGlobal("CSS", { + supports: vi.fn((property: string) => property === "border-shape"), + }); + const { container } = renderMesh( + { polygons: [QUAD] }, + {}, + { strategies: { disable: ["b"] } }, + ); + const poly = container.querySelector("i") as HTMLElement | null; + expect(container.querySelector("b")).toBeNull(); + expect(poly).toBeTruthy(); + expect(poly!.style.getPropertyValue("border-shape")).toContain("polygon("); + }); + it("hoists repeated baked solid paint to the mesh wrapper", () => { const { container } = renderMesh({ polygons: [TRIANGLE, TRIANGLE] }); const mesh = container.querySelector(".polycss-mesh") as HTMLElement; diff --git a/packages/vue/src/scene/PolyMesh.ts b/packages/vue/src/scene/PolyMesh.ts index 9fb2f933..632c8849 100644 --- a/packages/vue/src/scene/PolyMesh.ts +++ b/packages/vue/src/scene/PolyMesh.ts @@ -29,7 +29,7 @@ import { parseHexColor, projectCssVertexToGround, } from "@layoutit/polycss-core"; -import { usePolyMesh } from "./useMesh"; +import { usePolyMesh, type UseMeshOptions } from "./useMesh"; import { buildSeamBleedPolygonEdges, buildTextureEdgeRepairSets, @@ -104,6 +104,8 @@ export interface PolyMeshProps extends InteractionProps { * authored surface fidelity. Top-level prop wins over any meshResolution * that might be set inside parseOptions. */ meshResolution?: MeshResolution; + /** Parser options forwarded to parseObj/parseGltf/parseVox. */ + parseOptions?: UseMeshOptions; class?: string; position?: Vec3; scale?: number | Vec3; @@ -170,6 +172,7 @@ export const PolyMesh = defineComponent({ seamBleed: { type: [Number, String] as PropType, default: undefined }, castShadow: { type: Boolean as PropType, default: false }, meshResolution: { type: String as PropType, default: undefined }, + parseOptions: { type: Object as PropType, default: undefined }, class: { type: String }, position: { type: Array as unknown as PropType, default: undefined }, scale: { type: [Number, Array] as unknown as PropType, default: undefined }, @@ -190,16 +193,15 @@ export const PolyMesh = defineComponent({ setup(props, { slots, attrs, expose }) { // useMesh requires a Ref. Computed ref wraps the src prop. const srcRef = computed(() => props.src ?? ""); - // Merge mtl + meshResolution into the options passed to usePolyMesh. - // Top-level meshResolution wins over any meshResolution that could come - // from a future parseOptions prop (matches React behavior). + // Merge parseOptions + mtl + meshResolution into the options passed to + // usePolyMesh. Top-level meshResolution wins over parseOptions.meshResolution. const meshOptions = computed(() => { - const opts: Record = {}; + const opts: UseMeshOptions = { ...(props.parseOptions ?? {}) }; if (props.mtl) opts.mtlUrl = props.mtl; if (props.meshResolution !== undefined) opts.meshResolution = props.meshResolution; return Object.keys(opts).length > 0 ? opts : undefined; }); - const fetched = usePolyMesh(srcRef, meshOptions.value as import("./useMesh").UseMeshOptions | undefined); + const fetched = usePolyMesh(srcRef, meshOptions.value); const propPolygons = computed(() => props.src ? fetched.polygons.value : (props.polygons ?? []) @@ -802,6 +804,7 @@ export const PolyMesh = defineComponent({ : renderTextureBorderShapePoly({ entry: plan, solidPaintDefaults: solidPaintDefaults.value, + forceBorderShape: !textureAtlas.useFullRectSolid.value, }); }); diff --git a/packages/vue/src/scene/PolyScene.test.ts b/packages/vue/src/scene/PolyScene.test.ts index 6d506907..dd0659e1 100644 --- a/packages/vue/src/scene/PolyScene.test.ts +++ b/packages/vue/src/scene/PolyScene.test.ts @@ -152,6 +152,24 @@ describe("PolyScene (Vue) — polygon rendering", () => { expect(style).not.toContain("border-shape"); }); + it("falls back to atlas for projective solid quads on Safari", () => { + const userAgent = vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue( + "Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + ); + vi.stubGlobal("CSS", { supports: () => false }); + + try { + const { container } = renderScene({ + polygons: [NON_RECT_QUAD], + }); + expect(container.querySelector("b")).toBeNull(); + expect(container.querySelector("s")).toBeTruthy(); + } finally { + userAgent.mockRestore(); + vi.unstubAllGlobals(); + } + }); + it("renders multiple polygons", () => { const { container } = renderScene({ polygons: [TRIANGLE, QUAD] }); const polys = container.querySelectorAll("i,b,s,u"); @@ -225,6 +243,17 @@ describe("PolyScene (Vue) — autoCenter", () => { expect(t1).not.toBe(t2); }); + it("uses centerPolygons as the autoCenter bbox source without rendering them", () => { + const { container } = renderScene({ + polygons: [], + centerPolygons: [QUAD], + autoCenter: true, + }); + const scene = container.querySelector(".polycss-scene") as HTMLElement; + expect(container.querySelectorAll("i,b,s,u")).toHaveLength(0); + expect(scene.style.transform).toContain("translate3d(-50px, -50px, -50px)"); + }); + it("pan (target) and autoCenterOffset are independent — autoCenter does not zero out target", async () => { // Even with autoCenter the user's camera target should be preserved. // We can't drive orbit controls in a unit test, so we verify the diff --git a/packages/vue/src/scene/PolyScene.ts b/packages/vue/src/scene/PolyScene.ts index 582c055b..7fe55bf3 100644 --- a/packages/vue/src/scene/PolyScene.ts +++ b/packages/vue/src/scene/PolyScene.ts @@ -50,6 +50,7 @@ import { export interface PolySceneProps { polygons?: Polygon[]; + centerPolygons?: Polygon[]; perspective?: number; rotX?: number; rotY?: number; @@ -96,6 +97,7 @@ export const PolyScene = defineComponent({ inheritAttrs: false, props: { polygons: { type: Array as PropType, default: undefined }, + centerPolygons: { type: Array as PropType, default: undefined }, perspective: { type: Number }, rotX: { type: Number }, rotY: { type: Number }, @@ -208,12 +210,15 @@ export const PolyScene = defineComponent({ ); const inputPolygons = computed(() => props.polygons ?? []); + const centerInputPolygons = computed(() => props.centerPolygons ?? null); const sceneContextOptions = computed(() => ({ directionalLight: props.directionalLight, })); const sceneResult = usePolySceneContext(inputPolygons, sceneContextOptions); + const centerPolygons = computed(() => centerInputPolygons.value ?? inputPolygons.value); + const centerSceneResult = usePolySceneContext(centerPolygons, sceneContextOptions); // Scene transform is applied imperatively via applyTransformDirect, not via // Vue's reactive style binding. The sceneStyle computed previously read @@ -375,7 +380,7 @@ export const PolyScene = defineComponent({ // by orbit/map controls) picks it up on every pointer-driven camera move. const autoCenterOffset = computed(() => { if (!props.autoCenter) return [0, 0, 0]; - const bbox = sceneResult.value.sceneBbox; + const bbox = centerSceneResult.value.sceneBbox; return [ (bbox.min[0] + bbox.max[0]) / 2, (bbox.min[1] + bbox.max[1]) / 2, diff --git a/packages/vue/src/scene/atlas/filterPlans.test.ts b/packages/vue/src/scene/atlas/filterPlans.test.ts index cfc2eacd..851b855e 100644 --- a/packages/vue/src/scene/atlas/filterPlans.test.ts +++ b/packages/vue/src/scene/atlas/filterPlans.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect } from "vitest"; import type { Polygon } from "@layoutit/polycss-core"; -import { computeTextureAtlasPlanPublic } from "@layoutit/polycss-core"; +import { computeTextureAtlasPlanPublic, isProjectiveQuadPlan } from "@layoutit/polycss-core"; import { filterAtlasPlans } from "./filterPlans"; // --------------------------------------------------------------------------- @@ -55,6 +55,11 @@ const FLAT_TRIANGLE: Polygon = { color: "#ff0000", }; +const NON_RECT_QUAD: Polygon = { + vertices: [[0, 0, 0], [2, 0, 0], [2, 1, 0], [0, 2, 0]], + color: "#00ffff", +}; + const TEXTURED_QUAD: Polygon = { vertices: [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], texture: "https://example.com/tex.png", @@ -139,6 +144,14 @@ describe("filterAtlasPlans — strategy filter contracts", () => { expect(filtered[0]).not.toBeNull(); }); + it("projective solid quads stay in atlas on Safari", () => { + const plan = computeTextureAtlasPlanPublic(NON_RECT_QUAD, 0); + expect(plan && isProjectiveQuadPlan(plan)).toBe(true); + const doc = makeDoc({ userAgent: SAFARI_UA, borderShape: false }); + const filtered = filterAtlasPlans([plan], "baked", noDisable, doc); + expect(filtered[0]).toBe(plan); + }); + it("output length matches input length", () => { const plans = [ computeTextureAtlasPlanPublic(FLAT_RECT, 0), diff --git a/packages/vue/src/scene/atlas/filterPlans.ts b/packages/vue/src/scene/atlas/filterPlans.ts index 2a826cc8..95c4d41a 100644 --- a/packages/vue/src/scene/atlas/filterPlans.ts +++ b/packages/vue/src/scene/atlas/filterPlans.ts @@ -7,6 +7,7 @@ import type { } from "@layoutit/polycss-core"; import type { PolyTextureLightingMode } from "@layoutit/polycss-core"; import { isBorderShapeSupported, isSolidTriangleSupported } from "./detection"; +import { projectiveQuadSupported } from "./detection"; /** * Filter a plan array to the subset that needs atlas packing, given the active @@ -21,6 +22,7 @@ export function filterAtlasPlans( ): Array { return filterAtlasPlansCore(plans, textureLighting, disabled, { solidTriangleSupported: isSolidTriangleSupported(doc), + projectiveQuadSupported: doc ? projectiveQuadSupported(doc) : true, borderShapeSupported: isBorderShapeSupported(doc), }); } diff --git a/packages/vue/src/scene/atlas/solidTriangleStyle.ts b/packages/vue/src/scene/atlas/solidTriangleStyle.ts index 8d802f72..b16eeed3 100644 --- a/packages/vue/src/scene/atlas/solidTriangleStyle.ts +++ b/packages/vue/src/scene/atlas/solidTriangleStyle.ts @@ -26,7 +26,6 @@ export const DEFAULT_LIGHT_INTENSITY = 1; export const DEFAULT_AMBIENT_COLOR = "#ffffff"; export const DEFAULT_AMBIENT_INTENSITY = 0.4; export const BASIS_EPS = 1e-9; -const RECT_EPS = 1e-3; // Matches the canonical SOLID_TRIANGLE_BLEED constant. export const SOLID_TRIANGLE_BLEED = 0.75; const SOLID_TRIANGLE_CANONICAL_SIZE = 32; @@ -421,6 +420,27 @@ function stableTriangleEdgeAmounts( }); } +export function formatStableTriangleTransformScalars( + x0: number, x1: number, x2: number, + y0: number, y1: number, y2: number, + z0: number, z1: number, z2: number, + tx0: number, tx1: number, tx2: number, +): string { + const rx0 = Math.round(x0 * 1000) / 1000 || 0; + const rx1 = Math.round(x1 * 1000) / 1000 || 0; + const rx2 = Math.round(x2 * 1000) / 1000 || 0; + const ry0 = Math.round(y0 * 1000) / 1000 || 0; + const ry1 = Math.round(y1 * 1000) / 1000 || 0; + const ry2 = Math.round(y2 * 1000) / 1000 || 0; + const rz0 = Math.round(z0 * 1000) / 1000 || 0; + const rz1 = Math.round(z1 * 1000) / 1000 || 0; + const rz2 = Math.round(z2 * 1000) / 1000 || 0; + const rtx0 = Math.round(tx0 * 1000) / 1000 || 0; + const rtx1 = Math.round(tx1 * 1000) / 1000 || 0; + const rtx2 = Math.round(tx2 * 1000) / 1000 || 0; + return `matrix3d(${rx0},${rx1},${rx2},0,${ry0},${ry1},${ry2},0,${rz0},${rz1},${rz2},0,${rtx0},${rtx1},${rtx2},1)`; +} + function cssPoints(vertices: Vec3[], tile: number, elev: number): Vec3[] { return vertices.map((v) => [v[1] * tile, v[0] * tile, v[2] * elev]); } diff --git a/packages/vue/src/scene/atlas/stableTriangleDom.ts b/packages/vue/src/scene/atlas/stableTriangleDom.ts index 1421636f..73687aaf 100644 --- a/packages/vue/src/scene/atlas/stableTriangleDom.ts +++ b/packages/vue/src/scene/atlas/stableTriangleDom.ts @@ -23,6 +23,7 @@ import { quantizeCssColor, stepRgbToward, offsetConvexPolygonPoints, + formatStableTriangleTransformScalars, applySolidTrianglePaintStyle, solidTriangleBorderWidth, solidTriangleCanonicalSize, @@ -144,27 +145,6 @@ function offsetStableTrianglePoints( return [apexPtX, apexPtY, baseLeftX, baseLeftY, baseRightX, baseRightY]; } -function formatStableTriangleTransformScalars( - x0: number, x1: number, x2: number, - y0: number, y1: number, y2: number, - z0: number, z1: number, z2: number, - tx0: number, tx1: number, tx2: number, -): string { - const rx0 = Math.round(x0 * 1000) / 1000 || 0; - const rx1 = Math.round(x1 * 1000) / 1000 || 0; - const rx2 = Math.round(x2 * 1000) / 1000 || 0; - const ry0 = Math.round(y0 * 1000) / 1000 || 0; - const ry1 = Math.round(y1 * 1000) / 1000 || 0; - const ry2 = Math.round(y2 * 1000) / 1000 || 0; - const rz0 = Math.round(z0 * 1000) / 1000 || 0; - const rz1 = Math.round(z1 * 1000) / 1000 || 0; - const rz2 = Math.round(z2 * 1000) / 1000 || 0; - const rtx0 = Math.round(tx0 * 1000) / 1000 || 0; - const rtx1 = Math.round(tx1 * 1000) / 1000 || 0; - const rtx2 = Math.round(tx2 * 1000) / 1000 || 0; - return `matrix3d(${rx0},${rx1},${rx2},0,${ry0},${ry1},${ry2},0,${rz0},${rz1},${rz2},0,${rtx0},${rtx1},${rtx2},1)`; -} - function computeStableTriangleDomStyle( polygon: Polygon, options: StableTriangleDomUpdateOptions, diff --git a/packages/vue/src/scene/atlas/useTextureAtlas.ts b/packages/vue/src/scene/atlas/useTextureAtlas.ts index bba04b97..a19665d0 100644 --- a/packages/vue/src/scene/atlas/useTextureAtlas.ts +++ b/packages/vue/src/scene/atlas/useTextureAtlas.ts @@ -15,7 +15,7 @@ import type { PolyRenderStrategy, PolyRenderStrategiesOption, } from "@layoutit/polycss-core"; -import { isBorderShapeSupported, isSolidTriangleSupported } from "./detection"; +import { isBorderShapeSupported, isSolidTriangleSupported, projectiveQuadSupported } from "./detection"; import { filterAtlasPlans } from "./filterPlans"; import { packTextureAtlasPlansWithScale } from "./packing"; import { buildAtlasPages } from "./buildAtlasPages"; @@ -49,7 +49,10 @@ export function useTextureAtlas( ): TextureAtlasResult { const disabled = computed(() => new Set((strategies.value?.disable ?? []) as PolyRenderStrategy[])); const useFullRectSolid = computed(() => !disabled.value.has("b")); - const useProjectiveQuad = computed(() => useFullRectSolid.value); + const useProjectiveQuad = computed(() => { + const doc = typeof document !== "undefined" ? document : null; + return useFullRectSolid.value && (!doc || projectiveQuadSupported(doc)); + }); const useStableTriangle = computed(() => !disabled.value.has("u") && isSolidTriangleSupported()); const useBorderShape = computed( () => !disabled.value.has("i") && textureLighting.value !== "dynamic" && isBorderShapeSupported(), diff --git a/packages/vue/src/shapes/Poly.test.ts b/packages/vue/src/shapes/Poly.test.ts index 668d02ad..0ad5ea87 100644 --- a/packages/vue/src/shapes/Poly.test.ts +++ b/packages/vue/src/shapes/Poly.test.ts @@ -143,7 +143,7 @@ describe("Poly (Vue) — non-horizontal geometry", () => { expect(poly.style.height).toBe(""); }); - it("renders solid non-rect quads as projective b on Safari", () => { + it("falls back to atlas for projective solid quads on Safari", () => { const userAgent = vi.spyOn(window.navigator, "userAgent", "get").mockReturnValue( "Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", ); @@ -152,9 +152,7 @@ describe("Poly (Vue) — non-horizontal geometry", () => { try { const container = renderPoly({ vertices: NON_RECT_QUAD }); const poly = getPoly(container); - // Non-rect untextured quads are rendered as projective regardless of - // browser — the projective matrix path doesn't depend on CSS.supports. - expect(poly.tagName.toLowerCase()).toBe("b"); + expect(poly.tagName.toLowerCase()).toBe("s"); expect(poly.style.getPropertyValue("border-shape")).toBe(""); } finally { userAgent.mockRestore(); diff --git a/packages/vue/src/shapes/PolyShapes.test.ts b/packages/vue/src/shapes/PolyShapes.test.ts index 8a4dfdc6..56d87cb4 100644 --- a/packages/vue/src/shapes/PolyShapes.test.ts +++ b/packages/vue/src/shapes/PolyShapes.test.ts @@ -49,6 +49,15 @@ describe("PolyBox (Vue)", () => { const container = renderShape(h(PolyBox, { size: 100, color: "#ff6644" })); expect(hasLeaves(container)).toBe(true); }); + + it("threads face overrides into boxPolygons", () => { + const container = renderShape(h(PolyBox, { + size: 100, + color: "#ff6644", + faces: { top: false }, + })); + expect(container.querySelectorAll("i,b,s,u")).toHaveLength(5); + }); }); describe("PolyPlane (Vue)", () => { @@ -56,6 +65,19 @@ describe("PolyPlane (Vue)", () => { const container = renderShape(h(PolyPlane, { axis: 2, size: 50, color: "#cccccc" })); expect(hasLeaves(container)).toBe(true); }); + + it("threads offset into planePolygons", () => { + const base = renderShape(h(PolyPlane, { axis: 2, size: 1, color: "#cccccc" })); + const shifted = renderShape(h(PolyPlane, { + axis: 2, + size: 1, + offset: [5, 5], + color: "#cccccc", + })); + const baseTransform = (base.querySelector("i,b,s,u") as HTMLElement).style.transform; + const shiftedTransform = (shifted.querySelector("i,b,s,u") as HTMLElement).style.transform; + expect(shiftedTransform).not.toBe(baseTransform); + }); }); describe("PolyRing (Vue)", () => { diff --git a/packages/vue/src/shapes/PolyShapes.ts b/packages/vue/src/shapes/PolyShapes.ts index 3bc54bf3..ecec5a1e 100644 --- a/packages/vue/src/shapes/PolyShapes.ts +++ b/packages/vue/src/shapes/PolyShapes.ts @@ -21,16 +21,35 @@ import { conePolygons, torusPolygons, } from "@layoutit/polycss-core"; -import type { Vec3 } from "@layoutit/polycss-core"; +import type { + BoxPolygonsOptions, + PlanePolygonsOptions, + RingPolygonsOptions, + OctahedronPolygonsOptions, + SpherePolygonsOptions, + TetrahedronPolygonsOptions, + IcosahedronPolygonsOptions, + DodecahedronPolygonsOptions, + CylinderPolygonsOptions, + ConePolygonsOptions, + TorusPolygonsOptions, + PolyMaterial, + Vec2, + Vec3, +} from "@layoutit/polycss-core"; import { PolyMesh } from "../scene/PolyMesh"; +import type { PolyMeshProps } from "../scene/PolyMesh"; // ── Shared mesh prop pass-through helpers ──────────────────────────────────── // We spread the mesh-compatible props without src/mtl/polygons (those are // controlled by the shape component). Vue doesn't allow direct key exclusion // from interfaces, so we pick explicit passes through `attrs` where needed. +type ShapeMeshProps = Omit; // ── Fixed-geometry primitives ───────────────────────────────────────────────── +export interface PolyBoxProps extends ShapeMeshProps, BoxPolygonsOptions {} + export const PolyBox = defineComponent({ name: "PolyBox", props: { @@ -41,6 +60,10 @@ export const PolyBox = defineComponent({ max: { type: Array as unknown as PropType, default: undefined }, color: { type: String, default: undefined }, texture: { type: String, default: undefined }, + material: { type: Object as PropType, default: undefined }, + uvs: { type: Array as PropType, default: undefined }, + data: { type: Object as PropType, default: undefined }, + faces: { type: Object as PropType, default: undefined }, // Common mesh props position: { type: Array as unknown as PropType, default: undefined }, scale: { type: [Number, Array] as unknown as PropType, default: undefined }, @@ -57,6 +80,10 @@ export const PolyBox = defineComponent({ max: props.max, color: props.color, texture: props.texture, + material: props.material, + uvs: props.uvs, + data: props.data, + faces: props.faces, }), ); return () => @@ -71,11 +98,14 @@ export const PolyBox = defineComponent({ }, }); +export interface PolyPlaneProps extends ShapeMeshProps, PlanePolygonsOptions {} + export const PolyPlane = defineComponent({ name: "PolyPlane", props: { axis: { type: Number as PropType<0 | 1 | 2>, required: true }, size: { type: Number, default: undefined }, + offset: { type: [Number, Array] as unknown as PropType, default: undefined }, along: { type: Number, default: undefined }, color: { type: String, default: undefined }, // Common mesh props @@ -90,6 +120,7 @@ export const PolyPlane = defineComponent({ planePolygons({ axis: props.axis, size: props.size, + offset: props.offset, along: props.along, color: props.color, }), @@ -106,6 +137,8 @@ export const PolyPlane = defineComponent({ }, }); +export interface PolyRingProps extends ShapeMeshProps, RingPolygonsOptions {} + export const PolyRing = defineComponent({ name: "PolyRing", props: { @@ -143,6 +176,8 @@ export const PolyRing = defineComponent({ }, }); +export interface PolyOctahedronProps extends ShapeMeshProps, OctahedronPolygonsOptions {} + export const PolyOctahedron = defineComponent({ name: "PolyOctahedron", props: { @@ -176,6 +211,8 @@ export const PolyOctahedron = defineComponent({ }, }); +export interface PolyTetrahedronProps extends ShapeMeshProps, TetrahedronPolygonsOptions {} + export const PolyTetrahedron = defineComponent({ name: "PolyTetrahedron", props: { @@ -204,6 +241,8 @@ export const PolyTetrahedron = defineComponent({ }, }); +export interface PolyIcosahedronProps extends ShapeMeshProps, IcosahedronPolygonsOptions {} + export const PolyIcosahedron = defineComponent({ name: "PolyIcosahedron", props: { @@ -232,6 +271,8 @@ export const PolyIcosahedron = defineComponent({ }, }); +export interface PolyDodecahedronProps extends ShapeMeshProps, DodecahedronPolygonsOptions {} + export const PolyDodecahedron = defineComponent({ name: "PolyDodecahedron", props: { @@ -260,6 +301,8 @@ export const PolyDodecahedron = defineComponent({ }, }); +export interface PolySphereProps extends ShapeMeshProps, SpherePolygonsOptions {} + export const PolySphere = defineComponent({ name: "PolySphere", props: { @@ -291,6 +334,8 @@ export const PolySphere = defineComponent({ // ── Parametric primitives ───────────────────────────────────────────────────── +export interface PolyCylinderProps extends ShapeMeshProps, CylinderPolygonsOptions {} + export const PolyCylinder = defineComponent({ name: "PolyCylinder", props: { @@ -328,6 +373,8 @@ export const PolyCylinder = defineComponent({ }, }); +export interface PolyConeProps extends ShapeMeshProps, ConePolygonsOptions {} + export const PolyCone = defineComponent({ name: "PolyCone", props: { @@ -363,6 +410,8 @@ export const PolyCone = defineComponent({ }, }); +export interface PolyTorusProps extends ShapeMeshProps, TorusPolygonsOptions {} + export const PolyTorus = defineComponent({ name: "PolyTorus", props: { diff --git a/packages/vue/src/shapes/index.ts b/packages/vue/src/shapes/index.ts index e7cb26d7..693c5a51 100644 --- a/packages/vue/src/shapes/index.ts +++ b/packages/vue/src/shapes/index.ts @@ -13,3 +13,16 @@ export { PolyCone, PolyTorus, } from "./PolyShapes"; +export type { + PolyBoxProps, + PolyPlaneProps, + PolyRingProps, + PolyOctahedronProps, + PolySphereProps, + PolyTetrahedronProps, + PolyIcosahedronProps, + PolyDodecahedronProps, + PolyCylinderProps, + PolyConeProps, + PolyTorusProps, +} from "./PolyShapes"; diff --git a/packages/vue/src/styles/styles.test.ts b/packages/vue/src/styles/styles.test.ts index d163db85..c0a7cc74 100644 --- a/packages/vue/src/styles/styles.test.ts +++ b/packages/vue/src/styles/styles.test.ts @@ -64,6 +64,15 @@ describe("injectPolyBaseStyles", () => { expect(el.textContent).not.toContain("polycss-solid-triangle"); }); + it("includes transform-controls and shadow performance rules", () => { + injectPolyBaseStyles(document); + const el = document.getElementById("polycss-styles")!; + expect(el.textContent).toContain(".polycss-transform-controls i"); + expect(el.textContent).toContain("transition: color 150ms ease-out"); + expect(el.textContent).toContain(".polycss-scene q"); + expect(el.textContent).toContain("will-change: transform"); + }); + it("does nothing when doc is null-ish", () => { expect(() => injectPolyBaseStyles(null as unknown as Document) diff --git a/packages/vue/src/styles/styles.ts b/packages/vue/src/styles/styles.ts index c973d8cf..43c6e0c8 100644 --- a/packages/vue/src/styles/styles.ts +++ b/packages/vue/src/styles/styles.ts @@ -156,6 +156,27 @@ const CORE_BASE_STYLES = ` border-width: 0 16px 32px 16px; } +/* ── Gizmo override ─────────────────────────────────────────────────────── */ + +/* + * PolyTransformControls renders 3D arrows using the same polygon pipeline + * as user content, but the gizmo is a UI affordance — both faces of + * every polygon should remain visible regardless of which way the + * camera is looking. Otherwise the cuboid shafts and pyramid heads end + * up half-culled. + * + * Transitions on color, border-color, and background-color smooth the + * idle → hover → drag alpha changes across rect, border-shape, triangle, + * and atlas paths. + */ +.polycss-transform-controls i, +.polycss-transform-controls b, +.polycss-transform-controls s, +.polycss-transform-controls u { + backface-visibility: visible; + transition: color 150ms ease-out, border-color 150ms ease-out, background-color 150ms ease-out; +} + /* ── Dynamic lighting cascade vars (scene root → polygons) ─────────────── */ /* @@ -291,6 +312,7 @@ const CORE_BASE_STYLES = ` backface-visibility: visible; border-color: currentColor; pointer-events: none; + will-change: transform; } .polycss-scene q::before, .polycss-scene q::after { diff --git a/website/public/gallery/glb/poly-pizza/apple-tree.glb b/website/public/gallery/glb/poly-pizza/apple-tree.glb new file mode 100644 index 00000000..6cac4214 Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/apple-tree.glb differ diff --git a/website/public/gallery/glb/poly-pizza/broccoli.glb b/website/public/gallery/glb/poly-pizza/broccoli.glb new file mode 100644 index 00000000..7b5983be Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/broccoli.glb differ diff --git a/website/public/gallery/glb/poly-pizza/crab.glb b/website/public/gallery/glb/poly-pizza/crab.glb new file mode 100644 index 00000000..798823a5 Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/crab.glb differ diff --git a/website/public/gallery/glb/poly-pizza/ice-cream.glb b/website/public/gallery/glb/poly-pizza/ice-cream.glb new file mode 100644 index 00000000..8d77988f Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/ice-cream.glb differ diff --git a/website/public/gallery/glb/poly-pizza/monkey.glb b/website/public/gallery/glb/poly-pizza/monkey.glb new file mode 100644 index 00000000..f102091f Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/monkey.glb differ diff --git a/website/public/gallery/glb/poly-pizza/mr-brush.glb b/website/public/gallery/glb/poly-pizza/mr-brush.glb new file mode 100644 index 00000000..57df8263 Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/mr-brush.glb differ diff --git a/website/public/gallery/glb/poly-pizza/peanut.glb b/website/public/gallery/glb/poly-pizza/peanut.glb new file mode 100644 index 00000000..0044fe45 Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/peanut.glb differ diff --git a/website/public/gallery/glb/poly-pizza/pear.glb b/website/public/gallery/glb/poly-pizza/pear.glb new file mode 100644 index 00000000..8b31ec4c Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/pear.glb differ diff --git a/website/public/gallery/glb/poly-pizza/tiger.glb b/website/public/gallery/glb/poly-pizza/tiger.glb new file mode 100644 index 00000000..f88c55df Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/tiger.glb differ diff --git a/website/public/gallery/glb/poly-pizza/tomato.glb b/website/public/gallery/glb/poly-pizza/tomato.glb new file mode 100644 index 00000000..78268eee Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/tomato.glb differ diff --git a/website/public/gallery/glb/poly-pizza/triceratops.glb b/website/public/gallery/glb/poly-pizza/triceratops.glb new file mode 100644 index 00000000..40505c5b Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/triceratops.glb differ diff --git a/website/public/gallery/glb/poly-pizza/turkey.glb b/website/public/gallery/glb/poly-pizza/turkey.glb new file mode 100644 index 00000000..e91c6bd2 Binary files /dev/null and b/website/public/gallery/glb/poly-pizza/turkey.glb differ diff --git a/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx b/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx index efc53551..e292ae44 100644 --- a/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx +++ b/website/src/components/BuilderWorkbench/BuilderWorkbench.tsx @@ -33,7 +33,7 @@ import { serializeBuilderSceneToParam, updateBuilderSceneUrl, } from "./sceneUrl"; -import type { BuilderToolMode, PlacedItem, TargetMode, ToolMode } from "./types"; +import type { BuilderPlacementTarget, BuilderToolMode, PlacedItem, TargetMode, ToolMode } from "./types"; const TILE = 50; const BUILDER_IMPORT_EXTENSIONS = new Set(["obj", "glb", "vox"]); @@ -322,13 +322,17 @@ export default function BuilderWorkbench() { setSceneOptions({ ...DEFAULT_SCENE }); }, [replaceItems, setSelectedId]); - const handleAddShapeAt = useCallback(async (worldX: number, worldY: number) => { + const handleAddShapeAt = useCallback(async ({ worldX, worldY, surfaceWorldZ }: BuilderPlacementTarget) => { if (placingShapeId) return; const preset = BUILDER_SHAPE_PRESETS.find((shape) => shape.id === selectedShapeId); if (!preset) return; setPlacingShapeId(preset.id); try { - const placement = await buildPlacement(preset, worldX, worldY); + const terrainSample = sampleTerrain(terrainVertices, sceneOptions.gridResolution, worldX, worldY); + const elevation = typeof surfaceWorldZ === "number" && Number.isFinite(surfaceWorldZ) + ? Math.max(0, surfaceWorldZ - terrainSample.z) + : 0; + const placement = await buildPlacement(preset, worldX, worldY, { elevation }); if (!placement) return; const snapped = snapPlacement(placement, terrainVertices, sceneOptions.gridResolution, sceneOptions.snapToGrid); appendItems([snapped]); diff --git a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx index d7e350dc..e482cfb9 100644 --- a/website/src/components/BuilderWorkbench/components/BuilderScene.tsx +++ b/website/src/components/BuilderWorkbench/components/BuilderScene.tsx @@ -30,7 +30,7 @@ import { buildSolidWireframePolygons } from "../geometry/ghost"; import { meshBbox } from "../geometry/meshBbox"; import { projectScreenToWorldGround } from "../geometry/screenToWorld"; import { snapWorldToCellCenter } from "../geometry/snap"; -import type { BuilderToolMode, PlacedItem } from "../types"; +import type { BuilderPlacementTarget, BuilderToolMode, PlacedItem } from "../types"; import { BuilderCameraDragControls } from "./BuilderCameraDragControls"; const GROUND_FILL_COLORS = { @@ -61,7 +61,7 @@ export interface BuilderSceneProps { onSelectedMeshDrag: (id: string, worldX: number, worldY: number) => void; onStepSelectedElevation: (direction: 1 | -1) => void; builderTool: BuilderToolMode; - onAddShapeAt: (worldX: number, worldY: number) => void; + onAddShapeAt: (target: BuilderPlacementTarget) => void; onRemoveItem: (id: string) => void; selected: PlacedItem | null; } @@ -73,6 +73,92 @@ function selectedSurfaceWorldZ(item: PlacedItem): number { return item.position[2] / BASE_TILE + bbox.midZ * (1 - scale) + scale * bbox.minZ; } +function itemTopSurfaceWorldZ(item: PlacedItem & { rawPolygons: Polygon[] }, polygons: Polygon[]): number { + const bbox = meshBbox(polygons); + const scale = Math.max(item.fitScale * item.scale, 0.0001); + return item.position[2] / BASE_TILE + bbox.midZ * (1 - scale) + scale * bbox.maxZ; +} + +function itemBaseSurfaceWorldZ(item: PlacedItem & { rawPolygons: Polygon[] }, polygons: Polygon[]): number { + const bbox = meshBbox(polygons); + const scale = Math.max(item.fitScale * item.scale, 0.0001); + return item.position[2] / BASE_TILE + bbox.midZ * (1 - scale) + scale * bbox.minZ; +} + +interface PlacementSurface { + baseWorldZ: number; + surfaceWorldZ: number; + minWorldX: number; + maxWorldX: number; + minWorldY: number; + maxWorldY: number; +} + +type PaintLeafFace = "top" | "side" | "other"; + +function transformNormalZ(transform: string): number | null { + const matrix3d = transform.match(/^matrix3d\((.+)\)$/); + if (matrix3d) { + const values = matrix3d[1].split(",").map((value) => Number(value.trim())); + if (values.length !== 16 || values.some((value) => !Number.isFinite(value))) return null; + const nx = values[8]; + const ny = values[9]; + const nz = values[10]; + const len = Math.hypot(nx, ny, nz); + return len > 0 ? nz / len : null; + } + + const matrix = transform.match(/^matrix\((.+)\)$/); + if (matrix) return 1; + return null; +} + +function paintLeafFace(target: EventTarget | null): PaintLeafFace { + const el = target as Element | null; + const leaf = el?.closest("b,i,s,u") as HTMLElement | null; + if (!leaf || !leaf.closest(".builder-placed")) return "other"; + const normalZ = transformNormalZ(getComputedStyle(leaf).transform); + if (normalZ === null) return "other"; + if (normalZ > 0.5) return "top"; + if (Math.abs(normalZ) < 0.25) return "side"; + return "other"; +} + +function placementSurfaceForItem(item: PlacedItem & { rawPolygons: Polygon[] }, polygons: Polygon[]): PlacementSurface { + const bbox = meshBbox(polygons); + const scale = Math.max(item.fitScale * item.scale, 0.0001); + const rz = ((item.rotation[2] ?? 0) * Math.PI) / 180; + const cos = Math.cos(rz); + const sin = Math.sin(rz); + let minWorldX = Infinity; + let maxWorldX = -Infinity; + let minWorldY = Infinity; + let maxWorldY = -Infinity; + for (const [x, y] of [ + [bbox.minX, bbox.minY], + [bbox.maxX, bbox.minY], + [bbox.maxX, bbox.maxY], + [bbox.minX, bbox.maxY], + ] as const) { + const dx = (x - bbox.midX) * scale; + const dy = (y - bbox.midY) * scale; + const worldX = item.worldX + dx * cos - dy * sin; + const worldY = item.worldY + dx * sin + dy * cos; + minWorldX = Math.min(minWorldX, worldX); + maxWorldX = Math.max(maxWorldX, worldX); + minWorldY = Math.min(minWorldY, worldY); + maxWorldY = Math.max(maxWorldY, worldY); + } + return { + baseWorldZ: itemBaseSurfaceWorldZ(item, polygons), + surfaceWorldZ: itemTopSurfaceWorldZ(item, polygons), + minWorldX, + maxWorldX, + minWorldY, + maxWorldY, + }; +} + function zArrowDirectionFromTarget(target: Element | null): 1 | -1 | null { const arrow = target?.closest(".polycss-transform-arrow--z, .polycss-transform-arrow---z"); if (!arrow) return null; @@ -268,7 +354,8 @@ function BuilderSelectedMeshInteractionControls({ interface BuilderViewportToolControlsProps { tool: BuilderToolMode; sceneOptions: SceneOptionsState; - onAddShapeAt: (worldX: number, worldY: number) => void; + placementSurfaceById: Map; + onAddShapeAt: (target: BuilderPlacementTarget) => void; onRemoveItem: (id: string) => void; onDraggingChanged: (dragging: boolean) => void; } @@ -276,13 +363,28 @@ interface BuilderViewportToolControlsProps { function BuilderViewportToolControls({ tool, sceneOptions, + placementSurfaceById, onAddShapeAt, onRemoveItem, onDraggingChanged, }: BuilderViewportToolControlsProps): null { const { store, cameraElRef } = useCameraContext(); - const stateRef = useRef({ tool, sceneOptions, onAddShapeAt, onRemoveItem, onDraggingChanged }); - stateRef.current = { tool, sceneOptions, onAddShapeAt, onRemoveItem, onDraggingChanged }; + const stateRef = useRef({ + tool, + sceneOptions, + placementSurfaceById, + onAddShapeAt, + onRemoveItem, + onDraggingChanged, + }); + stateRef.current = { + tool, + sceneOptions, + placementSurfaceById, + onAddShapeAt, + onRemoveItem, + onDraggingChanged, + }; const downRef = useRef<{ x: number; y: number; target: EventTarget | null } | null>(null); useEffect(() => { @@ -295,7 +397,13 @@ function BuilderViewportToolControls({ return Boolean(el.closest(".builder-tool-ribbon, .shape-picker, .builder-camera-mode, .dn-floating-controls")); }; - const projectAt = (clientX: number, clientY: number): [number, number] | null => { + const snapHit = (hit: [number, number]): [number, number] => { + const { sceneOptions: options } = stateRef.current; + if (!options.snapToGrid || options.gridResolution <= 0) return hit; + return snapWorldToCellCenter(hit[0], hit[1], options.gridResolution); + }; + + const projectAt = (clientX: number, clientY: number, planeWorldZ = 0): [number, number] | null => { const state = stateRef.current; const hit = projectScreenToWorldGround({ clientX, @@ -303,10 +411,100 @@ function BuilderViewportToolControls({ cameraEl, sceneOptions: state.sceneOptions, autoCenterOffset: store.getState().autoCenterOffset, + planeWorldZ, }); - if (!hit) return null; - if (!state.sceneOptions.snapToGrid || state.sceneOptions.gridResolution <= 0) return hit; - return snapWorldToCellCenter(hit[0], hit[1], state.sceneOptions.gridResolution); + return hit; + }; + + const surfaceForTarget = (target: EventTarget | null): PlacementSurface | null => { + const el = target as Element | null; + const meshEl = el?.closest(".builder-placed") as HTMLElement | null; + const id = meshEl?.dataset.polyMeshId; + if (!id) return null; + return stateRef.current.placementSurfaceById.get(id) ?? null; + }; + + const clampToSurface = (hit: [number, number], surface: PlacementSurface): [number, number] => { + const { sceneOptions: options } = stateRef.current; + if (!options.snapToGrid || options.gridResolution <= 0) { + return [ + Math.min(surface.maxWorldX, Math.max(surface.minWorldX, hit[0])), + Math.min(surface.maxWorldY, Math.max(surface.minWorldY, hit[1])), + ]; + } + + const step = options.gridResolution; + const minCenterX = surface.minWorldX + step / 2; + const maxCenterX = surface.maxWorldX - step / 2; + const minCenterY = surface.minWorldY + step / 2; + const maxCenterY = surface.maxWorldY - step / 2; + const centerX = (surface.minWorldX + surface.maxWorldX) / 2; + const centerY = (surface.minWorldY + surface.maxWorldY) / 2; + return [ + minCenterX <= maxCenterX + ? Math.min(maxCenterX, Math.max(minCenterX, hit[0])) + : centerX, + minCenterY <= maxCenterY + ? Math.min(maxCenterY, Math.max(minCenterY, hit[1])) + : centerY, + ]; + }; + + const highestSurfaceAt = (worldX: number, worldY: number, fallback: PlacementSurface): number => { + const epsilon = Math.max(0.001, stateRef.current.sceneOptions.gridResolution * 0.01); + let top = fallback.surfaceWorldZ; + for (const surface of stateRef.current.placementSurfaceById.values()) { + if ( + worldX >= surface.minWorldX - epsilon && + worldX <= surface.maxWorldX + epsilon && + worldY >= surface.minWorldY - epsilon && + worldY <= surface.maxWorldY + epsilon + ) { + top = Math.max(top, surface.surfaceWorldZ); + } + } + return top; + }; + + const sidePlacementTarget = (hit: [number, number], surface: PlacementSurface): [number, number] => { + const { sceneOptions: options } = stateRef.current; + const step = options.gridResolution > 0 + ? options.gridResolution + : Math.max(surface.maxWorldX - surface.minWorldX, surface.maxWorldY - surface.minWorldY, 1); + const centerX = (surface.minWorldX + surface.maxWorldX) / 2; + const centerY = (surface.minWorldY + surface.maxWorldY) / 2; + const snapped = options.snapToGrid && options.gridResolution > 0 + ? snapWorldToCellCenter(hit[0], hit[1], step) + : hit; + const minCenterX = surface.minWorldX + step / 2; + const maxCenterX = surface.maxWorldX - step / 2; + const minCenterY = surface.minWorldY + step / 2; + const maxCenterY = surface.maxWorldY - step / 2; + const clampX = (value: number): number => + minCenterX <= maxCenterX + ? Math.min(maxCenterX, Math.max(minCenterX, value)) + : centerX; + const clampY = (value: number): number => + minCenterY <= maxCenterY + ? Math.min(maxCenterY, Math.max(minCenterY, value)) + : centerY; + const sides = [ + { axis: "x" as const, sign: -1 as const, distance: Math.abs(hit[0] - surface.minWorldX) }, + { axis: "x" as const, sign: 1 as const, distance: Math.abs(hit[0] - surface.maxWorldX) }, + { axis: "y" as const, sign: -1 as const, distance: Math.abs(hit[1] - surface.minWorldY) }, + { axis: "y" as const, sign: 1 as const, distance: Math.abs(hit[1] - surface.maxWorldY) }, + ].sort((a, b) => a.distance - b.distance); + const side = sides[0]; + if (side.axis === "x") { + return [ + side.sign < 0 ? surface.minWorldX - step / 2 : surface.maxWorldX + step / 2, + clampY(snapped[1]), + ]; + } + return [ + clampX(snapped[0]), + side.sign < 0 ? surface.minWorldY - step / 2 : surface.maxWorldY + step / 2, + ]; }; const onPointerDown = (event: PointerEvent): void => { @@ -336,12 +534,33 @@ function BuilderViewportToolControls({ return; } - const hit = projectAt(event.clientX, event.clientY); - if (!hit) return; + const face = paintLeafFace(event.target); + const surface = face !== "other" ? surfaceForTarget(event.target) : null; + let hit: [number, number] | null = null; + let surfaceWorldZ: number | null = null; + if (surface && face === "top") { + const projected = projectAt(event.clientX, event.clientY, surface.surfaceWorldZ); + if (!projected) return; + hit = clampToSurface(snapHit(projected), surface); + surfaceWorldZ = highestSurfaceAt(hit[0], hit[1], surface); + } else if (surface && face === "side") { + const projected = projectAt(event.clientX, event.clientY, surface.baseWorldZ); + if (!projected) return; + hit = sidePlacementTarget(projected, surface); + surfaceWorldZ = surface.baseWorldZ; + } else { + const projected = projectAt(event.clientX, event.clientY); + if (!projected) return; + hit = snapHit(projected); + } event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); - state.onAddShapeAt(hit[0], hit[1]); + state.onAddShapeAt({ + worldX: hit[0], + worldY: hit[1], + ...(surfaceWorldZ !== null ? { surfaceWorldZ } : null), + }); }; cameraEl.addEventListener("pointerdown", onPointerDown, true); @@ -418,6 +637,16 @@ export function BuilderScene({ baseZ: bbox.minZ, }, "#00d9ff", edgeHalf); }, [renderedPolygonsById, sceneOptions.gridResolution, selected]); + const placementSurfaceById = useMemo(() => { + const surfaces = new Map(); + for (const item of renderItems) { + surfaces.set( + item.id, + placementSurfaceForItem(item, renderedPolygonsById.get(item.id) ?? item.rawPolygons), + ); + } + return surfaces; + }, [renderItems, renderedPolygonsById]); const groundFillPolygons = useMemo(() => { const half = BUILDER_GROUND_SPAN / 2; const [cx, cy] = sceneOptions.target; @@ -445,6 +674,7 @@ export function BuilderScene({ onUpdateScene({ shadowMaxExtend: value }), ); useToggle(folder, "Show ground", showGround, (value) => onUpdateScene({ showGround: value })); + const groundColorControl = useColor(folder, "Ground color", groundColor, (value) => + onUpdateScene({ groundColor: value }), + ); + useEffect(() => { + groundColorControl?.setVisible(showGround); + }, [groundColorControl, showGround]); useToggle(folder, "Light helper", showLight, (value) => onUpdateScene({ showLight: value })); useSlider(folder, "Azimuth", { min: 0, max: 360, step: 1 }, lightAzimuth, (value) => diff --git a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx index aa2c2e5d..41940768 100644 --- a/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx +++ b/website/src/components/GalleryWorkbench/GalleryWorkbench.tsx @@ -66,7 +66,11 @@ import { useRouteSync, useGuiCameraSync, setRoutePresetId, + setRouteSceneOptions, + clearRouteSceneOptions, routeInitialPresetId, + routeInitialSceneOptions, + routeHasSceneOptions, } from "./hooks"; import { useFpvHost } from "../fpv"; import type { ObjParseOptions, GltfParseOptions, VoxParseOptions } from "@layoutit/polycss"; @@ -131,6 +135,7 @@ const DEFAULT_SCENE: SceneOptionsState = { castShadow: false, shadowMaxExtend: 2000, showGround: false, + groundColor: "#7d848e", fpvLook: true, fpvMove: true, fpvJump: true, @@ -512,6 +517,10 @@ function sceneDefaultsFor(model: PresetModel): SceneOptionsState { }; } +function sceneDefaultsForPresetId(id: string): SceneOptionsState { + return sceneDefaultsFor(PRESETS.find((preset) => preset.id === id) ?? PRESETS[0]); +} + function parserStateFor(model: PresetModel): ParserOptionsState { return { ...DEFAULT_PARSER, @@ -699,7 +708,12 @@ function openCodePen(html: string, title: string, target: string): void { export default function GalleryWorkbench() { const [initialPreset] = useState(resolveInitialPreset); - const [sceneOptions, setSceneOptions] = useState(() => sceneDefaultsFor(initialPreset)); + const [initialRouteSceneOptions] = useState(routeInitialSceneOptions); + const [initialRouteHasSceneOptions] = useState(routeHasSceneOptions); + const [sceneOptions, setSceneOptions] = useState(() => ({ + ...sceneDefaultsFor(initialPreset), + ...initialRouteSceneOptions, + })); const [parserOptions, setParserOptions] = useState(() => parserStateFor(initialPreset)); const [presetId, setPresetId] = useState(initialPreset.id); const [loaded, setLoaded] = useState(null); @@ -713,9 +727,9 @@ export default function GalleryWorkbench() { const [codePenState, setCodePenState] = useState<"idle" | "exporting">("idle"); const [codePenError, setCodePenError] = useState(null); const viewportRef = useRef(null); - const autoZoomPresetRef = useRef(null); - const autoAmbientPresetRef = useRef(null); - const autoKeyPresetRef = useRef(null); + const autoZoomPresetRef = useRef(initialRouteHasSceneOptions ? initialPreset.id : null); + const autoAmbientPresetRef = useRef(initialRouteHasSceneOptions ? initialPreset.id : null); + const autoKeyPresetRef = useRef(initialRouteHasSceneOptions ? initialPreset.id : null); const loadedModelKeyRef = useRef(null); // Selection + drag state for the React renderer's wrapper. @@ -751,6 +765,8 @@ export default function GalleryWorkbench() { const sceneOptionsRef = useRef(sceneOptions); sceneOptionsRef.current = sceneOptions; const domRefreshRafRef = useRef(0); + const sceneRouteTouchedRef = useRef(false); + const [sceneRouteRevision, setSceneRouteRevision] = useState(0); const requestGalleryDomRefresh = useCallback(() => { if (domRefreshRafRef.current) return; @@ -769,9 +785,15 @@ export default function GalleryWorkbench() { }; }, []); + const markSceneRouteDirty = useCallback(() => { + sceneRouteTouchedRef.current = true; + setSceneRouteRevision((revision) => revision + 1); + }, []); + const updateScene = useCallback((partial: Partial) => { + markSceneRouteDirty(); setSceneOptions((current) => ({ ...current, ...partial })); - }, []); + }, [markSceneRouteDirty]); const canPreviewSceneOptions = useCallback( (options: SceneOptionsState) => options.renderer === "vanilla" && transientSceneHandleRef.current !== null, @@ -792,12 +814,13 @@ export default function GalleryWorkbench() { }, [sceneOptions, responsiveZoomScale]); const handleRenderCameraChange = useCallback( (camera: { rotX: number; rotY: number; zoom: number; target?: ReactVec3 }) => { + markSceneRouteDirty(); handleCameraChange({ ...camera, zoom: camera.zoom / Math.max(responsiveZoomScale, 0.001), }); }, - [handleCameraChange, responsiveZoomScale], + [handleCameraChange, markSceneRouteDirty, responsiveZoomScale], ); const dropped = useDroppedFiles({ @@ -806,6 +829,7 @@ export default function GalleryWorkbench() { autoAmbientPresetRef.current = null; autoKeyPresetRef.current = null; setRoutePresetId(null); + clearRouteSceneOptions(); setPresetId(source.id); if (loadedModelKeyRef.current !== source.id) loadedModelKeyRef.current = null; setSelectedAnimation(""); @@ -832,6 +856,7 @@ export default function GalleryWorkbench() { ); const selectedPreset = availablePresets.find((preset) => preset.id === presetId) ?? PRESETS[0]; const selectedDroppedSource = dropped.droppedSource?.id === selectedPreset.id ? dropped.droppedSource : null; + const selectedSceneDefaults = useMemo(() => sceneDefaultsFor(selectedPreset), [selectedPreset]); const loadMeshResolution = activeMeshResolution(sceneOptions.meshResolution); const handleLoaded = useCallback((model: LoadedModel) => { const modelKey = selectedPreset.id; @@ -1045,6 +1070,7 @@ export default function GalleryWorkbench() { const resetToPreset = useCallback((id: string, options: { updateRoute?: boolean } = {}) => { const next = availablePresets.find((preset) => preset.id === id); + if (sceneRouteTouchedRef.current || routeHasSceneOptions()) markSceneRouteDirty(); autoZoomPresetRef.current = null; autoAmbientPresetRef.current = null; autoKeyPresetRef.current = null; @@ -1066,7 +1092,7 @@ export default function GalleryWorkbench() { rotX: next.rotX ?? current.rotX, rotY: next.rotY ?? current.rotY, })); - }, [availablePresets, dropped.droppedSource, animation.setReactAnimatedPolygons]); + }, [availablePresets, dropped.droppedSource, animation.setReactAnimatedPolygons, markSceneRouteDirty]); const handleRandomPreset = useCallback(() => { const next = randomPreset(); @@ -1114,8 +1140,29 @@ export default function GalleryWorkbench() { presetId, presetIds: ALL_PRESET_IDS, resetToPreset, + sceneDefaultsForPreset: sceneDefaultsForPresetId, + setSceneOptions, }); + useEffect(() => { + if (!sceneRouteTouchedRef.current) return; + if (selectedDroppedSource) { + clearRouteSceneOptions(); + return; + } + setRouteSceneOptions({ + sceneOptions, + sceneDefaults: selectedSceneDefaults, + presetId: selectedPreset.id, + }); + }, [ + sceneOptions, + sceneRouteRevision, + selectedDroppedSource, + selectedPreset.id, + selectedSceneDefaults, + ]); + useEffect(() => { requestGalleryDomRefresh(); }, [ @@ -1419,6 +1466,7 @@ export default function GalleryWorkbench() { castShadow={sceneOptions.castShadow} shadowMaxExtend={sceneOptions.shadowMaxExtend} showGround={sceneOptions.showGround} + groundColor={sceneOptions.groundColor} showLight={sceneOptions.showLight} lightAzimuth={sceneOptions.lightAzimuth} lightElevation={sceneOptions.lightElevation} diff --git a/website/src/components/GalleryWorkbench/hooks/index.ts b/website/src/components/GalleryWorkbench/hooks/index.ts index 3767e32a..7a6a5cc6 100644 --- a/website/src/components/GalleryWorkbench/hooks/index.ts +++ b/website/src/components/GalleryWorkbench/hooks/index.ts @@ -10,7 +10,15 @@ export type { UseScenePolygonsOptions, UseScenePolygonsResult } from "./useScene export { useAnimationFrames } from "./useAnimationFrames"; export type { UseAnimationFramesOptions, UseAnimationFramesResult } from "./useAnimationFrames"; -export { useRouteSync, setRoutePresetId, routeInitialPresetId } from "./useRouteSync"; +export { + useRouteSync, + setRoutePresetId, + setRouteSceneOptions, + clearRouteSceneOptions, + routeInitialPresetId, + routeInitialSceneOptions, + routeHasSceneOptions, +} from "./useRouteSync"; export type { UseRouteSyncOptions } from "./useRouteSync"; export { useGuiCameraSync } from "./useGuiCameraSync"; diff --git a/website/src/components/GalleryWorkbench/hooks/useRouteSync.ts b/website/src/components/GalleryWorkbench/hooks/useRouteSync.ts index cf6542e7..0555086f 100644 --- a/website/src/components/GalleryWorkbench/hooks/useRouteSync.ts +++ b/website/src/components/GalleryWorkbench/hooks/useRouteSync.ts @@ -1,9 +1,149 @@ -import { useEffect } from "react"; +import { useEffect, type Dispatch, type SetStateAction } from "react"; +import type { SceneOptionsState } from "../../types"; + +const MODEL_PARAM = "model"; +const SCENE_PARAM = "scene"; + +type SceneTarget = SceneOptionsState["target"]; +type SerializedGallerySceneOptionKey = keyof SerializedGallerySceneOptions; + +interface SerializedGallerySceneOptions { + r?: SceneOptionsState["renderer"]; + ap?: boolean; + ats?: number; + c?: boolean; + i?: boolean; + ar?: boolean; + axes?: boolean; + sel?: boolean; + hov?: boolean; + helper?: boolean; + z?: number; + rx?: number; + ry?: number; + p?: number | false; + az?: number; + el?: number; + key?: number; + kc?: string; + amb?: number; + amc?: string; + tl?: SceneOptionsState["textureLighting"]; + tq?: SceneOptionsState["textureQuality"]; + solid?: boolean; + mp?: SceneOptionsState["matrixPrecision"]; + bp?: SceneOptionsState["borderShapePrecision"]; + mr?: SceneOptionsState["meshResolution"]; + fill?: boolean; + outline?: boolean; + drag?: SceneOptionsState["dragMode"]; + t?: SceneTarget; + ds?: string; + shadow?: boolean; + reach?: number; + ground?: boolean; + gc?: string; + fl?: boolean; + fm?: boolean; + fj?: boolean; + fc?: boolean; + fms?: number; + fjv?: number; + fg?: number; + feh?: number; + fch?: number; + fls?: number; + fiy?: boolean; + frd?: number; +} + +interface SerializedGalleryScene { + v: 1; + o?: SerializedGallerySceneOptions; +} + +const STRATEGY_ORDER = ["b", "i", "u"] as const; +const COMPACT_SCENE_VERSION = "2"; +const COMPACT_NUMBER_SCALE = 10000; +const COMPACT_KEY_BY_OPTION: Record = { + r: "r", + ap: "P", + ats: "A", + c: "c", + i: "i", + ar: "n", + axes: "x", + sel: "s", + hov: "h", + helper: "l", + z: "z", + rx: "X", + ry: "Y", + p: "p", + az: "a", + el: "e", + key: "k", + kc: "K", + amb: "m", + amc: "M", + tl: "T", + tq: "q", + solid: "o", + mp: "u", + bp: "v", + mr: "w", + fill: "f", + outline: "O", + drag: "d", + t: "t", + ds: "b", + shadow: "S", + reach: "E", + ground: "g", + gc: "G", + fl: "L", + fm: "V", + fj: "J", + fc: "C", + fms: "1", + fjv: "2", + fg: "3", + feh: "4", + fch: "5", + fls: "6", + fiy: "7", + frd: "8", +}; +const COMPACT_OPTION_BY_KEY = Object.fromEntries( + Object.entries(COMPACT_KEY_BY_OPTION).map(([key, compact]) => [compact, key]), +) as Record; +const DOTTED_COMPACT_OPTION_BY_KEY: Record = { + ...COMPACT_OPTION_BY_KEY, + ms: "fms", + jv: "fjv", + fg: "fg", + eh: "feh", + ch: "fch", + ls: "fls", + iy: "fiy", + rd: "frd", +}; +const BOOLEAN_OPTIONS = new Set([ + "ap", "c", "i", "ar", "axes", "sel", "hov", "helper", + "solid", "fill", "outline", "shadow", "ground", + "fl", "fm", "fj", "fc", "fiy", +]); function getRoutePresetValue(): string { if (typeof window === "undefined") return ""; const params = new URLSearchParams(window.location.search); - return params.get("model") || ""; + return params.get(MODEL_PARAM) || ""; +} + +function getRouteSceneValue(): string { + if (typeof window === "undefined") return ""; + const params = new URLSearchParams(window.location.search); + return params.get(SCENE_PARAM) || ""; } function hashStringToUint32(value: string): number { @@ -19,6 +159,494 @@ function routeIdForPresetId(presetId: string): string { return String(hashStringToUint32(presetId)); } +function round(value: number): number { + return Number(value.toFixed(4)); +} + +function roundVec3(value: SceneTarget): SceneTarget { + return [round(value[0]), round(value[1]), round(value[2])]; +} + +function sameNumber(a: number, b: number): boolean { + return round(a) === round(b); +} + +function sameVec3(a: SceneTarget, b: SceneTarget): boolean { + return sameNumber(a[0], b[0]) && sameNumber(a[1], b[1]) && sameNumber(a[2], b[2]); +} + +function samePerspective( + a: SceneOptionsState["perspective"], + b: SceneOptionsState["perspective"], +): boolean { + if (a === false || b === false) return a === b; + if (typeof a === "number" || typeof b === "number") { + return typeof a === "number" && typeof b === "number" && sameNumber(a, b); + } + return a === b; +} + +function sameTextureQuality( + a: SceneOptionsState["textureQuality"], + b: SceneOptionsState["textureQuality"], +): boolean { + if (typeof a === "number" || typeof b === "number") { + return typeof a === "number" && typeof b === "number" && sameNumber(a, b); + } + return a === b; +} + +function addBoolean( + out: SerializedGallerySceneOptions, + key: K, + value: boolean, + defaultValue: boolean, +): void { + if (value !== defaultValue) out[key] = value as SerializedGallerySceneOptions[K]; +} + +function addNumber( + out: SerializedGallerySceneOptions, + key: K, + value: number, + defaultValue: number, +): void { + if (!sameNumber(value, defaultValue)) out[key] = round(value) as SerializedGallerySceneOptions[K]; +} + +function addString( + out: SerializedGallerySceneOptions, + key: K, + value: T, + defaultValue: T, +): void { + if (value !== defaultValue) out[key] = value as SerializedGallerySceneOptions[K]; +} + +function strategiesPayload(strategies: SceneOptionsState["disableStrategies"]): string { + return STRATEGY_ORDER.filter((strategy) => strategies.includes(strategy)).join(""); +} + +function sceneOptionsPayload( + options: SceneOptionsState, + defaults: SceneOptionsState, +): SerializedGallerySceneOptions | undefined { + const out: SerializedGallerySceneOptions = {}; + addString(out, "r", options.renderer, defaults.renderer); + addBoolean(out, "ap", options.animationPaused, defaults.animationPaused); + addNumber(out, "ats", options.animationTimeScale, defaults.animationTimeScale); + addBoolean(out, "c", options.autoCenter, defaults.autoCenter); + addBoolean(out, "i", options.interactive, defaults.interactive); + addBoolean(out, "ar", options.animate, defaults.animate); + addBoolean(out, "axes", options.showAxes, defaults.showAxes); + addBoolean(out, "sel", options.selection, defaults.selection); + addBoolean(out, "hov", options.hoverEffects, defaults.hoverEffects); + addBoolean(out, "helper", options.showLight, defaults.showLight); + addNumber(out, "z", options.zoom, defaults.zoom); + addNumber(out, "rx", options.rotX, defaults.rotX); + addNumber(out, "ry", options.rotY, defaults.rotY); + if (!samePerspective(options.perspective, defaults.perspective)) { + if (options.perspective === false) out.p = false; + else if (typeof options.perspective === "number") out.p = round(options.perspective); + } + addNumber(out, "az", options.lightAzimuth, defaults.lightAzimuth); + addNumber(out, "el", options.lightElevation, defaults.lightElevation); + addNumber(out, "key", options.lightIntensity, defaults.lightIntensity); + addString(out, "kc", options.lightColor, defaults.lightColor); + addNumber(out, "amb", options.ambientIntensity, defaults.ambientIntensity); + addString(out, "amc", options.ambientColor, defaults.ambientColor); + addString(out, "tl", options.textureLighting, defaults.textureLighting); + if (!sameTextureQuality(options.textureQuality, defaults.textureQuality)) { + out.tq = typeof options.textureQuality === "number" ? round(options.textureQuality) : options.textureQuality; + } + addBoolean(out, "solid", options.solidMaterials, defaults.solidMaterials); + addString(out, "mp", options.matrixPrecision, defaults.matrixPrecision); + addString(out, "bp", options.borderShapePrecision, defaults.borderShapePrecision); + addString(out, "mr", options.meshResolution, defaults.meshResolution); + addBoolean(out, "fill", options.interiorFill, defaults.interiorFill); + addBoolean(out, "outline", options.outlinePolygons, defaults.outlinePolygons); + addString(out, "drag", options.dragMode, defaults.dragMode); + if (!sameVec3(options.target, defaults.target)) out.t = roundVec3(options.target); + const currentStrategies = strategiesPayload(options.disableStrategies); + if (currentStrategies !== strategiesPayload(defaults.disableStrategies)) out.ds = currentStrategies; + addBoolean(out, "shadow", options.castShadow, defaults.castShadow); + addNumber(out, "reach", options.shadowMaxExtend, defaults.shadowMaxExtend); + addBoolean(out, "ground", options.showGround, defaults.showGround); + addString(out, "gc", options.groundColor, defaults.groundColor); + addBoolean(out, "fl", options.fpvLook, defaults.fpvLook); + addBoolean(out, "fm", options.fpvMove, defaults.fpvMove); + addBoolean(out, "fj", options.fpvJump, defaults.fpvJump); + addBoolean(out, "fc", options.fpvCrouch, defaults.fpvCrouch); + addNumber(out, "fms", options.fpvMoveSpeed, defaults.fpvMoveSpeed); + addNumber(out, "fjv", options.fpvJumpVelocity, defaults.fpvJumpVelocity); + addNumber(out, "fg", options.fpvGravity, defaults.fpvGravity); + addNumber(out, "feh", options.fpvEyeHeight, defaults.fpvEyeHeight); + addNumber(out, "fch", options.fpvCrouchHeight, defaults.fpvCrouchHeight); + addNumber(out, "fls", options.fpvLookSensitivity, defaults.fpvLookSensitivity); + addBoolean(out, "fiy", options.fpvInvertY, defaults.fpvInvertY); + addNumber(out, "frd", options.fpvRenderDistance, defaults.fpvRenderDistance); + return Object.keys(out).length > 0 ? out : undefined; +} + +function encodeCompactNumber(value: number): string { + return Math.round(round(value) * COMPACT_NUMBER_SCALE).toString(36); +} + +function decodeCompactNumber(value: string): number | undefined { + if (!/^-?[0-9a-z]+$/i.test(value)) return undefined; + const sign = value.startsWith("-") ? -1 : 1; + const digits = sign === -1 ? value.slice(1) : value; + const parsed = Number.parseInt(digits, 36); + if (!Number.isFinite(parsed)) return undefined; + return round((sign * parsed) / COMPACT_NUMBER_SCALE); +} + +function encodePackedNumber(value: number): string { + const encoded = encodeCompactNumber(value); + return `${encoded.length.toString(36)}${encoded}`; +} + +function encodeCompactColor(value: string): string | undefined { + const hex = value.replace(/^#/, "").toLowerCase(); + if (!/^[0-9a-f]{6}$/.test(hex)) return undefined; + return Number.parseInt(hex, 16).toString(36).padStart(5, "0"); +} + +function decodeCompactColor(value: string): string | undefined { + if (!/^[0-9a-z]{5}$/i.test(value)) return undefined; + const parsed = Number.parseInt(value, 36); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 0xffffff) return undefined; + return `#${parsed.toString(16).padStart(6, "0")}`; +} + +function readPackedNumber(value: string, index: number): { value: number; next: number } | undefined { + const length = Number.parseInt(value[index] ?? "", 36); + if (!Number.isFinite(length) || length <= 0) return undefined; + const start = index + 1; + const end = start + length; + if (end > value.length) return undefined; + const decoded = decodeCompactNumber(value.slice(start, end)); + return decoded === undefined ? undefined : { value: decoded, next: end }; +} + +function encodeEnum(value: T, values: Record): string { + return values[value]; +} + +function decodeEnum(value: string, values: Record): T | undefined { + for (const [decoded, encoded] of Object.entries(values)) { + if (encoded === value) return decoded as T; + } + return undefined; +} + +function strategiesMask(strategies: string): string { + let mask = 0; + if (strategies.includes("b")) mask |= 1; + if (strategies.includes("i")) mask |= 2; + if (strategies.includes("u")) mask |= 4; + return mask.toString(36); +} + +function strategiesFromMask(value: string): string | undefined { + if (!/^[0-7]$/.test(value)) return undefined; + const mask = Number.parseInt(value, 36); + return STRATEGY_ORDER.filter((strategy, index) => (mask & (1 << index)) !== 0).join(""); +} + +function encodeCompactValue(key: SerializedGallerySceneOptionKey, value: SerializedGallerySceneOptions[SerializedGallerySceneOptionKey]): string | undefined { + if (typeof value === "boolean") return value ? "" : "0"; + if (typeof value === "number") return encodePackedNumber(value); + if (key === "p") return value === false ? "n" : typeof value === "number" ? encodePackedNumber(value) : undefined; + if (key === "t" && isVec3(value)) return value.map(encodePackedNumber).join(""); + if (key === "kc" || key === "amc" || key === "gc") return typeof value === "string" ? encodeCompactColor(value) : undefined; + if (key === "ds") return typeof value === "string" ? strategiesMask(value) : undefined; + if (key === "r" && (value === "react" || value === "vanilla")) return encodeEnum(value, { react: "r", vanilla: "v" }); + if (key === "tl" && (value === "baked" || value === "dynamic")) return encodeEnum(value, { baked: "b", dynamic: "d" }); + if (key === "tq") { + if (value === "auto") return "a"; + return typeof value === "number" ? encodePackedNumber(value) : undefined; + } + if (key === "mp" || key === "bp") return value === "exact" ? "e" : typeof value === "string" ? value : undefined; + if (key === "mr" && (value === "lossless" || value === "lossy" || value === "disabled")) { + return encodeEnum(value, { lossless: "x", lossy: "y", disabled: "d" }); + } + if (key === "drag" && (value === "orbit" || value === "pan" || value === "fpv")) { + return encodeEnum(value, { orbit: "o", pan: "p", fpv: "f" }); + } + return typeof value === "string" ? value : undefined; +} + +function encodeCompactScene(payload: SerializedGallerySceneOptions): string { + const tokens: string[] = []; + for (const [rawKey, rawValue] of Object.entries(payload)) { + const key = rawKey as SerializedGallerySceneOptionKey; + const compactKey = COMPACT_KEY_BY_OPTION[key]; + const value = encodeCompactValue(key, rawValue as SerializedGallerySceneOptions[SerializedGallerySceneOptionKey]); + if (!compactKey || value === undefined) continue; + tokens.push(`${compactKey}${value}`); + } + return `${COMPACT_SCENE_VERSION}${tokens.join("")}`; +} + +function base64UrlToBytes(value: string): Uint8Array { + const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function decodeJson(value: string): unknown { + return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === "boolean"; +} + +function isRenderer(value: unknown): value is SceneOptionsState["renderer"] { + return value === "react" || value === "vanilla"; +} + +function isHexColor(value: unknown): value is string { + return typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value); +} + +function isTextureLighting(value: unknown): value is SceneOptionsState["textureLighting"] { + return value === "baked" || value === "dynamic"; +} + +function isTextureQuality(value: unknown): value is SceneOptionsState["textureQuality"] { + return value === "auto" || (isFiniteNumber(value) && value >= 0.1 && value <= 1); +} + +function isMatrixPrecision(value: unknown): value is SceneOptionsState["matrixPrecision"] { + return value === "exact" || value === "2" || value === "3" || value === "4" || value === "5" || value === "6"; +} + +function isBorderShapePrecision(value: unknown): value is SceneOptionsState["borderShapePrecision"] { + return isMatrixPrecision(value); +} + +function isMeshResolution(value: unknown): value is SceneOptionsState["meshResolution"] { + return value === "lossless" || value === "lossy" || value === "disabled"; +} + +function isDragMode(value: unknown): value is SceneOptionsState["dragMode"] { + return value === "orbit" || value === "pan" || value === "fpv"; +} + +function isVec3(value: unknown): value is SceneTarget { + return Array.isArray(value) && + value.length === 3 && + value.every((entry) => typeof entry === "number" && Number.isFinite(entry)); +} + +function routeStrategies(value: unknown): SceneOptionsState["disableStrategies"] | undefined { + if (typeof value !== "string") return undefined; + const strategies = STRATEGY_ORDER.filter((strategy) => value.includes(strategy)); + return strategies.length > 0 ? [...strategies] : []; +} + +function sceneOptionsFromPayload(o: SerializedGallerySceneOptions): Partial { + const disableStrategies = routeStrategies(o.ds); + return { + ...(isRenderer(o.r) ? { renderer: o.r } : null), + ...(isBoolean(o.ap) ? { animationPaused: o.ap } : null), + ...(isFiniteNumber(o.ats) ? { animationTimeScale: o.ats } : null), + ...(isBoolean(o.c) ? { autoCenter: o.c } : null), + ...(isBoolean(o.i) ? { interactive: o.i } : null), + ...(isBoolean(o.ar) ? { animate: o.ar } : null), + ...(isBoolean(o.axes) ? { showAxes: o.axes } : null), + ...(isBoolean(o.sel) ? { selection: o.sel } : null), + ...(isBoolean(o.hov) ? { hoverEffects: o.hov } : null), + ...(isBoolean(o.helper) ? { showLight: o.helper } : null), + ...(isFiniteNumber(o.z) ? { zoom: o.z } : null), + ...(isFiniteNumber(o.rx) ? { rotX: o.rx } : null), + ...(isFiniteNumber(o.ry) ? { rotY: o.ry } : null), + ...((o.p === false || isFiniteNumber(o.p)) ? { perspective: o.p } : null), + ...(isFiniteNumber(o.az) ? { lightAzimuth: o.az } : null), + ...(isFiniteNumber(o.el) ? { lightElevation: o.el } : null), + ...(isFiniteNumber(o.key) ? { lightIntensity: o.key } : null), + ...(isHexColor(o.kc) ? { lightColor: o.kc.toLowerCase() } : null), + ...(isFiniteNumber(o.amb) ? { ambientIntensity: o.amb } : null), + ...(isHexColor(o.amc) ? { ambientColor: o.amc.toLowerCase() } : null), + ...(isTextureLighting(o.tl) ? { textureLighting: o.tl } : null), + ...(isTextureQuality(o.tq) ? { textureQuality: o.tq } : null), + ...(isBoolean(o.solid) ? { solidMaterials: o.solid } : null), + ...(isMatrixPrecision(o.mp) ? { matrixPrecision: o.mp } : null), + ...(isBorderShapePrecision(o.bp) ? { borderShapePrecision: o.bp } : null), + ...(isMeshResolution(o.mr) ? { meshResolution: o.mr } : null), + ...(isBoolean(o.fill) ? { interiorFill: o.fill } : null), + ...(isBoolean(o.outline) ? { outlinePolygons: o.outline } : null), + ...(isDragMode(o.drag) ? { dragMode: o.drag } : null), + ...(isVec3(o.t) ? { target: roundVec3(o.t) } : null), + ...(disableStrategies ? { disableStrategies } : null), + ...(isBoolean(o.shadow) ? { castShadow: o.shadow } : null), + ...(isFiniteNumber(o.reach) ? { shadowMaxExtend: o.reach } : null), + ...(isBoolean(o.ground) ? { showGround: o.ground } : null), + ...(isHexColor(o.gc) ? { groundColor: o.gc.toLowerCase() } : null), + ...(isBoolean(o.fl) ? { fpvLook: o.fl } : null), + ...(isBoolean(o.fm) ? { fpvMove: o.fm } : null), + ...(isBoolean(o.fj) ? { fpvJump: o.fj } : null), + ...(isBoolean(o.fc) ? { fpvCrouch: o.fc } : null), + ...(isFiniteNumber(o.fms) ? { fpvMoveSpeed: o.fms } : null), + ...(isFiniteNumber(o.fjv) ? { fpvJumpVelocity: o.fjv } : null), + ...(isFiniteNumber(o.fg) ? { fpvGravity: o.fg } : null), + ...(isFiniteNumber(o.feh) ? { fpvEyeHeight: o.feh } : null), + ...(isFiniteNumber(o.fch) ? { fpvCrouchHeight: o.fch } : null), + ...(isFiniteNumber(o.fls) ? { fpvLookSensitivity: o.fls } : null), + ...(isBoolean(o.fiy) ? { fpvInvertY: o.fiy } : null), + ...(isFiniteNumber(o.frd) ? { fpvRenderDistance: o.frd } : null), + }; +} + +function decodeDottedCompactValue(key: SerializedGallerySceneOptionKey, value: string): SerializedGallerySceneOptions[SerializedGallerySceneOptionKey] | undefined { + if (BOOLEAN_OPTIONS.has(key)) return value === "" || value === "1" ? true : value === "0" ? false : undefined; + if (key === "p") return value === "n" ? false : decodeCompactNumber(value); + if (key === "t") { + const values = value.split("_").map(decodeCompactNumber); + return values.length === 3 && values.every((entry) => entry !== undefined) + ? values as SceneTarget + : undefined; + } + if (key === "kc" || key === "amc" || key === "gc") return /^[0-9a-f]{6}$/i.test(value) ? `#${value.toLowerCase()}` : undefined; + if (key === "ds") return strategiesFromMask(value) ?? (value === "-" ? "" : value); + if (key === "r") return decodeEnum(value, { react: "r", vanilla: "v" }); + if (key === "tl") return decodeEnum(value, { baked: "b", dynamic: "d" }); + if (key === "tq") return value === "a" ? "auto" : decodeCompactNumber(value); + if (key === "mp" || key === "bp") return value === "e" ? "exact" : value; + if (key === "mr") return decodeEnum(value, { lossless: "x", lossy: "y", disabled: "d" }); + if (key === "drag") return decodeEnum(value, { orbit: "o", pan: "p", fpv: "f" }); + return decodeCompactNumber(value) ?? value; +} + +function decodeDottedCompactRouteSceneOptions(routeValue: string): Partial | null { + const tokens = routeValue.split("."); + if (tokens[0] !== COMPACT_SCENE_VERSION || tokens.length < 2) return null; + const payload: SerializedGallerySceneOptions = {}; + for (const token of tokens.slice(1)) { + const compactKey = Object.keys(DOTTED_COMPACT_OPTION_BY_KEY) + .sort((a, b) => b.length - a.length) + .find((key) => token.startsWith(key)); + if (!compactKey) continue; + const optionKey = DOTTED_COMPACT_OPTION_BY_KEY[compactKey]; + const encodedValue = token.slice(compactKey.length); + if (!optionKey) continue; + const decodedValue = decodeDottedCompactValue(optionKey, encodedValue); + if (decodedValue !== undefined) { + (payload as Record)[optionKey] = decodedValue; + } + } + return Object.keys(payload).length > 0 ? sceneOptionsFromPayload(payload) : null; +} + +function readPackedValue( + key: SerializedGallerySceneOptionKey, + routeValue: string, + index: number, +): { value: SerializedGallerySceneOptions[SerializedGallerySceneOptionKey]; next: number } | undefined { + if (BOOLEAN_OPTIONS.has(key)) { + return routeValue[index] === "0" + ? { value: false, next: index + 1 } + : { value: true, next: index }; + } + if (key === "p") { + if (routeValue[index] === "n") return { value: false, next: index + 1 }; + return readPackedNumber(routeValue, index); + } + if (key === "t") { + const x = readPackedNumber(routeValue, index); + if (!x) return undefined; + const y = readPackedNumber(routeValue, x.next); + if (!y) return undefined; + const z = readPackedNumber(routeValue, y.next); + return z ? { value: [x.value, y.value, z.value], next: z.next } : undefined; + } + if (key === "kc" || key === "amc" || key === "gc") { + const color = decodeCompactColor(routeValue.slice(index, index + 5)); + return color ? { value: color, next: index + 5 } : undefined; + } + if (key === "ds") { + const strategies = strategiesFromMask(routeValue[index] ?? ""); + return strategies === undefined ? undefined : { value: strategies, next: index + 1 }; + } + if (key === "r") { + const value = decodeEnum(routeValue[index] ?? "", { react: "r", vanilla: "v" }); + return value ? { value, next: index + 1 } : undefined; + } + if (key === "tl") { + const value = decodeEnum(routeValue[index] ?? "", { baked: "b", dynamic: "d" }); + return value ? { value, next: index + 1 } : undefined; + } + if (key === "tq") { + if (routeValue[index] === "a") return { value: "auto", next: index + 1 }; + return readPackedNumber(routeValue, index); + } + if (key === "mp" || key === "bp") { + const value = routeValue[index]; + return value ? { value: value === "e" ? "exact" : value, next: index + 1 } : undefined; + } + if (key === "mr") { + const value = decodeEnum(routeValue[index] ?? "", { lossless: "x", lossy: "y", disabled: "d" }); + return value ? { value, next: index + 1 } : undefined; + } + if (key === "drag") { + const value = decodeEnum(routeValue[index] ?? "", { orbit: "o", pan: "p", fpv: "f" }); + return value ? { value, next: index + 1 } : undefined; + } + return readPackedNumber(routeValue, index); +} + +function decodePackedCompactRouteSceneOptions(routeValue: string): Partial | null { + if (!routeValue.startsWith(COMPACT_SCENE_VERSION) || routeValue.startsWith(`${COMPACT_SCENE_VERSION}.`)) return null; + const payload: SerializedGallerySceneOptions = {}; + let index = COMPACT_SCENE_VERSION.length; + while (index < routeValue.length) { + const optionKey = COMPACT_OPTION_BY_KEY[routeValue[index]]; + index += 1; + if (!optionKey) return null; + const decoded = readPackedValue(optionKey, routeValue, index); + if (!decoded) return null; + (payload as Record)[optionKey] = decoded.value; + index = decoded.next; + } + return Object.keys(payload).length > 0 ? sceneOptionsFromPayload(payload) : null; +} + +function decodeLegacyRouteSceneOptions(routeValue: string): Partial | null { + try { + const decoded = decodeJson(routeValue); + if (!decoded || typeof decoded !== "object") return null; + const scene = decoded as Partial; + if (scene.v !== 1 || !scene.o || typeof scene.o !== "object") return null; + return sceneOptionsFromPayload(scene.o); + } catch { + return null; + } +} + +function decodeRouteSceneOptions(routeValue: string): Partial | null { + if (!routeValue) return null; + return decodePackedCompactRouteSceneOptions(routeValue) ?? + decodeDottedCompactRouteSceneOptions(routeValue) ?? + decodeLegacyRouteSceneOptions(routeValue); +} + +function writeRouteParams(params: URLSearchParams): void { + if (typeof window === "undefined") return; + const newSearch = params.toString(); + const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ""}${window.location.hash}`; + if (newUrl !== `${window.location.pathname}${window.location.search}${window.location.hash}`) { + window.history.replaceState(null, "", newUrl); + } +} + function resolveRoutePresetId(routeValue: string, presetIds: string[]): string { const value = typeof routeValue === "string" ? routeValue.trim() : ""; if (!value) return ""; @@ -38,12 +666,44 @@ export function setRoutePresetId(presetId: string | null): void { if (next === current) return; const params = new URLSearchParams(window.location.search); - if (next) params.set("model", next); - else params.delete("model"); + if (next) params.set(MODEL_PARAM, next); + else params.delete(MODEL_PARAM); - const newSearch = params.toString(); - const newUrl = `${window.location.pathname}${newSearch ? `?${newSearch}` : ""}${window.location.hash}`; - window.history.replaceState(null, "", newUrl); + writeRouteParams(params); +} + +export function routeInitialSceneOptions(): Partial { + return decodeRouteSceneOptions(getRouteSceneValue()) ?? {}; +} + +export function routeHasSceneOptions(): boolean { + return decodeRouteSceneOptions(getRouteSceneValue()) !== null; +} + +export function setRouteSceneOptions({ + sceneOptions, + sceneDefaults, + presetId, +}: { + sceneOptions: SceneOptionsState; + sceneDefaults: SceneOptionsState; + presetId: string | null; +}): void { + if (typeof window === "undefined") return; + const payload = sceneOptionsPayload(sceneOptions, sceneDefaults); + const params = new URLSearchParams(window.location.search); + if (presetId) params.set(MODEL_PARAM, routeIdForPresetId(presetId)); + else params.delete(MODEL_PARAM); + if (payload) params.set(SCENE_PARAM, encodeCompactScene(payload)); + else params.delete(SCENE_PARAM); + writeRouteParams(params); +} + +export function clearRouteSceneOptions(): void { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + params.delete(SCENE_PARAM); + writeRouteParams(params); } export function routeInitialPresetId(presetIds: string[]): string { @@ -55,9 +715,17 @@ export interface UseRouteSyncOptions { presetId: string; presetIds: string[]; resetToPreset: (id: string) => void; + sceneDefaultsForPreset?: (id: string) => SceneOptionsState; + setSceneOptions?: Dispatch>; } -export function useRouteSync({ presetId, presetIds, resetToPreset }: UseRouteSyncOptions): void { +export function useRouteSync({ + presetId, + presetIds, + resetToPreset, + sceneDefaultsForPreset, + setSceneOptions, +}: UseRouteSyncOptions): void { useEffect(() => { const routeValue = getRoutePresetValue(); if (routeValue) { @@ -71,20 +739,26 @@ export function useRouteSync({ presetId, presetIds, resetToPreset }: UseRouteSyn const handlePopState = () => { const nextRouteValue = getRoutePresetValue(); - if (!nextRouteValue) return; - const nextPresetId = resolveRoutePresetId(nextRouteValue, presetIds); - if (!nextPresetId) { + const nextPresetId = nextRouteValue ? resolveRoutePresetId(nextRouteValue, presetIds) : ""; + if (nextRouteValue && !nextPresetId) { setRoutePresetId(null); return; } - if (nextPresetId !== presetId) { + if (nextPresetId && nextPresetId !== presetId) { resetToPreset(nextPresetId); } + const nextSceneOptions = decodeRouteSceneOptions(getRouteSceneValue()); + if (nextSceneOptions && setSceneOptions) { + setSceneOptions((current) => ({ + ...(nextPresetId && sceneDefaultsForPreset ? sceneDefaultsForPreset(nextPresetId) : current), + ...nextSceneOptions, + })); + } }; window.addEventListener("popstate", handlePopState); return () => { window.removeEventListener("popstate", handlePopState); }; - }, [presetId, resetToPreset]); + }, [presetId, presetIds, resetToPreset, sceneDefaultsForPreset, setSceneOptions]); } diff --git a/website/src/components/GalleryWorkbench/presets/presetFiles.ts b/website/src/components/GalleryWorkbench/presets/presetFiles.ts index 79d5e86d..8e1e860e 100644 --- a/website/src/components/GalleryWorkbench/presets/presetFiles.ts +++ b/website/src/components/GalleryWorkbench/presets/presetFiles.ts @@ -18,6 +18,7 @@ import { QUATERNIUS_ULTIMATE_SPACESHIPS_ATTRIBUTION, nasa3dAttribution, openGameArtAttribution, + polyPizzaAttribution, quaterniusAttribution, smithsonianOpenAccessAttribution, } from "./attributions"; @@ -376,6 +377,18 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ tris: 392, }, }, + { + file: "poly-pizza/broccoli.glb", + label: "Broccoli", + category: "Food & Drink", + attribution: polyPizzaAttribution("jeremy", "e2Z3XDxtT41", "CC-BY 3.0", 900), + }, + { + file: "poly-pizza/peanut.glb", + label: "Peanut", + category: "Food & Drink", + attribution: polyPizzaAttribution("jeremy", "f57FFcX01Tr", "CC-BY 3.0", 1820), + }, { file: "poly-pizza/sheep.glb", label: "Sheep", @@ -452,6 +465,36 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ tris: 796, }, }, + { + file: "poly-pizza/triceratops.glb", + label: "Triceratops", + category: "Animals", + attribution: polyPizzaAttribution("jeremy", "7GKj5zdc1RZ", "CC-BY 3.0", 892), + }, + { + file: "poly-pizza/tiger.glb", + label: "Tiger", + category: "Animals", + attribution: polyPizzaAttribution("jeremy", "54KLm0HdFWy", "CC-BY 3.0", 892), + }, + { + file: "poly-pizza/monkey.glb", + label: "Monkey", + category: "Animals", + attribution: polyPizzaAttribution("jeremy", "2mYaCEbogPq", "CC-BY 3.0", 680), + }, + { + file: "poly-pizza/crab.glb", + label: "Crab", + category: "Animals", + attribution: polyPizzaAttribution("jeremy", "bmZ6-LnPmp0", "CC-BY 3.0", 768), + }, + { + file: "poly-pizza/turkey.glb", + label: "Turkey", + category: "Animals", + attribution: polyPizzaAttribution("jeremy", "b3E2cqagRPn", "CC-BY 3.0", 336), + }, { file: "poly-pizza/ducky.glb", label: "Ducky", @@ -551,6 +594,24 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ tris: 101, }, }, + { + file: "poly-pizza/tomato.glb", + label: "Tomato", + category: "Food & Drink", + attribution: polyPizzaAttribution("jeremy", "dcvfRb1X5JE", "CC-BY 3.0", 144), + }, + { + file: "poly-pizza/pear.glb", + label: "Pear", + category: "Food & Drink", + attribution: polyPizzaAttribution("jeremy", "3Mp-3PRh7Tb", "CC-BY 3.0", 152), + }, + { + file: "poly-pizza/ice-cream.glb", + label: "Ice Cream", + category: "Food & Drink", + attribution: polyPizzaAttribution("jeremy", "27NZ4ejbkZl", "CC-BY 3.0", 408), + }, { file: "poly-pizza/rock.glb", label: "Low Poly Rock", @@ -574,6 +635,12 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ tris: 342, }, }, + { + file: "poly-pizza/apple-tree.glb", + label: "Apple Tree", + category: "Environment", + attribution: polyPizzaAttribution("jeremy", "2BUc5iO4nUo", "CC-BY 3.0", 644), + }, { file: "poly-pizza/box.glb", label: "Cardboard Box", @@ -629,6 +696,12 @@ export const POLY_PIZZA_PRESET_FILES: GalleryPresetFile[] = [ tris: 1216, }, }, + { + file: "poly-pizza/mr-brush.glb", + label: "Mr. Brush", + category: "Objects", + attribution: polyPizzaAttribution("jeremy", "0jKuzkEIurU", "CC-BY 3.0", 4644), + }, { file: "poly-pizza/arrow.glb", label: "Arrow", diff --git a/website/src/components/VanillaScene/VanillaScene.tsx b/website/src/components/VanillaScene/VanillaScene.tsx index 838f2aac..59f05909 100644 --- a/website/src/components/VanillaScene/VanillaScene.tsx +++ b/website/src/components/VanillaScene/VanillaScene.tsx @@ -37,6 +37,12 @@ const LIGHT_HELPER_TILE = 50; // Keep the visible ground just below the model floor; coplanar ground/car // faces z-fight during repaint-heavy light drags. const GROUND_Z_OFFSET = -0.04; +const GALLERY_GROUND_COLOR = "#7d848e"; +const GALLERY_GROUND_RGB = { r: 0x7d, g: 0x84, b: 0x8e }; +const GALLERY_GROUND_LIGHT_RESPONSE = 0.28; +const GALLERY_GROUND_RADIUS_MULTIPLIER = 2.5; +const GALLERY_GROUND_MODEL_RADIUS_MULTIPLIER = 1.75; +const GALLERY_GROUND_MIN_RADIUS = 40; // The shadow plane should sit above the visible ground, not above the model // floor, otherwise large live-updated SVG shadows can intersect low geometry. const SHADOW_GROUND_LIFT = 0.01; @@ -208,6 +214,60 @@ function ambientFromOptions(options: SceneOptionsState): PolyAmbientLight { }; } +function parseGalleryHexColor(value: string | undefined, fallback: typeof GALLERY_GROUND_RGB): typeof GALLERY_GROUND_RGB { + if (!value || !/^#[0-9a-f]{6}$/i.test(value)) return fallback; + return { + r: Number.parseInt(value.slice(1, 3), 16), + g: Number.parseInt(value.slice(3, 5), 16), + b: Number.parseInt(value.slice(5, 7), 16), + }; +} + +function clampColorChannel(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function mutedGalleryGroundColor( + directionalLight: PolyDirectionalLight, + ambientLight: PolyAmbientLight, + baseColor: string, +): string { + const base = parseGalleryHexColor(baseColor, GALLERY_GROUND_RGB); + const [dx, dy, dz] = directionalLight.direction; + const len = Math.hypot(dx, dy, dz) || 1; + const lambert = Math.max(0, dz / len); + const key = parseGalleryHexColor(directionalLight.color, { r: 255, g: 255, b: 255 }); + const ambient = parseGalleryHexColor(ambientLight.color, { r: 255, g: 255, b: 255 }); + const keyIntensity = directionalLight.intensity ?? 1; + const ambientIntensity = ambientLight.intensity ?? 0.4; + const mix = (base: number, ambientChannel: number, keyChannel: number) => { + const lit = base * ( + (ambientChannel / 255) * ambientIntensity + + (keyChannel / 255) * keyIntensity * lambert + ); + return clampColorChannel(base * (1 - GALLERY_GROUND_LIGHT_RESPONSE) + lit * GALLERY_GROUND_LIGHT_RESPONSE); + }; + return `rgb(${mix(base.r, ambient.r, key.r)} ${mix(base.g, ambient.g, key.g)} ${mix(base.b, ambient.b, key.b)})`; +} + +function applyGalleryGroundPaint( + handle: VanillaPolyMeshHandle | null, + directionalLight: PolyDirectionalLight, + ambientLight: PolyAmbientLight, + baseColor: string, +): void { + if (!handle) return; + const color = mutedGalleryGroundColor(directionalLight, ambientLight, baseColor); + const leaves = handle.element.querySelectorAll("b,i,s,u"); + for (const leaf of leaves) { + leaf.style.setProperty("color", color, "important"); + if (leaf.tagName.toLowerCase() === "s") { + leaf.style.setProperty("background", color, "important"); + leaf.style.setProperty("background-blend-mode", "normal", "important"); + } + } +} + function bakedLightingSignature( directionalLight: PolyDirectionalLight, ambientLight: PolyAmbientLight, @@ -319,6 +379,12 @@ export function VanillaScene({ onTransientHandleChangeRef.current = onTransientHandleChange; const onSceneDomChangeRef = useRef(onSceneDomChange); onSceneDomChangeRef.current = onSceneDomChange; + const directionalLightRef = useRef(directionalLight); + directionalLightRef.current = directionalLight; + const ambientLightRef = useRef(ambientLight); + ambientLightRef.current = ambientLight; + const groundColorRef = useRef(options.groundColor); + groundColorRef.current = options.groundColor; const helperScaleRef = useRef(helperScale); helperScaleRef.current = helperScale; const helperTargetRef = useRef(helperTarget); @@ -364,6 +430,7 @@ export function VanillaScene({ helperScaleRef.current * 0.7, ), }); + applyGalleryGroundPaint(groundHandleRef.current, nextDirectionalLight, ambientFromOptions(nextOptions), nextOptions.groundColor); }, []); const applyTransientSceneOptions = useCallback((nextOptions: SceneOptionsState): void => { @@ -391,6 +458,7 @@ export function VanillaScene({ helperScaleRef.current * 0.7, ), }); + applyGalleryGroundPaint(groundHandleRef.current, nextDirectionalLight, ambientFromOptions(nextOptions), nextOptions.groundColor); }, []); useEffect(() => { @@ -840,12 +908,14 @@ export function VanillaScene({ } else if (options.textureLighting !== "baked") { committedBakedLightingRef.current = nextLightingSignature; } + applyGalleryGroundPaint(groundHandleRef.current, directionalLight, ambientLight, options.groundColor); }, [ options.rotX, options.rotY, options.zoom, options.target, options.textureLighting, + options.groundColor, options.shadowMaxExtend, directionalLight, ambientLight, @@ -861,6 +931,7 @@ export function VanillaScene({ scene.setOptions({ strategies: { disable: options.disableStrategies }, }); + applyGalleryGroundPaint(groundHandleRef.current, directionalLightRef.current, ambientLightRef.current, groundColorRef.current); notifySceneDomChange(); }, [options.disableStrategies, notifySceneDomChange]); @@ -1016,10 +1087,10 @@ export function VanillaScene({ // Effect 3.5 — ground receiver. A flat quad in the XY plane (Z is "up" // in PolyCSS's world convention — the red-green plane in the axes helper - // is the floor) at the model's min-Z, sized to ~3× the model's horizontal - // span. Gives shadows something to land on. excludeFromAutoCenter so - // toggling it doesn't shift the camera pivot; castShadow:false because - // the floor doesn't shadow itself. + // is the floor) at the model's min-Z. Size from both footprint and full + // model scale so tall/narrow assets still get a usable floor. Gives shadows + // something to land on. excludeFromAutoCenter so toggling it doesn't shift + // the camera pivot; castShadow:false because the floor doesn't shadow itself. useEffect(() => { const scene = sceneRef.current; if (!scene) return; @@ -1048,8 +1119,13 @@ export function VanillaScene({ } return; } - const span = Math.max(maxX - minX, maxY - minY, 1); - const pad = span * 1.5; + const footprintSpan = Math.max(maxX - minX, maxY - minY, 1); + const modelSpan = Math.max(footprintSpan, maxZ - minZ, 1); + const pad = Math.max( + footprintSpan * GALLERY_GROUND_RADIUS_MULTIPLIER, + modelSpan * GALLERY_GROUND_MODEL_RADIUS_MULTIPLIER, + GALLERY_GROUND_MIN_RADIUS, + ); const cx = (minX + maxX) / 2; const cy = (minY + maxY) / 2; const z = minZ + GROUND_Z_OFFSET; @@ -1062,7 +1138,7 @@ export function VanillaScene({ ], // Medium gray — needs to be light enough that the 25% black shadow // on top has visible contrast (the page background is near-black). - color: "#7d848e", + color: options.groundColor || GALLERY_GROUND_COLOR, }; groundHandleRef.current = scene.add( { @@ -1073,6 +1149,8 @@ export function VanillaScene({ }, { excludeFromAutoCenter: true, castShadow: false }, ); + groundHandleRef.current.element.classList.add("dn-gallery-ground"); + applyGalleryGroundPaint(groundHandleRef.current, directionalLightRef.current, ambientLightRef.current, groundColorRef.current); notifySceneDomChange(); return () => { groundHandleRef.current?.dispose(); @@ -1085,6 +1163,7 @@ export function VanillaScene({ options.autoCenter, options.textureQuality, options.textureLighting, + options.groundColor, options.perspective, stableDirectionalForRebuild, stableAmbientForRebuild, diff --git a/website/src/components/types.ts b/website/src/components/types.ts index 72876d1b..0cdd8c01 100644 --- a/website/src/components/types.ts +++ b/website/src/components/types.ts @@ -75,6 +75,7 @@ export interface SceneOptionsState { * Caps the SVG backing store at low light elevations. */ shadowMaxExtend: number; showGround: boolean; + groundColor: string; fpvLook: boolean; fpvMove: boolean; fpvJump: boolean;