diff --git a/.agents/skills/chrome-trace/SKILL.md b/.agents/skills/chrome-trace/SKILL.md index 3476843f..62f03183 100644 --- a/.agents/skills/chrome-trace/SKILL.md +++ b/.agents/skills/chrome-trace/SKILL.md @@ -20,7 +20,7 @@ Use Playwright with Chromium and the Chrome DevTools Protocol `Tracing` domain t Do not draw conclusions from FPS alone. Use FPS/frame-time summaries only as the symptom; use trace groups and top events as the explanation. -## Polycss Trace Runners +## PolyCSS Trace Runners Use `scripts/trace.mjs` as the front door: @@ -46,7 +46,7 @@ Use `trace.mjs drag` for real `PolyOrbitControls` pointer-drag traces on `nonvox Use `trace.mjs generic` for arbitrary pages and interactions that are not covered by a polycss bench page. -When interpreting polycss traces, map the result back to the render model: +When interpreting PolyCSS traces, map the result back to the render model: - `FunctionCall`, `EventDispatch`, `FireAnimationFrame`: JS/input work. Unexpected sustained per-frame work is suspicious outside imported skeletal animation. - `UpdateLayoutTree`, `RecalculateStyles`: style recalculation, often CSS variable or selector invalidation cost. diff --git a/.agents/skills/compat-hunter/SKILL.md b/.agents/skills/compat-hunter/SKILL.md new file mode 100644 index 00000000..f46cc9e3 --- /dev/null +++ b/.agents/skills/compat-hunter/SKILL.md @@ -0,0 +1,97 @@ +--- +name: compat-hunter +description: Use when hunting for OBJ/GLB/glTF/VOX parser compatibility issues by streaming candidate models, parsing each immediately, deleting clean files, and retaining only actionable failures, unknown warnings, or unexplained zero-polygon outputs. Especially useful in the polycss repo with scripts/compat-hunter.mjs. +--- + +# Compat Hunter + +Use this skill when the user wants to keep digging for parser compatibility issues without building a fixed local corpus. + +## Workflow + +1. In the repo, prefer the reusable script: + + ```bash + pnpm compat-hunter -- --max-models 2000 + ``` + + Equivalent explicit form: + + ```bash + pnpm --filter @layoutit/polycss-core build + node .agents/skills/compat-hunter/scripts/compat-hunter.mjs --max-models 2000 + ``` + +2. Let the script stream remote OBJ/GLB/glTF/VOX candidates, parse each model, and discard clean files. Reports are written under `bench/results/`, which is ignored by git. + +3. Treat these as known non-actionable unless the user asks to support them: + - glTF POINTS/LINES/LINE_LOOP/LINE_STRIP primitives. + - Required Draco or meshopt compressed primitives skipped with a warning. + +4. Stop and inspect anything classified as: + - `throw` + - `unknown-warning` + - `obj-zero-no-warning` + - `glb-zero-no-warning` + +5. If an actionable parser issue is found, keep the saved file under the report's `interesting/` directory, add a focused parser test using that behavior, implement the smallest fix, and rerun the focused parser tests plus the hunter on the saved file or source class. + +## Useful Commands + +Fresh Objaverse and expanded GitHub stream: + +```bash +pnpm compat-hunter -- --max-models 5000 --max-bytes 10mb --timeout-ms 30000 +``` + +Objaverse only, later shards: + +```bash +pnpm compat-hunter -- --sources objaverse --objaverse-shards 120:220 --max-models 5000 +``` + +GitHub only: + +```bash +pnpm compat-hunter -- --sources github --max-models 500 +``` + +Poly Haven only: + +```bash +pnpm compat-hunter -- --sources polyhaven --polyhaven-limit 250 --max-models 200 +``` + +VOX-heavy GitHub sources: + +```bash +pnpm compat-hunter -- --sources github --github-repos ephtracy/voxel-model@master:vox/,mikelovesrobots/mmmm@master:vox/ --max-models 1000 +``` + +Local directory: + +```bash +pnpm compat-hunter -- --sources local --local-root /tmp/models --max-models 1000 +``` + +Keep known-warning files too: + +```bash +pnpm compat-hunter -- --keep-known --max-models 500 +``` + +Continue after interesting cases: + +```bash +pnpm compat-hunter -- --no-stop-on-interesting --max-models 2000 +``` + +## Reporting + +Summarize the final `report.json` with: + +```bash +pnpm compat-hunter -- --report bench/results//report.json +``` + +In the final response, state the attempted/parsed counts, whether any interesting files were retained, and whether the findings are actionable. Do not imply a clean stream proves full compatibility; it only means this pass found no new actionable parser issue. diff --git a/.agents/skills/compat-hunter/agents/openai.yaml b/.agents/skills/compat-hunter/agents/openai.yaml new file mode 100644 index 00000000..6e53a709 --- /dev/null +++ b/.agents/skills/compat-hunter/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Compat Hunter" + short_description: "Stream model parser compatibility hunts" + default_prompt: "Use $compat-hunter to stream OBJ/GLB/glTF/VOX models until a parser compatibility issue appears." diff --git a/.agents/skills/compat-hunter/scripts/compat-hunter.mjs b/.agents/skills/compat-hunter/scripts/compat-hunter.mjs new file mode 100755 index 00000000..a7ccc480 --- /dev/null +++ b/.agents/skills/compat-hunter/scripts/compat-hunter.mjs @@ -0,0 +1,734 @@ +#!/usr/bin/env node +/** + * Stream OBJ/GLB/glTF/VOX files through the core parser and keep only compatibility + * cases worth inspecting. Clean models are never written to disk. + * + * Usage: + * pnpm --filter @layoutit/polycss-core build + * node .agents/skills/compat-hunter/scripts/compat-hunter.mjs + * node .agents/skills/compat-hunter/scripts/compat-hunter.mjs --sources objaverse --max-models 5000 + * node .agents/skills/compat-hunter/scripts/compat-hunter.mjs --sources github --max-models 500 + * node .agents/skills/compat-hunter/scripts/compat-hunter.mjs --sources polyhaven --max-models 200 + * node .agents/skills/compat-hunter/scripts/compat-hunter.mjs --sources github --github-repos ephtracy/voxel-model@master:vox/ + * node .agents/skills/compat-hunter/scripts/compat-hunter.mjs --local-root /tmp/models --out bench/results/local-hunt + */ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { performance } from "node:perf_hooks"; +import { parseGltf, parseObj, parseVox } from "../../../../packages/core/dist/index.js"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); +const argv = process.argv.slice(2).filter((arg) => arg !== "--"); +const VALID_SOURCES = new Set(["objaverse", "github", "polyhaven", "local"]); +const textEncoder = new TextEncoder(); + +function fail(message) { + console.error(`compat-hunter: ${message}`); + process.exit(1); +} + +function flag(name) { + return argv.indexOf(`--${name}`); +} + +function hasFlag(name) { + return flag(name) >= 0; +} + +function optStr(name, dflt = "") { + const index = flag(name); + if (index < 0) return dflt; + const value = argv[index + 1]; + if (!value || value.startsWith("--")) fail(`--${name} requires a value`); + return value; +} + +function optNum(name, dflt) { + const raw = optStr(name, ""); + if (!raw) return dflt; + const value = Number(raw); + return Number.isFinite(value) ? value : dflt; +} + +function parseByteLimit(raw) { + const text = String(raw).trim().toLowerCase(); + const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/.exec(text); + if (!match) return Number(raw) || 0; + const value = Number(match[1]); + const unit = match[2] ?? "b"; + const multiplier = unit === "gb" ? 1024 ** 3 : unit === "mb" ? 1024 ** 2 : unit === "kb" ? 1024 : 1; + return Math.floor(value * multiplier); +} + +function usage() { + console.log(`Usage: node .agents/skills/compat-hunter/scripts/compat-hunter.mjs [options] + +Options: + --sources Comma-separated sources: objaverse,github,polyhaven,local. Default: objaverse,github + --max-models Stop after this many attempted models. Default: 2000 + --max-bytes Skip remote files above this size. Default: 10mb + --concurrency Parallel remote downloads/parses. Default: 8 + --timeout-ms Fetch timeout for API/file downloads. Default: 30000 + --out Report directory. Default: bench/results/compat-hunter- + --progress Print progress every n attempts. Default: 100 + --report Print a compact summary for an existing report and exit. + --stop-on-interesting Stop after first actionable case. Default: true + --no-stop-on-interesting Continue after actionable cases until --max-models + --keep-known Save files that only hit known non-actionable warnings. + --local-root Local OBJ/GLB/glTF/VOX tree when --sources includes local. + --objaverse-shards Objaverse shard range, inclusive. Default: 20:120 + --github-repos repo specs owner/repo@branch[:prefix], comma-separated. + --polyhaven-limit Max Poly Haven asset metadata records to inspect. Default: 250 + --skip-manifest Manifest with selected[].path to skip. Default: previous 5k manifest if present +`); +} + +if (hasFlag("help")) { + usage(); + process.exit(0); +} + +if (hasFlag("report")) { + summarizeReport(resolve(repoRoot, optStr("report"))); + process.exit(0); +} + +const sources = optStr("sources", "objaverse,github") + .split(",") + .map((source) => source.trim()) + .filter(Boolean); +for (const source of sources) { + if (!VALID_SOURCES.has(source)) { + fail(`unknown source "${source}" (expected one of ${[...VALID_SOURCES].join(", ")})`); + } +} +const maxModels = Math.max(1, optNum("max-models", 2000)); +const maxBytes = parseByteLimit(optStr("max-bytes", "10mb")); +const concurrency = Math.max(1, optNum("concurrency", 8)); +const timeoutMs = Math.max(0, optNum("timeout-ms", 30000)); +const progressEvery = Math.max(0, optNum("progress", 100)); +const stopOnInteresting = !hasFlag("no-stop-on-interesting"); +const keepKnown = hasFlag("keep-known"); +const startedAt = new Date().toISOString(); +const outRoot = resolve(repoRoot, optStr( + "out", + `bench/results/compat-hunter-${startedAt.replace(/[:.]/g, "-")}`, +)); +const interestingRoot = join(outRoot, "interesting"); +const knownRoot = join(outRoot, "known"); +const reportPath = join(outRoot, "report.json"); +mkdirSync(interestingRoot, { recursive: true }); +if (keepKnown) mkdirSync(knownRoot, { recursive: true }); + +const defaultSkipManifest = resolve(repoRoot, "bench/results/parser-corpus-5000-objaverse/manifest.json"); +const skipManifestPath = optStr("skip-manifest", existsSync(defaultSkipManifest) ? defaultSkipManifest : ""); +const skippedSourcePaths = new Set(); +if (skipManifestPath && existsSync(resolve(repoRoot, skipManifestPath))) { + const manifest = JSON.parse(readFileSync(resolve(repoRoot, skipManifestPath), "utf8")); + for (const item of manifest.selected ?? []) { + if (item.path) skippedSourcePaths.add(item.path); + } +} + +const knownWarningCounts = new Map(); +const knownErrorCounts = new Map(); +const failureCounts = new Map(); +const sourceCounts = new Map(); +const cleanSamples = []; +const knownSamples = []; +const interesting = []; +const failedDownloads = []; +let cleanCount = 0; +let knownCount = 0; +let attempted = 0; +let parsed = 0; +let totalPolygons = 0; +let stop = false; +let nextQueueIndex = 0; + +function stableShuffle(items, seed) { + let state = seed >>> 0; + const next = () => { + state = (Math.imul(1664525, state) + 1013904223) >>> 0; + return state / 0x100000000; + }; + const out = [...items]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(next() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +function bump(map, key) { + map.set(key, (map.get(key) ?? 0) + 1); +} + +function counterObject(map) { + return Object.fromEntries([...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))); +} + +function sourceStats(source) { + if (!sourceCounts.has(source)) { + sourceCounts.set(source, { + queued: 0, + attempted: 0, + parsed: 0, + clean: 0, + known: 0, + interesting: 0, + failedDownloads: 0, + totalPolygons: 0, + }); + } + return sourceCounts.get(source); +} + +function bumpSource(source, field, amount = 1) { + const stats = sourceStats(source); + stats[field] = (stats[field] ?? 0) + amount; +} + +function sourceCountsObject() { + return Object.fromEntries([...sourceCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]))); +} + +function summarizeReport(file) { + const report = JSON.parse(readFileSync(file, "utf8")); + console.log(JSON.stringify({ + counts: report.counts, + sourceCounts: report.sourceCounts, + knownWarningsByMessage: report.knownWarningsByMessage, + knownErrorsByMessage: report.knownErrorsByMessage, + failuresByMessage: report.failuresByMessage, + interesting: report.interesting, + }, null, 2)); +} + +function isKnownWarning(warning) { + return /^Skipped primitives with unsupported mode \d+ \((POINTS|LINES|LINE_LOOP|LINE_STRIP)\)$/.test(warning) + || warning === "Skipped primitives with unsupported required extension KHR_draco_mesh_compression" + || warning === "Skipped primitives with unsupported required extension EXT_meshopt_compression"; +} + +function isKnownError(message) { + return /^parseGltf: only glTF v2 supported/.test(message) + || /^parseGltf: only glTF asset v2 supported/.test(message) + || /^parseGltf: glTF asset requires minVersion/.test(message); +} + +function toArrayBuffer(bytes) { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +function classify(parsed, ext) { + const warnings = parsed.warnings ?? []; + const unknownWarnings = warnings.filter((warning) => !isKnownWarning(warning)); + if (unknownWarnings.length > 0) return { kind: "unknown-warning", unknownWarnings }; + if (parsed.polygons.length === 0 && warnings.length === 0) return { kind: `${ext}-zero-no-warning`, unknownWarnings }; + if (parsed.polygons.length === 0 && warnings.some(isKnownWarning)) return { kind: "known-zero", unknownWarnings }; + if (warnings.length > 0) return { kind: "known-warning", unknownWarnings }; + return { kind: "clean", unknownWarnings }; +} + +function rowForItem(item, testedIndex, extra = {}) { + return { + testedIndex, + source: item.source, + sourcePath: item.sourcePath, + ext: item.ext, + size: item.size, + url: item.url, + ...extra, + }; +} + +function writeReport(done = false) { + const report = { + startedAt, + updatedAt: new Date().toISOString(), + done, + policy: { + sources, + maxModels, + maxBytes, + concurrency, + timeoutMs, + stopOnInteresting, + keepKnown, + cleanFilesDeleted: true, + skippedSourcePaths: skippedSourcePaths.size, + }, + counts: { + queued: queue.length, + attempted, + parsed, + clean: cleanCount, + known: knownCount, + interesting: interesting.length, + failedDownloads: failedDownloads.length, + totalPolygons, + }, + sourceCounts: sourceCountsObject(), + knownWarningsByMessage: counterObject(knownWarningCounts), + knownErrorsByMessage: counterObject(knownErrorCounts), + failuresByMessage: counterObject(failureCounts), + interesting, + knownSamples: knownSamples.slice(-100), + cleanSamples: cleanSamples.slice(-25), + failedDownloads: failedDownloads.slice(-100), + }; + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); +} + +async function fetchWithTimeout(url) { + const response = await fetch(url, { + headers: { "User-Agent": "polycss-compat-hunter" }, + signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined, + }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + return response; +} + +async function fetchJson(url) { + const response = await fetchWithTimeout(url); + return response.json(); +} + +async function fetchBytes(url) { + const response = await fetchWithTimeout(url); + return new Uint8Array(await response.arrayBuffer()); +} + +async function fetchText(url) { + const response = await fetchWithTimeout(url); + return response.text(); +} + +async function listObjaverse() { + const [startRaw, endRaw] = optStr("objaverse-shards", "20:120").split(":"); + const start = Number(startRaw); + const end = Number(endRaw); + const out = []; + for (let shard = start; shard <= end && out.length < maxModels * 3; shard++) { + const shardName = `000-${String(shard).padStart(3, "0")}`; + const api = `https://huggingface.co/api/datasets/allenai/objaverse/tree/main/glbs/${shardName}?recursive=false&expand=false`; + try { + const entries = await fetchJson(api); + const files = entries + .filter((entry) => + entry.type === "file" + && entry.path?.endsWith(".glb") + && entry.size > 0 + && entry.size <= maxBytes + && !skippedSourcePaths.has(entry.path) + ) + .map((entry) => ({ + source: "allenai/objaverse@main", + sourcePath: entry.path, + ext: "glb", + size: entry.size, + url: `https://huggingface.co/datasets/allenai/objaverse/resolve/main/${entry.path}`, + })); + out.push(...stableShuffle(files, 0x509c + shard)); + console.log(`listed objaverse ${shardName}: +${files.length}, queue=${out.length}`); + } catch (error) { + const source = "allenai/objaverse@main"; + failedDownloads.push({ + source, + sourcePath: shardName, + stage: "list", + error: error instanceof Error ? error.message : String(error), + }); + bumpSource(source, "failedDownloads"); + } + } + return out; +} + +function parseRepoSpec(spec) { + const [repoAndBranch, prefix = ""] = spec.split(":"); + const [repoName, branch = "main"] = repoAndBranch.split("@"); + const [owner, repo] = repoName.split("/"); + if (!owner || !repo || !branch) fail(`bad GitHub repo spec "${spec}" (expected owner/repo@branch[:prefix])`); + return { owner, repo, branch, prefix }; +} + +async function listGithub() { + const defaultRepos = [ + "alecjacobson/common-3d-test-models@master", + "mrdoob/three.js@dev:examples/models/", + "KhronosGroup/glTF-Sample-Assets@main:Models/", + "KhronosGroup/glTF-Sample-Models@main:2.0/", + "google/draco@main:testdata/", + "google/model-viewer@master", + "assimp/assimp@master:test/models/", + "ephtracy/voxel-model@master:vox/", + "mikelovesrobots/mmmm@master:vox/", + ]; + const specs = optStr("github-repos", defaultRepos.join(",")) + .split(",") + .map((spec) => spec.trim()) + .filter(Boolean) + .map(parseRepoSpec); + const out = []; + for (const spec of specs) { + const source = `${spec.owner}/${spec.repo}@${spec.branch}`; + const api = `https://api.github.com/repos/${spec.owner}/${spec.repo}/git/trees/${spec.branch}?recursive=1`; + try { + const json = await fetchJson(api); + const files = (json.tree ?? []) + .filter((entry) => + entry.type === "blob" + && (!spec.prefix || entry.path.startsWith(spec.prefix)) + && /\.(obj|glb|gltf|vox)$/i.test(entry.path) + && entry.size > 0 + && entry.size <= maxBytes + ) + .map((entry) => ({ + source, + sourcePath: entry.path, + ext: extname(entry.path).slice(1).toLowerCase(), + size: entry.size, + url: `https://raw.githubusercontent.com/${spec.owner}/${spec.repo}/${spec.branch}/${entry.path}`, + baseUrl: `https://raw.githubusercontent.com/${spec.owner}/${spec.repo}/${spec.branch}/${entry.path}`, + })); + out.push(...files); + console.log(`listed github ${source}: +${files.length}, queue=${out.length}`); + } catch (error) { + failedDownloads.push({ + source, + sourcePath: spec.prefix || ".", + stage: "list", + error: error instanceof Error ? error.message : String(error), + }); + bumpSource(source, "failedDownloads"); + } + } + return stableShuffle(out, 0xc017); +} + +function selectPolyhavenGltf(assetId, files) { + const variants = []; + for (const [resolution, formats] of Object.entries(files.gltf ?? {})) { + const gltf = formats?.gltf; + if (!gltf?.url) continue; + const includeUrls = {}; + let size = gltf.size ?? 0; + for (const [uri, include] of Object.entries(gltf.include ?? {})) { + if (!uri.toLowerCase().endsWith(".bin") || !include?.url) continue; + includeUrls[uri] = include.url; + size += include.size ?? 0; + } + variants.push({ + resolution, + size, + url: gltf.url, + includeUrls, + }); + } + return variants + .filter((variant) => variant.size > 0 && variant.size <= maxBytes) + .sort((a, b) => a.size - b.size || a.resolution.localeCompare(b.resolution))[0] ?? null; +} + +async function listPolyhaven() { + const limit = Math.max(1, optNum("polyhaven-limit", 250)); + const source = "polyhaven@models"; + const out = []; + try { + const assets = await fetchJson("https://api.polyhaven.com/assets?t=models"); + const ids = stableShuffle(Object.keys(assets), 0x9017).slice(0, limit); + for (const assetId of ids) { + if (out.length >= maxModels * 2) break; + try { + const files = await fetchJson(`https://api.polyhaven.com/files/${encodeURIComponent(assetId)}`); + const selected = selectPolyhavenGltf(assetId, files); + if (!selected) continue; + out.push({ + source, + sourcePath: `${assetId}/${selected.resolution}`, + ext: "gltf", + size: selected.size, + url: selected.url, + baseUrl: selected.url, + includeUrls: selected.includeUrls, + }); + } catch (error) { + failedDownloads.push({ + source, + sourcePath: assetId, + stage: "list", + error: error instanceof Error ? error.message : String(error), + }); + bumpSource(source, "failedDownloads"); + } + } + console.log(`listed polyhaven models: +${out.length}, inspected=${ids.length}`); + } catch (error) { + failedDownloads.push({ + source, + sourcePath: ".", + stage: "list", + error: error instanceof Error ? error.message : String(error), + }); + bumpSource(source, "failedDownloads"); + } + return stableShuffle(out, 0x9a11); +} + +function walkLocal(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walkLocal(full)); + continue; + } + const ext = extname(entry.name).slice(1).toLowerCase(); + if (ext === "obj" || ext === "glb" || ext === "gltf" || ext === "vox") out.push(full); + } + return out; +} + +function listLocal() { + const rootArg = optStr("local-root", ""); + if (!rootArg) return []; + const root = resolve(repoRoot, rootArg); + const files = walkLocal(root) + .map((file) => ({ + source: `local:${root}`, + sourcePath: relative(root, file), + ext: extname(file).slice(1).toLowerCase(), + size: readFileSync(file).byteLength, + url: file, + baseUrl: file, + })) + .filter((item) => item.size <= maxBytes); + console.log(`listed local ${root}: +${files.length}`); + return stableShuffle(files, 0x10ca1); +} + +async function downloadItem(item) { + if (item.source.startsWith("local:")) { + return item.ext === "obj" || item.ext === "gltf" ? readFileSync(item.url, "utf8") : readFileSync(item.url); + } + return item.ext === "obj" || item.ext === "gltf" ? fetchText(item.url) : fetchBytes(item.url); +} + +async function loadExternalGltfBuffers(item, text) { + const doc = JSON.parse(text); + const buffers = new Map(); + const expectedBytes = (doc.buffers ?? []) + .filter((buffer) => buffer.uri && !buffer.uri.startsWith("data:")) + .reduce((sum, buffer) => sum + (buffer.byteLength ?? 0), textEncoder.encode(text).byteLength); + if (expectedBytes > maxBytes) { + throw new Error(`external glTF buffers exceed --max-bytes (${expectedBytes} > ${maxBytes})`); + } + for (const buffer of doc.buffers ?? []) { + const uri = buffer.uri; + if (!uri || uri.startsWith("data:")) continue; + if (item.source.startsWith("local:")) { + buffers.set(uri, new Uint8Array(readFileSync(resolve(dirname(item.url), uri)))); + continue; + } + const url = item.includeUrls?.[uri] ?? new URL(uri, item.baseUrl ?? item.url).href; + buffers.set(uri, await fetchBytes(url)); + } + return buffers; +} + +async function prepareItem(item) { + const data = await downloadItem(item); + if (item.ext !== "gltf") return { data, keepData: data, parseOptions: undefined, externalBuffers: new Map() }; + const externalBuffers = await loadExternalGltfBuffers(item, data); + return { + data: textEncoder.encode(data), + keepData: data, + externalBuffers, + parseOptions: { + baseUrl: item.baseUrl ?? item.url, + resolveBuffer: (uri) => { + const bytes = externalBuffers.get(uri); + if (!bytes) throw new Error(`compat-hunter: missing prefetched glTF buffer ${uri}`); + return bytes; + }, + }, + }; +} + +function parseItem(item, prepared) { + if (item.ext === "obj") return parseObj(prepared.data); + if (item.ext === "vox") return parseVox(toArrayBuffer(prepared.data)); + return parseGltf(prepared.data, prepared.parseOptions); +} + +function keepPathFor(item, testedIndex, root) { + return join(root, `${String(testedIndex).padStart(5, "0")}-${item.sourcePath.replace(/[^A-Za-z0-9._-]+/g, "-")}`); +} + +function writeKeptItem(item, testedIndex, root, prepared) { + const keptPath = keepPathFor(item, testedIndex, root); + if (item.ext !== "gltf") { + writeFileSync(keptPath, prepared.keepData); + return keptPath; + } + mkdirSync(keptPath, { recursive: true }); + writeFileSync(join(keptPath, "model.gltf"), prepared.keepData); + const buffersDir = join(keptPath, "buffers"); + mkdirSync(buffersDir, { recursive: true }); + const buffers = []; + for (const [uri, bytes] of prepared.externalBuffers.entries()) { + const fileName = uri.replace(/[^A-Za-z0-9._-]+/g, "-"); + writeFileSync(join(buffersDir, fileName), bytes); + buffers.push({ uri, fileName, bytes: bytes.byteLength }); + } + writeFileSync(join(keptPath, "external-buffers.json"), `${JSON.stringify(buffers, null, 2)}\n`); + return keptPath; +} + +async function handleItem(item, testedIndex) { + const started = performance.now(); + let prepared; + try { + prepared = await prepareItem(item); + } catch (error) { + failedDownloads.push(rowForItem(item, testedIndex, { + stage: "download", + error: error instanceof Error ? error.message : String(error), + ms: Math.round((performance.now() - started) * 100) / 100, + })); + bumpSource(item.source, "failedDownloads"); + return; + } + + try { + const parsedResult = parseItem(item, prepared); + const ms = Math.round((performance.now() - started) * 100) / 100; + const classification = classify(parsedResult, item.ext); + const warnings = parsedResult.warnings ?? []; + const row = rowForItem(item, testedIndex, { + kind: classification.kind, + polygonCount: parsedResult.polygons.length, + warningCount: warnings.length, + warnings, + triangleCount: parsedResult.metadata?.triangleCount, + unknownWarnings: classification.unknownWarnings, + ms, + }); + parsed++; + bumpSource(item.source, "parsed"); + totalPolygons += parsedResult.polygons.length; + bumpSource(item.source, "totalPolygons", parsedResult.polygons.length); + parsedResult.dispose?.(); + + if (classification.kind === "clean") { + cleanCount++; + bumpSource(item.source, "clean"); + if (cleanSamples.length < 25 || testedIndex % progressEvery === 0) { + cleanSamples.push({ + testedIndex, + source: item.source, + sourcePath: item.sourcePath, + polygonCount: row.polygonCount, + ms, + }); + } + return; + } + + if (classification.kind === "known-warning" || classification.kind === "known-zero") { + knownCount++; + bumpSource(item.source, "known"); + for (const warning of warnings) bump(knownWarningCounts, warning); + if (keepKnown) { + const keptPath = writeKeptItem(item, testedIndex, knownRoot, prepared); + row.keptPath = keptPath; + } + knownSamples.push(row); + return; + } + + const keptPath = writeKeptItem(item, testedIndex, interestingRoot, prepared); + interesting.push({ ...row, keptPath }); + bumpSource(item.source, "interesting"); + console.log(`FOUND ${classification.kind} at ${testedIndex}: ${item.source} ${item.sourcePath}`); + if (stopOnInteresting) stop = true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const knownError = isKnownError(message); + if (knownError) { + knownCount++; + bumpSource(item.source, "known"); + bump(knownErrorCounts, message); + const row = rowForItem(item, testedIndex, { + kind: "known-error", + error: message, + ms: Math.round((performance.now() - started) * 100) / 100, + }); + if (keepKnown) row.keptPath = writeKeptItem(item, testedIndex, knownRoot, prepared); + knownSamples.push(row); + return; + } + bump(failureCounts, message); + const keptPath = writeKeptItem(item, testedIndex, interestingRoot, prepared); + interesting.push(rowForItem(item, testedIndex, { + kind: "throw", + error: message, + keptPath, + ms: Math.round((performance.now() - started) * 100) / 100, + })); + bumpSource(item.source, "interesting"); + console.log(`FOUND throw at ${testedIndex}: ${item.source} ${item.sourcePath}: ${message}`); + if (stopOnInteresting) stop = true; + } +} + +async function buildQueue() { + const chunks = []; + if (sources.includes("objaverse")) chunks.push(await listObjaverse()); + if (sources.includes("github")) chunks.push(await listGithub()); + if (sources.includes("polyhaven")) chunks.push(await listPolyhaven()); + if (sources.includes("local")) chunks.push(listLocal()); + return stableShuffle(chunks.flat(), 0x705e); +} + +const queue = await buildQueue(); +for (const item of queue) bumpSource(item.source, "queued"); +if (queue.length === 0) { + writeReport(true); + throw new Error("compat-hunter: no OBJ/GLB/glTF/VOX candidates were listed"); +} + +async function worker() { + while (!stop) { + if (attempted >= maxModels) return; + const item = queue[nextQueueIndex++]; + if (!item) return; + const testedIndex = ++attempted; + bumpSource(item.source, "attempted"); + await handleItem(item, testedIndex); + if (progressEvery > 0 && (attempted % progressEvery === 0 || stop)) { + console.log( + `attempted=${attempted} parsed=${parsed} clean=${cleanCount} known=${knownCount} ` + + `interesting=${interesting.length} failedDownloads=${failedDownloads.length}`, + ); + writeReport(stop); + } + } +} + +writeReport(false); +await Promise.all(Array.from({ length: concurrency }, () => worker())); +writeReport(true); +console.log(JSON.stringify({ + reportPath, + attempted, + parsed, + clean: cleanCount, + known: knownCount, + interesting: interesting.length, + failedDownloads: failedDownloads.length, + totalPolygons, +}, null, 2)); diff --git a/AGENTS.md b/AGENTS.md index 270cc098..98740cc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ -# Polycss — agent guide +# PolyCSS — agent guide This file is the single source of truth for AI coding agents (Claude Code, Cursor, etc.). `CLAUDE.md` is a symlink to this file — **always edit `AGENTS.md`, never `CLAUDE.md`**. The constraints below describe the current design and the rules we work under; if a request conflicts with one of them, push back before doing it. ## What this repo is -`polycss` is a CSS-based polygon mesh rendering engine. It paints 3D meshes by emitting one DOM element per polygon, transforming it with `matrix3d`, and letting the browser composite the result. No WebGL, no canvas-per-frame. Rasterisation only happens once, into a texture atlas; everything after that is pure DOM + CSS. +PolyCSS is a CSS-based polygon mesh rendering engine. It paints 3D meshes by emitting one DOM element per polygon, transforming it with `matrix3d`, and letting the browser composite the result. No WebGL, no canvas-per-frame. Rasterisation only happens once, into a texture atlas; everything after that is pure DOM + CSS. Monorepo layout (pnpm workspaces): @@ -81,6 +81,7 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n ## Naming (three.js parity) +- Brand text is **PolyCSS**. Keep lowercase `polycss` only for literal package names, import paths, CSS classes, domains, and other code identifiers. - Every public export gets a `Poly` prefix. Exceptions are generic math types: `Vec2`, `Vec3`, `Polygon`, `PolyMaterial` (already prefixed). - **Hooks/composables:** `usePolyCamera`, `usePolyMesh`, `usePolySceneContext`, `usePolySelect`, `usePolySelectionApi`, `usePolyAnimation`. - **Components:** `PolyPerspectiveCamera`, `PolyOrthographicCamera`, `PolyOrbitControls`, `PolyMapControls`, `PolyTransformControls`, `PolySelect`, `PolyAxesHelper`, `PolyDirectionalLightHelper`. @@ -89,15 +90,15 @@ If you find yourself wanting a `requestAnimationFrame` loop to update many DOM n - **Vanilla factories:** `create*` names stay as-is (`createPolyScene`, `createTransformControls`, `createSelect`). - **HTML custom elements:** `poly-` prefix + kebab-case. Existing tags: ``, ``, ``, ``, ``, ``, ``. Any new element follows the same shape (e.g. ``, ``). - **Leaf DOM tags (``, ``, ``, ``):** internal render-strategy tags. Not part of the public API and not user-facing — do not document them as such. -- `PolyCamera` is a kept alias for `PolyOrthographicCamera` — the ergonomic default, optimised for iso/voxel/diagrammatic scenes which is polycss's structural strength. **Not deprecated.** +- `PolyCamera` is a kept alias for `PolyOrthographicCamera` — the ergonomic default, optimised for iso/voxel/diagrammatic scenes which is PolyCSS's structural strength. **Not deprecated.** ## Cross-package discipline The React and Vue packages are mirror images. **Any public API change in one must land in the other in the same PR.** Same names, same arguments, same defaults, same return shapes (allowing for idiomatic differences — refs vs reactives, `useEffect` vs `watchEffect`). -When you change `packages/polycss` or `packages/core` in a way that affects the public surface (new option, renamed export, changed default), the React and Vue bindings update in the same PR. Don't ship a polycss change that leaves the bindings stale. +When you change `packages/polycss` or `packages/core` in a way that affects the public surface (new option, renamed export, changed default), the React and Vue bindings update in the same PR. Don't ship a PolyCSS change that leaves the bindings stale. -**Renderer-owned browser glue.** The canvas atlas pipeline (`buildAtlasPages` + helpers), browser-feature detection (`isBorderShapeSupported`, `isSolidTriangleSupported`, `resolveSolidTrianglePrimitive`), direct voxel renderer (`voxelRenderer.ts`), and injected `.polycss-scene` / `.polycss-camera` base styles exist as **independent copies** across the three renderers. This includes `packages/polycss/src/render/atlas/`, `packages/react/src/scene/atlas/`, `packages/vue/src/scene/atlas/`, the three renderer-local `voxelRenderer.ts` files, and the three sibling `styles.ts` files. This is deliberate — each renderer is self-contained on its dep graph (React/Vue do not import from polycss). The trade-off is that a bug fix in any of these files MUST be mirrored into the other two. Coverage is pinned per copy by the co-located test files. +**Renderer-owned browser glue.** The canvas atlas pipeline (`buildAtlasPages` + helpers), browser-feature detection (`isBorderShapeSupported`, `isSolidTriangleSupported`, `resolveSolidTrianglePrimitive`), direct voxel renderer (`voxelRenderer.ts`), and injected `.polycss-scene` / `.polycss-camera` base styles exist as **independent copies** across the three renderers. This includes `packages/polycss/src/render/atlas/`, `packages/react/src/scene/atlas/`, `packages/vue/src/scene/atlas/`, the three renderer-local `voxelRenderer.ts` files, and the three sibling `styles.ts` files. This is deliberate — each renderer is self-contained on its dep graph (React/Vue do not import from the `polycss` package). The trade-off is that a bug fix in any of these files MUST be mirrored into the other two. Coverage is pinned per copy by the co-located test files. Before opening a PR: @@ -105,7 +106,7 @@ Before opening a PR: - [ ] If I touched a Vue component/composable, the React component/hook matches. - [ ] If I added an option to a `polycss` factory, both bindings expose it. - [ ] If I renamed a `core` export, every package that imports it is updated. -- [ ] If I touched the canvas atlas pipeline (`rasterise.ts` / `buildAtlasPages.ts`), browser-feature detection, or direct voxel renderer in ONE renderer, the same fix lands in the other two renderers (polycss + react + vue) in this PR. +- [ ] If I touched the canvas atlas pipeline (`rasterise.ts` / `buildAtlasPages.ts`), browser-feature detection, or direct voxel renderer in ONE renderer, the same fix lands in the other two renderers (`polycss` + react + vue) in this PR. - [ ] If I touched any of the three `styles.ts` (`packages/polycss/src/styles/styles.ts`, `packages/react/src/styles/styles.ts`, `packages/vue/src/styles/styles.ts`), the other two are consistent — CSS rules cover every emitted tag for both lighting modes, and shared properties like `will-change: transform` on `.polycss-scene` exist in all three. - [ ] Website docs (`website/src/content/docs/**`) and READMEs reflect any user-visible change. - [ ] If I changed a render strategy, lighting mode, naming convention, or the JS-in-render-loop rules, `AGENTS.md` reflects the new state in this same PR. diff --git a/README.md b/README.md index 0b012ea1..2a6fb249 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A CSS polygon mesh library. A 3D engine for the DOM. Renders OBJ/MTL, GLB and VO Visit [polycss.com](https://polycss.com) for docs and model examples. -polycss-primitives-banner +PolyCSS primitives banner ## Installation @@ -35,7 +35,7 @@ You can also load PolyCSS directly from a CDN. Here is a minimal custom-element ``` -polycss-intro +PolyCSS intro ## Framework Components diff --git a/examples/html/baked-shapes/index.html b/examples/html/baked-shapes/index.html index db16d2eb..a4ed444a 100644 --- a/examples/html/baked-shapes/index.html +++ b/examples/html/baked-shapes/index.html @@ -3,7 +3,7 @@ - baked-shapes — polycss HTML + baked-shapes — PolyCSS HTML -

