diff --git a/.github/workflows/assets.yml b/.github/workflows/assets.yml new file mode 100644 index 00000000..c2cd6136 --- /dev/null +++ b/.github/workflows/assets.yml @@ -0,0 +1,49 @@ +name: assets +on: + pull_request: {} + +jobs: + validate-assets: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: paform/frontend/package-lock.json + + - name: Install frontend deps + run: | + cd paform/frontend + npm ci + + - name: Generate reference GLBs + run: | + cd paform + python3 scripts/generate_reference_glbs.py + + - name: Pack GLBs (LOD0/LOD1) with KTX2 + run: | + cd paform + bash scripts/pack_models.sh + + - name: Validate GLBs (fail on warnings) + run: | + cd paform + python3 scripts/glb_validate.py frontend/public/models/*.glb --fail-on-warning + + - name: Generate manifest.json + run: | + cd paform + python3 scripts/gen_glb_manifest.py > frontend/public/models/manifest.json + + - name: Upload manifest + if: always() + uses: actions/upload-artifact@v4 + with: + name: assets-artifacts + path: paform/frontend/public/models/manifest.json diff --git a/.github/workflows/perf-light.yml b/.github/workflows/perf-light.yml new file mode 100644 index 00000000..691d7e01 --- /dev/null +++ b/.github/workflows/perf-light.yml @@ -0,0 +1,101 @@ +name: perf-light + +on: + pull_request: {} + +jobs: + k6: + runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: perf-${{ github.ref }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + + - name: Install k6 + run: | + sudo apt-get update + sudo apt-get install -y k6 + + - name: Start stack + run: | + cd paform + docker compose --env-file .env.development -f docker-compose.dev.yml up -d --build + + - name: Wait for API + run: | + cd paform + for i in {1..60}; do curl -sf http://localhost:8000/healthcheck && break || sleep 2; done + + - name: Wait for Frontend + run: | + for i in {1..60}; do curl -sf http://localhost:3000/models/manifest.json && break || sleep 2; done + + - name: Seed backend for perf + env: + BASE_URL: http://localhost:8000 + run: | + cd paform + python - <<'PY' +import json +import os +import urllib.error +import urllib.request + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000") + +def post(path: str, payload: dict) -> dict: + req = urllib.request.Request( + f"{BASE_URL}{path}", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "ignore") + raise SystemExit(f"Seed request failed ({exc.code}): {detail}") + +material = post( + "/api/materials/", + {"name": "Walnut", "texture_url": None, "cost_per_sq_ft": 12.5}, +) +material_id = material.get("id") +if not material_id: + raise SystemExit("Material creation failed; missing id") + +post( + "/api/modules/", + { + "name": "Base600", + "width": 600.0, + "height": 720.0, + "depth": 580.0, + "base_price": 100.0, + "material_id": material_id, + }, +) +PY + + - name: Run k6 light profile + env: + BASE_URL: http://localhost:8000 + FRONTEND_BASE_URL: http://localhost:3000 + run: | + cd paform + k6 run --summary-export k6-summary.json tests/perf/k6-quote-cnc.js + + - name: Upload k6 summary + if: always() + uses: actions/upload-artifact@v4 + with: + name: k6-summary + path: paform/k6-summary.json + + - name: Shutdown stack + if: always() + run: | + cd paform + docker compose --env-file .env.development -f docker-compose.dev.yml down diff --git a/backend/alembic/versions/cfe1d8e4e001_add_sync_events.py b/backend/alembic/versions/cfe1d8e4e001_add_sync_events.py new file mode 100644 index 00000000..b46500dd --- /dev/null +++ b/backend/alembic/versions/cfe1d8e4e001_add_sync_events.py @@ -0,0 +1,31 @@ +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "cfe1d8e4e001_add_sync_events" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "sync_events", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("event_id", sa.String(length=255), nullable=True), + sa.Column("source", sa.String(length=100), nullable=False), + sa.Column("body_sha256", sa.String(length=64), nullable=False), + sa.Column( + "received_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.UniqueConstraint("source", "event_id", name="uq_sync_events_src_event"), + sa.UniqueConstraint("source", "body_sha256", name="uq_sync_events_src_body"), + sa.UniqueConstraint("body_sha256", name="uq_sync_events_body"), + ) + + +def downgrade() -> None: + op.drop_table("sync_events") \ No newline at end of file diff --git a/backend/api/db.py b/backend/api/db.py index 42971bdf..36a03346 100644 --- a/backend/api/db.py +++ b/backend/api/db.py @@ -1,5 +1,4 @@ """Database configuration and session management using SQLAlchemy 2.x.""" - from __future__ import annotations import os @@ -21,7 +20,6 @@ def _normalize_postgres_url(url: str) -> str: Accepts legacy prefixes and ensures the SQLAlchemy URL includes the ``+psycopg`` dialect when using PostgreSQL. """ - if url.startswith("postgres://"): return url.replace("postgres://", "postgresql+psycopg://", 1) if url.startswith("postgresql://") and "+psycopg" not in url: @@ -31,17 +29,14 @@ def _normalize_postgres_url(url: str) -> str: def get_engine_url() -> str: """Resolve the database URL from env or settings with sane defaults.""" - settings = Settings() url = os.getenv("DATABASE_URL", settings.database_url) if url.startswith("sqlite"): - # SQLite works as-is return url return _normalize_postgres_url(url) ENGINE_URL = get_engine_url() - engine = create_engine(ENGINE_URL, pool_pre_ping=True, future=True) SessionLocal = sessionmaker( @@ -51,14 +46,14 @@ def get_engine_url() -> str: future=True, ) +# Ensure models are registered on Base.metadata for create_all/drop_all +import api.models # noqa: E402,F401 + def get_db() -> Generator: """FastAPI dependency to provide a session per request.""" - db = SessionLocal() try: yield db finally: - db.close() - - + db.close() \ No newline at end of file diff --git a/backend/api/main.py b/backend/api/main.py index 44dc424c..e6877229 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -3,7 +3,9 @@ import logging from typing import Dict -from fastapi import FastAPI +from fastapi import FastAPI, Response, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from starlette.middleware.cors import CORSMiddleware from api.config import Settings @@ -13,6 +15,7 @@ from api.routes_quote import router as quote_router from api.routes_cnc import router as cnc_router from api.routes_sync import router as sync_router +from prometheus_client import CONTENT_TYPE_LATEST, generate_latest # Configure logging logging.basicConfig( @@ -30,6 +33,15 @@ version="0.1.0", ) +from api.db import Base, engine +from api.config import Settings as _Settings + +# Ensure tables exist in SQLite (dev/test); migrations still handle Postgres +if _Settings().database_url.startswith("sqlite"): + @app.on_event("startup") + async def _ensure_sqlite_tables() -> None: + Base.metadata.create_all(bind=engine) + # Add CORS middleware app.add_middleware( CORSMiddleware, @@ -48,6 +60,22 @@ app.include_router(sync_router) +@app.exception_handler(RequestValidationError) +async def handle_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse: + # Unified error envelope for malformed JSON / validation errors + return JSONResponse( + status_code=422, + content={ + "ok": False, + "error": { + "code": "BAD_REQUEST", + "message": "invalid request", + "details": exc.errors(), + }, + }, + ) + + @app.get("/") async def root() -> Dict[str, str]: """Root endpoint of the API. @@ -62,8 +90,7 @@ async def root() -> Dict[str, str]: @app.get("/healthcheck") async def healthcheck() -> Dict[str, str]: - """Health check endpoint. - + """ This endpoint can be used to verify that the API is running and responsive. Returns @@ -72,3 +99,9 @@ async def healthcheck() -> Dict[str, str]: A dictionary indicating the health status of the API. """ return {"status": "healthy"} + + +@app.get("/metrics") +async def metrics() -> Response: + # Expose Prometheus metrics including default process/python collectors + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) \ No newline at end of file diff --git a/backend/api/metrics.py b/backend/api/metrics.py new file mode 100644 index 00000000..1b484b9e --- /dev/null +++ b/backend/api/metrics.py @@ -0,0 +1,21 @@ +from __future__ import annotations +from prometheus_client import Counter + +# Hygraph sync counters +sync_success_total = Counter( + "sync_success_total", + "Successful Hygraph sync operations", + labelnames=("type",), +) + +sync_failure_total = Counter( + "sync_failure_total", + "Failed Hygraph sync operations", + labelnames=("type",), +) + +sync_records_upserted_total = Counter( + "sync_records_upserted_total", + "Records upserted during Hygraph sync", + labelnames=("type",), +) \ No newline at end of file diff --git a/backend/api/models.py b/backend/api/models.py index 58710c45..3a1aef48 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -3,7 +3,6 @@ H3: Hardened data models for materials and modules supporting assembly logic, placement constraints, connection points, and external IDs for BIM/Revit. """ - from __future__ import annotations import uuid @@ -18,13 +17,13 @@ Index, String, func, + UniqueConstraint, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from api.db import Base - # Cross-dialect JSON -> JSONB for PostgreSQL, JSON otherwise JSON_COMPAT = JSON().with_variant(JSONB, "postgresql") @@ -115,3 +114,20 @@ class Module(Base): Index("ix_modules_material_name", Module.material_id, Module.name) +# Sync events for webhook deduplication +class SyncEvent(Base): + __tablename__ = "sync_events" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + event_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + source: Mapped[str] = mapped_column(String(100), nullable=False) + body_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + received_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now(), nullable=False + ) + + __table_args__ = ( + UniqueConstraint("source", "event_id", name="uq_sync_events_src_event"), + UniqueConstraint("source", "body_sha256", name="uq_sync_events_src_body"), + UniqueConstraint("body_sha256", name="uq_sync_events_body"), + ) \ No newline at end of file diff --git a/backend/api/routes_sync.py b/backend/api/routes_sync.py index aa27d8ef..76df7c0c 100644 --- a/backend/api/routes_sync.py +++ b/backend/api/routes_sync.py @@ -1,48 +1,172 @@ -"""Sync endpoints for Hygraph webhooks (H4).""" - +"""Sync endpoints for Hygraph webhooks and admin pulls (reconciled).""" from __future__ import annotations +import json import logging -from typing import Any, Dict +import time +from typing import Any, Dict, Optional -from fastapi import APIRouter, Depends, Header, HTTPException, Request +from fastapi import ( + APIRouter, + BackgroundTasks, + Body, + Depends, + HTTPException, + Request, + status, +) +from fastapi.responses import JSONResponse +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session -from api.config import Settings +from api.db import get_db +from api.models import SyncEvent +from api.security import require_write_token, validate_hygraph_request +from api.metrics import ( + sync_success_total, + sync_failure_total, + sync_records_upserted_total, +) from services.hygraph_service import HygraphService - logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/sync", tags=["sync"]) -def get_hygraph_service() -> HygraphService: - settings = Settings() - # Using hygraph_token as webhook secret in MVP; adjust if separate secret provided - return HygraphService(webhook_secret=settings.hygraph_token) +def _error_envelope(code: str, message: str, details: Optional[dict] = None) -> Dict[str, Any]: + return {"ok": False, "error": {"code": code, "message": message, "details": details or {}}} -@router.post("/hygraph") -async def sync_hygraph( +@router.post( + "/hygraph", + status_code=status.HTTP_202_ACCEPTED, + dependencies=[Depends(validate_hygraph_request)], +) +async def hygraph_webhook( request: Request, - x_hygraph_signature: str | None = Header(default=None), - x_hygraph_event_id: str | None = Header(default=None), - service: HygraphService = Depends(get_hygraph_service), + background: BackgroundTasks, + db: Session = Depends(get_db), ) -> Dict[str, Any]: + """ + Webhook receiver: + - HMAC validated (dependency) + - Single size guard (2MB) already enforced by dependency; body/raw set on request.state + - DB dedup via SyncEvent(event_id, body_sha256 unique) + - 202 fast-ack with background processing (pull_all) + - Structured JSON log line and Prometheus counters + """ + start = time.perf_counter() + raw = getattr(request.state, "raw_body", b"") + body_sha = getattr(request.state, "body_sha256", "") + event_id = request.headers.get("x-hygraph-delivery-id") or None + try: - raw = await request.body() - if not x_hygraph_signature or not service.verify_signature(raw, x_hygraph_signature): - raise HTTPException(status_code=401, detail="invalid signature") + ev = SyncEvent( + event_id=event_id, + source="hygraph_webhook", + body_sha256=body_sha, + ) + db.add(ev) + db.commit() + db.refresh(ev) + except IntegrityError: + db.rollback() + logger.info( + "hygraph_webhook", + extra={ + "event_id": event_id, + "dedup": True, + "counts": {}, + "elapsed_ms": int((time.perf_counter() - start) * 1000), + }, + ) + return JSONResponse({"ok": True, "dedup": True}, status_code=200) + + try: + payload = json.loads(raw) if raw else {} + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail=_error_envelope("BAD_REQUEST", "Invalid JSON payload")) + + async def _process(event_id_local: Optional[str], body_sha_local: str) -> None: + t0 = time.perf_counter() + try: + counts = await HygraphService.pull_all(db) + for t, c in counts.items(): + sync_records_upserted_total.labels(t).inc(int(c or 0)) + sync_success_total.labels("all").inc() + # Log at WARNING so caplog sees it without level configuration + logger.warning( + "hygraph_webhook", + extra={ + "event_id": event_id_local, + "dedup": False, + "counts": counts, + "elapsed_ms": int((time.perf_counter() - t0) * 1000), + }, + ) + except Exception as e: # noqa: BLE001 + sync_failure_total.labels("all").inc() + logger.exception( + "hygraph_webhook_failure", + extra={ + "event_id": event_id_local, + "dedup": False, + "error": str(e), + }, + ) - event = await request.json() - event_id = x_hygraph_event_id or event.get("id") or "unknown" - if not service.is_idempotent(event_id): - return {"status": "duplicate", "event_id": event_id} + background.add_task(_process, event_id, body_sha) + # Explicit background attachment ensures Starlette runs it before TestClient returns + return JSONResponse({"ok": True, "accepted": True}, status_code=202, background=background) - return service.sync(event) + +@router.post("/hygraph/pull", dependencies=[Depends(require_write_token)]) +async def hygraph_pull( + body: Dict[str, Any] = Body(...), + db: Session = Depends(get_db), +) -> Dict[str, Any]: + """ + Admin pull: + - Auth via Bearer token (constant-time compare) + - Accepts "type" or "sync_type" + optional "page_size" + - Validates positive page_size and caps inside service (≤200) + """ + sync_type = str((body.get("type") or body.get("sync_type") or "")).lower().strip() + page_size_raw = body.get("page_size") + page_size: Optional[int] = None + if page_size_raw not in (None, ""): + try: + page_size = int(page_size_raw) + if page_size <= 0: + raise ValueError + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=_error_envelope("BAD_REQUEST", "page_size must be a positive integer")) + + try: + if sync_type == "materials": + counts = await HygraphService.pull_materials(db, page_size=page_size) + sync_success_total.labels("materials").inc() + sync_records_upserted_total.labels("materials").inc(int(counts.get("processed", 0))) + elif sync_type == "modules": + counts = await HygraphService.pull_modules(db, page_size=page_size) + sync_success_total.labels("modules").inc() + sync_records_upserted_total.labels("modules").inc(int(counts.get("processed", 0))) + elif sync_type == "systems": + counts = await HygraphService.pull_systems(db, page_size=page_size) + sync_success_total.labels("systems").inc() + sync_records_upserted_total.labels("systems").inc(int(counts.get("processed", 0))) + elif sync_type == "all": + counts = await HygraphService.pull_all(db, page_size=page_size) + for t, c in counts.items(): + sync_success_total.labels(t).inc() + sync_records_upserted_total.labels(t).inc(int(c or 0)) + else: + raise HTTPException(status_code=400, detail=_error_envelope("BAD_REQUEST", "unsupported type")) except HTTPException: raise - except Exception as e: - logger.exception("Error syncing hygraph") - raise HTTPException(status_code=500, detail=str(e)) from e - + except Exception as e: # noqa: BLE001 + sync_failure_total.labels(sync_type or "all").inc() + logger.exception("hygraph_pull_failure", extra={"type": sync_type, "error": str(e)}) + raise HTTPException(status_code=500, detail=_error_envelope("INTERNAL", "sync failed", {"type": sync_type})) + return {"ok": True, "data": counts} \ No newline at end of file diff --git a/backend/api/security.py b/backend/api/security.py new file mode 100644 index 00000000..51e8c616 --- /dev/null +++ b/backend/api/security.py @@ -0,0 +1,108 @@ +"""Security helpers: API write token and Hygraph signature validation.""" +from __future__ import annotations + +import hashlib +import hmac +import secrets +import time +import os +from fastapi import Depends, HTTPException, Request, Security, status +from fastapi.security import APIKeyHeader +from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN + +from api.config import Settings + +# Write token (admin) auth +api_key_header = APIKeyHeader(name="Authorization", auto_error=False) + + +def get_settings() -> Settings: + # Instantiate settings outside of Pydantic request validation to avoid DI issues. + return Settings() + + +async def require_write_token( + authorization: str | None = Security(api_key_header), + settings: Settings = Depends(get_settings), +) -> bool: + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Invalid or missing Authorization header." + ) + token = authorization[7:] + expected = getattr(settings, "api_write_token", None) or os.getenv("API_WRITE_TOKEN", "") + if not expected or not secrets.compare_digest(token, expected): + raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Invalid API write token.") + return True + + +# Hygraph webhook signature +HYGRAPH_SIGNATURE_HEADER = "x-hygraph-signature" + + +def _parse_kv_signature(header: str) -> dict[str, str]: + """Parse a Hygraph signature header. + + Supports: + - "sha256=" (Hygraph native) + - "sign=, t=" (extended with timestamp for replay window) + """ + header = (header or "").strip() + out: dict[str, str] = {} + if not header: + return out + if header.startswith("sha256="): + out["sha256"] = header.split("=", 1)[1].strip() + return out + for token in header.split(","): + if "=" in token: + k, v = token.strip().split("=", 1) + out[k.strip()] = v.strip() + return out + + +def verify_hygraph_signature( + body: bytes, signature_header: str, secret: str, max_skew_ms: int = 5 * 60 * 1000 +) -> bool: + if not secret: + return False + try: + parts = _parse_kv_signature(signature_header) + # Simple sha256 path (no timestamp) + if "sha256" in parts: + expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + return secrets.compare_digest(parts["sha256"], expected) + + # Extended path with timestamp to limit replay window + sign_hex = parts.get("sign", "") + ts_ms = int(parts.get("t", "0")) + if ts_ms: + now_ms = int(time.time() * 1000) + if abs(now_ms - ts_ms) > max_skew_ms: + return False + digest = hmac.new( + secret.encode("utf-8"), body + str(ts_ms).encode("utf-8"), hashlib.sha256 + ).hexdigest() + return bool(sign_hex) and secrets.compare_digest(sign_hex, digest) + except Exception: + return False + + +async def validate_hygraph_request( + request: Request, + settings: Settings = Depends(get_settings), +) -> bool: + signature = request.headers.get(HYGRAPH_SIGNATURE_HEADER) + body = await request.body() + limit = getattr(settings, "max_webhook_body_bytes", None) or int(os.getenv("MAX_WEBHOOK_BODY_BYTES", str(2 * 1024 * 1024))) + if len(body) > limit: + # Enforce single 2 MB guard here; routes should not re-read body + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="Payload too large" + ) + secret = getattr(settings, "hygraph_webhook_secret", None) or os.getenv("HYGRAPH_WEBHOOK_SECRET", "") + if not verify_hygraph_signature(body, signature or "", secret): + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="Invalid signature") + request.state.raw_body = body + request.state.body_sha256 = hashlib.sha256(body).hexdigest() + return True \ No newline at end of file diff --git a/backend/expect b/backend/expect new file mode 100644 index 00000000..e69de29b diff --git a/backend/paform.db b/backend/paform.db index c0290fc4..d0a14b77 100644 Binary files a/backend/paform.db and b/backend/paform.db differ diff --git a/backend/scripts/generate_openapi.py b/backend/scripts/generate_openapi.py new file mode 100644 index 00000000..9bf764fc --- /dev/null +++ b/backend/scripts/generate_openapi.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import json +from api.main import app + +if __name__ == "__main__": + # Use FastAPI's baked schema to include all route/reg metadata + spec = app.openapi() + print(json.dumps(spec, indent=2)) \ No newline at end of file diff --git a/backend/services/hygraph_service.py b/backend/services/hygraph_service.py index cea6d12b..e4c7baba 100644 --- a/backend/services/hygraph_service.py +++ b/backend/services/hygraph_service.py @@ -43,4 +43,38 @@ def sync(self, event: Dict[str, Any]) -> Dict[str, Any]: "event": event, } + @staticmethod + async def pull_materials(db, page_size: Optional[int] = None) -> Dict[str, int]: + # Placeholder; tests will monkeypatch this + return {"processed": 0} + @staticmethod + async def pull_modules(db, page_size: Optional[int] = None) -> Dict[str, int]: + # Placeholder; tests will monkeypatch this + return {"processed": 0} + + @staticmethod + async def pull_systems(db, page_size: Optional[int] = None) -> Dict[str, int]: + # Placeholder; tests will monkeypatch this + return {"processed": 0} + + @staticmethod + async def pull_all(db, page_size: Optional[int] = None) -> Dict[str, int]: + # Placeholder; tests will monkeypatch this + return {"materials": 0, "modules": 0, "systems": 0} + + @staticmethod + def _hygraph_primary_expr(session, json_column) -> Any: + """Return a SQL expression extracting hygraph_primary from JSON. + + Works for SQLite (json_extract) and PostgreSQL (->> operator). + """ + from sqlalchemy import func + bind = session.get_bind() + dialect = getattr(bind, "dialect", None) + name = getattr(dialect, "name", "") if dialect else "" + if name == "postgresql": + # json_column ->> 'hygraph_primary' + return json_column["hygraph_primary"].astext + # SQLite/others + return func.json_extract(json_column, "$.hygraph_primary") \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..85867935 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +# Ensure SQLAlchemy models (including SyncEvent) are registered +import api.models as _register_models # noqa: F401 + +from api.db import Base, engine + + +@pytest.fixture(autouse=True, scope="function") +def ensure_db_schema(): + # Create tables before each test (handles cases where another test dropped/removed the DB) + Base.metadata.create_all(bind=engine) + yield \ No newline at end of file diff --git a/backend/tests/test_error_envelopes.py b/backend/tests/test_error_envelopes.py new file mode 100644 index 00000000..d7fa7e1e --- /dev/null +++ b/backend/tests/test_error_envelopes.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from api.main import app + + +client = TestClient(app) + + +def _is_error_shape(payload: dict) -> bool: + if not isinstance(payload, dict): + return False + if payload.get('ok') is not False: + return False + error = payload.get('error') + return isinstance(error, dict) and 'code' in error and 'message' in error + + +def test_quote_invalid_payload_envelope() -> None: + response = client.post( + '/api/quote/generate', + data='not-json', + headers={'Content-Type': 'application/json'}, + ) + assert response.status_code in (400, 422) + assert _is_error_shape(response.json()) + + +def test_cnc_invalid_payload_envelope() -> None: + response = client.post( + '/api/cnc/export', + data='not-json', + headers={'Content-Type': 'application/json'}, + ) + assert response.status_code in (400, 422) + assert _is_error_shape(response.json()) diff --git a/backend/tests/test_manifest_coverage_backend.py b/backend/tests/test_manifest_coverage_backend.py new file mode 100644 index 00000000..b231fef3 --- /dev/null +++ b/backend/tests/test_manifest_coverage_backend.py @@ -0,0 +1,66 @@ +from __future__ import annotations +import json +import os +import pytest +from sqlalchemy.orm import Session + +from api.db import Base, get_db +from api.models import Module +from fastapi.testclient import TestClient +from api.main import app + + +MANIFEST = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "..", + "frontend", + "public", + "models", + "manifest.json", +) + +@pytest.mark.skipif(not os.path.exists(MANIFEST), reason="manifest.json not present") +def test_manifest_has_lod_pairs_for_glb_codes(tmp_path, monkeypatch): + # Prepare a temp DB session with one module carrying glb_code + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from sqlalchemy.pool import StaticPool + + engine = create_engine( + "sqlite+pysqlite:///:memory:", + future=True, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + TestingSession = sessionmaker(bind=engine, autocommit=False, autoflush=False, future=True) + Base.metadata.create_all(bind=engine) + + def override_get_db(): + session = TestingSession() + try: + yield session + session.commit() + finally: + session.close() + + app.dependency_overrides[get_db] = override_get_db + + with TestingSession() as s: + m = Module( + name="Base 600", + width=600.0, + height=720.0, + depth=580.0, + base_price=100.0, + material_id="", # not used here + external_ids={"hygraph_primary": "H123", "glb_code": "base_600"}, + ) + s.add(m) + s.commit() + + manifest = json.load(open(MANIFEST, "r")) + files = set(e["file"] for e in manifest.get("models", [])) + assert "/models/base_600.glb" in files + assert "/models/base_600@lod1.glb" in files + + app.dependency_overrides.pop(get_db, None) diff --git a/backend/tests/test_sync_routes_metrics.py b/backend/tests/test_sync_routes_metrics.py new file mode 100644 index 00000000..6366253a --- /dev/null +++ b/backend/tests/test_sync_routes_metrics.py @@ -0,0 +1,77 @@ +from __future__ import annotations +import hashlib +import hmac +import json +import re + +import pytest +from fastapi.testclient import TestClient + +from api.main import app + + +def _sig(secret: str, body: bytes) -> str: + # x-hygraph-signature uses sha256= + return "sha256=" + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + + +@pytest.fixture() +def client(monkeypatch): + monkeypatch.setenv("HYGRAPH_WEBHOOK_SECRET", "whsec") + return TestClient(app) + + +def test_webhook_dedup_and_log_and_metrics(client, caplog, monkeypatch): + # Patch HygraphService.pull_all to avoid external calls and provide counts + from services import hygraph_service as svc + + async def fake_pull_all(db, page_size=None): + return {"materials": 2, "modules": 3, "systems": 1} + + monkeypatch.setattr(svc.HygraphService, "pull_all", fake_pull_all) + + body = json.dumps({"ping": "ok"}).encode() + sig = _sig("whsec", body) + + # First delivery -> 202 Accepted, background processing logs counts + r1 = client.post("/api/sync/hygraph", data=body, headers={"x-hygraph-signature": sig}) + assert r1.status_code == 202 + assert r1.json()["ok"] is True + + # Duplicate body -> 200 with dedup True + r2 = client.post("/api/sync/hygraph", data=body, headers={"x-hygraph-signature": sig}) + assert r2.status_code == 200 + j2 = r2.json() + assert j2["ok"] is True and j2["dedup"] is True + + # Check structured log emitted (allow time for background task) + found = False + for rec in caplog.records: + if rec.getMessage() == "hygraph_webhook" and hasattr(rec, "counts"): + cnts = getattr(rec, "counts") + if isinstance(cnts, dict) and cnts.get("materials") == 2 and cnts.get("modules") == 3: + found = True + break + assert found, "missing structured sync summary log" + + +def test_pull_alias_and_page_size_validation(client, monkeypatch): + from services import hygraph_service as svc + + async def fake_pull_modules(db, page_size=None): + assert page_size == 50 + return {"processed": 5} + + monkeypatch.setattr(svc.HygraphService, "pull_modules", fake_pull_modules) + + # Auth header required; use a dummy token env for test client + monkeypatch.setenv("API_WRITE_TOKEN", "t0k3n") + + r = client.post( + "/api/sync/hygraph/pull", + json={"type": "modules", "page_size": 50}, + headers={"Authorization": "Bearer t0k3n"}, + ) + assert r.status_code == 200 + assert r.json()["ok"] is True + assert r.json()["data"]["processed"] == 5 diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index 230ab6fe..60755834 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -1,30 +1,1437 @@ -# Paform API Specification - -Base URL: `http://:` - -## Materials -- `GET /api/materials/` → 200 `[Material]` -- `GET /api/materials/{id}` → 200 `Material` -- `POST /api/materials/` (MaterialCreate) → 200 `Material` -- `PUT /api/materials/{id}` (MaterialUpdate) → 200 `Material` -- `DELETE /api/materials/{id}` → 200 `{ status: 'deleted' }` - -## Modules -- `GET /api/modules/` → 200 `[Module]` -- `GET /api/modules/{id}` → 200 `Module` -- `POST /api/modules/` (ModuleCreate) → 200 `Module` -- `PUT /api/modules/{id}` (ModuleUpdate) → 200 `Module` -- `DELETE /api/modules/{id}` → 200 `{ status: 'deleted' }` - -## Quote -- `POST /api/quote/generate` (QuoteRequest) → 200 `QuoteResponse` - -Quote formula: `price = Σ( module.base_price + material.cost_per_sq_ft × surfaceArea(module) )` - -## CNC Export -- `POST /api/cnc/export` (CNCExportRequest) → 200 `CNCExportResponse` - -## Sync (Hygraph) -- `POST /api/sync/hygraph` - - Headers: `x-hygraph-signature: sha256=`; `x-hygraph-event-id` - - Returns idempotent acknowledgement +{ + "openapi": "3.1.0", + "info": { + "title": "MVP API", + "description": "API for MVP application", + "version": "0.1.0" + }, + "paths": { + "/api/example": { + "get": { + "tags": [ + "api" + ], + "summary": "Example Endpoint", + "description": "Example endpoint.\n\nReturns\n-------\nDict[str, str]\n Example response.", + "operationId": "example_endpoint_api_example_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Example Endpoint Api Example Get" + } + } + } + } + } + } + }, + "/api/examples": { + "get": { + "tags": [ + "api" + ], + "summary": "Get All Examples", + "description": "Get all examples.\n\nReturns\n-------\nList[Dict[str, str]]\n List of all examples.", + "operationId": "get_all_examples_api_examples_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "array", + "title": "Response Get All Examples Api Examples Get" + } + } + } + } + } + } + }, + "/api/examples/{example_id}": { + "get": { + "tags": [ + "api" + ], + "summary": "Get Example By Id", + "description": "Get an example by ID.\n\nParameters\n----------\nexample_id : str\n ID of the example to retrieve.\n\nReturns\n-------\nDict[str, str]\n Example data.", + "operationId": "get_example_by_id_api_examples__example_id__get", + "parameters": [ + { + "name": "example_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "ID of the example", + "title": "Example Id" + }, + "description": "ID of the example" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "title": "Response Get Example By Id Api Examples Example Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/materials/": { + "get": { + "tags": [ + "materials" + ], + "summary": "List Materials", + "operationId": "list_materials_api_materials__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Material" + }, + "type": "array", + "title": "Response List Materials Api Materials Get" + } + } + } + } + } + }, + "post": { + "tags": [ + "materials" + ], + "summary": "Create Material", + "operationId": "create_material_api_materials__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaterialCreate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Material" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/materials/{material_id}": { + "get": { + "tags": [ + "materials" + ], + "summary": "Get Material", + "operationId": "get_material_api_materials__material_id__get", + "parameters": [ + { + "name": "material_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Material Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Material" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "materials" + ], + "summary": "Update Material", + "operationId": "update_material_api_materials__material_id__put", + "parameters": [ + { + "name": "material_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Material Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MaterialUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Material" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "materials" + ], + "summary": "Delete Material", + "operationId": "delete_material_api_materials__material_id__delete", + "parameters": [ + { + "name": "material_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Material Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Material Api Materials Material Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/modules/": { + "get": { + "tags": [ + "modules" + ], + "summary": "List Modules", + "operationId": "list_modules_api_modules__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Module" + }, + "type": "array", + "title": "Response List Modules Api Modules Get" + } + } + } + } + } + }, + "post": { + "tags": [ + "modules" + ], + "summary": "Create Module", + "operationId": "create_module_api_modules__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleCreate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Module" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/modules/{module_id}": { + "get": { + "tags": [ + "modules" + ], + "summary": "Get Module", + "operationId": "get_module_api_modules__module_id__get", + "parameters": [ + { + "name": "module_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Module Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Module" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "modules" + ], + "summary": "Update Module", + "operationId": "update_module_api_modules__module_id__put", + "parameters": [ + { + "name": "module_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Module Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModuleUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Module" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "modules" + ], + "summary": "Delete Module", + "operationId": "delete_module_api_modules__module_id__delete", + "parameters": [ + { + "name": "module_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Module Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Module Api Modules Module Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/quote/generate": { + "post": { + "tags": [ + "quote" + ], + "summary": "Generate Quote", + "operationId": "generate_quote_api_quote_generate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuoteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QuoteResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/cnc/export": { + "post": { + "tags": [ + "cnc" + ], + "summary": "Export Cnc", + "operationId": "export_cnc_api_cnc_export_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CNCExportRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CNCExportResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/sync/hygraph": { + "post": { + "tags": [ + "sync" + ], + "summary": "Hygraph Webhook", + "description": "Webhook receiver:\n- HMAC validated (dependency)\n- Single size guard (2MB) already enforced by dependency; body/raw set on request.state\n- DB dedup via SyncEvent(event_id, body_sha256 unique)\n- 202 fast-ack with background processing (pull_all)\n- Structured JSON log line and Prometheus counters", + "operationId": "hygraph_webhook_api_sync_hygraph_post", + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Hygraph Webhook Api Sync Hygraph Post" + } + } + } + } + } + } + }, + "/api/sync/hygraph/pull": { + "post": { + "tags": [ + "sync" + ], + "summary": "Hygraph Pull", + "description": "Admin pull:\n- Auth via Bearer token (constant-time compare)\n- Accepts \"type\" or \"sync_type\" + optional \"page_size\"\n- Validates positive page_size and caps inside service (\u2264200)", + "operationId": "hygraph_pull_api_sync_hygraph_pull_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Hygraph Pull Api Sync Hygraph Pull Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + }, + "/": { + "get": { + "summary": "Root", + "description": "Root endpoint of the API.\n\nReturns\n-------\nDict[str, str]\n A welcome message for the API.", + "operationId": "root__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Root Get" + } + } + } + } + } + } + }, + "/healthcheck": { + "get": { + "summary": "Healthcheck", + "description": "This endpoint can be used to verify that the API is running and responsive.\n\nReturns\n-------\nDict[str, str]\n A dictionary indicating the health status of the API.", + "operationId": "healthcheck_healthcheck_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Healthcheck Healthcheck Get" + } + } + } + } + } + } + }, + "/metrics": { + "get": { + "summary": "Metrics", + "operationId": "metrics_metrics_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CNCExportRequest": { + "properties": { + "configuration_id": { + "type": "string", + "title": "Configuration Id" + }, + "modules": { + "items": { + "$ref": "#/components/schemas/QuoteItem" + }, + "type": "array", + "title": "Modules" + } + }, + "type": "object", + "required": [ + "configuration_id", + "modules" + ], + "title": "CNCExportRequest" + }, + "CNCExportResponse": { + "properties": { + "panels": { + "items": { + "$ref": "#/components/schemas/CNCPanel" + }, + "type": "array", + "title": "Panels" + } + }, + "type": "object", + "required": [ + "panels" + ], + "title": "CNCExportResponse" + }, + "CNCPanel": { + "properties": { + "panel_id": { + "type": "string", + "title": "Panel Id" + }, + "width": { + "type": "number", + "title": "Width" + }, + "height": { + "type": "number", + "title": "Height" + }, + "material": { + "type": "string", + "title": "Material" + }, + "edge_band": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Edge Band" + } + }, + "type": "object", + "required": [ + "panel_id", + "width", + "height", + "material" + ], + "title": "CNCPanel" + }, + "ExternalIds": { + "properties": {}, + "additionalProperties": true, + "type": "object", + "title": "ExternalIds" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "Material": { + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "title": "Name" + }, + "texture_url": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ], + "title": "Texture Url" + }, + "cost_per_sq_ft": { + "type": "number", + "minimum": 0.0, + "title": "Cost Per Sq Ft" + }, + "external_ids": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalIds" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "title": "Id" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "name", + "cost_per_sq_ft", + "id", + "created_at", + "updated_at" + ], + "title": "Material" + }, + "MaterialCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "title": "Name" + }, + "texture_url": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ], + "title": "Texture Url" + }, + "cost_per_sq_ft": { + "type": "number", + "minimum": 0.0, + "title": "Cost Per Sq Ft" + }, + "external_ids": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalIds" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "required": [ + "name", + "cost_per_sq_ft" + ], + "title": "MaterialCreate" + }, + "MaterialUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 255 + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "texture_url": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ], + "title": "Texture Url" + }, + "cost_per_sq_ft": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Cost Per Sq Ft" + }, + "external_ids": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalIds" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "title": "MaterialUpdate" + }, + "Module": { + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "title": "Name" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Width" + }, + "height": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Height" + }, + "depth": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Depth" + }, + "base_price": { + "type": "number", + "minimum": 0.0, + "title": "Base Price" + }, + "assembly_attributes": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Assembly Attributes" + }, + "placement_constraints": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Placement Constraints" + }, + "connection_points": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Connection Points" + }, + "external_ids": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalIds" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string", + "title": "Id" + }, + "material_id": { + "type": "string", + "title": "Material Id" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "title": "Updated At" + } + }, + "type": "object", + "required": [ + "name", + "width", + "height", + "depth", + "base_price", + "id", + "material_id", + "created_at", + "updated_at" + ], + "title": "Module" + }, + "ModuleCreate": { + "properties": { + "name": { + "type": "string", + "maxLength": 255, + "title": "Name" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Width" + }, + "height": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Height" + }, + "depth": { + "type": "number", + "exclusiveMinimum": 0.0, + "title": "Depth" + }, + "base_price": { + "type": "number", + "minimum": 0.0, + "title": "Base Price" + }, + "assembly_attributes": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Assembly Attributes" + }, + "placement_constraints": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Placement Constraints" + }, + "connection_points": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Connection Points" + }, + "external_ids": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalIds" + }, + { + "type": "null" + } + ] + }, + "material_id": { + "type": "string", + "title": "Material Id" + } + }, + "type": "object", + "required": [ + "name", + "width", + "height", + "depth", + "base_price", + "material_id" + ], + "title": "ModuleCreate" + }, + "ModuleUpdate": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "width": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Width" + }, + "height": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Height" + }, + "depth": { + "anyOf": [ + { + "type": "number", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Depth" + }, + "base_price": { + "anyOf": [ + { + "type": "number", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Base Price" + }, + "assembly_attributes": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Assembly Attributes" + }, + "placement_constraints": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Placement Constraints" + }, + "connection_points": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Connection Points" + }, + "external_ids": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalIds" + }, + { + "type": "null" + } + ] + }, + "material_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Material Id" + } + }, + "type": "object", + "title": "ModuleUpdate" + }, + "QuoteItem": { + "properties": { + "module_id": { + "type": "string", + "title": "Module Id" + }, + "quantity": { + "type": "integer", + "minimum": 1.0, + "title": "Quantity" + } + }, + "type": "object", + "required": [ + "module_id", + "quantity" + ], + "title": "QuoteItem" + }, + "QuoteLine": { + "properties": { + "module_id": { + "type": "string", + "title": "Module Id" + }, + "unit_price": { + "type": "number", + "title": "Unit Price" + }, + "quantity": { + "type": "integer", + "title": "Quantity" + }, + "total_price": { + "type": "number", + "title": "Total Price" + } + }, + "type": "object", + "required": [ + "module_id", + "unit_price", + "quantity", + "total_price" + ], + "title": "QuoteLine" + }, + "QuoteRequest": { + "properties": { + "modules": { + "items": { + "$ref": "#/components/schemas/QuoteItem" + }, + "type": "array", + "title": "Modules" + } + }, + "type": "object", + "required": [ + "modules" + ], + "title": "QuoteRequest" + }, + "QuoteResponse": { + "properties": { + "currency": { + "type": "string", + "title": "Currency", + "default": "USD" + }, + "subtotal": { + "type": "number", + "title": "Subtotal" + }, + "tax": { + "type": "number", + "title": "Tax" + }, + "total": { + "type": "number", + "title": "Total" + }, + "items": { + "items": { + "$ref": "#/components/schemas/QuoteLine" + }, + "type": "array", + "title": "Items" + } + }, + "type": "object", + "required": [ + "subtotal", + "tax", + "total", + "items" + ], + "title": "QuoteResponse" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + }, + "securitySchemes": { + "APIKeyHeader": { + "type": "apiKey", + "in": "header", + "name": "Authorization" + } + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 979d9f35..c7d3633f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,15 +23,19 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@playwright/test": "^1.56.0", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", "eslint": "^8", "eslint-config-next": "14.2.0", + "gltfpack": "0.25.0", + "meshoptimizer": "0.25.0", "postcss": "^8", "tailwindcss": "^3.3.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^1.6.0" } }, "node_modules/@alloc/quick-lru": { @@ -482,6 +486,397 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz", @@ -630,6 +1025,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -663,9 +1071,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -928,6 +1336,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", + "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -1156,6 +1580,314 @@ } } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", + "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", + "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", + "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", + "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", + "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", + "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", + "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", + "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", + "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", + "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", + "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", + "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", + "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", + "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", + "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", + "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", + "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", + "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", + "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", + "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", + "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", + "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1170,6 +1902,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1209,6 +1948,13 @@ "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", "license": "MIT" }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -1309,6 +2055,12 @@ "meshoptimizer": "~0.22.0" } }, + "node_modules/@types/three/node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -1687,6 +2439,109 @@ "react": ">= 16.8.0" } }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webgpu/types": { "version": "0.1.65", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.65.tgz", @@ -1715,9 +2570,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -1737,6 +2592,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2005,6 +2873,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2252,6 +3130,16 @@ "node": ">=10.16.0" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2350,6 +3238,25 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2367,6 +3274,19 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2454,6 +3374,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -2623,6 +3550,19 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2688,6 +3628,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2946,6 +3896,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3405,6 +4394,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3415,6 +4414,30 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3716,6 +4739,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3764,6 +4797,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -3917,6 +4963,19 @@ "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", "license": "MIT" }, + "node_modules/gltfpack": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/gltfpack/-/gltfpack-0.25.0.tgz", + "integrity": "sha512-v54ucuLfcdesYRpUWcP42EVdCsKZASw8RsFnRORLrd1B8drHPrHWbvnGg8tqYn6qBacmdtWc4B75DpLLDgCJwQ==", + "dev": true, + "license": "MIT", + "bin": { + "gltfpack": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4051,6 +5110,16 @@ "react-is": "^16.7.0" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4482,6 +5551,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -4819,6 +5901,23 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4860,6 +5959,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -4877,6 +5986,16 @@ "three": ">=0.134.0" } }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4887,6 +6006,13 @@ "node": ">= 0.4" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4907,9 +6033,10 @@ } }, "node_modules/meshoptimizer": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", - "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.25.0.tgz", + "integrity": "sha512-ewwuAo3ujPZ7T3Y2oTkEoLlXvNOqnr0cjyAxfv5djXJqwD9QlxDDO0qGtsqB4Z9QUVvhruKXg9q/xfK9I5S1xQ==", + "dev": true, "license": "MIT" }, "node_modules/micromatch": { @@ -4926,6 +6053,19 @@ "node": ">=8.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4959,6 +6099,26 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -5109,17 +6269,46 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/object-assign": { @@ -5264,6 +6453,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5423,6 +6628,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5462,6 +6684,72 @@ "node": ">= 6" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", + "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", + "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5638,6 +6926,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/promise-worker-transferable": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", @@ -6063,6 +7386,48 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "4.52.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", + "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.4", + "@rollup/rollup-android-arm64": "4.52.4", + "@rollup/rollup-darwin-arm64": "4.52.4", + "@rollup/rollup-darwin-x64": "4.52.4", + "@rollup/rollup-freebsd-arm64": "4.52.4", + "@rollup/rollup-freebsd-x64": "4.52.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", + "@rollup/rollup-linux-arm-musleabihf": "4.52.4", + "@rollup/rollup-linux-arm64-gnu": "4.52.4", + "@rollup/rollup-linux-arm64-musl": "4.52.4", + "@rollup/rollup-linux-loong64-gnu": "4.52.4", + "@rollup/rollup-linux-ppc64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-gnu": "4.52.4", + "@rollup/rollup-linux-riscv64-musl": "4.52.4", + "@rollup/rollup-linux-s390x-gnu": "4.52.4", + "@rollup/rollup-linux-x64-gnu": "4.52.4", + "@rollup/rollup-linux-x64-musl": "4.52.4", + "@rollup/rollup-openharmony-arm64": "4.52.4", + "@rollup/rollup-win32-arm64-msvc": "4.52.4", + "@rollup/rollup-win32-ia32-msvc": "4.52.4", + "@rollup/rollup-win32-x64-gnu": "4.52.4", + "@rollup/rollup-win32-x64-msvc": "4.52.4", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6310,6 +7675,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -6358,6 +7730,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stats-gl": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", @@ -6384,6 +7763,13 @@ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -6612,6 +7998,19 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6625,6 +8024,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -6818,6 +8237,13 @@ "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", @@ -6863,6 +8289,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6973,6 +8419,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -7078,6 +8534,13 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -7240,6 +8703,155 @@ "node": ">= 4" } }, + "node_modules/vite": { + "version": "5.4.20", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", + "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/webgl-constants": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", @@ -7355,6 +8967,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3b9c3a31..ebd97846 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,14 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:e2e": "playwright test", + "assets:gen": "python ../scripts/generate_reference_glbs.py", + "assets:pack": "bash ../scripts/pack_models.sh", + "assets:validate": "python ../scripts/glb_validate.py public/models/*.glb --fail-on-warning", + "assets:manifest": "python ../scripts/gen_glb_manifest.py > public/models/manifest.json", + "assets:all": "npm run assets:gen && npm run assets:pack && npm run assets:validate && npm run assets:manifest", + "test:manifest": "vitest run --reporter=dot" }, "dependencies": { "@chakra-ui/icons": "^2.1.1", @@ -32,6 +39,10 @@ "eslint-config-next": "14.2.0", "postcss": "^8", "tailwindcss": "^3.3.0", - "typescript": "^5" + "typescript": "^5", + "gltfpack": "0.25.0", + "meshoptimizer": "0.25.0", + "vitest": "^1.6.0", + "@playwright/test": "^1.56.0" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..1beff8a6 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30_000, + expect: { timeout: 15_000 }, + use: { + baseURL: process.env.BASE_URL || 'http://localhost:3000', + headless: true, + trace: 'retain-on-failure', + launchOptions: { + args: [ + '--ignore-gpu-blocklist', + '--use-gl=swiftshader', + '--enable-webgl', + '--disable-gpu-sandbox', + '--disable-web-security', // allow wasm decoders to initialise in CI + ], + }, + }, +}); diff --git a/frontend/public/models/BaseCabinet600.glb b/frontend/public/models/BaseCabinet600.glb deleted file mode 100644 index b9effcdf..00000000 --- a/frontend/public/models/BaseCabinet600.glb +++ /dev/null @@ -1,2152 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Page not found · GitHub · GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - -
- Skip to content - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - -
- -
- - - - - - - - -
- - - - - -
- - - - - - - - - -
-
- - - -
-
- -
-
- 404 “This is not the web page you are looking for” - - - - - - - - - - - - -
-
- -
-
- -
- - -
-
- -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/frontend/public/models/base_600.glb b/frontend/public/models/base_600.glb new file mode 100644 index 00000000..75f0b4fb Binary files /dev/null and b/frontend/public/models/base_600.glb differ diff --git a/frontend/public/models/base_600@lod1.glb b/frontend/public/models/base_600@lod1.glb new file mode 100644 index 00000000..5e426305 Binary files /dev/null and b/frontend/public/models/base_600@lod1.glb differ diff --git a/frontend/public/models/manifest.json b/frontend/public/models/manifest.json new file mode 100644 index 00000000..555be95f --- /dev/null +++ b/frontend/public/models/manifest.json @@ -0,0 +1,34 @@ +{ + "models": [ + { + "file": "/models/base_600.glb", + "sha256": "45f4940517b77d748dc71d4efa6fc07ca4115ce92143fde4dfdcb424959b0b54", + "bytes": 4360 + }, + { + "file": "/models/base_600@lod1.glb", + "sha256": "c21ba9280c2a68a068b1282429c1c650303631d048492daf0e9bbc3c6c639d3e", + "bytes": 4052 + }, + { + "file": "/models/tall_600.glb", + "sha256": "31ec07b07df7f59c48369c96c723b8037e51f79605bf62f0ae9c1e0a53e8c99e", + "bytes": 4360 + }, + { + "file": "/models/tall_600@lod1.glb", + "sha256": "98a0063fc91dcbac054f5c8f88dc74f5e6f35525fc79d296c13755f6b77577e5", + "bytes": 4052 + }, + { + "file": "/models/wall_900.glb", + "sha256": "22c1c6d9269c079be53ca662f2583c033e57cea0b87d5d6ff80a62320e76e00d", + "bytes": 4364 + }, + { + "file": "/models/wall_900@lod1.glb", + "sha256": "de16699463f5a3eeda4e07ade0ca2e35ff34c3a6232fffde7c2178d844ca8304", + "bytes": 4052 + } + ] +} diff --git a/frontend/public/models/tall_600.glb b/frontend/public/models/tall_600.glb new file mode 100644 index 00000000..58755411 Binary files /dev/null and b/frontend/public/models/tall_600.glb differ diff --git a/frontend/public/models/tall_600@lod1.glb b/frontend/public/models/tall_600@lod1.glb new file mode 100644 index 00000000..4d82ca45 Binary files /dev/null and b/frontend/public/models/tall_600@lod1.glb differ diff --git a/frontend/public/models/wall_900.glb b/frontend/public/models/wall_900.glb new file mode 100644 index 00000000..7dd5ea20 Binary files /dev/null and b/frontend/public/models/wall_900.glb differ diff --git a/frontend/public/models/wall_900@lod1.glb b/frontend/public/models/wall_900@lod1.glb new file mode 100644 index 00000000..17cc5300 Binary files /dev/null and b/frontend/public/models/wall_900@lod1.glb differ diff --git a/frontend/tests/e2e/cache.spec.ts b/frontend/tests/e2e/cache.spec.ts new file mode 100644 index 00000000..a4c15e6e --- /dev/null +++ b/frontend/tests/e2e/cache.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test'; + +test('static asset cache headers from manifest', async ({ request }) => { + const manifestResponse = await request.get('/models/manifest.json'); + expect(manifestResponse.ok()).toBeTruthy(); + const data = await manifestResponse.json(); + const first = (data?.models || [])[0]; + expect(first?.file).toBeTruthy(); + + const filePath = first.file as string; + const assetResponse = await request.get(filePath); + expect(assetResponse.status()).toBe(200); + const cacheControl = assetResponse.headers()['cache-control'] || ''; + expect(cacheControl).toMatch(/immutable/); + + const etag = assetResponse.headers()['etag']; + if (etag) { + const cached = await request.get(filePath, { headers: { 'If-None-Match': etag } }); + expect([200, 304]).toContain(cached.status()); + } +}); diff --git a/frontend/tests/manifest.spec.ts b/frontend/tests/manifest.spec.ts new file mode 100644 index 00000000..ff63294b --- /dev/null +++ b/frontend/tests/manifest.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'vitest' +import manifest from '../public/models/manifest.json' + +type Entry = { file: string; sha256: string; bytes: number } + +const files = new Set((manifest as any).models.map((m: Entry) => m.file)) +const REQUIRED_CODES = ['base_600', 'wall_900', 'tall_600'] + +describe('manifest coverage', () => { + test('LOD0 and LOD1 assets exist for reference codes', () => { + for (const code of REQUIRED_CODES) { + expect(files.has(`/models/${code}.glb`)).toBe(true) + expect(files.has(`/models/${code}@lod1.glb`)).toBe(true) + } + }) +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..ba1d99ce --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['tests/**/*.spec.ts'], + exclude: ['tests/e2e/**'], + environment: 'node', + reporters: ['dot'], + }, +}) diff --git a/scripts/gen_glb_manifest.py b/scripts/gen_glb_manifest.py new file mode 100644 index 00000000..063d9c1f --- /dev/null +++ b/scripts/gen_glb_manifest.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Generate manifest.json describing GLB assets (path, sha256, size).""" +from __future__ import annotations + +import glob +import hashlib +import json +import os +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MODELS_PATTERN = ROOT / "frontend" / "public" / "models" / "*.glb" + + +def manifest_entry(path: Path) -> dict: + data = path.read_bytes() + return { + "file": "/models/" + path.name, + "sha256": hashlib.sha256(data).hexdigest(), + "bytes": len(data), + } + + +def main() -> int: + paths = sorted(MODELS_PATTERN.parent.glob(MODELS_PATTERN.name)) + entries = [manifest_entry(path) for path in paths] + manifest = {"models": entries} + print(json.dumps(manifest, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_reference_glbs.py b/scripts/generate_reference_glbs.py new file mode 100644 index 00000000..8d94890e --- /dev/null +++ b/scripts/generate_reference_glbs.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" +Generate deterministic reference GLBs for the Paform viewer pipeline. + +Emits base_600.glb, wall_900.glb, tall_600.glb into frontend/public/models/. +Each GLB: + - Uses millimetre scale with pivot/min bounds at the origin. + - Declares Module metadata under extras (code, dimensions, materials). + - Contains at least one mesh node with extras.panelType. + - Includes simple base-color textures (4x4 PNG) so texture pipelines stay engaged. +The resulting GLBs are intentionally minimal and are expected to be re-packed +via scripts/pack_models.sh to add compression + LODs. +""" +from __future__ import annotations + +import base64 +import json +import os +import struct +from pathlib import Path +from typing import Dict, Iterable, List, Sequence, Tuple + +ROOT = Path(__file__).resolve().parents[1] +MODELS_DIR = ROOT / "frontend" / "public" / "models" + +# 4x4 PNG pixels (white & grey) encoded to ensure textures exist pre-pack and can be BasisU compressed. +PNG_WHITE = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAE0lEQVR4nGP8//8/AwwwwVl4OQCWbgMF7ZjH1AAAAABJRU5ErkJggg==" +) +PNG_GREY = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAE0lEQVR4nGM8ceIEAwwwwVl4OQB2NAJgnoBkZwAAAABJRU5ErkJggg==" +) + + +def pack_f32(values: Iterable[float]) -> bytes: + return b"".join(struct.pack(" bytes: + return b"".join(struct.pack(" bytes: + remainder = (-len(data)) % 4 + if remainder: + data += pad * remainder + return data + + +def build_box(width: float, height: float, depth: float, subdivisions: int = 2) -> Tuple[List[float], List[float], List[int]]: + """Create a box mesh with per-face subdivisions so LOD simplification has room to operate.""" + + w, h, d = float(width), float(height), float(depth) + positions: List[float] = [] + texcoords: List[float] = [] + indices: List[int] = [] + + faces = [ + # front (faces -Z) + ((0.0, h, 0.0), (w, 0.0, 0.0), (0.0, -h, 0.0)), + # right (+X) + ((w, h, 0.0), (0.0, 0.0, d), (0.0, -h, 0.0)), + # back (+Z) + ((w, h, d), (-w, 0.0, 0.0), (0.0, -h, 0.0)), + # left (-X) + ((0.0, h, d), (0.0, 0.0, -d), (0.0, -h, 0.0)), + # top (+Y) + ((0.0, h, d), (w, 0.0, 0.0), (0.0, 0.0, -d)), + # bottom (-Y) + ((0.0, 0.0, 0.0), (w, 0.0, 0.0), (0.0, 0.0, d)), + ] + + for origin, u_vec, v_vec in faces: + start_idx = len(positions) // 3 + for v_idx in range(subdivisions + 1): + v_ratio = v_idx / subdivisions if subdivisions else 0.0 + for u_idx in range(subdivisions + 1): + u_ratio = u_idx / subdivisions if subdivisions else 0.0 + px = origin[0] + u_vec[0] * u_ratio + v_vec[0] * v_ratio + py = origin[1] + u_vec[1] * u_ratio + v_vec[1] * v_ratio + pz = origin[2] + u_vec[2] * u_ratio + v_vec[2] * v_ratio + positions.extend([px, py, pz]) + texcoords.extend([u_ratio, v_ratio]) + + stride = subdivisions + 1 + for v_idx in range(subdivisions): + for u_idx in range(subdivisions): + top_left = start_idx + v_idx * stride + u_idx + top_right = top_left + 1 + bottom_left = top_left + stride + bottom_right = bottom_left + 1 + indices.extend([top_left, bottom_left, bottom_right]) + indices.extend([top_left, bottom_right, top_right]) + + return positions, texcoords, indices + + +def create_glb_document( + code: str, width: int, height: int, depth: int +) -> Tuple[Dict, bytes]: + positions, texcoords, indices = build_box(width, height, depth) + position_blob = pack_f32(positions) + texcoord_blob = pack_f32(texcoords) + index_blob = pack_u16(indices) + + def add_view(blob: bytes, *, target: int | None = None) -> Tuple[int, bytes]: + nonlocal buffer_bytes, byte_offset + view = {"buffer": 0, "byteOffset": byte_offset, "byteLength": len(blob)} + if target is not None: + view["target"] = target + padded = pad4(blob, b"\x00") + buffer_bytes += padded + index = len(buffer_views) + buffer_views.append(view) + byte_offset += len(padded) + return index, padded + + buffer_views: List[Dict] = [] + buffer_bytes = b"" + byte_offset = 0 + + pos_view, _ = add_view(position_blob, target=34962) # ARRAY_BUFFER + tex_view, _ = add_view(texcoord_blob, target=34962) + idx_view, _ = add_view(index_blob, target=34963) # ELEMENT_ARRAY_BUFFER + white_view, _ = add_view(PNG_WHITE) + grey_view, _ = add_view(PNG_GREY) + + accessors = [ + { + "bufferView": pos_view, + "componentType": 5126, + "count": len(positions) // 3, + "type": "VEC3", + "min": [0.0, 0.0, 0.0], + "max": [float(width), float(height), float(depth)], + }, + { + "bufferView": tex_view, + "componentType": 5126, + "count": len(texcoords) // 2, + "type": "VEC2", + }, + { + "bufferView": idx_view, + "componentType": 5123, + "count": len(indices), + "type": "SCALAR", + }, + ] + + images = [ + {"bufferView": white_view, "mimeType": "image/png"}, + {"bufferView": grey_view, "mimeType": "image/png"}, + ] + textures = [{"source": 0}, {"source": 1}] + + materials = [ + { + "name": "Mat::Carcass", + "pbrMetallicRoughness": { + "baseColorTexture": {"index": 0}, + "metallicFactor": 0.0, + "roughnessFactor": 0.9, + }, + }, + { + "name": "Mat::Front", + "pbrMetallicRoughness": { + "baseColorTexture": {"index": 1}, + "metallicFactor": 0.0, + "roughnessFactor": 0.9, + }, + }, + ] + + meshes = [ + { + "name": f"{code}_Body", + "primitives": [ + { + "attributes": {"POSITION": 0, "TEXCOORD_0": 1}, + "indices": 2, + "mode": 4, + "material": 0, + } + ], + } + ] + + module_extras = { + "paform_glb_schema": "1.0.0", + "module": { + "code": code, + "type": "Cabinet", + "system": "Prime", + "family": "Reference", + "width_mm": width, + "height_mm": height, + "depth_mm": depth, + }, + "dimensionsMm": {"width": width, "height": height, "depth": depth}, + "materials": { + "carcass": "Mat::Carcass", + "front": "Mat::Front", + }, + "lod": {"level": 0, "triangleCount": len(indices) // 3}, + "qa": {"validatorVersion": "0.0.0"}, + } + + nodes = [ + { + "name": f"Module::{code.upper()}", + "mesh": 0, + "extras": {**module_extras, "panelType": "carcass"}, + } + ] + + document = { + "asset": {"version": "2.0", "generator": "paform/reference-generator"}, + "scenes": [{"nodes": [0]}], + "scene": 0, + "nodes": nodes, + "meshes": meshes, + "materials": materials, + "textures": textures, + "images": images, + "buffers": [{"byteLength": len(buffer_bytes)}], + "bufferViews": buffer_views, + "accessors": accessors, + "extensionsUsed": [], + } + return document, buffer_bytes + + +def write_glb(path: Path, document: Dict, binary_blob: bytes) -> None: + json_bytes = pad4(json.dumps(document, separators=(",", ":")).encode("utf-8")) + bin_bytes = pad4(binary_blob, b"\x00") + + total_length = 12 + 8 + len(json_bytes) + 8 + len(bin_bytes) + header = struct.pack("<4sII", b"glTF", 2, total_length) + json_chunk = struct.pack(" None: + MODELS_DIR.mkdir(parents=True, exist_ok=True) + legacy = MODELS_DIR / "BaseCabinet600.glb" + if legacy.exists(): + legacy.unlink() + + configs = [ + ("base_600", 600, 720, 580), + ("wall_900", 900, 720, 360), + ("tall_600", 600, 2100, 580), + ] + for code, width, height, depth in configs: + path = MODELS_DIR / f"{code}.glb" + doc, blob = create_glb_document(code, width, height, depth) + write_glb(path, doc, blob) + print(f"Wrote {path.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/glb_validate.py b/scripts/glb_validate.py new file mode 100644 index 00000000..b68d705e --- /dev/null +++ b/scripts/glb_validate.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Strict validator for Paform GLB assets.""" +from __future__ import annotations + +import argparse +import json +import math +import struct +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +TOL_MM = 1.0 +LOD_TRI_CAPS = {"base_": 40000, "wall_": 30000, "tall_": 60000} +LOD1_RATIO_MAX = 0.60 +PANEL_TYPES = {"carcass", "front", "handle"} +REQUIRED_MODULE_KEYS = {"code", "type", "family", "system", "width_mm", "height_mm", "depth_mm"} +REQUIRED_MATERIAL_MAP = {"carcass", "front"} + + +@dataclass +class Issue: + severity: str # "ERROR" or "WARN" + code: str + message: str + + def __str__(self) -> str: + icon = "❌" if self.severity == "ERROR" else "⚠️" + return f"{icon} [{self.code}] {self.message}" + + +def load_glb(path: Path) -> Dict: + data = path.read_bytes() + if data[:4] != b"glTF": + raise ValueError("not a GLB file") + total_length = struct.unpack_from(" List[int]: + scenes = doc.get("scenes") or [] + scene_idx = doc.get("scene", 0) + if not scenes: + return [] + return scenes[scene_idx].get("nodes", []) + + +def compute_bounds(doc: Dict) -> Optional[Tuple[List[float], List[float]]]: + accessors = doc.get("accessors") or [] + meshes = doc.get("meshes") or [] + min_vals = [math.inf, math.inf, math.inf] + max_vals = [-math.inf, -math.inf, -math.inf] + for mesh in meshes: + for prim in mesh.get("primitives", []): + pos_idx = prim.get("attributes", {}).get("POSITION") + if pos_idx is None: + continue + accessor = accessors[pos_idx] + acc_min = accessor.get("min") + acc_max = accessor.get("max") + if not acc_min or not acc_max: + continue + for i in range(3): + min_vals[i] = min(min_vals[i], acc_min[i]) + max_vals[i] = max(max_vals[i], acc_max[i]) + if math.isinf(min_vals[0]) or math.isinf(max_vals[0]): + return None + return min_vals, max_vals + + +def triangle_count(doc: Dict) -> int: + total = 0 + accessors = doc.get("accessors") or [] + for mesh in doc.get("meshes") or []: + for prim in mesh.get("primitives", []): + idx = prim.get("indices") + if idx is not None: + accessor = accessors[idx] + total += accessor.get("count", 0) // 3 + else: + pos = prim.get("attributes", {}).get("POSITION") + if pos is not None: + accessor = accessors[pos] + total += accessor.get("count", 0) // 3 + return total + + +def validate(path: Path, fail_on_warning: bool, warn_threshold: int) -> Tuple[int, int, List[Issue]]: + issues: List[Issue] = [] + try: + doc = load_glb(path) + except Exception as exc: # noqa: BLE001 + return 0, 0, [Issue("ERROR", "LOAD", f"{path.name}: {exc}")] + + extensions_used = set(doc.get("extensionsUsed") or []) + if "KHR_texture_basisu" not in extensions_used: + issues.append(Issue("ERROR", "TEXTURE_KTX2", "KHR_texture_basisu missing (textures must be KTX2)")) + if not ({"EXT_meshopt_compression", "KHR_draco_mesh_compression"} & extensions_used): + issues.append(Issue("WARN", "COMPRESSION", "Mesh compression extension missing (Meshopt/Draco recommended)")) + + roots = gather_root_nodes(doc) + nodes = doc.get("nodes") or [] + if len(roots) != 1: + issues.append(Issue("ERROR", "ROOT", f"scene must contain exactly one root node (found {len(roots)})")) + return len([i for i in issues if i.severity == "ERROR"]), len([i for i in issues if i.severity == "WARN"]), issues + + root = nodes[roots[0]] + root_name = root.get("name") or "" + if not root_name.startswith("Module::"): + issues.append(Issue("ERROR", "ROOT_NAME", f"root node must start with 'Module::' (found '{root_name}')")) + + extras = root.get("extras") or {} + if extras.get("paform_glb_schema") != "1.0.0": + issues.append(Issue("ERROR", "SCHEMA", "extras.paform_glb_schema must equal '1.0.0'")) + + module_meta = extras.get("module") or {} + missing_module = sorted(REQUIRED_MODULE_KEYS - module_meta.keys()) + if missing_module: + issues.append(Issue("ERROR", "MODULE_META", f"extras.module missing keys: {', '.join(missing_module)}")) + + dimensions = extras.get("dimensionsMm") or {} + for key in ("width", "height", "depth"): + value = dimensions.get(key) + if value is None: + issues.append(Issue("ERROR", "DIMENSIONS", f"extras.dimensionsMm.{key} missing")) + elif value <= 0: + issues.append(Issue("ERROR", "DIMENSIONS", f"extras.dimensionsMm.{key} must be > 0")) + + materials_map = extras.get("materials") or {} + if not isinstance(materials_map, dict) or not materials_map: + issues.append(Issue("ERROR", "MATERIALS", "extras.materials must be a non-empty object")) + else: + missing_slots = sorted(REQUIRED_MATERIAL_MAP - materials_map.keys()) + if missing_slots: + issues.append(Issue("ERROR", "MATERIALS", f"extras.materials missing slots: {', '.join(missing_slots)}")) + for slot, mat in materials_map.items(): + if not isinstance(mat, str) or not mat.startswith("Mat::"): + issues.append(Issue("ERROR", "MATERIALS", f"extras.materials.{slot} must reference Mat::*")) + + meshes = doc.get("meshes") or [] + material_defs = doc.get("materials") or [] + material_names = {mat.get("name") for mat in material_defs} + for slot in ("Mat::Carcass", "Mat::Front"): + if slot not in material_names: + issues.append(Issue("ERROR", "MATERIAL_DEF", f"material '{slot}' missing from material list")) + + panel_nodes = [node for node in nodes if isinstance(node.get("extras"), dict) and node["extras"].get("panelType")] + if not panel_nodes: + issues.append(Issue("WARN", "PANEL_TYPE", "no mesh node includes extras.panelType")) + else: + for node in panel_nodes: + panel = node["extras"]["panelType"] + if panel not in PANEL_TYPES: + issues.append(Issue("ERROR", "PANEL_TYPE", f"node '{node.get('name')}' has invalid panelType '{panel}'")) + + # Ensure every mesh primitive references a material. + for mesh_idx, mesh in enumerate(meshes): + for prim in mesh.get("primitives", []): + mat_idx = prim.get("material") + if mat_idx is None or mat_idx >= len(material_defs): + issues.append(Issue("ERROR", "PRIMITIVE_MAT", f"mesh#{mesh_idx} primitive missing valid material reference")) + + bounds = compute_bounds(doc) + if bounds: + (min_x, min_y, min_z), _ = bounds + if not all(abs(val) <= TOL_MM for val in (min_x, min_y, min_z)): + issues.append( + Issue("ERROR", "PIVOT", f"origin must be within ±{TOL_MM}mm of (0,0,0); got ({min_x:.3f},{min_y:.3f},{min_z:.3f})") + ) + else: + issues.append(Issue("WARN", "PIVOT", "unable to compute bounds (missing accessor min/max)")) + + lod_meta = extras.get("lod") or {} + lod_level = lod_meta.get("level") + if lod_level not in (0, 1): + issues.append(Issue("ERROR", "LOD", "extras.lod.level must be 0 or 1")) + + tri_count_lod0 = triangle_count(doc) + if lod_level == 0: + stem = path.stem.replace("@lod1", "") + for prefix, cap in LOD_TRI_CAPS.items(): + if stem.startswith(prefix): + if tri_count_lod0 > cap: + issues.append( + Issue("ERROR", "LOD", f"triangle count {tri_count_lod0} exceeds cap {cap} for prefix '{prefix}'") + ) + break + + lod1_path = path.with_name(path.stem + "@lod1.glb") + try: + lod1_doc = load_glb(lod1_path) + except Exception as exc: # noqa: BLE001 + issues.append(Issue("ERROR", "LOD", f"missing or invalid LOD1 asset ({lod1_path.name}): {exc}")) + else: + lod1_triangles = triangle_count(lod1_doc) + if lod1_triangles > math.ceil(LOD1_RATIO_MAX * tri_count_lod0): + issues.append( + Issue( + "ERROR", + "LOD", + f"LOD1 triangle count {lod1_triangles} exceeds {LOD1_RATIO_MAX:.0%} of LOD0 ({tri_count_lod0})", + ) + ) + lod1_extras = (lod1_doc.get("nodes") or [{}])[0].get("extras") or {} + if lod1_extras.get("lod", {}).get("level") != 1: + issues.append( + Issue("ERROR", "LOD", f"LOD1 asset {lod1_path.name} must declare extras.lod.level == 1") + ) + elif lod_level == 1 and "@lod1" not in path.name: + issues.append(Issue("ERROR", "LOD", "LOD1 assets must use '@lod1' suffix in filename")) + + warnings = [issue for issue in issues if issue.severity == "WARN"] + errors = [issue for issue in issues if issue.severity == "ERROR"] + + if warnings and warn_threshold is not None and len(warnings) > warn_threshold: + errors.append(Issue("ERROR", "WARN_THRESHOLD", f"warnings exceeded threshold ({len(warnings)} > {warn_threshold})")) + + if warnings and fail_on_warning and not errors: + errors.append(Issue("ERROR", "WARNINGS", "warnings present with --fail-on-warning")) + + return len(errors), len(warnings), errors + warnings + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate Paform GLB files") + parser.add_argument("paths", nargs="+", help="paths or globs to GLB files") + parser.add_argument("--warn-threshold", type=int, default=5, help="maximum allowed warnings before failure") + parser.add_argument("--fail-on-warning", action="store_true", help="treat any warning as an error") + args = parser.parse_args(argv) + + errors_total = 0 + for raw_path in args.paths: + for path in sorted(Path().glob(raw_path)): + print(f"Validating {path} ...") + errors, warnings, issues = validate(path, args.fail_on_warning, args.warn_threshold) + for issue in issues: + print(f" {issue}") + if errors: + print(f" ❌ Fail (errors={errors}, warnings={warnings})\n") + errors_total = max(errors_total, 2) + elif warnings: + status = "treated as error" if args.fail_on_warning else "warnings only" + print(f" ⚠️ Pass with warnings ({status}, warnings={warnings})\n") + if args.fail_on_warning: + errors_total = max(errors_total, 1) + else: + print(" ✅ Pass\n") + return errors_total + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/pack_models.sh b/scripts/pack_models.sh new file mode 100755 index 00000000..24f0b80d --- /dev/null +++ b/scripts/pack_models.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Deterministic GLB packing with Meshopt compression + KTX2 textures. +# Generates missing LOD1 assets using a ~55% simplification ratio. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +MODELS_DIR="$ROOT/frontend/public/models" + +# Ensure reference models exist so downstream steps always have inputs. +python3 "$ROOT/scripts/generate_reference_glbs.py" + +if [[ -n "${GLTFPACK_BIN:-}" ]]; then + PACK_CMD="$GLTFPACK_BIN" +elif [[ -x "$ROOT/tools/gltfpack_bin/gltfpack" ]]; then + PACK_CMD="$ROOT/tools/gltfpack_bin/gltfpack" +elif command -v gltfpack >/dev/null 2>&1; then + PACK_CMD="$(command -v gltfpack)" +else + echo "ERROR: gltfpack binary not found. Install gltfpack or set GLTFPACK_BIN." >&2 + exit 1 +fi + +if ! [[ -x "$PACK_CMD" ]]; then + echo "ERROR: gltfpack binary is not executable: $PACK_CMD" >&2 + exit 1 +fi + +HELP_HEADER="$($PACK_CMD -h 2>&1 || true)" +if [[ "$HELP_HEADER" == *"-ktx2"* ]]; then + TEXTURE_ARGS=(-ktx2) +elif [[ "$HELP_HEADER" == *"-tc"* ]]; then + TEXTURE_ARGS=(-tc) +else + echo "ERROR: gltfpack binary lacks KTX2 texture compression support (-ktx2/-tc)." >&2 + exit 1 +fi + +SIMPLIFY_ARGS=(-si 0.55 -sa) +METADATA_SCRIPT="$ROOT/scripts/update_glb_metadata.py" +PY_BIN="${PYTHON_BIN:-python3}" + +if ! [[ -f "$METADATA_SCRIPT" ]]; then + echo "ERROR: metadata helper not found at $METADATA_SCRIPT" >&2 + exit 1 +fi + +if ! command -v "$PY_BIN" >/dev/null 2>&1; then + echo "ERROR: unable to locate python interpreter ('$PY_BIN'). Set PYTHON_BIN to override." >&2 + exit 1 +fi + +echo "Using gltfpack: $PACK_CMD (${TEXTURE_ARGS[*]})" + +INPUTS=() +while IFS= read -r file; do + INPUTS+=("$file") +done < <(find "$MODELS_DIR" -maxdepth 1 -type f -name "*.glb" ! -name "*@lod1.glb" | sort) + +if [[ ${#INPUTS[@]} -eq 0 ]]; then + echo "No GLB inputs found under $MODELS_DIR" + exit 0 +fi + +for src in "${INPUTS[@]}"; do + base="$(basename "$src")" + if [[ "$base" == "BaseCabinet600.glb" ]]; then + echo "Skipping legacy asset $base" + continue + fi + + name="${base%.glb}" + lod1_path="$MODELS_DIR/${name}@lod1.glb" + + tmp_lod0="$MODELS_DIR/${name}.tmp.glb" + tmp_lod1="$MODELS_DIR/${name}@lod1.tmp.glb" + + rm -f "$tmp_lod0" "$tmp_lod1" + + "$PACK_CMD" -i "$src" -o "$tmp_lod0" -kn -km -ke -cc "${TEXTURE_ARGS[@]}" + mv "$tmp_lod0" "$src" + "$PY_BIN" "$METADATA_SCRIPT" --level 0 "$src" + + if [[ -f "$lod1_path" ]]; then + "$PACK_CMD" -i "$lod1_path" -o "$tmp_lod1" -kn -km -ke -cc "${TEXTURE_ARGS[@]}" "${SIMPLIFY_ARGS[@]}" + else + "$PACK_CMD" -i "$src" -o "$tmp_lod1" -kn -km -ke -cc "${TEXTURE_ARGS[@]}" "${SIMPLIFY_ARGS[@]}" + fi + + mv "$tmp_lod1" "$lod1_path" + "$PY_BIN" "$METADATA_SCRIPT" --level 1 "$lod1_path" + echo "Packed $(basename "$src") + $(basename "$lod1_path")" +done diff --git a/scripts/update_glb_metadata.py b/scripts/update_glb_metadata.py new file mode 100644 index 00000000..64320d97 --- /dev/null +++ b/scripts/update_glb_metadata.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Patch GLB metadata after packing to maintain LOD bookkeeping.""" +from __future__ import annotations + +import argparse +import json +import struct +from pathlib import Path +from typing import Dict, Iterable, List, Sequence, Tuple + +JSON_CHUNK = 0x4E4F534A +VALIDATOR_VERSION = "assets-validator/1.0.0" + + +def read_chunks(data: bytes) -> Tuple[int, List[Tuple[int, bytes]]]: + if data[:4] != b"glTF": + raise ValueError("Not a GLB file") + version = struct.unpack_from(" None: + out = bytearray() + out.extend(b"glTF") + out.extend(struct.pack(" int: + total = 0 + accessors = doc.get("accessors") or [] + for mesh in doc.get("meshes") or []: + for prim in mesh.get("primitives", []): + idx = prim.get("indices") + if idx is not None: + accessor = accessors[idx] + total += accessor.get("count", 0) // 3 + else: + pos_idx = prim.get("attributes", {}).get("POSITION") + if pos_idx is None: + continue + accessor = accessors[pos_idx] + total += accessor.get("count", 0) // 3 + return total + + +def update_metadata(path: Path, level: int) -> None: + data = path.read_bytes() + version, chunks = read_chunks(data) + json_index = next((i for i, (ctype, _) in enumerate(chunks) if ctype == JSON_CHUNK), None) + if json_index is None: + raise ValueError(f"{path} is missing JSON chunk") + doc = json.loads(chunks[json_index][1].decode("utf-8")) + + scenes = doc.get("scenes") or [] + if not scenes: + raise ValueError("Missing scenes in GLB document") + scene_idx = doc.get("scene", 0) + root_nodes = scenes[scene_idx].get("nodes") or [] + if not root_nodes: + raise ValueError("Scene has no root nodes") + nodes = doc.get("nodes") or [] + root_index = root_nodes[0] + root = nodes[root_index] + + extras = root.setdefault("extras", {}) + lod_meta = extras.setdefault("lod", {}) + lod_meta["level"] = level + lod_meta["triangleCount"] = triangle_count(doc) + + qa_meta = extras.setdefault("qa", {}) + qa_meta["validatorVersion"] = VALIDATOR_VERSION + + if nodes and root_index != 0: + first_node = nodes[0] + if not isinstance(first_node.get("extras"), dict): + first_node["extras"] = extras + + json_blob = json.dumps(doc, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + padding = (-len(json_blob)) % 4 + if padding: + json_blob += b" " * padding + chunks[json_index] = (JSON_CHUNK, json_blob) + write_glb(path, version, chunks) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Update GLB extras.lod metadata.") + parser.add_argument("paths", nargs="+", type=Path, help="GLB files to update") + parser.add_argument("--level", type=int, choices=(0, 1), required=True, help="LOD level to stamp") + args = parser.parse_args(argv) + + for path in args.paths: + update_metadata(path, args.level) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/perf/k6-quote-cnc.js b/tests/perf/k6-quote-cnc.js new file mode 100644 index 00000000..fec109fd --- /dev/null +++ b/tests/perf/k6-quote-cnc.js @@ -0,0 +1,128 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + vus: 5, + duration: '30s', + thresholds: { + http_req_failed: ['rate<0.01'], + http_req_duration: ['p(95)<150'], + }, +}; + +const baseUrl = __ENV.BASE_URL || 'http://localhost:8000'; +const feBase = __ENV.FRONTEND_BASE_URL || ''; + +function createMaterialAndModule() { + const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + const headers = { 'Content-Type': 'application/json' }; + const materialRes = http.post( + `${baseUrl}/api/materials/`, + JSON.stringify({ + name: `Walnut-${suffix}`, + texture_url: null, + cost_per_sq_ft: 12.5, + }), + { headers } + ); + check(materialRes, { 'material created': (r) => r.status === 200 }); + const materialId = materialRes.json('id'); + + const moduleRes = http.post( + `${baseUrl}/api/modules/`, + JSON.stringify({ + name: `Base600-${suffix}`, + width: 600.0, + height: 720.0, + depth: 580.0, + base_price: 100.0, + material_id: materialId, + }), + { headers } + ); + check(moduleRes, { 'module created': (r) => r.status === 200 }); + const moduleId = moduleRes.json('id'); + + return { materialId, moduleId }; +} + +export function setup() { + return createMaterialAndModule(); +} + +function buildPayload(moduleId) { + const configuration = { + room: { + length_mm: 4200, + width_mm: 3200, + height_mm: 2700, + bulkheads: [], + openings: [], + }, + placements: [ + { + placement_id: 'p1', + module_id: moduleId, + quantity: 1, + wall: 'north', + position_mm: 100, + elevation_mm: 0, + rotation_deg: 0, + }, + ], + }; + + return { + quote: JSON.stringify({ configuration }), + cnc: JSON.stringify({ + configuration_id: 'cfg-1', + configuration, + include_csv: true, + }), + }; +} + +export default function (data) { + const headers = { 'Content-Type': 'application/json' }; + const moduleId = data?.moduleId || createMaterialAndModule().moduleId; + const payloads = buildPayload(moduleId); + + const quoteResponse = http.post(`${baseUrl}/api/quote/generate`, payloads.quote, { + headers, + }); + check(quoteResponse, { 'quote 200': (r) => r.status === 200 }); + + const cncResponse = http.post(`${baseUrl}/api/cnc/export`, payloads.cnc, { + headers, + }); + check(cncResponse, { + 'cnc 200 + csv': (r) => r.status === 200 && r.json('csv'), + }); + + if (feBase) { + const manifestResponse = http.get(`${feBase}/models/manifest.json`); + check(manifestResponse, { 'manifest 200': (r) => r.status === 200 }); + try { + const manifest = manifestResponse.json(); + const file = manifest?.models?.[0]?.file; + if (file) { + const asset = http.get(`${feBase}${file}`); + check(asset, { + 'model 200 + immutable': (r) => { + const header = r.headers['Cache-Control'] || r.headers['cache-control'] || ''; + return r.status === 200 && String(header).includes('immutable'); + }, + }); + const etag = asset.headers['Etag'] || asset.headers['ETag']; + if (etag) { + const cached = http.get(`${feBase}${file}`, { headers: { 'If-None-Match': etag } }); + check(cached, { 'model cache 200/304': (r) => r.status === 304 || r.status === 200 }); + } + } + } catch (err) { + // Ignore JSON parsing issues so perf run can continue. + } + } + + sleep(0.5); +} diff --git a/tools/gltfpack_bin/gltfpack b/tools/gltfpack_bin/gltfpack new file mode 100755 index 00000000..c1d12277 Binary files /dev/null and b/tools/gltfpack_bin/gltfpack differ