polycss — HTML (custom elements)

+

PolyCSS — HTML (custom elements)

  • baked-shapes
  • multi-mesh
  • diff --git a/examples/html/multi-mesh/index.html b/examples/html/multi-mesh/index.html index e08d2394..a2495c1f 100644 --- a/examples/html/multi-mesh/index.html +++ b/examples/html/multi-mesh/index.html @@ -3,7 +3,7 @@ - multi-mesh — polycss HTML + multi-mesh — PolyCSS HTML diff --git a/examples/react/baked-shapes/index.html b/examples/react/baked-shapes/index.html index 5021bfe6..589cba71 100644 --- a/examples/react/baked-shapes/index.html +++ b/examples/react/baked-shapes/index.html @@ -3,7 +3,7 @@ - baked-shapes — polycss React + baked-shapes — PolyCSS React diff --git a/examples/react/index.html b/examples/react/index.html index a3949ebc..2a6f9fef 100644 --- a/examples/react/index.html +++ b/examples/react/index.html @@ -3,7 +3,7 @@ - polycss — React examples + PolyCSS — React examples -

    polycss — React examples

    +

    PolyCSS — React examples

    • baked-shapes
    • multi-mesh
    • diff --git a/examples/react/multi-mesh/index.html b/examples/react/multi-mesh/index.html index f01e852d..384bcab4 100644 --- a/examples/react/multi-mesh/index.html +++ b/examples/react/multi-mesh/index.html @@ -3,7 +3,7 @@ - multi-mesh — polycss React + multi-mesh — PolyCSS React diff --git a/examples/react/solid-glb/index.html b/examples/react/solid-glb/index.html index b3e1e336..77a1e560 100644 --- a/examples/react/solid-glb/index.html +++ b/examples/react/solid-glb/index.html @@ -3,7 +3,7 @@ - solid-glb — polycss React + solid-glb — PolyCSS React diff --git a/examples/react/textured-glb/index.html b/examples/react/textured-glb/index.html index cd59b0f4..754f4a80 100644 --- a/examples/react/textured-glb/index.html +++ b/examples/react/textured-glb/index.html @@ -3,7 +3,7 @@ - textured-glb — polycss React + textured-glb — PolyCSS React diff --git a/examples/react/voxel/index.html b/examples/react/voxel/index.html index 7cfdb3b9..69262d4e 100644 --- a/examples/react/voxel/index.html +++ b/examples/react/voxel/index.html @@ -3,7 +3,7 @@ - voxel — polycss React + voxel — PolyCSS React diff --git a/examples/vanilla/animated/index.html b/examples/vanilla/animated/index.html index 88603149..2afc97eb 100644 --- a/examples/vanilla/animated/index.html +++ b/examples/vanilla/animated/index.html @@ -3,7 +3,7 @@ - animated — polycss vanilla + animated — PolyCSS vanilla -

      polycss — vanilla (imperative API)

      +

      PolyCSS — vanilla (imperative API)

      • baked-shapes
      • multi-mesh
      • diff --git a/examples/vanilla/multi-mesh/index.html b/examples/vanilla/multi-mesh/index.html index d1f31d93..07d88fe1 100644 --- a/examples/vanilla/multi-mesh/index.html +++ b/examples/vanilla/multi-mesh/index.html @@ -3,7 +3,7 @@ - multi-mesh — polycss vanilla + multi-mesh — PolyCSS vanilla diff --git a/examples/vue/baked-shapes/index.html b/examples/vue/baked-shapes/index.html index 950e0fa7..f4608ed0 100644 --- a/examples/vue/baked-shapes/index.html +++ b/examples/vue/baked-shapes/index.html @@ -3,7 +3,7 @@ - baked-shapes — polycss Vue + baked-shapes — PolyCSS Vue diff --git a/examples/vue/index.html b/examples/vue/index.html index 4a07226f..c298d2a4 100644 --- a/examples/vue/index.html +++ b/examples/vue/index.html @@ -3,7 +3,7 @@ - polycss — Vue examples + PolyCSS — Vue examples -

        polycss — Vue examples

        +

        PolyCSS — Vue examples

        • baked-shapes
        • multi-mesh
        • diff --git a/examples/vue/multi-mesh/index.html b/examples/vue/multi-mesh/index.html index f3805e7e..1b27e673 100644 --- a/examples/vue/multi-mesh/index.html +++ b/examples/vue/multi-mesh/index.html @@ -3,7 +3,7 @@ - multi-mesh — polycss Vue + multi-mesh — PolyCSS Vue diff --git a/examples/vue/solid-glb/index.html b/examples/vue/solid-glb/index.html index 9b2720f3..f5e5f9b7 100644 --- a/examples/vue/solid-glb/index.html +++ b/examples/vue/solid-glb/index.html @@ -3,7 +3,7 @@ - solid-glb — polycss Vue + solid-glb — PolyCSS Vue diff --git a/examples/vue/textured-glb/index.html b/examples/vue/textured-glb/index.html index 7ce4ea41..4add433e 100644 --- a/examples/vue/textured-glb/index.html +++ b/examples/vue/textured-glb/index.html @@ -3,7 +3,7 @@ - textured-glb — polycss Vue + textured-glb — PolyCSS Vue diff --git a/examples/vue/voxel/index.html b/examples/vue/voxel/index.html index 65d356aa..a525cd85 100644 --- a/examples/vue/voxel/index.html +++ b/examples/vue/voxel/index.html @@ -3,7 +3,7 @@ - voxel — polycss Vue + voxel — PolyCSS Vue diff --git a/package.json b/package.json index 00aeb4e2..096c9140 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "bench:minecraft-movement": "pnpm --filter @layoutit/polycss-examples-vanilla build && node bench/minecraft-movement-bench.mjs", "bench:lossy": "pnpm --filter @layoutit/polycss-core build && node bench/lossy-optimizer-bench.mjs", "bench:lossy:corpus": "pnpm --filter @layoutit/polycss-core build && node bench/lossy-corpus-bench.mjs", + "compat-hunter": "pnpm --filter @layoutit/polycss-core build && node .agents/skills/compat-hunter/scripts/compat-hunter.mjs", "bench:seams": "pnpm --filter @layoutit/polycss-core build && node bench/seam-gap-bench.mjs", "bench:seams:render": "pnpm --filter @layoutit/polycss-core build && node bench/seam-gap-bench.mjs --render", "bench:visual": "node bench/build.mjs && node bench/perf-visual.mjs" diff --git a/packages/core/README.md b/packages/core/README.md index dfee357d..5d571c90 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,14 +1,14 @@

          - polycss + PolyCSS

          -# polycss +# PolyCSS A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. Visit [polycss.com](https://polycss.com) for docs and model examples. -polycss scene +PolyCSS scene ## Installation @@ -23,7 +23,7 @@ npm install @layoutit/polycss-vue npm install @layoutit/polycss ``` -You can also load polycss directly from a CDN. Here is a minimal custom-element scene: +You can also load PolyCSS directly from a CDN. Here is a minimal custom-element scene: ```html @@ -171,7 +171,7 @@ Supported formats: ## Performance -polycss renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. +PolyCSS renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. - One visible polygon becomes one leaf DOM element. - Flat rectangles and stable quads use solid CSS leaves. @@ -200,7 +200,7 @@ For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, wh | `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. | | `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. | -## Made with polycss +## Made with PolyCSS [Layoutit Voxels](https://voxels.layoutit.com) -> A CSS Voxel editor diff --git a/packages/core/src/atlas/solidTrianglePlan.ts b/packages/core/src/atlas/solidTrianglePlan.ts index bc9b01a1..4f417d73 100644 --- a/packages/core/src/atlas/solidTrianglePlan.ts +++ b/packages/core/src/atlas/solidTrianglePlan.ts @@ -434,7 +434,7 @@ export function computeSolidTrianglePlanFromCssPoints( ? basisHint : { a, b, c }; // Use the pre-resolved primitive from computeOptions — the browser-global - // resolution that formerly happened here now happens in the polycss wrapper. + // resolution that formerly happened here now happens in the PolyCSS wrapper. const primitive = computeOptions.primitive ?? computeOptions.resolvedPrimitive ?? "border"; return { index, diff --git a/packages/core/src/camera/camera.ts b/packages/core/src/camera/camera.ts index ccc52423..a470d5d4 100644 --- a/packages/core/src/camera/camera.ts +++ b/packages/core/src/camera/camera.ts @@ -2,7 +2,7 @@ import type { Vec3 } from "../types"; /** - * Base tile size in CSS pixels. One polycss world unit = BASE_TILE CSS + * Base tile size in CSS pixels. One PolyCSS world unit = BASE_TILE CSS * pixels (pre-scale). Used to convert world-coordinate target values to * CSS translations in the transform string. */ @@ -20,7 +20,7 @@ export type AutoRotateOption = boolean | number | AutoRotateConfig; * World-coordinate camera state (Three.js-style). * * `target` is the world point that should appear at the viewport centre. - * Polycss world axes: [0]=X (rows/south), [1]=Y (cols/east), [2]=Z (up). + * PolyCSS world axes: [0]=X (rows/south), [1]=Y (cols/east), [2]=Z (up). * * `pan`, `tilt`, and `depthOffset` are gone. Translations now live inside * `target` so they happen BEFORE rotations — enabling correct world-space @@ -114,7 +114,7 @@ export function createIsometricCamera(initial: Partial = {}): Camer const height = (input.rows ?? 0) * tileSize; // Convert world target to CSS-space translation. - // Polycss world→CSS mapping: world[0]→CSS Y, world[1]→CSS X, world[2]→CSS Z. + // PolyCSS world→CSS mapping: world[0]→CSS Y, world[1]→CSS X, world[2]→CSS Z. // Negate so that the world moves such that `target` ends up at scene origin. const [tx, ty, tz] = state.target; const cssX = ty * tileSize; // world Y → CSS X diff --git a/packages/core/src/helpers/arrowPolygons.ts b/packages/core/src/helpers/arrowPolygons.ts index f5a983ea..913e8e99 100644 --- a/packages/core/src/helpers/arrowPolygons.ts +++ b/packages/core/src/helpers/arrowPolygons.ts @@ -5,7 +5,7 @@ * drag handle for `` — same primitive recipe as * `axesHelperPolygons`, plus an arrowhead. * - * Returned polygons are in standard polycss world space and intended + * Returned polygons are in standard PolyCSS world space and intended * to be wrapped in the framework's PolyMesh equivalent for rendering. */ import type { Polygon, Vec3 } from "../types"; @@ -51,7 +51,7 @@ function shaftPolygons( makeAxisVec(axis, along, sideA, sideB); // Vertex layout matches axesHelperPolygons' axisBox: 4 corners at // each end of the box, 6 quad faces. Same winding so the cuboid - // renders front-faces-out under polycss's backface-visibility:hidden. + // renders front-faces-out under PolyCSS's backface-visibility:hidden. const c0 = m(from, -half, -half); const c1 = m(from, half, -half); const c2 = m(from, half, half); diff --git a/packages/core/src/helpers/axesPolygons.ts b/packages/core/src/helpers/axesPolygons.ts index 571cf0da..a435a7bb 100644 --- a/packages/core/src/helpers/axesPolygons.ts +++ b/packages/core/src/helpers/axesPolygons.ts @@ -3,7 +3,7 @@ * cuboids stretching along world-X, world-Y and world-Z. Mirrors the * convention `red=X, green=Y, blue=Z`. * - * Returned polygons are in the standard polycss world-space convention + * Returned polygons are in the standard PolyCSS world-space convention * (`+X right, +Y forward, +Z up`). Wrap with the framework's PolyMesh / * PolyScene equivalent to render. */ diff --git a/packages/core/src/helpers/boxPolygons.ts b/packages/core/src/helpers/boxPolygons.ts index 58464773..c12398b1 100644 --- a/packages/core/src/helpers/boxPolygons.ts +++ b/packages/core/src/helpers/boxPolygons.ts @@ -1,7 +1,7 @@ /** * Axis-aligned box/cuboid geometry as six quad polygons. * - * Returned polygons are in standard polycss world space: + * Returned polygons are in standard PolyCSS world space: * +X = right, +Y = front/forward, +Z = top/up. */ import type { Polygon, Vec2, Vec3 } from "../types"; diff --git a/packages/core/src/helpers/conePolygons.ts b/packages/core/src/helpers/conePolygons.ts index 18f60f52..61b5d45b 100644 --- a/packages/core/src/helpers/conePolygons.ts +++ b/packages/core/src/helpers/conePolygons.ts @@ -5,7 +5,7 @@ * to zero. The top cap is omitted (no area at the tip), and side faces are * emitted as triangles. * - * Polycss world space: +X right, +Y forward, +Z up. Cone axis is Z; the apex + * PolyCSS world space: +X right, +Y forward, +Z up. Cone axis is Z; the apex * is at Z = +height/2 and the base at Z = -height/2. */ import type { Polygon } from "../types"; diff --git a/packages/core/src/helpers/cylinderPolygons.ts b/packages/core/src/helpers/cylinderPolygons.ts index 415e2d88..7f7f730f 100644 --- a/packages/core/src/helpers/cylinderPolygons.ts +++ b/packages/core/src/helpers/cylinderPolygons.ts @@ -12,7 +12,7 @@ * Z = +height/2. Side quads are axis-aligned in the cylinder's own local * frame, which maximises the chance of hitting the quad fast-path. * - * Polycss world space: +X right, +Y forward, +Z up. The cylinder axis is + * PolyCSS world space: +X right, +Y forward, +Z up. The cylinder axis is * the Z axis so a typical upright pillar stands without any extra rotation. */ import type { Polygon, Vec3 } from "../types"; diff --git a/packages/core/src/helpers/dodecahedronPolygons.ts b/packages/core/src/helpers/dodecahedronPolygons.ts index 52d3995c..cc8cb314 100644 --- a/packages/core/src/helpers/dodecahedronPolygons.ts +++ b/packages/core/src/helpers/dodecahedronPolygons.ts @@ -8,7 +8,7 @@ * lies on a single tangent plane. No triangulation is needed. The renderer * uses (border-shape) on Chromium and elsewhere for non-quad polygons. * - * Polycss world space: +X right, +Y forward, +Z up. + * PolyCSS world space: +X right, +Y forward, +Z up. */ import type { Polygon, Vec3 } from "../types"; diff --git a/packages/core/src/helpers/icosahedronPolygons.ts b/packages/core/src/helpers/icosahedronPolygons.ts index 43981fc7..ad5599d9 100644 --- a/packages/core/src/helpers/icosahedronPolygons.ts +++ b/packages/core/src/helpers/icosahedronPolygons.ts @@ -4,7 +4,7 @@ * Vertices are placed on a sphere of radius `size` centered at the origin. * Faces wind CCW from the outside. * - * Polycss world space: +X right, +Y forward, +Z up. + * PolyCSS world space: +X right, +Y forward, +Z up. */ import type { Polygon, Vec3 } from "../types"; diff --git a/packages/core/src/helpers/planePolygons.ts b/packages/core/src/helpers/planePolygons.ts index 0ff48b03..976a0cc1 100644 --- a/packages/core/src/helpers/planePolygons.ts +++ b/packages/core/src/helpers/planePolygons.ts @@ -5,7 +5,7 @@ * attached mesh along two axes simultaneously (XY, XZ, or YZ), instead of * the single-axis motion the arrow shafts provide. * - * The polygon lives in standard polycss world space; wrap it in the + * The polygon lives in standard PolyCSS world space; wrap it in the * framework's PolyMesh equivalent for rendering. */ import type { Polygon, Vec3 } from "../types"; diff --git a/packages/core/src/helpers/ringPolygons.ts b/packages/core/src/helpers/ringPolygons.ts index 9a9e496e..ef5d1351 100644 --- a/packages/core/src/helpers/ringPolygons.ts +++ b/packages/core/src/helpers/ringPolygons.ts @@ -9,7 +9,7 @@ * "rotation circle" and keeps the polygon count proportional to the * `segments` knob. * - * Returned polygons are in standard polycss world space and intended + * Returned polygons are in standard PolyCSS world space and intended * to be wrapped in the framework's PolyMesh equivalent for rendering. */ import type { Polygon, Vec3 } from "../types"; diff --git a/packages/core/src/helpers/spherePolygons.ts b/packages/core/src/helpers/spherePolygons.ts index 9a3af40e..9e0094c9 100644 --- a/packages/core/src/helpers/spherePolygons.ts +++ b/packages/core/src/helpers/spherePolygons.ts @@ -3,7 +3,7 @@ * triangular faces. Each subdivision step quadruples the face count: * subdivisions 0 → 20, 1 → 80, 2 → 320, 3 → 1280 (capped). * - * Vertex coordinates use polycss world space: +X right, +Y forward, +Z up. + * Vertex coordinates use PolyCSS world space: +X right, +Y forward, +Z up. * The sphere is centered at the origin; all vertices sit at distance `radius`. * Faces wind CCW from the outside (outward normal = away from origin). */ diff --git a/packages/core/src/helpers/tetrahedronPolygons.ts b/packages/core/src/helpers/tetrahedronPolygons.ts index caafb686..f5e33e47 100644 --- a/packages/core/src/helpers/tetrahedronPolygons.ts +++ b/packages/core/src/helpers/tetrahedronPolygons.ts @@ -4,7 +4,7 @@ * Vertices are placed so the tetrahedron is centered at the origin. * Faces wind CCW from the outside. * - * Polycss world space: +X right, +Y forward, +Z up. + * PolyCSS world space: +X right, +Y forward, +Z up. */ import type { Polygon, Vec3 } from "../types"; diff --git a/packages/core/src/helpers/torusPolygons.ts b/packages/core/src/helpers/torusPolygons.ts index 4db18230..50dcd40f 100644 --- a/packages/core/src/helpers/torusPolygons.ts +++ b/packages/core/src/helpers/torusPolygons.ts @@ -2,7 +2,7 @@ * Torus geometry — Z-axis ring plane. * * The torus is centered at the origin. The ring lies in the XY plane (the - * ground plane in polycss world space, where Z is up). The donut hole points + * ground plane in PolyCSS world space, where Z is up). The donut hole points * along the Z axis. * * Geometry: `radialSegments × tubularSegments` quads on the surface. @@ -11,7 +11,7 @@ * heaviest of the built-in primitives. Reduce radialSegments / tubularSegments * if render budget is tight. * - * Polycss world space: +X right, +Y forward, +Z up. + * PolyCSS world space: +X right, +Y forward, +Z up. */ import type { Polygon, Vec3 } from "../types"; diff --git a/packages/core/src/math/rotation.ts b/packages/core/src/math/rotation.ts index da97a3fd..0d58c7c1 100644 --- a/packages/core/src/math/rotation.ts +++ b/packages/core/src/math/rotation.ts @@ -2,7 +2,7 @@ import type { Vec3 } from "../types"; /** * Apply CSS-style chained `rotateX(rx) rotateY(ry) rotateZ(rz)` rotation - * to a 3D vector. Matches the matrix composition used by polycss mesh + * to a 3D vector. Matches the matrix composition used by PolyCSS mesh * wrapper transforms (see `buildTransform` in each PolyMesh implementation). * * CSS composes `transform: rotateX(rx) rotateY(ry) rotateZ(rz)` as the diff --git a/packages/core/src/parser/parseGltf.test.ts b/packages/core/src/parser/parseGltf.test.ts index 3d430913..da04f869 100644 --- a/packages/core/src/parser/parseGltf.test.ts +++ b/packages/core/src/parser/parseGltf.test.ts @@ -757,6 +757,52 @@ describe("parseGltf", () => { [0, 5, 0], ]); }); + + it("treats byteStride: 0 as tightly packed data", () => { + const positions = [ + 0, 0, 0, + 2, 0, 0, + 0, 1, 0, + ]; + const bin = new Uint8Array(positions.length * 4); + const view = new DataView(bin.buffer); + for (let i = 0; i < positions.length; i++) { + view.setFloat32(i * 4, positions[i], true); + } + const doc = { + asset: { version: "2.0" }, + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ primitives: [{ attributes: { POSITION: 0 }, mode: 4 }] }], + accessors: [{ + bufferView: 0, + byteOffset: 0, + componentType: 5126, + count: 3, + type: "VEC3", + }], + bufferViews: [{ + buffer: 0, + byteOffset: 0, + byteLength: bin.length, + byteStride: 0, + }], + buffers: [{ byteLength: bin.length }], + }; + const result = parseGltf(buildGlb({ doc, binData: bin }), { + upAxis: "z", + targetSize: 10, + gridShift: 0, + }); + + expect(result.polygons).toHaveLength(1); + expect(result.polygons[0].vertices).toEqual([ + [0, 0, 0], + [10, 0, 0], + [0, 5, 0], + ]); + }); }); describe("sparse accessors", () => { @@ -806,7 +852,7 @@ describe("parseGltf", () => { }); describe("triangle topology modes", () => { - function buildFourVertexModeGlb(mode: 5 | 6): ArrayBuffer { + function buildFourVertexModeGlb(mode: number): ArrayBuffer { const positions = [ 0, 0, 0, 1, 0, 0, @@ -840,6 +886,18 @@ describe("parseGltf", () => { const result = parseGltf(buildFourVertexModeGlb(6)); expect(result.polygons).toHaveLength(2); }); + + it.each([ + [0, "POINTS"], + [1, "LINES"], + ])("mode=%i (%s) is skipped with a warning", (mode, modeName) => { + const result = parseGltf(buildFourVertexModeGlb(mode)); + + expect(result.polygons).toEqual([]); + expect(result.warnings).toEqual([ + `Skipped primitives with unsupported mode ${mode} (${modeName})`, + ]); + }); }); describe("unsupported extensions", () => { @@ -872,6 +930,72 @@ describe("parseGltf", () => { expect(result.polygons).toEqual([]); expect(result.warnings.some((warning) => warning.includes("KHR_draco_mesh_compression"))).toBe(true); }); + + it("skips required meshopt-compressed bufferView primitives before reading extension fallback buffers", () => { + const doc = { + asset: { version: "2.0" }, + extensionsRequired: ["EXT_meshopt_compression"], + scene: 0, + scenes: [{ nodes: [0] }], + nodes: [{ mesh: 0 }], + meshes: [{ + name: "MeshoptCompressed", + primitives: [{ + attributes: { POSITION: 0 }, + indices: 1, + mode: 4, + }], + }], + accessors: [ + { bufferView: 0, componentType: 5126, count: 3, type: "VEC3" }, + { bufferView: 1, componentType: 5123, count: 3, type: "SCALAR" }, + ], + bufferViews: [ + { + buffer: 1, + byteOffset: 0, + byteLength: 36, + byteStride: 12, + extensions: { + EXT_meshopt_compression: { + buffer: 0, + byteOffset: 0, + byteLength: 4, + byteStride: 12, + mode: "ATTRIBUTES", + count: 3, + }, + }, + }, + { + buffer: 1, + byteOffset: 36, + byteLength: 6, + extensions: { + EXT_meshopt_compression: { + buffer: 0, + byteOffset: 0, + byteLength: 4, + byteStride: 2, + mode: "TRIANGLES", + count: 3, + }, + }, + }, + ], + buffers: [ + { byteLength: 4 }, + { + byteLength: 42, + extensions: { EXT_meshopt_compression: { fallback: true } }, + }, + ], + }; + const result = parseGltf(buildGlb({ doc, binData: new Uint8Array(4) })); + + expect(result.polygons).toEqual([]); + expect(result.warnings.some((warning) => warning.includes("EXT_meshopt_compression"))).toBe(true); + }); }); describe("material color", () => { diff --git a/packages/core/src/parser/parseGltf.ts b/packages/core/src/parser/parseGltf.ts index 70998dfb..e0196f40 100644 --- a/packages/core/src/parser/parseGltf.ts +++ b/packages/core/src/parser/parseGltf.ts @@ -18,7 +18,7 @@ * * After parsing, the mesh is uniformly scaled to fit `targetSize` units * and the y/z axes are cyclically permuted (so glTF's +Y-up becomes - * polycss's +Z-up without inverting handedness — a single y↔z swap would + * PolyCSS's +Z-up without inverting handedness — a single y↔z swap would * flip every triangle's winding and break backface culling). */ import type { @@ -52,7 +52,7 @@ export interface GltfParseOptions { /** * Which axis is "up" in the source mesh. * - "y" (default, glTF spec): cyclic permutation (x,y,z) → (z,x,y) so - * +Y ends up on polycss's +Z (elevation). + * +Y ends up on PolyCSS's +Z (elevation). * - "z" (Blender-style, FBX2glTF often emits this): identity, no swap. * Pick "z" if the model lands on its side / lies down instead of * standing. @@ -90,6 +90,16 @@ const TYPE_COUNT: Record = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16, }; +const PRIMITIVE_MODE_NAMES: Record = { + 0: "POINTS", + 1: "LINES", + 2: "LINE_LOOP", + 3: "LINE_STRIP", + 4: "TRIANGLES", + 5: "TRIANGLE_STRIP", + 6: "TRIANGLE_FAN", +}; + interface GltfAccessor { bufferView?: number; byteOffset?: number; @@ -115,6 +125,12 @@ interface GltfBufferView { byteOffset?: number; byteLength: number; byteStride?: number; + extensions?: Record; +} +interface GltfBuffer { + byteLength: number; + uri?: string; + extensions?: Record; } interface GltfTextureInfo { index: number; // index into doc.textures[] @@ -159,7 +175,7 @@ interface GltfNode { mesh?: number; skin?: number; children?: number[]; - /** TRS — polycss reads either matrix or these three components. */ + /** TRS — PolyCSS reads either matrix or these three components. */ matrix?: number[]; translation?: number[]; rotation?: number[]; // quaternion (x, y, z, w) @@ -205,7 +221,7 @@ interface GltfDoc { materials?: GltfMaterial[]; accessors?: GltfAccessor[]; bufferViews?: GltfBufferView[]; - buffers?: { byteLength: number; uri?: string }[]; + buffers?: GltfBuffer[]; images?: GltfImage[]; textures?: GltfTexture[]; samplers?: GltfSampler[]; @@ -288,6 +304,7 @@ function resolveBuffers( resolveBuffer?: (uri: string) => Uint8Array | Promise, ): Uint8Array[] { const specs = doc.buffers ?? []; + const canSkipMeshoptFallbackBuffers = (doc.extensionsRequired ?? []).includes("EXT_meshopt_compression"); return specs.map((buffer, index) => { const uri = buffer.uri; if (uri) { @@ -300,6 +317,9 @@ function resolveBuffers( throw new Error(`parseGltf: external buffer URI "${uri}" — provide options.resolveBuffer`); } if (index === 0 && glbBin) return glbBin; + if (canSkipMeshoptFallbackBuffers && buffer.extensions?.EXT_meshopt_compression) { + return new Uint8Array(0); + } throw new Error(`parseGltf: buffer[${index}] has no uri and no GLB BIN chunk`); }); } @@ -328,6 +348,10 @@ function assertAccessorFits(acc: GltfAccessor, view: GltfBufferView, stride: num } } +function resolveAccessorStride(view: GltfBufferView, packedBytes: number): number { + return view.byteStride !== undefined && view.byteStride > 0 ? view.byteStride : packedBytes; +} + type AccessorArray = Float32Array | Uint16Array | Uint32Array | Uint8Array | Int8Array | Int16Array; function typedArrayFromValues(componentType: number, values: number[], normalized: boolean | undefined): AccessorArray { @@ -362,7 +386,7 @@ function readAccessor(doc: GltfDoc, buffers: Uint8Array[], accessorIdx: number): const offset = (view.byteOffset ?? 0) + (acc.byteOffset ?? 0); const elements = acc.count * componentCount; const packedBytes = bytesPerComponent * componentCount; - const stride = view.byteStride ?? packedBytes; + const stride = resolveAccessorStride(view, packedBytes); assertAccessorFits(acc, view, stride, packedBytes); if (stride === packedBytes) { @@ -449,7 +473,7 @@ function readAccessorComponents(doc: GltfDoc, buffers: Uint8Array[], accessorIdx if (acc.bufferView !== undefined) { const { buffer, view } = resolveBufferView(doc, buffers, acc.bufferView); const start = buffer.byteOffset + (view.byteOffset ?? 0) + (acc.byteOffset ?? 0); - const stride = view.byteStride ?? packedBytes; + const stride = resolveAccessorStride(view, packedBytes); assertAccessorFits(acc, view, stride, packedBytes); const data = new DataView(buffer.buffer); let write = 0; @@ -1353,6 +1377,29 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp warnings.push(warning); } + function accessorUsesBufferViewExtension(accessorIdx: number | undefined, extensionName: string): boolean { + if (accessorIdx === undefined) return false; + const acc = doc.accessors?.[accessorIdx]; + if (!acc) return false; + const bufferViewHasExtension = (bufferViewIdx: number): boolean => + doc.bufferViews?.[bufferViewIdx]?.extensions?.[extensionName] !== undefined; + if (acc.bufferView !== undefined && bufferViewHasExtension(acc.bufferView)) return true; + if (acc.sparse) { + if (bufferViewHasExtension(acc.sparse.indices.bufferView)) return true; + if (bufferViewHasExtension(acc.sparse.values.bufferView)) return true; + } + return false; + } + + function primitiveUsesRequiredMeshopt(prim: GltfPrimitive): boolean { + if (!requiredExtensions.has("EXT_meshopt_compression")) return false; + if (prim.extensions?.EXT_meshopt_compression) return true; + if (accessorUsesBufferViewExtension(prim.indices, "EXT_meshopt_compression")) return true; + return Object.values(prim.attributes ?? {}).some((accessorIdx) => + accessorUsesBufferViewExtension(accessorIdx, "EXT_meshopt_compression") + ); + } + interface RawTri { v0: Vec3; v1: Vec3; @@ -1404,7 +1451,14 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp if (!mesh) return; for (const prim of mesh.primitives) { const mode = prim.mode ?? 4; - if (mode !== 4 && mode !== 5 && mode !== 6) continue; + if (mode !== 4 && mode !== 5 && mode !== 6) { + const modeName = PRIMITIVE_MODE_NAMES[mode] ?? `mode ${mode}`; + pushWarningOnce( + `unsupported-mode:${mode}`, + `Skipped primitives with unsupported mode ${mode} (${modeName})`, + ); + continue; + } if (prim.extensions?.KHR_draco_mesh_compression && requiredExtensions.has("KHR_draco_mesh_compression")) { pushWarningOnce( "KHR_draco_mesh_compression", @@ -1412,6 +1466,13 @@ export function parseGltf(input: ArrayBuffer | Uint8Array, options?: GltfParseOp ); continue; } + if (primitiveUsesRequiredMeshopt(prim)) { + pushWarningOnce( + "EXT_meshopt_compression", + "Skipped primitives with unsupported required extension EXT_meshopt_compression", + ); + continue; + } if (prim.attributes.POSITION === undefined) { pushWarningOnce( `missing-position:${meshIdx}`, diff --git a/packages/core/src/parser/parseObj.test.ts b/packages/core/src/parser/parseObj.test.ts index 638c8411..305f7d63 100644 --- a/packages/core/src/parser/parseObj.test.ts +++ b/packages/core/src/parser/parseObj.test.ts @@ -209,6 +209,24 @@ describe("parseObj — face index formats (v, v/vt, v/vt/vn, v//vn)", () => { expect(r.polygons).toHaveLength(1); expect(r.polygons[0].uvs).toBeUndefined(); }); + + it("negative vertex and texture indices resolve relative to the current OBJ lists", () => { + const obj = [ + "v 0 0 0", + "v 1 0 0", + "v 0 1 0", + "vt 0 0", + "vt 1 0", + "vt 0 1", + "usemtl Tex", + "f -3/-3 -2/-2 -1/-1", + ].join("\n"); + const r = parseObj(obj, { materialTextures: { Tex: "img.png" } }); + + expect(r.polygons).toHaveLength(1); + expect(r.polygons[0].texture).toBe("img.png"); + expect(r.polygons[0].uvs).toEqual([[0, 0], [1, 0], [0, 1]]); + }); }); describe("parseObj — usemtl color resolution", () => { diff --git a/packages/core/src/parser/parseObj.ts b/packages/core/src/parser/parseObj.ts index c174959b..3e3fab6e 100644 --- a/packages/core/src/parser/parseObj.ts +++ b/packages/core/src/parser/parseObj.ts @@ -16,7 +16,7 @@ * `Plane` next to the actual model) that shouldn't render. * * The mesh is fit to `targetSize` units and remapped from OBJ's +Y-up - * convention to polycss's +Z-up via the cyclic permutation (x,y,z) → (z,x,y), + * convention to PolyCSS's +Z-up via the cyclic permutation (x,y,z) → (z,x,y), * which preserves handedness so triangle winding stays consistent. * * Vertex coords are kept as floats; bbox is NOT computed per-polygon @@ -115,6 +115,12 @@ export function parseObj(text: string, options?: ObjParseOptions): ParseResult { return materialColor.get(name)!; }; + const resolveIndex = (rawIndex: string, length: number): number => { + const index = parseInt(rawIndex, 10); + if (!Number.isFinite(index)) return NaN; + return index < 0 ? length + index : index - 1; + }; + const lines = text.split("\n"); for (const raw of lines) { if (raw.length === 0 || raw.charCodeAt(0) === 35) continue; // skip "" and "#" @@ -137,10 +143,10 @@ export function parseObj(text: string, options?: ObjParseOptions): ParseResult { const uvIdx: (number | null)[] = []; for (const p of parts) { const slash = p.split("/"); - idx.push(parseInt(slash[0], 10) - 1); + idx.push(resolveIndex(slash[0], verts.length)); const vtRaw = slash[1]; if (vtRaw && vtRaw.length > 0) { - const v = parseInt(vtRaw, 10) - 1; + const v = resolveIndex(vtRaw, uvs.length); uvIdx.push(Number.isFinite(v) ? v : null); } else { uvIdx.push(null); @@ -173,7 +179,7 @@ export function parseObj(text: string, options?: ObjParseOptions): ParseResult { const scale = maxDim > 0 ? targetSize / maxDim : 1; // Cyclic axis permutation (x,y,z) → (z,x,y) puts OBJ's +Y up axis into - // polycss's +Z (elevation). Single axis swaps invert handedness; a cyclic + // PolyCSS's +Z (elevation). Single axis swaps invert handedness; a cyclic // shift doesn't, so triangle CCW-from-outside winding survives. const round = (n: number) => Math.round(n * 1000) / 1000; const grid: Vec3[] = verts.map(([x, y, z]) => [ diff --git a/packages/core/src/parser/parseVox.ts b/packages/core/src/parser/parseVox.ts index 588acf74..a9b4efdf 100644 --- a/packages/core/src/parser/parseVox.ts +++ b/packages/core/src/parser/parseVox.ts @@ -14,10 +14,10 @@ * Face culling: for each voxel, each of its 6 neighbours is checked. When a * neighbour cell is empty (or out of grid bounds) the shared face is visible * and emitted as a quad polygon. - * Winding follows CCW-from-outside convention, consistent with polycss's + * Winding follows CCW-from-outside convention, consistent with PolyCSS's * backface culling. * - * Coordinate system: MagicaVoxel is Z-up — same as polycss — so no axis + * Coordinate system: MagicaVoxel is Z-up — same as PolyCSS — so no axis * permutation is needed (unlike OBJ/glTF which are Y-up and need a cyclic * swap). Voxel coordinates are always non-negative (origin at 0), so no * shift is required by default. diff --git a/packages/core/src/shadow/projection.ts b/packages/core/src/shadow/projection.ts index 12296028..1da5a6ad 100644 --- a/packages/core/src/shadow/projection.ts +++ b/packages/core/src/shadow/projection.ts @@ -32,7 +32,7 @@ export const BAKED_SHADOW_MIN_UP = 0.01; * shadow leaf. * * `lightDir` is the direction the light TRAVELS (e.g. `[0, 0, -1]` is - * straight down). Polycss world Z is up, and the world→CSS axis swap + * straight down). PolyCSS world Z is up, and the world→CSS axis swap * leaves Z alone — see styles.ts for the full convention. * * `groundCssZ` is the receiver plane in CSS-Z (= world-Z) coordinates, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e57b83fd..ff036809 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -29,7 +29,7 @@ export type MeshResolution = "lossless" | "lossy"; * and the difference adds up. Destructure with `const [x, y, z] = v` when * you need named axes. * - * Polycss world space convention: +X right, +Y forward, +Z up. + * PolyCSS world space convention: +X right, +Y forward, +Z up. */ export type Vec3 = [number, number, number]; @@ -88,7 +88,7 @@ export interface PolyAmbientLight { * * In CSS terms, a material bundles the `background-image` source plus paint * config. When a polygon references a material AND its UVs form an - * axis-aligned rectangle, polycss renders the polygon as an with + * axis-aligned rectangle, PolyCSS renders the polygon as an with * `background-image: url(material.texture)` directly — no per-polygon canvas * rasterization, browser-cached texture, mounting / unmounting one polygon * does not affect any other. @@ -100,7 +100,7 @@ export interface PolyAmbientLight { export interface PolyMaterial { /** Image source. Anything `background-image: url(...)` can use. */ texture: string; - /** Optional unique key (used by polycss to dedupe / cache). Caller can + /** Optional unique key (used by PolyCSS to dedupe / cache). Caller can * pass a stable string; if omitted, the material's identity is its object * reference. */ key?: string; @@ -143,7 +143,7 @@ export interface Polygon { /** * Shared material. When set, `material.texture` takes precedence over the * inline `texture` field. If the polygon's UVs form an axis-aligned - * rectangle, polycss uses the direct CSS background-image path (no per- + * rectangle, PolyCSS uses the direct CSS background-image path (no per- * polygon canvas rasterization). Falls back to the atlas path otherwise. */ material?: PolyMaterial; diff --git a/packages/polycss/README.md b/packages/polycss/README.md index dfee357d..5d571c90 100644 --- a/packages/polycss/README.md +++ b/packages/polycss/README.md @@ -1,14 +1,14 @@

          - polycss + PolyCSS

          -# polycss +# PolyCSS A CSS polygon mesh engine. A 3D renderer for the DOM. Renders OBJ, glTF, GLB, MagicaVoxel `.vox`, and generated primitives as real HTML elements transformed with CSS `matrix3d(...)`. Supports colors, textures, lighting, shadows, controls, selection, animation, and per-polygon interaction. Works with React, Vue, custom elements, or plain JavaScript. Visit [polycss.com](https://polycss.com) for docs and model examples. -polycss scene +PolyCSS scene ## Installation @@ -23,7 +23,7 @@ npm install @layoutit/polycss-vue npm install @layoutit/polycss ``` -You can also load polycss directly from a CDN. Here is a minimal custom-element scene: +You can also load PolyCSS directly from a CDN. Here is a minimal custom-element scene: ```html @@ -171,7 +171,7 @@ Supported formats: ## Performance -polycss renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. +PolyCSS renders in the DOM, so performance is mostly determined by how many polygons are mounted and how much texture atlas area they consume. The renderer uses several CSS strategies so simple surfaces stay cheap and textured or irregular surfaces fall back to atlas slices. - One visible polygon becomes one leaf DOM element. - Flat rectangles and stable quads use solid CSS leaves. @@ -200,7 +200,7 @@ For diagnostics, all renderer packages export `collectPolyRenderStats(root)`, wh | `@layoutit/polycss-react` | React components, hooks, controls, and core re-exports. | | `@layoutit/polycss-vue` | Vue 3 components, composables, controls, and core re-exports. | -## Made with polycss +## Made with PolyCSS [Layoutit Voxels](https://voxels.layoutit.com) -> A CSS Voxel editor diff --git a/packages/polycss/package.json b/packages/polycss/package.json index 4f8f0c2d..0d1bf8c1 100644 --- a/packages/polycss/package.json +++ b/packages/polycss/package.json @@ -1,7 +1,7 @@ { "name": "@layoutit/polycss", "version": "0.2.0", - "description": "Polycss vanilla / custom-elements + imperative API. Renders OBJ / glTF / GLB mesh polygons as DOM via CSS matrix3d.", + "description": "PolyCSS vanilla / custom-elements + imperative API. Renders OBJ / glTF / GLB mesh polygons as DOM via CSS matrix3d.", "type": "module", "main": "dist/index.cjs", "module": "dist/index.js", diff --git a/packages/polycss/src/api/createPolyCamera.ts b/packages/polycss/src/api/createPolyCamera.ts index 282704eb..c4b1c565 100644 --- a/packages/polycss/src/api/createPolyCamera.ts +++ b/packages/polycss/src/api/createPolyCamera.ts @@ -99,7 +99,7 @@ export function createPolyOrthographicCamera( /** * Ergonomic alias for `createPolyOrthographicCamera`. The default camera in - * polycss is orthographic because the engine's structural advantages + * PolyCSS is orthographic because the engine's structural advantages * (integer-pixel atlas slicing, DOM-as-render-tree) are most visible in * iso/voxel/diagrammatic scenes. Use `createPolyPerspectiveCamera` explicitly * when depth foreshortening is needed. diff --git a/packages/polycss/src/api/createPolyFirstPersonControls.ts b/packages/polycss/src/api/createPolyFirstPersonControls.ts index c9d2f775..09ea958d 100644 --- a/packages/polycss/src/api/createPolyFirstPersonControls.ts +++ b/packages/polycss/src/api/createPolyFirstPersonControls.ts @@ -190,14 +190,14 @@ export function createPolyFirstPersonControls( // True first-person model (matches three.js PointerLockControls semantics): // - `cameraOrigin` is the camera's WORLD position (the eye). // - `target` is a DERIVED point ahead of the camera along its look - // direction at offset `perspective / tile`, so polycss's perspective + // direction at offset `perspective / tile`, so PolyCSS's perspective // viewer (located at +CSS_Z from scene origin) mathematically coincides // with `cameraOrigin` in world space. // - Mouselook rotates `target` AROUND `cameraOrigin` (origin fixed) → // in-place rotation, not orbit. // - WASD moves `cameraOrigin` (target follows via the same offset). // - // Without this separation, polycss's rotation pivots around `target` itself, + // Without this separation, PolyCSS's rotation pivots around `target` itself, // which is camera position with distance=0 — that's orbit-style and reads // as "the camera circles a point in front of itself" when you mouselook. let cameraOrigin: [number, number, number] = [0, 0, opts.groundZ + opts.eyeHeight]; @@ -205,7 +205,7 @@ export function createPolyFirstPersonControls( function forwardDir(rotX: number, rotY: number): [number, number, number] { const rx = (rotX * Math.PI) / 180; const ry = (rotY * Math.PI) / 180; - // Derived from polycss's scene transform inverse: the world direction + // Derived from PolyCSS's scene transform inverse: the world direction // that maps to CSS -Z (into the screen) under `rotateX(rotX) rotate(rotY)` // + the axis swap (worldY→CSS X, worldX→CSS Y). return [ @@ -411,7 +411,7 @@ export function createPolyFirstPersonControls( } if (dirty) { - // Re-derive target from the new origin so polycss's perspective viewer + // Re-derive target from the new origin so PolyCSS's perspective viewer // tracks the camera. Without this, walking forward would move // `cameraOrigin` but target would stay put, and the visible center // would drift behind us. diff --git a/packages/polycss/src/api/createPolyScene.ts b/packages/polycss/src/api/createPolyScene.ts index 4deb79b1..7e7af732 100644 --- a/packages/polycss/src/api/createPolyScene.ts +++ b/packages/polycss/src/api/createPolyScene.ts @@ -1620,7 +1620,7 @@ export function createPolyScene( const w2 = worldCss(face.vertices[2]!, rpos); const e1: Vec3 = [w1[0] - O[0], w1[1] - O[1], w1[2] - O[2]]; const e2: Vec3 = [w2[0] - O[0], w2[1] - O[1], w2[2] - O[2]]; - // Normal = e2 × e1 (NOT e1 × e2). polycss uses an axis swap + // Normal = e2 × e1 (NOT e1 × e2). PolyCSS uses an axis swap // (world Y → CSS X) when emitting leaves, which flips // handedness. The atlas builder's outward face normal in CSS // coords is the LEFT-hand cross product (= -right-hand). For @@ -2051,7 +2051,7 @@ export function createPolyScene( // Recomputes the shadow ground plane from the minimum world-Z across all // casting meshes. World Z stays as CSS Z under the world→CSS axis swap. - // In polycss's world convention Z is up — the red-green plane in the axes + // In PolyCSS's world convention Z is up — the red-green plane in the axes // helper is the floor. An optional `lift` (in world units) raises the // plane slightly above the bbox floor to prevent z-fighting with // receiver polygons. diff --git a/packages/polycss/src/api/createSelect.ts b/packages/polycss/src/api/createSelect.ts index 591d3a8b..dc3cbe85 100644 --- a/packages/polycss/src/api/createSelect.ts +++ b/packages/polycss/src/api/createSelect.ts @@ -1,5 +1,5 @@ /** - * createSelect — additive selection layer for vanilla polycss scenes. + * createSelect — additive selection layer for vanilla PolyCSS scenes. * Mirrors the React `