Drag the card · pinch or wheel to zoom · flip to inspect the back
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`
-
-const canvas = document.querySelector('#scene')!
-const statusElement = document.querySelector('#status')!
-const performanceElement = document.querySelector('#performance')!
-const loadingElement = document.querySelector('#loading')!
-const hintElement = document.querySelector('#hint')!
-const topbar = document.querySelector('.topbar')!
-const controlsToggle = document.querySelector('#controls-toggle')!
-const modeSelect = document.querySelector('#mode')!
-const fixtureSelect = document.querySelector('#fixture')!
-const finishSelect = document.querySelector('#finish')!
-const substrateSelect = document.querySelector('#substrate')!
-const poseSelect = document.querySelector('#pose')!
-const lightingPresetSelect = document.querySelector('#lighting-preset')!
-const lightTypeSelect = document.querySelector('#light-type')!
-const flipButton = document.querySelector('#flip')!
-const resetButton = document.querySelector('#reset')!
-const sweepButton = document.querySelector('#sweep')!
-const artworkFileInput = document.querySelector('#artwork-file')!
-const maskFileInput = document.querySelector('#mask-file')!
-const experimentFileInput = document.querySelector('#experiment-file')!
-const packControlsElement = document.querySelector('#pack-controls')!
-const packStatusElement = document.querySelector('#pack-status')!
-const packTearProgress = document.querySelector('#pack-tear')!
-const packPrimaryButton = document.querySelector('#pack-primary')!
-const packRestartButton = document.querySelector('#pack-restart')!
-const packSkipButton = document.querySelector('#pack-skip')!
-
-function setControlsCollapsed(collapsed: boolean) {
- topbar.classList.toggle('controls-collapsed', collapsed)
- document.documentElement.classList.toggle('compact-controls', collapsed)
- controlsToggle.setAttribute('aria-expanded', String(!collapsed))
- controlsToggle.querySelector('.controls-toggle-icon')!.textContent =
- collapsed ? 'v' : '^'
-}
-
-setControlsCollapsed(window.matchMedia('(max-width: 880px)').matches)
-
-const renderer = new THREE.WebGLRenderer({
- canvas,
- antialias: true,
- alpha: false,
- powerPreference: 'high-performance',
-})
-renderer.outputColorSpace = THREE.SRGBColorSpace
-renderer.toneMapping = THREE.ACESFilmicToneMapping
-renderer.toneMappingExposure = 1.0
-renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
-
-const scene = new THREE.Scene()
-scene.background = new THREE.Color('#0b0e14')
-
-const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
-camera.position.set(0, 0, 8.2)
-
-const orbit = new OrbitControls(camera, canvas)
-orbit.enableDamping = true
-orbit.dampingFactor = 0.08
-orbit.enablePan = true
-orbit.minDistance = 5.0
-orbit.maxDistance = 12
-orbit.target.set(0, 0, 0)
-orbit.mouseButtons.LEFT = THREE.MOUSE.ROTATE
-orbit.mouseButtons.RIGHT = THREE.MOUSE.PAN
-orbit.enabled = false
-
-const environments: Record = {
- Studio: createStudioCubeTexture(),
- Warm: createStudioCubeTexture(['#e1aa62', '#402419', '#fff0c8', '#20120c', '#9c5d35', '#28130d']),
- Cool: createStudioCubeTexture(['#94bfe8', '#14283d', '#e8f4ff', '#0b1522', '#527da8', '#111c2d']),
- Dark: createStudioCubeTexture(['#544836', '#090d16', '#8b7655', '#05070c', '#27344c', '#120d0b']),
- Neutral: createStudioCubeTexture(['#d9dde2', '#30343a', '#ffffff', '#1a1d22', '#9ca3ac', '#25282e']),
-}
-let activeEnvironmentName: EnvironmentName = 'Studio'
-let activeEnvironment = environments[activeEnvironmentName]
-scene.environment = activeEnvironment
-
-const cardRoot = new THREE.Group()
-scene.add(cardRoot)
-
-const edgeMaterial = new THREE.MeshPhysicalMaterial({
- color: '#9c8c69',
- roughness: 0.48,
- metalness: 0.05,
- clearcoat: 0.18,
-})
-const loader = new THREE.TextureLoader()
-const normalMap = await loader.loadAsync('/reference/linen-normal.png')
-normalMap.colorSpace = THREE.NoColorSpace
-normalMap.wrapS = normalMap.wrapT = THREE.RepeatWrapping
-
-const backTexture = await loader.loadAsync('/reference/card-back.png')
-backTexture.colorSpace = THREE.SRGBColorSpace
-backTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
-
-const initialArtwork = await loader.loadAsync(fixtureAssets.David.artwork)
-const initialMask = await loader.loadAsync(fixtureAssets.David.mask)
-configureFrontTexture(initialArtwork, THREE.SRGBColorSpace)
-configureFrontTexture(initialMask, THREE.NoColorSpace)
-
-const lightPosition = new THREE.Vector3(2.2, 2.5, 4.4)
-const frontMaterial = createCardMaterial(
- initialArtwork,
- initialMask,
- normalMap,
- activeEnvironment,
- lightPosition,
-)
-const backMaterial = new THREE.MeshPhysicalMaterial({
- map: backTexture,
- roughness: 0.42,
- metalness: 0,
- clearcoat: 0.2,
- clearcoatRoughness: 0.24,
- normalMap: null,
- side: THREE.DoubleSide,
-})
-
-cardRoot.add(createCardGeometry(frontMaterial, backMaterial, edgeMaterial))
-
-const lightTarget = new THREE.Object3D()
-scene.add(lightTarget)
-
-const pointLight = new THREE.PointLight('#fff0d0', 34, 20, 1.7)
-const directionalLight = new THREE.DirectionalLight('#fff0d0', 5.4)
-const spotLight = new THREE.SpotLight('#fff0d0', 54, 20, THREE.MathUtils.degToRad(28), 0.55, 1.7)
-directionalLight.target = lightTarget
-spotLight.target = lightTarget
-for (const light of [pointLight, directionalLight, spotLight]) {
- light.position.copy(lightPosition)
- light.castShadow = false
- scene.add(light)
-}
-
-const floor = new THREE.Mesh(
- new THREE.PlaneGeometry(30, 30),
- new THREE.MeshStandardMaterial({ color: '#090b10', roughness: 0.82, metalness: 0.05 }),
-)
-floor.rotation.x = -Math.PI / 2
-floor.position.y = -2.7
-floor.receiveShadow = true
-scene.add(floor)
-
-const controls: MaterialControls & {
- lightX: number
- lightY: number
- lightZ: number
- lightIntensity: number
- lightColor: string
- lightType: LightType
- lightingPreset: LightingPreset
- exposure: number
- target: ManipulationTarget
-} = {
- finish: 'Holographic',
- substrate: 'Paper',
- finishStrength: 0.6,
- roughness: 0.23,
- normalStrength: 0.14,
- environmentIntensity: 0.7,
- condition: 1,
- imperfectionSeed: 81251,
- lightX: lightPosition.x,
- lightY: lightPosition.y,
- lightZ: lightPosition.z,
- lightIntensity: 3,
- lightColor: '#fff0d0',
- lightType: 'Point',
- lightingPreset: 'Studio',
- exposure: 1,
- target: 'Card',
-}
-
-const lightingPresets: Record, {
- environment: EnvironmentName
- lightType: LightType
- lightColor: string
- lightPosition: [number, number, number]
- lightIntensity: number
- environmentIntensity: number
- exposure: number
-}> = {
- Studio: {
- environment: 'Studio',
- lightType: 'Point',
- lightColor: '#fff0d0',
- lightPosition: [2.2, 2.5, 4.4],
- lightIntensity: 3,
- environmentIntensity: 0.7,
- exposure: 1,
- },
- 'Warm gallery': {
- environment: 'Warm',
- lightType: 'Spot',
- lightColor: '#ffd29a',
- lightPosition: [-2.8, 3.1, 4.8],
- lightIntensity: 4,
- environmentIntensity: 0.52,
- exposure: 1.04,
- },
- 'Cool window': {
- environment: 'Cool',
- lightType: 'Directional',
- lightColor: '#bddcff',
- lightPosition: [-3.4, 4.2, 5.2],
- lightIntensity: 2.7,
- environmentIntensity: 0.78,
- exposure: 1,
- },
- 'Dramatic spot': {
- environment: 'Dark',
- lightType: 'Spot',
- lightColor: '#ffe1ad',
- lightPosition: [3.6, 1.9, 3.6],
- lightIntensity: 4.8,
- environmentIntensity: 0.28,
- exposure: 0.92,
- },
- 'Flat review': {
- environment: 'Neutral',
- lightType: 'Directional',
- lightColor: '#ffffff',
- lightPosition: [0.6, 0.8, 5.5],
- lightIntensity: 2.25,
- environmentIntensity: 1,
- exposure: 1,
- },
-}
-let applyingLightingPreset = false
-
-const gui = new GUI({ title: 'Material proof' })
-const surfaceFolder = gui.addFolder('Surface')
-surfaceFolder.add(controls, 'finish', ['Printed ink', 'Foil', 'Holographic']).name('Finish').onChange((value: FinishName) => {
- pauseScriptedMotion()
- finishSelect.value = value
- updateMaterial()
- updateStatus()
-})
-surfaceFolder.add(controls, 'substrate', ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood']).name('Material').onChange((value: SubstrateName) => {
- pauseScriptedMotion()
- substrateSelect.value = value
- updateMaterial()
- updateEdgeMaterial()
- updateStatus()
-})
-surfaceFolder.add(controls, 'finishStrength', 0, 1, 0.01).name('Finish strength').onChange(() => {
- pauseScriptedMotion()
- updateMaterial()
-})
-surfaceFolder.add(controls, 'roughness', 0.05, 0.8, 0.01).name('Roughness').onChange(() => {
- pauseScriptedMotion()
- updateMaterial()
-})
-surfaceFolder.add(controls, 'normalStrength', 0, 0.45, 0.01).name('Surface detail').onChange(() => {
- pauseScriptedMotion()
- updateMaterial()
-})
-surfaceFolder.add(controls, 'environmentIntensity', 0, 1.5, 0.01).name('Environment').onChange(() => {
- pauseScriptedMotion()
- markLightingCustom()
- updateMaterial()
-})
-
-const wearFolder = gui.addFolder('Wear')
-wearFolder.add(controls, 'condition', 0, 1, 0.001).name('Condition (1=mint)').onChange(() => {
- pauseScriptedMotion()
- updateMaterial()
- updateEdgeMaterial()
-})
-wearFolder.add(controls, 'imperfectionSeed', 1, 99999, 1).name('Seed').onChange(() => {
- pauseScriptedMotion()
- updateMaterial()
-})
-wearFolder.add({
- randomize: () => {
- pauseScriptedMotion()
- const randomValue = crypto.getRandomValues(new Uint32Array(1))[0]
- controls.imperfectionSeed = 1 + randomValue % 99999
- updateMaterial()
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
- },
-}, 'randomize').name('Randomize seed')
-
-const lightFolder = gui.addFolder('Light')
-lightFolder.add(controls, 'lightingPreset', ['Studio', 'Warm gallery', 'Cool window', 'Dramatic spot', 'Flat review', 'Custom']).name('Preset').onChange((value: LightingPreset) => {
- if (value !== 'Custom') applyLightingPreset(value)
-})
-lightFolder.add(controls, 'lightType', ['Point', 'Directional', 'Spot']).name('Type').onChange((value: LightType) => {
- pauseScriptedMotion()
- controls.lightType = value
- lightTypeSelect.value = value
- markLightingCustom()
- updateLight()
-})
-lightFolder.addColor(controls, 'lightColor').name('Color').onChange(() => {
- pauseScriptedMotion()
- markLightingCustom()
- updateLight()
-})
-lightFolder.add(controls, 'lightX', -6, 6, 0.05).name('X').onChange(() => {
- const value = controls.lightX
- pauseScriptedMotion()
- controls.lightX = value
- markLightingCustom()
- updateLight()
-})
-lightFolder.add(controls, 'lightY', -6, 6, 0.05).name('Y').onChange(() => {
- const value = controls.lightY
- pauseScriptedMotion()
- controls.lightY = value
- markLightingCustom()
- updateLight()
-})
-lightFolder.add(controls, 'lightZ', 0.5, 9, 0.05).name('Z').onChange(() => {
- const value = controls.lightZ
- pauseScriptedMotion()
- controls.lightZ = value
- markLightingCustom()
- updateLight()
-})
-lightFolder.add(controls, 'lightIntensity', 0, 8, 0.05).name('Intensity').onChange(() => {
- pauseScriptedMotion()
- markLightingCustom()
- updateLight()
-})
-lightFolder.add(controls, 'exposure', 0.5, 1.8, 0.01).name('Exposure').onChange(() => {
- pauseScriptedMotion()
- markLightingCustom()
- renderer.toneMappingExposure = controls.exposure
-})
-gui.add(controls, 'target', ['Card', 'Camera']).name('Drag target').onChange(() => {
- pauseScriptedMotion()
- updateMode()
-})
-
-const assetActions = {
- loadArtwork: () => artworkFileInput.click(),
- loadMask: () => maskFileInput.click(),
- restoreFixture: () => {
- pauseScriptedMotion()
- void loadFixture(fixtureSelect.value as FixtureName)
- },
-}
-const assetFolder = gui.addFolder('Assets')
-assetFolder.add(assetActions, 'loadArtwork').name('Load artwork image')
-assetFolder.add(assetActions, 'loadMask').name('Load finish mask')
-assetFolder.add(assetActions, 'restoreFixture').name('Restore selected card')
-
-const experimentControls = {
- name: 'comparison-1',
-}
-const experimentActions = {
- save: () => saveExperimentToBrowser(),
- load: () => void loadExperimentFromBrowser(),
- remove: () => removeExperimentFromBrowser(),
- exportJson: () => exportExperimentJson(),
- importJson: () => experimentFileInput.click(),
- capturePng: () => captureExperimentPng(),
-}
-const experimentFolder = gui.addFolder('Experiments')
-experimentFolder.add(experimentControls, 'name').name('Name')
-experimentFolder.add(experimentActions, 'save').name('Save in browser')
-experimentFolder.add(experimentActions, 'load').name('Load from browser')
-experimentFolder.add(experimentActions, 'remove').name('Delete browser save')
-experimentFolder.add(experimentActions, 'exportJson').name('Export JSON')
-experimentFolder.add(experimentActions, 'importJson').name('Import JSON')
-experimentFolder.add(experimentActions, 'capturePng').name('Capture PNG')
-gui.hide()
-
-let mode: AppMode = 'Inspect'
-let textureRequest = 0
-let activeFixture: FixtureName = 'David'
-let customArtworkName: string | undefined
-let customMaskName: string | undefined
-let dragging = false
-let lastPointer = new THREE.Vector2()
-const activePointers = new Map()
-let lastPinchDistance: number | undefined
-const rotationTarget = new THREE.Euler()
-let scriptedMotion = false
-let scriptStartedAt = 0
-let scriptStartRotation = new THREE.Euler()
-let scriptStartLight = new THREE.Vector3()
-let scriptTargetLightX = 0
-const clock = new THREE.Clock()
-const frameTimes: number[] = []
-let lastPerformanceUpdate = performance.now()
-let lastFrameTime: number | undefined
-let pack: PackOpening | undefined
-let packLoading = false
-let packError: string | undefined
-
-function updateMaterial() {
- applyMaterialControls(frontMaterial, controls)
- pack?.syncLighting(frontMaterial)
-}
-
-function updateEdgeMaterial() {
- applyEdgeMaterialControls(edgeMaterial, controls)
-}
-
-function syncRuntimeLightPositions() {
- pointLight.position.copy(lightPosition)
- directionalLight.position.copy(lightPosition)
- spotLight.position.copy(lightPosition)
-}
-
-function updateLight() {
- lightPosition.set(controls.lightX, controls.lightY, controls.lightZ)
- syncRuntimeLightPositions()
- pointLight.color.set(controls.lightColor)
- directionalLight.color.set(controls.lightColor)
- spotLight.color.set(controls.lightColor)
- pointLight.intensity = controls.lightIntensity * 11.3
- directionalLight.intensity = controls.lightIntensity * 1.8
- spotLight.intensity = controls.lightIntensity * 18
- pointLight.visible = controls.lightType === 'Point'
- directionalLight.visible = controls.lightType === 'Directional'
- spotLight.visible = controls.lightType === 'Spot'
- const lightModes: Record = {
- Point: 0,
- Directional: 1,
- Spot: 2,
- }
- frontMaterial.uniforms.lightMode.value = lightModes[controls.lightType]
- frontMaterial.uniforms.lightColor.value.set(controls.lightColor)
- frontMaterial.uniforms.lightIntensity.value = controls.lightIntensity
- pack?.syncLighting(frontMaterial)
-}
-
-function markLightingCustom() {
- if (applyingLightingPreset || controls.lightingPreset === 'Custom') return
- controls.lightingPreset = 'Custom'
- lightingPresetSelect.value = 'Custom'
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
-}
-
-function applyLightingPreset(presetName: Exclude) {
- pauseScriptedMotion()
- applyingLightingPreset = true
- const preset = lightingPresets[presetName]
- controls.lightingPreset = presetName
- controls.lightType = preset.lightType
- controls.lightColor = preset.lightColor
- controls.lightX = preset.lightPosition[0]
- controls.lightY = preset.lightPosition[1]
- controls.lightZ = preset.lightPosition[2]
- controls.lightIntensity = preset.lightIntensity
- controls.environmentIntensity = preset.environmentIntensity
- controls.exposure = preset.exposure
- lightingPresetSelect.value = presetName
- lightTypeSelect.value = preset.lightType
- activeEnvironmentName = preset.environment
- activeEnvironment = environments[activeEnvironmentName]
- scene.environment = activeEnvironment
- frontMaterial.uniforms.studioEnvironment.value = activeEnvironment
- renderer.toneMappingExposure = preset.exposure
- updateMaterial()
- updateLight()
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
- applyingLightingPreset = false
-}
-
-function updateMode() {
- const previousMode = mode
- mode = modeSelect.value as AppMode
- if (previousMode !== mode) {
- dragging = false
- lastPinchDistance = undefined
- for (const id of activePointers.keys()) {
- if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id)
- }
- activePointers.clear()
- if (mode === 'Pack') {
- // Freeze the normal pose, including a partially finished flip, while it is hidden.
- rotationTarget.copy(cardRoot.rotation)
- loadingElement.hidden = true
- if (window.matchMedia('(max-width: 880px)').matches) setControlsCollapsed(true)
- void preparePack()
- } else {
- pack?.setActive(false)
- }
- }
- cardRoot.visible = mode !== 'Pack'
- document.documentElement.classList.toggle('pack-mode', mode === 'Pack')
- packControlsElement.hidden = mode !== 'Pack'
- const cameraTarget = mode === 'Lab' && controls.target === 'Camera'
- orbit.enabled = cameraTarget
- canvas.classList.toggle('camera-target', cameraTarget)
- gui.show(mode === 'Lab')
- hintElement.textContent = mode === 'Inspect'
- ? 'Drag the card · pinch or wheel to zoom · flip to inspect the back'
- : cameraTarget
- ? 'Camera target · drag to orbit · pinch or wheel to zoom'
- : 'Card target · drag to rotate · pinch or wheel to zoom'
- updateStatus()
-}
-
-function updateStatus() {
- if (mode === 'Pack') {
- updatePackUI()
- return
- }
- const artworkLabel = customArtworkName ? `Custom: ${customArtworkName}` : activeFixture
- const maskLabel = customMaskName ? ` · mask: ${customMaskName}` : ''
- statusElement.textContent = `${artworkLabel} · ${controls.substrate} · ${controls.finish}${maskLabel}`
-}
-
-function updatePackUI() {
- if (mode !== 'Pack') return
- const view = pack?.view
- packControlsElement.dataset.state = pack?.state ?? (packError ? 'error' : 'loading')
- packControlsElement.dataset.paused = String(pack?.paused ?? false)
- packTearProgress.hidden = !!pack && pack.state !== 'sealed' && pack.state !== 'opening'
- packTearProgress.value = pack?.tearProgress ?? 0
- packPrimaryButton.textContent = view?.action ?? (packError ? 'Retry loading' : 'Loading…')
- packPrimaryButton.disabled = packLoading || pack?.state === 'complete'
- packRestartButton.disabled = !pack
- packSkipButton.disabled = !pack || pack.state === 'complete'
- packSkipButton.title = 'Finish this motion or move to the next resting state'
- const status = view?.status ?? packError ?? 'Preparing the prototype pack…'
- if (packStatusElement.textContent !== status) packStatusElement.textContent = status
- statusElement.textContent = 'Foil tear proof · 3 authored cards · no random rewards'
- hintElement.textContent = view?.hint ?? 'Local reference artwork · approved runtime card materials'
-}
-
-async function preparePack() {
- if (pack) {
- pack.syncLighting(frontMaterial)
- pack.setActive(true)
- updatePackUI()
- return
- }
- if (packLoading) return
- packLoading = true
- packError = undefined
- updatePackUI()
- try {
- // Separate texture ownership keeps local artwork, experiments and fixture disposal independent.
- const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask,
- fixtureAssets.Timothy.artwork, fixtureAssets.Timothy.mask]
- const results = await Promise.allSettled(paths.map((path) => loader.loadAsync(path)))
- const failure = results.find((result) => result.status === 'rejected')
- if (failure?.status === 'rejected') {
- for (const result of results) {
- if (result.status === 'fulfilled') result.value.dispose()
- }
- throw failure.reason
- }
- const textures = results.map((result, index) => {
- if (result.status !== 'fulfilled') throw new Error(`Missing pack asset: ${paths[index]}`)
- configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace)
- return result.value
- })
- pack = new PackOpening({
- canvas,
- textures: {
- David: { artwork: textures[0], mask: textures[1] },
- Timothy: { artwork: textures[2], mask: textures[3] },
- },
- backMaterial,
- normalMap,
- environment: activeEnvironment,
- lightPosition,
- onChange: updatePackUI,
- })
- scene.add(pack.root)
- pack.syncLighting(frontMaterial)
- pack.setActive(mode === 'Pack')
- pack.resize(canvas.clientWidth, canvas.clientHeight)
- } catch (error) {
- packError = `Pack assets unavailable: ${error instanceof Error ? error.message : String(error)}`
- console.error('Could not prepare pack prototype', error)
- } finally {
- packLoading = false
- updatePackUI()
- }
-}
-
-function isFiniteNumber(value: unknown): value is number {
- return typeof value === 'number' && Number.isFinite(value)
-}
-
-function isVectorTuple(value: unknown): value is [number, number, number] {
- return Array.isArray(value) &&
- value.length === 3 &&
- value.every(isFiniteNumber)
-}
-
-function isExperimentSnapshot(value: unknown): value is ExperimentSnapshot {
- if (typeof value !== 'object' || value === null) return false
- const snapshot = value as Record
- const fixtureValid = snapshot.fixture === 'David' || snapshot.fixture === 'Timothy'
- const finishValid =
- snapshot.finish === 'Printed ink' ||
- snapshot.finish === 'Foil' ||
- snapshot.finish === 'Holographic'
- const substrateValid =
- snapshot.substrate === 'Paper' ||
- snapshot.substrate === 'Linen' ||
- snapshot.substrate === 'Plastic' ||
- snapshot.substrate === 'Metal' ||
- snapshot.substrate === 'Wood'
- const lightingPresetValid =
- snapshot.lightingPreset === 'Studio' ||
- snapshot.lightingPreset === 'Warm gallery' ||
- snapshot.lightingPreset === 'Cool window' ||
- snapshot.lightingPreset === 'Dramatic spot' ||
- snapshot.lightingPreset === 'Flat review' ||
- snapshot.lightingPreset === 'Custom'
- const environmentValid =
- snapshot.environment === 'Studio' ||
- snapshot.environment === 'Warm' ||
- snapshot.environment === 'Cool' ||
- snapshot.environment === 'Dark' ||
- snapshot.environment === 'Neutral'
- const lightTypeValid =
- snapshot.lightType === 'Point' ||
- snapshot.lightType === 'Directional' ||
- snapshot.lightType === 'Spot'
- const optionalNamesValid =
- (snapshot.customArtworkName === undefined || typeof snapshot.customArtworkName === 'string') &&
- (snapshot.customMaskName === undefined || typeof snapshot.customMaskName === 'string')
-
- const schemaValid = snapshot.schemaVersion === 1 || snapshot.schemaVersion === 2
- const wearValid = snapshot.schemaVersion === 1 ||
- (isFiniteNumber(snapshot.condition) && isFiniteNumber(snapshot.imperfectionSeed))
-
- return schemaValid &&
- snapshot.runtimeRevision === 'runtime-look-v4-2026-09-07' &&
- typeof snapshot.name === 'string' &&
- typeof snapshot.savedAt === 'string' &&
- fixtureValid &&
- optionalNamesValid &&
- finishValid &&
- substrateValid &&
- isFiniteNumber(snapshot.finishStrength) &&
- isFiniteNumber(snapshot.roughness) &&
- isFiniteNumber(snapshot.normalStrength) &&
- isFiniteNumber(snapshot.environmentIntensity) &&
- wearValid &&
- lightingPresetValid &&
- environmentValid &&
- lightTypeValid &&
- typeof snapshot.lightColor === 'string' &&
- /^#[0-9a-f]{6}$/i.test(snapshot.lightColor) &&
- isVectorTuple(snapshot.lightPosition) &&
- isFiniteNumber(snapshot.lightIntensity) &&
- isFiniteNumber(snapshot.exposure) &&
- isVectorTuple(snapshot.cardRotation) &&
- isVectorTuple(snapshot.cameraPosition) &&
- isVectorTuple(snapshot.orbitTarget)
-}
-
-function normalizedExperimentName() {
- const name = experimentControls.name.trim()
- if (!name) {
- statusElement.textContent = 'Enter an experiment name first.'
- return undefined
- }
- return name
-}
-
-function createExperimentSnapshot(name: string): ExperimentSnapshot {
- pauseScriptedMotion()
- return {
- schemaVersion: 2,
- runtimeRevision: 'runtime-look-v4-2026-09-07',
- name,
- savedAt: new Date().toISOString(),
- fixture: activeFixture,
- customArtworkName,
- customMaskName,
- finish: controls.finish,
- substrate: controls.substrate,
- finishStrength: controls.finishStrength,
- roughness: controls.roughness,
- normalStrength: controls.normalStrength,
- environmentIntensity: controls.environmentIntensity,
- condition: controls.condition,
- imperfectionSeed: controls.imperfectionSeed,
- lightingPreset: controls.lightingPreset,
- environment: activeEnvironmentName,
- lightType: controls.lightType,
- lightColor: controls.lightColor,
- lightPosition: [lightPosition.x, lightPosition.y, lightPosition.z],
- lightIntensity: controls.lightIntensity,
- exposure: controls.exposure,
- cardRotation: [cardRoot.rotation.x, cardRoot.rotation.y, cardRoot.rotation.z],
- cameraPosition: [camera.position.x, camera.position.y, camera.position.z],
- orbitTarget: [orbit.target.x, orbit.target.y, orbit.target.z],
- }
-}
-
-function safeFilename(name: string) {
- const safe = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
- return safe || 'card-experiment'
-}
-
-function downloadBlob(blob: Blob, filename: string) {
- const url = URL.createObjectURL(blob)
- const anchor = document.createElement('a')
- anchor.href = url
- anchor.download = filename
- anchor.click()
- setTimeout(() => URL.revokeObjectURL(url), 0)
-}
-
-function experimentStorageKey(name: string) {
- return `sanctification-card-lab:experiment:${name}`
-}
-
-function saveExperimentToBrowser() {
- const name = normalizedExperimentName()
- if (!name) return
- try {
- localStorage.setItem(
- experimentStorageKey(name),
- JSON.stringify(createExperimentSnapshot(name)),
- )
- statusElement.textContent = `Saved experiment "${name}" in this browser.`
- } catch (error) {
- statusElement.textContent =
- `Could not save experiment: ${error instanceof Error ? error.message : String(error)}`
- }
-}
-
-async function loadExperimentFromBrowser() {
- const name = normalizedExperimentName()
- if (!name) return
- try {
- const stored = localStorage.getItem(experimentStorageKey(name))
- if (stored === null) {
- statusElement.textContent = `No browser experiment named "${name}".`
- return
- }
- const parsed: unknown = JSON.parse(stored)
- if (!isExperimentSnapshot(parsed)) {
- statusElement.textContent = `Experiment "${name}" has an unsupported or invalid format.`
- return
- }
- await applyExperimentSnapshot(parsed)
- } catch (error) {
- statusElement.textContent =
- `Could not load experiment: ${error instanceof Error ? error.message : String(error)}`
- }
-}
-
-function removeExperimentFromBrowser() {
- const name = normalizedExperimentName()
- if (!name) return
- const key = experimentStorageKey(name)
- if (localStorage.getItem(key) === null) {
- statusElement.textContent = `No browser experiment named "${name}".`
- return
- }
- localStorage.removeItem(key)
- statusElement.textContent = `Deleted browser experiment "${name}".`
-}
-
-function exportExperimentJson() {
- const name = normalizedExperimentName()
- if (!name) return
- const snapshot = createExperimentSnapshot(name)
- downloadBlob(
- new Blob([JSON.stringify(snapshot, null, 2)], { type: 'application/json' }),
- `${safeFilename(name)}.card-experiment.json`,
- )
- statusElement.textContent = `Exported experiment "${name}".`
-}
-
-function captureExperimentPng() {
- const name = normalizedExperimentName()
- if (!name) return
- pauseScriptedMotion()
- resizeRenderer()
- renderer.render(scene, camera)
- canvas.toBlob((blob) => {
- if (blob === null) {
- statusElement.textContent = 'Could not capture the current render.'
- return
- }
- downloadBlob(blob, `${safeFilename(name)}.png`)
- statusElement.textContent = `Captured experiment "${name}".`
- }, 'image/png')
-}
-
-async function applyExperimentSnapshot(snapshot: ExperimentSnapshot) {
- pauseScriptedMotion()
- fixtureSelect.value = snapshot.fixture
- await loadFixture(snapshot.fixture)
- controls.finish = snapshot.finish
- controls.substrate = snapshot.substrate
- controls.finishStrength = THREE.MathUtils.clamp(snapshot.finishStrength, 0, 1)
- controls.roughness = THREE.MathUtils.clamp(snapshot.roughness, 0.05, 0.8)
- controls.normalStrength = THREE.MathUtils.clamp(snapshot.normalStrength, 0, 0.45)
- controls.environmentIntensity = THREE.MathUtils.clamp(snapshot.environmentIntensity, 0, 1.5)
- controls.condition = THREE.MathUtils.clamp(snapshot.condition ?? 1, 0, 1)
- controls.imperfectionSeed = Math.round(
- THREE.MathUtils.clamp(snapshot.imperfectionSeed ?? 81251, 1, 99999),
- )
- controls.lightingPreset = snapshot.lightingPreset
- controls.lightType = snapshot.lightType
- controls.lightColor = snapshot.lightColor
- controls.lightX = THREE.MathUtils.clamp(snapshot.lightPosition[0], -6, 6)
- controls.lightY = THREE.MathUtils.clamp(snapshot.lightPosition[1], -6, 6)
- controls.lightZ = THREE.MathUtils.clamp(snapshot.lightPosition[2], 0.5, 9)
- controls.lightIntensity = THREE.MathUtils.clamp(snapshot.lightIntensity, 0, 8)
- controls.exposure = THREE.MathUtils.clamp(snapshot.exposure, 0.5, 1.8)
- activeEnvironmentName = snapshot.environment
- activeEnvironment = environments[activeEnvironmentName]
- scene.environment = activeEnvironment
- frontMaterial.uniforms.studioEnvironment.value = activeEnvironment
- renderer.toneMappingExposure = controls.exposure
- finishSelect.value = controls.finish
- substrateSelect.value = controls.substrate
- lightingPresetSelect.value = controls.lightingPreset
- lightTypeSelect.value = controls.lightType
- poseSelect.value = 'Free'
- cardRoot.rotation.set(
- THREE.MathUtils.clamp(snapshot.cardRotation[0], -1.25, 1.25),
- snapshot.cardRotation[1],
- THREE.MathUtils.clamp(snapshot.cardRotation[2], -Math.PI, Math.PI),
- )
- rotationTarget.copy(cardRoot.rotation)
- camera.position.set(...snapshot.cameraPosition)
- if (camera.position.lengthSq() < 0.0001) camera.position.set(0, 0, 8.2)
- camera.position.setLength(THREE.MathUtils.clamp(camera.position.length(), 5, 12))
- orbit.target.set(
- THREE.MathUtils.clamp(snapshot.orbitTarget[0], -5, 5),
- THREE.MathUtils.clamp(snapshot.orbitTarget[1], -5, 5),
- THREE.MathUtils.clamp(snapshot.orbitTarget[2], -5, 5),
- )
- orbit.update()
- updateMaterial()
- updateEdgeMaterial()
- updateLight()
- experimentControls.name = snapshot.name
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
-
- const customAssetNote = snapshot.customArtworkName || snapshot.customMaskName
- ? ' Local artwork or masks must be loaded again.'
- : ''
- if (mode !== 'Pack') statusElement.textContent = `Loaded experiment "${snapshot.name}".${customAssetNote}`
-}
-
-function syncLightControls() {
- controls.lightX = lightPosition.x
- controls.lightY = lightPosition.y
- controls.lightZ = lightPosition.z
-}
-
-function pauseScriptedMotion() {
- if (!scriptedMotion) return
- scriptedMotion = false
- rotationTarget.copy(cardRoot.rotation)
- syncLightControls()
- sweepButton.textContent = 'Play sweep'
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
-}
-
-function applyInspectionPose(pose: InspectionPose) {
- pauseScriptedMotion()
- if (pose === 'Free') return
- const poses: Record, [number, number, number]> = {
- Front: [-0.06, 0.12, 0],
- Grazing: [-0.14, THREE.MathUtils.degToRad(52), 0],
- Edge: [-0.05, Math.PI / 2, 0],
- Back: [-0.06, Math.PI + 0.12, 0],
- }
- rotationTarget.set(...poses[pose])
-}
-
-function resetView() {
- pauseScriptedMotion()
- const resetFixture = activeFixture !== 'David' || customArtworkName !== undefined || customMaskName !== undefined
- rotationTarget.set(-0.06, 0.12, 0)
- cardRoot.rotation.copy(rotationTarget)
- camera.position.set(0, 0, 8.2)
- orbit.target.set(0, 0, 0)
- orbit.update()
- modeSelect.value = 'Inspect'
- controls.target = 'Card'
- fixtureSelect.value = 'David'
- finishSelect.value = 'Holographic'
- substrateSelect.value = 'Paper'
- poseSelect.value = 'Front'
- controls.finish = 'Holographic'
- controls.substrate = 'Paper'
- controls.finishStrength = 0.6
- controls.roughness = 0.23
- controls.normalStrength = 0.14
- controls.condition = 1
- controls.imperfectionSeed = 81251
- applyLightingPreset('Studio')
- updateMaterial()
- updateEdgeMaterial()
- updateMode()
- if (resetFixture) {
- void loadFixture('David')
- } else {
- updateStatus()
- }
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
-}
-
-async function loadFixture(nextFixture: FixtureName) {
- const request = ++textureRequest
- loadingElement.hidden = false
- statusElement.textContent = `Loading ${nextFixture} artwork and finish mask…`
- const asset = fixtureAssets[nextFixture]
-
- try {
- const results = await Promise.allSettled([
- loader.loadAsync(asset.artwork),
- loader.loadAsync(asset.mask),
- ])
- const [artworkResult, maskResult] = results
- if (artworkResult.status === 'rejected') {
- if (maskResult.status === 'fulfilled') maskResult.value.dispose()
- throw artworkResult.reason
- }
- if (maskResult.status === 'rejected') {
- artworkResult.value.dispose()
- throw maskResult.reason
- }
- const artwork = artworkResult.value
- const mask = maskResult.value
- configureFrontTexture(artwork, THREE.SRGBColorSpace)
- configureFrontTexture(mask, THREE.NoColorSpace)
-
- if (request !== textureRequest) {
- artwork.dispose()
- mask.dispose()
- return
- }
-
- const previousArtwork = frontMaterial.uniforms.artwork.value as THREE.Texture
- const previousMask = frontMaterial.uniforms.finishMask.value as THREE.Texture
- frontMaterial.uniforms.artwork.value = artwork
- frontMaterial.uniforms.finishMask.value = mask
- frontMaterial.uniformsNeedUpdate = true
- activeFixture = nextFixture
- customArtworkName = undefined
- customMaskName = undefined
- previousArtwork.dispose()
- previousMask.dispose()
- updateStatus()
- } catch (error) {
- if (request === textureRequest) {
- fixtureSelect.value = activeFixture
- if (mode !== 'Pack') {
- statusElement.textContent = `Could not load ${nextFixture}: ${error instanceof Error ? error.message : String(error)}`
- }
- }
- } finally {
- if (request === textureRequest) loadingElement.hidden = true
- }
-}
-
-async function loadLocalTexture(file: File, target: 'artwork' | 'mask') {
- const request = ++textureRequest
- const objectUrl = URL.createObjectURL(file)
- loadingElement.hidden = false
- statusElement.textContent = `Loading ${target} from ${file.name}…`
-
- try {
- const texture = await loader.loadAsync(objectUrl)
- configureFrontTexture(
- texture,
- target === 'artwork' ? THREE.SRGBColorSpace : THREE.NoColorSpace,
- )
- if (request !== textureRequest) {
- texture.dispose()
- return
- }
-
- const uniformName = target === 'artwork' ? 'artwork' : 'finishMask'
- const previousTexture = frontMaterial.uniforms[uniformName].value as THREE.Texture
- frontMaterial.uniforms[uniformName].value = texture
- frontMaterial.uniformsNeedUpdate = true
- previousTexture.dispose()
- if (target === 'artwork') {
- customArtworkName = file.name
- } else {
- customMaskName = file.name
- }
- updateStatus()
- } catch (error) {
- if (request === textureRequest && mode !== 'Pack') {
- statusElement.textContent =
- `Could not load ${file.name}: ${error instanceof Error ? error.message : String(error)}`
- }
- } finally {
- URL.revokeObjectURL(objectUrl)
- if (request === textureRequest) loadingElement.hidden = true
- }
-}
-
-function startSweep() {
- if (scriptedMotion) {
- pauseScriptedMotion()
- return
- }
- scriptedMotion = true
- scriptStartedAt = clock.getElapsedTime()
- scriptStartRotation.copy(cardRoot.rotation)
- rotationTarget.copy(cardRoot.rotation)
- scriptStartLight.copy(lightPosition)
- scriptTargetLightX = scriptStartLight.x >= 0 ? -3.2 : 3.2
- poseSelect.value = 'Free'
- sweepButton.textContent = 'Stop sweep'
-}
-
-function resizeRenderer() {
- const width = canvas.clientWidth
- const height = canvas.clientHeight
- const pixelRatio = Math.min(window.devicePixelRatio, 2)
- const targetWidth = Math.floor(width * pixelRatio)
- const targetHeight = Math.floor(height * pixelRatio)
- if (canvas.width !== targetWidth || canvas.height !== targetHeight) {
- renderer.setPixelRatio(pixelRatio)
- renderer.setSize(width, height, false)
- camera.aspect = width / height
- camera.updateProjectionMatrix()
- pack?.resize(width, height)
- }
-}
-
-function setCameraDistance(distance: number) {
- const offset = camera.position.clone().sub(orbit.target)
- if (offset.lengthSq() < 0.0001) offset.set(0, 0, 1)
- offset.setLength(THREE.MathUtils.clamp(distance, orbit.minDistance, orbit.maxDistance))
- camera.position.copy(orbit.target).add(offset)
-}
-
-function getPinchDistance() {
- const pointers = [...activePointers.values()]
- return pointers.length < 2 ? undefined : pointers[0].distanceTo(pointers[1])
-}
-
-canvas.addEventListener('pointerdown', (event) => {
- if (mode === 'Pack') {
- pack?.pointerDown(event)
- return
- }
- if (orbit.enabled || event.button !== 0) return
- pauseScriptedMotion()
- activePointers.set(event.pointerId, new THREE.Vector2(event.clientX, event.clientY))
- canvas.setPointerCapture(event.pointerId)
- if (activePointers.size === 1) {
- dragging = true
- poseSelect.value = 'Free'
- lastPointer.set(event.clientX, event.clientY)
- } else {
- dragging = false
- lastPinchDistance = getPinchDistance()
- }
-})
-
-canvas.addEventListener('pointermove', (event) => {
- if (mode === 'Pack') {
- pack?.pointerMove(event)
- return
- }
- const pointer = activePointers.get(event.pointerId)
- if (!pointer) return
- pointer.set(event.clientX, event.clientY)
- if (activePointers.size >= 2) {
- const pinchDistance = getPinchDistance()
- if (pinchDistance && lastPinchDistance && pinchDistance > 0) {
- const cameraDistance = camera.position.distanceTo(orbit.target)
- setCameraDistance(cameraDistance * lastPinchDistance / pinchDistance)
- }
- lastPinchDistance = pinchDistance
- return
- }
- if (!dragging) return
- const dx = event.clientX - lastPointer.x
- const dy = event.clientY - lastPointer.y
- cardRoot.rotation.y += dx * 0.008
- cardRoot.rotation.x += dy * 0.008
- cardRoot.rotation.x = THREE.MathUtils.clamp(cardRoot.rotation.x, -1.25, 1.25)
- rotationTarget.copy(cardRoot.rotation)
- lastPointer.set(event.clientX, event.clientY)
-})
-
-canvas.addEventListener('pointerup', (event) => {
- if (mode === 'Pack') {
- pack?.pointerUp(event)
- return
- }
- activePointers.delete(event.pointerId)
- if (canvas.hasPointerCapture(event.pointerId)) {
- canvas.releasePointerCapture(event.pointerId)
- }
- lastPinchDistance = getPinchDistance()
- const remainingPointer = activePointers.values().next().value as THREE.Vector2 | undefined
- dragging = remainingPointer !== undefined
- if (remainingPointer) lastPointer.copy(remainingPointer)
-})
-
-canvas.addEventListener('pointercancel', (event) => {
- if (mode === 'Pack') {
- pack?.pointerUp(event, true)
- return
- }
- activePointers.delete(event.pointerId)
- lastPinchDistance = getPinchDistance()
- const remainingPointer = activePointers.values().next().value as THREE.Vector2 | undefined
- dragging = remainingPointer !== undefined
- if (remainingPointer) lastPointer.copy(remainingPointer)
-})
-
-canvas.addEventListener('wheel', (event) => {
- if (mode === 'Pack') {
- event.preventDefault()
- pack?.wheel(event)
- return
- }
- if (orbit.enabled) return
- event.preventDefault()
- pauseScriptedMotion()
- const distance = camera.position.distanceTo(orbit.target)
- setCameraDistance(distance + event.deltaY * 0.006)
-}, { passive: false })
-
-canvas.addEventListener('lostpointercapture', (event) => {
- if (mode === 'Pack') pack?.pointerUp(event, true)
-})
-packPrimaryButton.addEventListener('click', () => {
- if (pack) pack.primary()
- else void preparePack()
-})
-packRestartButton.addEventListener('click', () => pack?.restart())
-packSkipButton.addEventListener('click', () => pack?.skip())
-
-modeSelect.addEventListener('change', () => {
- pauseScriptedMotion()
- updateMode()
-})
-fixtureSelect.addEventListener('change', () => {
- pauseScriptedMotion()
- void loadFixture(fixtureSelect.value as FixtureName)
-})
-substrateSelect.addEventListener('change', () => {
- pauseScriptedMotion()
- controls.substrate = substrateSelect.value as SubstrateName
- updateMaterial()
- updateEdgeMaterial()
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
- updateStatus()
-})
-finishSelect.addEventListener('change', () => {
- pauseScriptedMotion()
- controls.finish = finishSelect.value as FinishName
- updateMaterial()
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
- updateStatus()
-})
-poseSelect.addEventListener('change', () => {
- applyInspectionPose(poseSelect.value as InspectionPose)
-})
-lightingPresetSelect.addEventListener('change', () => {
- const preset = lightingPresetSelect.value as LightingPreset
- if (preset === 'Custom') {
- controls.lightingPreset = 'Custom'
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
- return
- }
- applyLightingPreset(preset)
-})
-lightTypeSelect.addEventListener('change', () => {
- pauseScriptedMotion()
- controls.lightType = lightTypeSelect.value as LightType
- markLightingCustom()
- updateLight()
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
-})
-artworkFileInput.addEventListener('change', () => {
- const file = artworkFileInput.files?.[0]
- artworkFileInput.value = ''
- if (file) {
- pauseScriptedMotion()
- void loadLocalTexture(file, 'artwork')
- }
-})
-maskFileInput.addEventListener('change', () => {
- const file = maskFileInput.files?.[0]
- maskFileInput.value = ''
- if (file) {
- pauseScriptedMotion()
- void loadLocalTexture(file, 'mask')
- }
-})
-experimentFileInput.addEventListener('change', () => {
- const file = experimentFileInput.files?.[0]
- experimentFileInput.value = ''
- if (!file) return
- void (async () => {
- try {
- const parsed: unknown = JSON.parse(await file.text())
- if (!isExperimentSnapshot(parsed)) {
- statusElement.textContent = `${file.name} is not a supported card experiment.`
- return
- }
- await applyExperimentSnapshot(parsed)
- } catch (error) {
- statusElement.textContent =
- `Could not import ${file.name}: ${error instanceof Error ? error.message : String(error)}`
- }
- })()
-})
-flipButton.addEventListener('click', () => {
- pauseScriptedMotion()
- poseSelect.value = 'Free'
- const currentYaw = cardRoot.rotation.y
- const normalized = THREE.MathUtils.euclideanModulo(currentYaw, Math.PI * 2)
- rotationTarget.copy(cardRoot.rotation)
- rotationTarget.y = currentYaw +
- (normalized > Math.PI / 2 && normalized < Math.PI * 1.5 ? -Math.PI : Math.PI)
-})
-resetButton.addEventListener('click', resetView)
-sweepButton.addEventListener('click', startSweep)
-controlsToggle.addEventListener('click', () => {
- setControlsCollapsed(!topbar.classList.contains('controls-collapsed'))
- requestAnimationFrame(resizeRenderer)
-})
-orbit.addEventListener('start', pauseScriptedMotion)
-
-function updatePerformance(now: number, deltaMs: number) {
- frameTimes.push(deltaMs)
- if (frameTimes.length > 240) frameTimes.shift()
- if (now - lastPerformanceUpdate < 1000 || frameTimes.length < 10) return
- const sorted = [...frameTimes].sort((a, b) => a - b)
- const median = sorted[Math.floor(sorted.length * 0.5)]
- const p95 = sorted[Math.floor(sorted.length * 0.95)]
- const worst = sorted[sorted.length - 1]
- performanceElement.textContent =
- `${renderer.domElement.width}×${renderer.domElement.height} · median ${median.toFixed(1)} ms · p95 ${p95.toFixed(1)} ms · worst ${worst.toFixed(1)} ms`
- lastPerformanceUpdate = now
-}
-
-function animate(frameTime: number) {
- const deltaSeconds = lastFrameTime === undefined
- ? 0
- : Math.min((frameTime - lastFrameTime) / 1000, 0.1)
- if (lastFrameTime !== undefined) {
- updatePerformance(frameTime, frameTime - lastFrameTime)
- }
- lastFrameTime = frameTime
- const elapsed = clock.getElapsedTime()
- resizeRenderer()
- if (mode !== 'Pack') orbit.update()
-
- if (mode !== 'Pack' && !dragging && !scriptedMotion) {
- cardRoot.rotation.x = THREE.MathUtils.damp(cardRoot.rotation.x, rotationTarget.x, 9, deltaSeconds)
- cardRoot.rotation.y = THREE.MathUtils.damp(cardRoot.rotation.y, rotationTarget.y, 9, deltaSeconds)
- cardRoot.rotation.z = THREE.MathUtils.damp(cardRoot.rotation.z, rotationTarget.z, 9, deltaSeconds)
- }
-
- if (mode !== 'Pack' && scriptedMotion) {
- const progress = (elapsed - scriptStartedAt) / 5
- if (progress >= 1) {
- scriptedMotion = false
- cardRoot.rotation.copy(scriptStartRotation)
- rotationTarget.copy(scriptStartRotation)
- lightPosition.copy(scriptStartLight)
- syncLightControls()
- updateLight()
- sweepButton.textContent = 'Play sweep'
- gui.controllersRecursive().forEach((controller) => controller.updateDisplay())
- } else {
- const eased = progress * progress * (3 - 2 * progress)
- const wave = Math.sin(eased * Math.PI)
- cardRoot.rotation.x = scriptStartRotation.x - 0.12 * wave
- cardRoot.rotation.y = scriptStartRotation.y + THREE.MathUtils.degToRad(24) * wave
- lightPosition.set(
- THREE.MathUtils.lerp(scriptStartLight.x, scriptTargetLightX, wave),
- scriptStartLight.y + wave * 0.8,
- scriptStartLight.z,
- )
- syncRuntimeLightPositions()
- }
- }
-
- if (mode === 'Pack') pack?.tick(frameTime)
- renderer.render(scene, mode === 'Pack' && pack ? pack.camera : camera)
-}
-
-renderer.setAnimationLoop(animate)
-window.addEventListener('resize', resizeRenderer)
-updateMaterial()
-updateEdgeMaterial()
-updateLight()
-updateMode()
-resetView()
-loadingElement.hidden = true
-updateStatus()
diff --git a/sanctification-tcg/card-harness/src/packOpening.ts b/sanctification-tcg/card-harness/src/packOpening.ts
deleted file mode 100644
index 20a0f16..0000000
--- a/sanctification-tcg/card-harness/src/packOpening.ts
+++ /dev/null
@@ -1,582 +0,0 @@
-import * as THREE from 'three'
-import { cardSceneDimensions, createCardGeometry } from './cardGeometry'
-import { createFoilWrapper } from './foilWrapper'
-import {
- applyEdgeMaterialControls,
- applyMaterialControls,
- createCardMaterial,
- type FinishName,
- type SubstrateName,
-} from './cardMaterial'
-
-export type PackFixture = 'David' | 'Timothy'
-export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted'
- | 'revealing' | 'inspecting' | 'advancing' | 'complete'
-
-export const packContents: readonly {
- fixture: PackFixture
- finish: FinishName
- substrate: SubstrateName
-}[] = [
- { fixture: 'David', finish: 'Printed ink', substrate: 'Linen' },
- { fixture: 'Timothy', finish: 'Foil', substrate: 'Paper' },
- { fixture: 'David', finish: 'Holographic', substrate: 'Metal' },
-]
-
-// Leaves ~0.039 scene units between the surfaces of adjacent 0.88-scale cards.
-const stackDepthSpacing = 0.055
-const cardSweepRadius = Math.hypot(
- cardSceneDimensions.width, cardSceneDimensions.height, cardSceneDimensions.thickness,
-) / 2
-// Clearance is based on the full corner sweep, not just the card's thickness or front pose.
-const inspectionDepth = Math.max(3, cardSweepRadius + 0.5)
-const inspectionCameraRetreat = inspectionDepth - 0.85
-
-interface PackOptions {
- canvas: HTMLCanvasElement
- textures: Record
- backMaterial: THREE.Material
- normalMap: THREE.Texture
- environment: THREE.CubeTexture
- lightPosition: THREE.Vector3
- onChange: () => void
-}
-
-interface Pose {
- position: THREE.Vector3
- quaternion: THREE.Quaternion
- scale: THREE.Vector3
-}
-
-interface Track {
- object: THREE.Object3D
- from: Pose
- to: Pose
-}
-
-interface Motion {
- tracks: Track[]
- startedAt: number
- elapsed: number
- duration: number
- finish: () => void
- progress?: (value: number) => void
-}
-
-function pose(x: number, y: number, z: number, rx = 0, ry = 0, rz = 0, scale = 1): Pose {
- return {
- position: new THREE.Vector3(x, y, z),
- quaternion: new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)),
- scale: new THREE.Vector3(scale, scale, scale),
- }
-}
-
-function setPose(object: THREE.Object3D, value: Pose) {
- object.position.copy(value.position)
- object.quaternion.copy(value.quaternion)
- object.scale.copy(value.scale)
-}
-
-function track(object: THREE.Object3D, to: Pose): Track {
- return {
- object,
- from: {
- position: object.position.clone(),
- quaternion: object.quaternion.clone(),
- scale: object.scale.clone(),
- },
- to,
- }
-}
-
-export class PackOpening {
- readonly root = new THREE.Group()
- readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
- state: PackState = 'sealed'
- paused = false
- private readonly options: PackOptions
- private readonly wrapper = createFoilWrapper()
- private readonly cards
- private readonly reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
- private motion: Motion | undefined
- private active = false
- private index = 0
- private readonly revealed = new Set()
- private completed = false
- private zoom = 1
- private fitDistance = 9.6
- private cameraRetreat = 0
- private cameraTargetY = 0
- private openingProgress = 0
- private seamDrag: { id: number; x: number; progress: number; travel: number } | undefined
- private readonly touch = new THREE.Vector3()
- private touchTarget = 0
- private lastTick: number | undefined
- private readonly pointers = new Map()
- private tap: { id: number; x: number; y: number; at: number } | undefined
- private pinchDistance: number | undefined
-
- constructor(options: PackOptions) {
- this.options = options
- this.root.name = 'PACK_PROTOTYPE'
- this.root.position.y = 0.22
- this.root.visible = false
- this.root.add(this.wrapper.root)
- this.cards = packContents.map((spec) => {
- const textures = options.textures[spec.fixture]
- const material = createCardMaterial(
- textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition,
- )
- applyMaterialControls(material, {
- ...spec, finishStrength: 0.6, roughness: 0.23, normalStrength: 0.14,
- environmentIntensity: 0.7, condition: 1, imperfectionSeed: 81251,
- })
- const edge = new THREE.MeshPhysicalMaterial()
- const object = createCardGeometry(material, options.backMaterial, edge)
- const front = object.getObjectByName('CARD_FRONT')!
- this.root.add(object)
- return { object, material, edge, front }
- })
- this.restart()
- }
-
- get view() {
- const number = `${this.index + 1} / ${this.cards.length}`
- const spec = packContents[this.index]
- const views: Record = {
- sealed: { action: 'Tear open', status: 'Sealed · three cards', hint: 'Drag the gold top seam to the right · or use Tear open' },
- opening: {
- action: this.motion && !this.paused ? 'Opening…' : 'Finish opening',
- status: this.openingProgress < 0.38
- ? `Tearing seal · ${Math.round(this.tearProgress * 100)}%`
- : this.openingProgress < 0.52 ? 'Strip curling away'
- : this.openingProgress < 0.62 ? 'Spreading the foil mouth'
- : this.openingProgress < 0.87 ? 'Sliding out three cards' : 'Settling the face-down stack',
- hint: this.openingProgress < 0.38
- ? 'Pull right along the seam · release to hold · Finish opening resumes'
- : 'Touch or wheel pauses motion · Skip finishes this step',
- },
- stackReady: { action: 'Lift card', status: `Card ${number} · face-down stack`, hint: 'Tap or Lift card · identities stay hidden until the flip' },
- lifting: { action: 'Lifting…', status: `Card ${number} · lifting face down`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
- lifted: { action: 'Reveal card', status: `Card ${number} · ready to turn`, hint: 'Tap or Reveal card to flip the front into view' },
- revealing: { action: 'Revealing…', status: `Card ${number} · turning over`, hint: 'Touch or wheel pauses motion · Skip finishes this step' },
- inspecting: {
- action: this.index === this.cards.length - 1 ? 'Finish pack' : 'Next card',
- status: `${number} · ${spec.fixture} · ${spec.substrate} · ${spec.finish}`,
- hint: 'Drag to rotate · pinch / wheel to zoom · tap to continue',
- },
- advancing: { action: 'Advancing…', status: 'Setting the revealed card aside', hint: 'Touch or wheel pauses motion · Skip finishes this step' },
- complete: { action: 'Pack complete', status: 'Complete · all three cards inspected', hint: 'Restart to replay the same authored order' },
- }
- return this.paused && this.motion
- ? { ...views[this.state], action: 'Continue', hint: 'Motion paused in place · Continue resumes · Skip finishes this step' }
- : views[this.state]
- }
-
- get tearProgress() { return Math.min(1, this.openingProgress / 0.38) }
-
- syncLighting(source: THREE.ShaderMaterial) {
- for (const { material } of this.cards) {
- for (const name of ['studioEnvironment', 'environmentIntensity', 'lightMode', 'lightIntensity']) {
- material.uniforms[name].value = source.uniforms[name].value
- }
- material.uniforms.lightColor.value.copy(source.uniforms.lightColor.value)
- }
- }
-
- setActive(active: boolean) {
- if (!active) this.pause(performance.now())
- this.clearPointers()
- this.active = active
- this.root.visible = active
- this.options.onChange()
- }
-
- resize(width: number, height: number) {
- if (width <= 0 || height <= 0) return
- this.camera.aspect = width / height
- this.camera.updateProjectionMatrix()
- const tangent = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2))
- this.fitDistance = Math.max(9.6, 3.95 / (2 * tangent * this.camera.aspect))
- this.updateCamera()
- }
-
- private updateCamera() {
- this.camera.position.set(0, this.cameraTargetY, this.fitDistance * this.zoom + this.cameraRetreat)
- this.camera.lookAt(0, this.cameraTargetY, 0)
- }
-
- private stackPose(offset: number) {
- return pose(offset * 0.045, -offset * 0.045, -0.08 - offset * stackDepthSpacing, 0, Math.PI, 0, 0.88)
- }
-
- private reservePose(offset: number) {
- const target = this.stackPose(offset)
- target.position.x += 1.05
- target.position.y -= 0.4
- return target
- }
-
- restart() {
- this.clearPointers()
- this.motion = undefined
- this.paused = false
- this.index = 0
- this.revealed.clear()
- this.completed = false
- this.zoom = 1
- this.cameraRetreat = 0
- this.cameraTargetY = 0
- this.openingProgress = 0
- this.touch.set(0, 0, 0)
- this.touchTarget = 0
- this.lastTick = undefined
- this.updateCamera()
- this.wrapper.root.visible = true
- setPose(this.wrapper.root, pose(0, 0, 0))
- this.wrapper.deform(0, 0, 0, this.touch)
- this.cards.forEach((card, index) => {
- card.object.visible = true
- card.front.visible = false
- applyEdgeMaterialControls(card.edge, { substrate: 'Paper', condition: 1 })
- setPose(card.object, this.stackPose(index))
- })
- this.setState('sealed')
- }
-
- private shapeWrapper() {
- const stripExit = (this.camera.position.z + 4)
- * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect + 2
- this.wrapper.deform(this.tearProgress,
- THREE.MathUtils.smoothstep(this.openingProgress, 0.38, 0.52),
- THREE.MathUtils.smoothstep(this.openingProgress, 0.5, 0.62), this.touch, stripExit)
- }
-
- private setOpeningProgress(value: number) {
- this.openingProgress = value
- this.shapeWrapper()
- const smooth = THREE.MathUtils.smoothstep
- const settling = smooth(value, 0.87, 1)
- const framing = smooth(value, 0.52, 0.76) * (1 - settling)
- this.cameraRetreat = 8 * framing
- this.cameraTargetY = 1.6 * framing
- this.updateCamera()
- this.cards.forEach((card, index) => {
- const target = this.stackPose(index)
- // All cards clear the mouth before the pouch moves aside. Depth spacing never changes.
- target.position.y += 4.4 * smooth(value, 0.62 + index * 0.018, 0.79 + index * 0.018) * (1 - settling)
- setPose(card.object, target)
- })
- const aside = smooth(value, 0.83, 0.87)
- const halfViewWidth = this.camera.position.z
- * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect
- this.wrapper.root.position.x = -(halfViewWidth + 2) * aside
- this.wrapper.root.visible = value < 0.87
- this.options.onChange()
- }
-
- private open() {
- const start = this.openingProgress
- this.begin('opening', [], 3200 * (1 - start), () => {
- this.wrapper.root.visible = false
- this.setState('stackReady')
- }, (value) => this.setOpeningProgress(THREE.MathUtils.lerp(start, 1, value)))
- }
-
- private setState(state: PackState) {
- this.state = state
- const revealPhase = state === 'revealing' || state === 'inspecting' || state === 'advancing'
- this.cards.forEach((card, index) => {
- card.object.visible = state !== 'complete' && index >= this.index
- card.front.visible = revealPhase && index === this.index
- })
- if (state === 'inspecting' && !this.revealed.has(this.index)) {
- this.revealed.add(this.index)
- applyEdgeMaterialControls(this.cards[this.index].edge, { ...packContents[this.index], condition: 1 })
- this.options.canvas.dispatchEvent(new CustomEvent('packreveal', {
- detail: { index: this.index + 1, ...packContents[this.index] },
- }))
- }
- if (state === 'complete' && !this.completed) {
- this.completed = true
- this.options.canvas.dispatchEvent(new CustomEvent('packcomplete', { detail: { count: this.revealed.size } }))
- }
- this.options.onChange()
- }
-
- private begin(
- state: PackState, tracks: Track[], duration: number, finish: () => void,
- progress?: (value: number) => void,
- ) {
- this.paused = false
- this.motion = {
- tracks, duration: this.reducedMotion.matches ? 70 : duration,
- startedAt: performance.now(), elapsed: 0, finish, progress,
- }
- this.setState(state)
- }
-
- primary() {
- if (!this.active) return
- this.clearPointers()
- if (this.motion) {
- if (this.paused) {
- this.paused = false
- this.motion.startedAt = performance.now()
- this.options.onChange()
- }
- return
- }
- const card = this.cards[this.index]
- switch (this.state) {
- case 'sealed':
- case 'opening':
- this.open()
- break
- case 'stackReady': {
- // This is the stack card itself, not a second lift actor or a cloned placeholder.
- const tracks = [track(card.object, pose(0, 0.14, inspectionDepth, 0, Math.PI))]
- for (let next = this.index + 1; next < this.cards.length; next++) {
- tracks.push(track(this.cards[next].object, this.reservePose(next - this.index - 1)))
- }
- const startRetreat = this.cameraRetreat
- this.begin('lifting', tracks, 620, () => this.setState('lifted'), (value) => {
- // Camera framing follows the lift; updateCamera remains the sole camera transform writer.
- this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, inspectionCameraRetreat, value)
- this.updateCamera()
- })
- break
- }
- case 'lifted':
- this.begin('revealing', [track(card.object, pose(0, 0.14, inspectionDepth, -0.06, Math.PI * 2 - 0.12))], 820,
- () => this.setState('inspecting'))
- break
- case 'inspecting': {
- const halfViewWidth = (this.camera.position.z - inspectionDepth)
- * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect
- const exitX = -Math.max(4.8, halfViewWidth + cardSweepRadius + 0.3)
- // Stay on the safe plane even when advancing from an arbitrary manually rotated pose.
- const tracks = [track(card.object, pose(exitX, 0.3, inspectionDepth, 0.1, -0.5, -0.18, 0.85))]
- for (let next = this.index + 1; next < this.cards.length; next++) {
- tracks.push(track(this.cards[next].object, this.stackPose(next - this.index - 1)))
- }
- const startRetreat = this.cameraRetreat
- this.begin('advancing', tracks, 650, () => {
- if (this.index === this.cards.length - 1) {
- this.setState('complete')
- } else {
- this.index++
- this.setState('stackReady')
- }
- }, (value) => {
- this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, 0, value)
- this.updateCamera()
- })
- break
- }
- default:
- break
- }
- }
-
- skip() {
- if (!this.active) return
- this.clearPointers()
- if (!this.motion) this.primary()
- if (this.motion) this.finishMotion()
- }
-
- private finishMotion() {
- const motion = this.motion
- if (!motion) return
- for (const item of motion.tracks) setPose(item.object, item.to)
- motion.progress?.(1)
- this.motion = undefined
- this.paused = false
- motion.finish()
- }
-
- tick(now: number) {
- const delta = Math.min(0.05, Math.max(0, (now - (this.lastTick ?? now)) / 1000))
- this.lastTick = now
- if (!this.active) return
- if (Math.abs(this.touch.z - this.touchTarget) > 0.001) {
- this.touch.z = this.reducedMotion.matches ? this.touchTarget
- : THREE.MathUtils.lerp(this.touch.z, this.touchTarget, 1 - Math.exp(-delta * 18))
- this.shapeWrapper()
- }
- const motion = this.motion
- if (!motion || this.paused) return
- const progress = THREE.MathUtils.clamp((motion.elapsed + now - motion.startedAt) / motion.duration, 0, 1)
- if (progress >= 1) {
- this.finishMotion()
- return
- }
- const eased = progress * progress * (3 - 2 * progress)
- for (const { object, from, to } of motion.tracks) {
- object.position.lerpVectors(from.position, to.position, eased)
- object.quaternion.slerpQuaternions(from.quaternion, to.quaternion, eased)
- object.scale.lerpVectors(from.scale, to.scale, eased)
- }
- motion.progress?.(eased)
- }
-
- private pause(now: number) {
- const interrupted = this.motion !== undefined
- this.tick(now)
- if (this.motion && !this.paused) {
- this.motion.elapsed += now - this.motion.startedAt
- this.paused = true
- this.options.onChange()
- }
- return interrupted
- }
-
- pointerDown(event: PointerEvent) {
- if (!this.active || event.button !== 0) return
- const interrupted = this.pause(performance.now())
- this.pointers.set(event.pointerId, new THREE.Vector2(event.clientX, event.clientY))
- this.options.canvas.setPointerCapture(event.pointerId)
- this.tap = this.pointers.size === 1 && !interrupted
- ? { id: event.pointerId, x: event.clientX, y: event.clientY, at: performance.now() }
- : undefined
- this.pinchDistance = this.getPinchDistance()
- if (this.pointers.size > 1) {
- this.endSeamDrag()
- this.touchTarget = 0
- return
- }
- if (this.state === 'sealed' || (this.state === 'opening' && this.openingProgress < 0.38)) {
- const local = this.wrapperPoint(event.clientX, event.clientY)
- if (local && Math.abs(local.x) < 1.8 && local.y > -2.24 && local.y < 2.5) {
- this.touch.set(local.x, Math.min(local.y, 1.7), this.touch.z)
- this.touchTarget = 1
- }
- const seam = this.seamScreenBounds()
- if (event.clientX >= seam.left - 18 && event.clientX <= seam.right + 18
- && Math.abs(event.clientY - seam.y) <= Math.max(22, seam.height)) {
- this.motion = undefined
- this.seamDrag = { id: event.pointerId, x: event.clientX,
- progress: this.openingProgress, travel: Math.max(80, (seam.right - seam.left) * 0.75) }
- this.paused = false
- }
- }
- }
-
- pointerMove(event: PointerEvent) {
- const pointer = this.pointers.get(event.pointerId)
- if (!pointer) return
- const dx = event.clientX - pointer.x
- const dy = event.clientY - pointer.y
- pointer.set(event.clientX, event.clientY)
- if (this.tap && Math.hypot(event.clientX - this.tap.x, event.clientY - this.tap.y) > 6) {
- this.tap = undefined
- }
- if (this.pointers.size >= 2) {
- const distance = this.getPinchDistance()
- if (distance && this.pinchDistance) this.setZoom(this.zoom * this.pinchDistance / distance)
- this.pinchDistance = distance
- } else if (this.seamDrag?.id === event.pointerId) {
- const drag = this.seamDrag
- const progress = THREE.MathUtils.clamp(drag.progress
- + Math.max(0, event.clientX - drag.x) / drag.travel * 0.38, this.openingProgress, 0.38)
- if (progress > this.openingProgress && !this.tap) {
- if (this.state === 'sealed') this.setState('opening')
- this.setOpeningProgress(progress)
- }
- } else if (this.state === 'inspecting') {
- const object = this.cards[this.index].object
- object.rotation.y += dx * 0.008
- object.rotation.x = THREE.MathUtils.clamp(object.rotation.x + dy * 0.008, -1.25, 1.25)
- } else if (this.touchTarget) {
- const local = this.wrapperPoint(event.clientX, event.clientY)
- if (local) {
- this.touch.x = THREE.MathUtils.clamp(local.x, -1.68, 1.68)
- this.touch.y = THREE.MathUtils.clamp(local.y, -2.24, 1.7)
- this.shapeWrapper()
- }
- }
- }
-
- pointerUp(event: PointerEvent, cancelled = false) {
- if (!this.pointers.has(event.pointerId)) return
- const tearing = this.seamDrag?.id === event.pointerId
- if (tearing) this.endSeamDrag()
- this.touchTarget = 0
- const tap = this.tap
- this.tap = undefined
- this.pointers.delete(event.pointerId)
- this.pinchDistance = this.getPinchDistance()
- if (this.options.canvas.hasPointerCapture(event.pointerId)) {
- this.options.canvas.releasePointerCapture(event.pointerId)
- }
- if (tearing && !cancelled && this.tearProgress >= 1) {
- this.primary()
- return
- }
- if (!cancelled && tap?.id === event.pointerId && performance.now() - tap.at < 600
- && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) <= 6) this.primary()
- }
-
- wheel(event: WheelEvent) {
- event.preventDefault()
- this.pause(performance.now())
- this.clearPointers()
- const pixels = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 240 : 1)
- this.setZoom(this.zoom * Math.exp(pixels * 0.001))
- }
-
- private setZoom(zoom: number) {
- this.zoom = THREE.MathUtils.clamp(zoom, 0.62, 1.45)
- this.updateCamera()
- }
-
- private getPinchDistance() {
- const pointers = [...this.pointers.values()]
- return pointers.length < 2 ? undefined : pointers[0].distanceTo(pointers[1])
- }
-
- private clearPointers() {
- this.endSeamDrag()
- this.touchTarget = 0
- this.tap = undefined
- const ids = [...this.pointers.keys()]
- this.pointers.clear()
- for (const id of ids) {
- if (this.options.canvas.hasPointerCapture(id)) this.options.canvas.releasePointerCapture(id)
- }
- this.pinchDistance = undefined
- }
-
- private endSeamDrag() {
- if (!this.seamDrag) return
- this.seamDrag = undefined
- if (this.state === 'opening' && !this.motion) this.paused = true
- this.options.onChange()
- }
-
- private seamScreenBounds() {
- const rect = this.options.canvas.getBoundingClientRect()
- this.root.updateMatrixWorld(true)
- this.camera.updateMatrixWorld(true)
- const screen = (x: number, y: number) => {
- const point = this.wrapper.root.localToWorld(new THREE.Vector3(x, y, -0.09)).project(this.camera)
- return { x: rect.left + (point.x + 1) * rect.width / 2,
- y: rect.top + (1 - point.y) * rect.height / 2 }
- }
- const left = screen(-1.68, 2.12)
- const right = screen(1.68, 2.12)
- return { left: left.x, right: right.x, y: left.y, height: Math.abs(screen(0, 1.97).y - screen(0, 2.28).y) }
- }
-
- private wrapperPoint(x: number, y: number) {
- const rect = this.options.canvas.getBoundingClientRect()
- this.camera.updateMatrixWorld(true)
- this.root.updateMatrixWorld(true)
- const ray = new THREE.Raycaster()
- ray.setFromCamera(new THREE.Vector2((x - rect.left) / rect.width * 2 - 1,
- 1 - (y - rect.top) / rect.height * 2), this.camera)
- const point = ray.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 0, 1), -0.28), new THREE.Vector3())
- return point ? this.wrapper.root.worldToLocal(point) : undefined
- }
-}
diff --git a/sanctification-tcg/card-harness/src/style.css b/sanctification-tcg/card-harness/src/style.css
deleted file mode 100644
index ce804a5..0000000
--- a/sanctification-tcg/card-harness/src/style.css
+++ /dev/null
@@ -1,496 +0,0 @@
-:root {
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
- color: #f3efe5;
- background: #080a0e;
- font-synthesis: none;
- text-rendering: optimizeLegibility;
-}
-
-* {
- box-sizing: border-box;
-}
-
-html,
-body,
-#app {
- width: 100%;
- height: 100%;
- margin: 0;
- overflow: hidden;
-}
-
-button,
-select {
- color: inherit;
- font: inherit;
-}
-
-.app-shell {
- display: grid;
- grid-template-rows: auto minmax(0, 1fr) auto;
- width: 100%;
- height: 100%;
- background:
- radial-gradient(circle at 50% -20%, rgba(108, 127, 160, 0.18), transparent 45%),
- #080a0e;
-}
-
-.topbar {
- z-index: 3;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: space-between;
- gap: 24px;
- min-height: 82px;
- padding: 14px 20px;
- border-bottom: 1px solid rgba(227, 215, 181, 0.16);
- background: rgba(11, 14, 20, 0.92);
- backdrop-filter: blur(18px);
-}
-
-.title-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
-}
-
-.controls-toggle {
- display: none;
- align-items: center;
- gap: 8px;
- white-space: nowrap;
-}
-
-.controls-toggle-icon {
- width: 12px;
- color: #d6b860;
- font-size: 16px;
- line-height: 1;
-}
-
-.eyebrow {
- margin: 0 0 3px;
- color: #b8a979;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.16em;
- text-transform: uppercase;
-}
-
-h1 {
- margin: 0;
- font-family: Georgia, "Times New Roman", serif;
- font-size: clamp(20px, 2.2vw, 30px);
- font-weight: 500;
- letter-spacing: 0.01em;
-}
-
-.toolbar {
- display: flex;
- flex: 1 1 720px;
- flex-wrap: wrap;
- min-width: 0;
- align-items: flex-end;
- justify-content: flex-end;
- gap: 8px;
-}
-
-.toolbar label {
- display: grid;
- flex: 1 1 110px;
- min-width: 100px;
- gap: 4px;
- color: #9da3ad;
- font-size: 11px;
- letter-spacing: 0.04em;
-}
-
-select,
-button {
- height: 34px;
- border: 1px solid rgba(227, 215, 181, 0.2);
- border-radius: 7px;
- background: #171b23;
- outline: none;
-}
-
-select {
- width: 100%;
- min-width: 0;
- padding: 0 28px 0 10px;
-}
-
-button {
- padding: 0 13px;
- cursor: pointer;
-}
-
-button:hover,
-select:hover {
- border-color: rgba(218, 186, 102, 0.62);
- background: #202630;
-}
-
-button:focus-visible,
-select:focus-visible {
- outline: 2px solid #d6b860;
- outline-offset: 2px;
-}
-
-.viewport-shell {
- position: relative;
- min-height: 0;
-}
-
-#scene {
- display: block;
- width: 100%;
- height: 100%;
- cursor: grab;
- touch-action: none;
-}
-
-#scene:active {
- cursor: grabbing;
-}
-
-#scene.camera-target {
- cursor: move;
-}
-
-.status-panel,
-.hint,
-.loading {
- position: absolute;
- pointer-events: none;
- border: 1px solid rgba(227, 215, 181, 0.14);
- background: rgba(10, 13, 18, 0.76);
- backdrop-filter: blur(12px);
-}
-
-.status-panel {
- top: 16px;
- left: 16px;
- display: grid;
- gap: 4px;
- max-width: calc(100% - 32px);
- padding: 9px 12px;
- border-radius: 8px;
- color: #d8d2c2;
- font-size: 12px;
-}
-
-#performance {
- color: #89909b;
- font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
- font-size: 10px;
-}
-
-.hint {
- bottom: 16px;
- left: 50%;
- padding: 8px 12px;
- border-radius: 999px;
- color: #aeb4bc;
- font-size: 12px;
- transform: translateX(-50%);
- white-space: nowrap;
-}
-
-.loading {
- z-index: 4;
- top: 50%;
- left: 50%;
- padding: 12px 16px;
- border-radius: 9px;
- transform: translate(-50%, -50%);
-}
-
-footer {
- display: flex;
- justify-content: space-between;
- gap: 16px;
- padding: 8px 16px;
- border-top: 1px solid rgba(227, 215, 181, 0.12);
- color: #777f8a;
- background: #0b0e14;
- font-size: 11px;
-}
-
-footer a {
- color: #bba96e;
-}
-
-.lil-gui.root {
- --background-color: rgba(16, 20, 27, 0.94);
- --widget-color: #252c37;
- --hover-color: #303946;
- --focus-color: #3b4655;
- --text-color: #ede8dc;
- --number-color: #e1c56e;
- top: 98px;
- right: 16px;
-}
-
-.pack-controls[hidden] {
- display: none;
-}
-
-.pack-controls {
- position: absolute;
- bottom: max(12px, env(safe-area-inset-bottom));
- left: 50%;
- display: grid;
- gap: 8px;
- width: min(440px, calc(100% - 24px));
- padding: 10px 12px;
- border: 1px solid rgba(227, 215, 181, 0.22);
- border-radius: 12px;
- background: rgba(10, 13, 18, 0.9);
- backdrop-filter: blur(12px);
- transform: translateX(-50%);
- text-align: center;
-}
-
-#pack-status {
- color: #d8d2c2;
- font-size: 12px;
-}
-
-#pack-tear {
- appearance: none;
- width: 100%;
- height: 3px;
- border: 0;
- border-radius: 2px;
- overflow: hidden;
- background: #293039;
- accent-color: #bba36a;
-}
-
-#pack-tear::-webkit-progress-bar {
- background: #293039;
-}
-
-#pack-tear::-webkit-progress-value {
- background: #bba36a;
-}
-
-#pack-tear::-moz-progress-bar {
- background: #bba36a;
-}
-
-.pack-actions {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto;
- gap: 8px;
-}
-
-.pack-actions button {
- min-height: 44px;
- height: auto;
- padding: 8px 12px;
-}
-
-#pack-primary {
- border-color: #9b8750;
- background: #393222;
- color: #f5e5b4;
- font-weight: 600;
-}
-
-button:disabled {
- opacity: 0.5;
- cursor: default;
-}
-
-.pack-mode .toolbar > :not(:first-child) {
- display: none;
-}
-
-.pack-mode .toolbar {
- flex: 0 1 180px;
-}
-
-.pack-mode .hint {
- bottom: 118px;
- max-width: calc(100% - 24px);
- text-align: center;
- white-space: normal;
-}
-
-.pack-mode .status-panel {
- max-width: min(440px, calc(100% - 32px));
-}
-
-@media (max-width: 1120px) {
- .topbar {
- align-items: flex-start;
- }
-
- .toolbar {
- flex-basis: 100%;
- justify-content: flex-start;
- }
-}
-
-@media (max-width: 880px) {
- .topbar {
- gap: 10px;
- padding: 10px 12px;
- }
-
- .title-row {
- width: 100%;
- }
-
- .controls-toggle {
- display: inline-flex;
- }
-
- .toolbar {
- width: 100%;
- justify-content: flex-start;
- }
-
- .topbar.controls-collapsed {
- min-height: 0;
- }
-
- .topbar.controls-collapsed .toolbar {
- display: none;
- }
-
- .toolbar label {
- flex-basis: calc(33.333% - 6px);
- }
-
- .toolbar button {
- flex: 1 1 120px;
- }
-
- .hint {
- bottom: 10px;
- max-width: calc(100% - 24px);
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .lil-gui.root {
- top: 210px;
- }
-
- .compact-controls .lil-gui.root {
- top: 74px;
- }
-}
-
-@media (max-width: 560px) {
- .topbar {
- gap: 8px;
- }
-
- .toolbar {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .toolbar label {
- width: 100%;
- min-width: 0;
- }
-
- .toolbar button {
- width: 100%;
- min-width: 0;
- padding-inline: 8px;
- }
-
- .lil-gui.root {
- top: 288px;
- }
-
- .pack-mode .toolbar {
- display: flex;
- }
-
- .pack-mode .status-panel {
- top: 8px;
- left: 12px;
- padding: 6px 9px;
- font-size: 10px;
- }
-
- .pack-mode #performance {
- font-size: 9px;
- }
-
- .pack-mode .hint {
- bottom: 114px;
- padding: 5px 9px;
- font-size: 10px;
- }
-
- .pack-controls {
- gap: 6px;
- padding: 8px;
- }
-
- #pack-status {
- font-size: 11px;
- }
-
- .pack-actions {
- gap: 6px;
- }
-
- .pack-actions button {
- padding-inline: 10px;
- font-size: 12px;
- }
-}
-
-@media (max-height: 540px) and (min-aspect-ratio: 4/3) {
- .pack-controls {
- right: 12px;
- left: auto;
- width: min(280px, 32vw);
- padding: 8px;
- transform: none;
- }
-
- .pack-actions {
- grid-template-columns: 1fr 1fr;
- gap: 6px;
- }
-
- .pack-actions button {
- padding-inline: 8px;
- font-size: 12px;
- }
-
- #pack-primary {
- grid-row: 1;
- grid-column: 1 / -1;
- }
-
- #pack-status {
- font-size: 11px;
- }
-
- .pack-mode .hint {
- right: 12px;
- bottom: 170px;
- left: auto;
- width: min(280px, 32vw);
- font-size: 10px;
- transform: none;
- }
-
- .pack-mode .status-panel {
- max-width: 28vw;
- font-size: 10px;
- }
-}
diff --git a/sanctification-tcg/card-harness/tsconfig.json b/sanctification-tcg/card-harness/tsconfig.json
deleted file mode 100644
index 206b5e0..0000000
--- a/sanctification-tcg/card-harness/tsconfig.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
- "compilerOptions": {
- "target": "es2023",
- "module": "esnext",
- "lib": ["ES2023", "DOM"],
- "types": ["vite/client"],
- "allowArbitraryExtensions": true,
- "skipLibCheck": true,
-
- /* Bundler mode */
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "verbatimModuleSyntax": true,
- "moduleDetection": "force",
- "noEmit": true,
-
- /* Linting */
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "erasableSyntaxOnly": true,
- "noFallthroughCasesInSwitch": true
- },
- "include": ["src"]
-}
diff --git a/sanctification-tcg/common.png b/sanctification-tcg/common.png
deleted file mode 100644
index a6e3b95..0000000
Binary files a/sanctification-tcg/common.png and /dev/null differ
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/SKILL.md
deleted file mode 100644
index adaf892..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/SKILL.md
+++ /dev/null
@@ -1,124 +0,0 @@
----
-name: flutter_scene-idioms
-version: 4
-description: Write correct flutter_scene code. Use this whenever building 3D with the flutter_scene Dart/Flutter engine (rendering a scene, geometry, materials, lighting, loading a .glb model, animation, custom shaders). It corrects the wrong assumptions models carry from three.js, Godot, and Unity, and names the APIs and traps that are specific to this engine.
----
-
-# Building with flutter_scene
-
-flutter_scene is a realtime 3D engine for Flutter, built on Flutter GPU. It has a retained scene graph (`Scene` holds `Node`s, nodes carry a `Mesh`), physically based materials, image-based lighting, and a deep post-processing stack.
-
-**The one thing to internalize: this is not three.js, Godot, or Unity, and the API diverges from all three in specific ways.** Most first-attempt failures come from reaching for another engine's spelling. The corrections below are the highest-value part of this skill; read them before writing code.
-
-## Do not reach for these (they do not exist or will break the build)
-
-- **Not the master channel.** flutter_scene runs on **Flutter 3.47 stable or newer**. Do not run `flutter channel master`; it resolves worse, not better.
-- **Not `--enable-impeller`, not `--enable-experiment=native-assets`.** The run flag is **`--enable-flutter-gpu`** and nothing else. `--enable-experiment=native-assets` actively breaks the build on Dart 3.10+.
-- **Not `package:vector_math/vector_math_64.dart`.** flutter_scene uses **`package:vector_math/vector_math.dart`**. The `_64` types are a different, incompatible `Vector3`.
-- **Not `Node.fromAsset(...)`, not `loadModel(...)`.** Load a preprocessed model with **`loadScene('assets/x.glb')`** (returns `Future`), or a runtime glTF with `Node.fromGlbAsset` / `Node.fromGlbBytes`.
-- **Not a hand-rolled `CustomPainter` + `Ticker`.** Display a scene with the **`SceneView`** widget; it drives the per-frame loop for you.
-- **Not `node.position.set(x, y, z)`.** See transforms below.
-- **Not `.model` files or `buildModels`.** The offline format is `.fsceneb`, produced by the `flutter_scene:init` build hook; you load it by source path with `loadScene`.
-- **Not the removed `Environment` class.** Environment lighting is `EnvironmentMap` on `Scene.environment`.
-
-## Setup
-
-```sh
-flutter pub add flutter_scene
-dart run flutter_scene:init # installs the build hook, sets up assets
-flutter run --enable-flutter-gpu # native; add -d chrome for web
-```
-
-`flutter_scene:init` is required setup, not optional. Rendering is gated on `Scene.initializeStaticResources()`; until it completes the engine prints "Flutter Scene is not ready to render. Skipping frame."
-
-## Minimal scene (this compiles as-is)
-
-```dart
-import 'package:flutter/material.dart';
-import 'package:flutter_scene/scene.dart';
-import 'package:vector_math/vector_math.dart' as vm;
-
-void main() => runApp(const MaterialApp(home: CubeView()));
-
-class CubeView extends StatefulWidget {
- const CubeView({super.key});
- @override
- State createState() => _CubeViewState();
-}
-
-class _CubeViewState extends State {
- final Scene scene = Scene();
- bool ready = false;
-
- @override
- void initState() {
- super.initState();
- // Geometry and materials touch the shader bundle, so build them only
- // after the engine's static resources are up.
- Scene.initializeStaticResources().then((_) {
- scene.add(Node(
- mesh: Mesh(CuboidGeometry(vm.Vector3(1, 1, 1)), PhysicallyBasedMaterial()),
- ));
- if (mounted) setState(() => ready = true);
- });
- }
-
- @override
- Widget build(BuildContext context) {
- if (!ready) return const SizedBox.expand();
- return SceneView(scene, camera: PerspectiveCamera(position: vm.Vector3(2, 2, -4)));
- }
-}
-```
-
-An unset `Scene.environment` still gives image-based lighting (a default studio map is resolved at render), so a bare `PhysicallyBasedMaterial()` is lit without any light setup.
-
-## Choosing declarative or imperative
-
-flutter_scene has two ways to build a scene, and picking the wrong one is a structural decision that is expensive to undo later. Choose up front by what the app does, not by which reads nicer.
-
-**Declarative (inline widgets).** Describe the scene as Flutter widgets under `SceneView.declarative(children: [...])`, using `SceneMesh`, `SceneNode`, and `SceneModel`. Flutter's own rebuild diffing keeps the rendered scene in sync with your widget state, the same way it keeps the UI in sync. Reach for this when app state maps cleanly onto a fixed set of objects on screen and nothing is simulation-like, for example a product configurator where a few `SceneMesh`es track some `setState` values.
-
-**Imperative (retained scene graph).** Own a `Scene`, add `Node`s, attach `Component`s, and display it with `SceneView(scene, camera: ..., onTick: ...)`. Track state the way you would in another game engine. The cleanest shape is a plain Dart `Game` class that owns the `Scene` and holds the game state, with the scene build and per-frame tick routed into it, and behavior living in custom `Component`s attached to nodes that the engine runs through the component lifecycle hooks. Reach for this for anything with real simulation.
-
-**Go imperative when** the scene has complex physics, network replication, a character walking around, or procedural generation. Any one of these means imperative.
-**Stay declarative when** state maps directly to a fixed set of shown objects and nothing ticks or simulates.
-
-The two interoperate. A mostly-declarative scene can drop to an imperative node where it needs one, and an imperative scene can mount declarative subtrees. See `references/architecture.md` for the `Game`-class pattern, component-driven nodes, and the hybrid seam.
-
-## The API shape (where it diverges from what you expect)
-
-**Transforms.** `Node` has `position`, `rotation` (a `Quaternion`), and `scale`, but they are whole-value get/set, not the mutable spelling other engines use. Assign the whole vector (`node.position = vm.Vector3(0, 1, 0)` or `node.position += ...`). The getters return copies, so `node.position.x = 5` does nothing and throws in debug. For a raw matrix edit use `node.localTransform = matrix` or `node.mutateLocalTransform((m) => m.translateByVector3(...))`; a bare in-place edit of `node.localTransform` never moves the node, because the cache is not told.
-
-**Geometry.** Ten built-in primitives (`CuboidGeometry`, `SphereGeometry`, `IcosphereGeometry`, `CylinderGeometry` with separate top/bottom radii so cones are free, `CapsuleGeometry`, `TorusGeometry`, `PlaneGeometry`, `DiscGeometry`, `RingGeometry`, `WedgeGeometry`), plus swept geometry (`ExtrudeGeometry`, `TubeGeometry`, `RibbonGeometry`), lines (`PolylineGeometry`), and `GeometryBuilder`/`MeshData` for custom meshes. Do not hand-pack a `ByteData` vertex buffer before checking these.
-
-**Materials.** `PhysicallyBasedMaterial` (base color, metallic, roughness, normal, emissive, plus clearcoat/sheen/transmission/etc.), `UnlitMaterial`, `ShaderMaterial` for custom shaders. Texture slots take a `TextureSource` (from `loadTexture(path)`), not a raw `gpu.Texture`.
-
-**Camera.** `PerspectiveCamera(position: ..., target: ...)`. There is no orthographic camera built in.
-
-## What you are probably underestimating (it is all here)
-
-Models trained on older or thinner information assume flutter_scene has no lighting, no shadows, and no post-processing. It has all of it. Before hand-rolling any of these, know they exist: **directional/point/spot/area lights, shadows (PCSS, contact shadows), GTAO ambient occlusion, screen-space reflections, parallax-corrected reflection probes, SSGI, depth of field, god rays, fog, auto exposure, LUT color grading, bloom, lens flares, MSAA/SMAA/FXAA, tone mapping, instancing, LOD.** See `references/what-exists.md` for the full surface with the class names.
-
-## Traps that fail silently (wrong pixels, no error)
-
-- **Custom `ShaderMaterial` output is linear HDR premultiplied by alpha.** No tone mapping or gamma in your shader; the `ResolvePass` applies exposure, tone mapping, and the display transform. Linearize sRGB texture samples yourself. See `MATERIALS.md`.
-- **Never hand-roll a per-triangle winding flip to fix glTF orientation.** The importers handle the coordinate conversion; a manual flip leaves normals and IBL wrong.
-- **Do not emit a vertex buffer at the wrong stride.** Unskinned is 72 bytes/vertex, skinned is 104; the attribute order is fixed. Use `GeometryBuilder`, do not guess the layout.
-
-## More depth
-
-- `references/architecture.md` for the declarative-vs-imperative choice in depth, the `Game`-class pattern, component-driven nodes, and hybrid interop.
-- `references/what-exists.md` for the full API surface (the false-absence fix).
-- `references/traps.md` for the complete silent-failure list.
-- The repo-root `MATERIALS.md` for the custom-shader contract.
-
-## Keeping this skill current
-
-This skill ships inside the flutter_scene package, so upgrading flutter_scene can carry a newer revision of it than the copy installed in the project. To check, run:
-
-```sh
-dart run flutter_scene:skills --check
-```
-
-It reports the installed and bundled skill versions and exits non-zero when an update is available. If the installed flutter_scene ships a newer skill than what is installed, tell the user, since they are working against out-of-date guidance, and offer to update it with `dart run flutter_scene:skills` (which touches only the skill, not their build hook or pubspec). Worth a check when you start substantial flutter_scene work or when the user mentions upgrading the package.
\ No newline at end of file
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/architecture.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/architecture.md
deleted file mode 100644
index cd2f025..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/architecture.md
+++ /dev/null
@@ -1,297 +0,0 @@
-# Structuring a flutter_scene app
-
-Which API to build the scene with, and how to combine them. The short version is in SKILL.md;
-this is the depth, with patterns that compile against 0.22.0.
-
-flutter_scene exposes the same scene graph two ways. The declarative widgets describe it as Flutter
-widgets that rebuild-diff into the graph; the imperative API hands you the retained `Scene`/`Node`/
-`Component` graph directly. They are not competing renderers, they drive the same engine. The choice
-is about how your app tracks state, and it is worth making deliberately because reworking a large
-scene from one to the other is a rewrite.
-
----
-
-## The decision
-
-Go **declarative** when app state maps directly onto a fixed set of shown objects and nothing
-simulates. A product configurator, a data-driven diagram, a few models whose transforms follow some
-`setState` values. Flutter already owns the state, and the widgets keep the scene tracking it for
-free.
-
-Go **imperative** when the scene simulates. Complex physics, network replication, a character
-walking around under input, procedural generation. Any one of these means imperative. Here the
-scene's state is the app's state, it changes every frame, and you want to own the loop rather than
-express each frame as a widget rebuild.
-
-If you are unsure, ask whether anything in the scene changes on its own between user actions. If yes,
-imperative. If the scene only changes when the user changes a value, declarative.
-
----
-
-## Declarative
-
-`SceneView.declarative` owns an internal `Scene`; its `children` are the whole scene description.
-
-```dart
-class Configurator extends StatefulWidget {
- const Configurator({super.key});
- @override
- State createState() => _ConfiguratorState();
-}
-
-class _ConfiguratorState extends State {
- // Build engine objects once, not per rebuild. Constructing GPU resources
- // every build is the main performance hazard of the declarative layer.
- final Geometry _geometry = CuboidGeometry(vm.Vector3(1, 1, 1));
- final PhysicallyBasedMaterial _material = PhysicallyBasedMaterial();
- double _spin = 0;
-
- @override
- Widget build(BuildContext context) {
- return Column(children: [
- Expanded(
- child: SceneView.declarative(
- camera: PerspectiveCamera(position: vm.Vector3(2, 2, -4)),
- children: [
- SceneMesh(
- geometry: _geometry,
- material: _material,
- rotation: vm.Quaternion.axisAngle(vm.Vector3(0, 1, 0), _spin),
- ),
- ],
- ),
- ),
- Slider(
- value: _spin,
- max: 6.28,
- onChanged: (v) => setState(() => _spin = v),
- ),
- ]);
- }
-}
-```
-
-The scene tracks widget state through the normal rebuild path. Note two things the example shows:
-engine objects (`geometry`, `material`) are created once and held as fields (they are diffed by
-identity, and rebuilding them every frame is the classic mistake), while cheap value props
-(`rotation`) are fine to pass fresh each build.
-
-Declarative building blocks (all under `SceneView.declarative` or a `SceneView` with `children`):
-
-- `SceneMesh(geometry:, material:, ...)` a node with a mesh.
-- `SceneNode(...)` a bare transform node, for grouping children.
-- `SceneModel('assets/x.glb', animations: [...])` a loaded model (runtime glTF path).
-- `SceneSubtree(parent:, children:)` mounts children under a given imperative `Node`.
-- Every node widget takes `position`/`rotation`/`scale` (or a full `transform`), `visible`,
- `components:` (attach imperative `Component`s), `controller:` (a `SceneNodeController` handle), and
- `children:`.
-
----
-
-## Imperative
-
-Own the `Scene`, add `Node`s, attach `Component`s, display with `SceneView(scene, camera:, onTick:)`.
-For anything beyond a demo, do not scatter this across a `StatefulWidget`. Put it in a plain Dart
-class that owns the scene and the game state, and keep the widget thin.
-
-```dart
-// Pure Dart, no Flutter import. Owns the scene and the game state.
-class Game {
- final Scene scene = Scene();
- late final Node player;
-
- Future load() async {
- await Scene.initializeStaticResources();
-
- // The camera lives in the scene as a node, not on the widget. A camera
- // node's transform is its view: the translation is the eye, local +Z is
- // the look direction, +Y is up. lookAtFrom sets both at once, so there is
- // no view-matrix math to hand-roll.
- final cameraNode = Node()
- ..addComponent(CameraComponent(activateOnMount: true))
- ..lookAtFrom(vm.Vector3(0, 3, -8), vm.Vector3.zero());
- scene.add(cameraNode);
- // activateOnMount makes this the scene's primary camera when the node
- // mounts, so SceneView needs no `camera:` argument. (The first mounted
- // camera auto-promotes anyway; this states the intent explicitly, and is
- // how you pick one when several cameras exist.)
-
- player = Node(mesh: Mesh(CuboidGeometry(vm.Vector3(1, 1, 1)),
- PhysicallyBasedMaterial()));
- player.addComponent(PlayerController());
- scene.add(player);
- }
-
- // Per-frame app logic that is not tied to one node. Component updates run
- // on their own (see below), so this is for whole-game concerns.
- void tick(double dt) {
- // advance timers, spawn waves, read input, etc.
- }
-}
-```
-
-```dart
-// Thin widget: builds the game, forwards ticks, renders the scene.
-class GameView extends StatefulWidget {
- const GameView({super.key});
- @override
- State createState() => _GameViewState();
-}
-
-class _GameViewState extends State {
- final Game game = Game();
- bool _ready = false;
-
- @override
- void initState() {
- super.initState();
- game.load().then((_) {
- if (mounted) setState(() => _ready = true);
- });
- }
-
- @override
- Widget build(BuildContext context) {
- if (!_ready) return const SizedBox.expand();
- // No `camera:` here: the view resolves the scene's active camera, which is
- // the CameraComponent added in Game.load. Resolution order is the explicit
- // `camera:` (absent), then `cameraBuilder`, then `scene.camera` (the active
- // CameraComponent), then a default camera.
- return SceneView(
- game.scene,
- onTick: (elapsed, dt) => game.tick(dt),
- );
- }
-}
-```
-
-### The active camera
-
-The scene owns which camera is active, and there are three levers:
-
-- **`CameraComponent(activateOnMount: true)`** (above) selects this camera when its node mounts.
-- **`cameraComponent.makeActive()`** switches to it at runtime, for example a chase-cam to a
- cutscene camera. Before its node mounts the choice is deferred and applied on mount.
-- **`scene.camera = someCamera`** sets any `Camera` as the override directly, and `scene.camera`
- reads the active one back.
-
-With no camera set at all, the first mounted `CameraComponent` auto-promotes, and a scene with none
-still renders through a default camera. Move or rotate a `CameraComponent`'s node to move the view;
-the `NodeCamera` reads the node's world transform live each frame. Aim it with `node.lookAt(target)`
-(rotate toward a world point) or `node.lookAtFrom(eye, target)` (position and aim in one call); +Z is
-the forward axis, so the same helpers aim lights and imported models. A follow-cam is then a one-line
-component that calls `node.lookAtFrom(...)` in `update` each frame.
-
-### Camera controllers (interactive cameras)
-
-For a user-controlled camera, do not hand-roll the drag/scroll/key math: attach a camera controller
-component to the camera node. `OrbitCameraController` (turntable around a target, drag rotates, scroll
-dollies), `FlyCameraController` (WASD + drag free flight, `moveVertical: false` gives grounded
-first-person), and `FollowCameraController` (third-person that eases behind a target node). Each holds
-the camera state, eases toward it with frame-rate-independent smoothing, clamps pitch so the view
-never flips, and writes the node via `lookAtFrom`.
-
-Wire input with the `CameraControls` widget wrapping the view; it forwards Flutter gestures and keys
-to the controller. `SceneView` itself has no camera-input knobs, so nothing camera-specific leaks into
-it.
-
-```dart
-final camera = Node()
- ..addComponent(CameraComponent(activateOnMount: true))
- ..addComponent(OrbitCameraController(target: vm.Vector3.zero(), distance: 8));
-scene.add(camera);
-
-// In build:
-return CameraControls(
- controller: camera.getComponent()!,
- child: SceneView(scene),
-);
-```
-
-The controllers also expose intent methods (`orbitBy`, `dollyBy`, `panBy`, `look`), so an app with its
-own input handling can drive them without the widget.
-
-### Behavior lives in components, not in the tick
-
-The bulk of per-object logic should be custom `Component`s, not a giant `onTick`. A component is
-attached to a node and the engine runs it through the lifecycle. Crucially, component ticks are
-driven automatically by the render path, so you do not call them yourself, and `onTick` is only for
-game-wide concerns that do not belong to a single node.
-
-```dart
-class PlayerController extends Component {
- vm.Vector3 velocity = vm.Vector3.zero();
-
- @override
- void onMount() {
- // node is available here; wire up input, cache references.
- }
-
- @override
- void update(double deltaSeconds) {
- // `node` is the node this component is attached to.
- node.mutateLocalTransform(
- (m) => m.translateByVector3(velocity * deltaSeconds),
- );
- }
-}
-```
-
-Component lifecycle hooks (subclass `Component`, override what you need):
-
-- `onAttach()` added to a node, before it is in a live scene.
-- `Future onLoad()` async setup (await assets); mount waits for it.
-- `onMount()` the node entered a live scene; `node` is usable.
-- `update(double deltaSeconds)` per rendered frame.
-- `fixedUpdate(double fixedDt)` fixed-step, driven by the physics accumulator when a `PhysicsWorld`
- is present. Put physics-coupled logic here, not in `update`.
-- `onUnmount()` / `onDetach()` teardown.
-
-This is the structure that scales. A character is a node with a controller component; an enemy is a
-node with an AI component; a pickup is a node with a trigger component. The `Game` class holds what
-is genuinely global (score, wave state, the input map), and everything spatial is a component on a
-node.
-
----
-
-## Hybrid interop
-
-The two APIs share one graph, so you can mix them at the seam that suits the app.
-
-**Declarative shell, imperative pockets.** A declarative node accepts `components:`, so an otherwise
-declarative scene can attach imperative behavior to any node without leaving the widget tree.
-
-```dart
-SceneMesh(
- geometry: _geometry,
- material: _material,
- components: [Spinner()], // a custom Component, ticked by the engine
-)
-```
-
-**Imperative scene, declarative subtrees.** `SceneView(scene, children: [...])` mounts declarative
-widgets over an app-owned scene. Use `SceneSubtree(parent: someNode, children: [...])` to attach a
-declarative subtree under a specific imperative node, for example UI-like markers that follow a
-game object.
-
-```dart
-SceneView(
- game.scene,
- camera: PerspectiveCamera(position: vm.Vector3(0, 3, -8)),
- children: [
- SceneSubtree(
- parent: game.player,
- children: [SceneModel('assets/hat.glb')],
- ),
- ],
-)
-```
-
-**Reaching an imperative node from a declarative widget.** Pass a `SceneNodeController` as
-`controller:` and read `controller.node` for the managed `Node` (null while unmounted). This is the
-escape hatch when a declarative node needs an imperative handle for a one-off operation.
-
-The rule of thumb: pick the mode that matches how the *majority* of the scene is driven, then use the
-seam above for the exceptions. Do not build a whole simulation out of declarative widgets to avoid
-the imperative API, and do not hand-roll a diffing layer over the imperative graph to avoid the
-declarative one.
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/traps.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/traps.md
deleted file mode 100644
index aaa2384..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/traps.md
+++ /dev/null
@@ -1,602 +0,0 @@
-# flutter_scene silent-failure traps
-
-Mistakes that produce wrong pixels with no exception and no console message. Each entry gives the
-mistake, what you see, and what to do instead. Sorted worst-first (most likely to hit, hardest to
-diagnose from the symptom).
-
-Some of these are now caught by the engine in version 0.22.0. Those are tagged **[0.22.0 catches
-this]** with what the engine does, so if you see that error you know what it means. The rest are
-still silent, so you have to recognize them yourself.
-
----
-
-## 1. Editing a transform in place instead of assigning it
-
-**Mistake.** `node.localTransform.setTranslation(v)`, `node.localTransform..rotateY(t)`,
-`node.position.x = 5`, or any edit of the matrix/vector a getter returns. This is the natural
-`vector_math` style and the first thing most people reach for.
-
-**Symptom.** The node does not move. Not "moves wrong", nothing happens, forever, including its
-children and bounds. Reading `node.localTransform`/`node.position` back shows the value you wrote, so
-the state looks correct while the render disagrees.
-
-**Do instead.** Assign a fresh value (`node.position = ...`, `node.localTransform = node.localTransform.clone()..translateByVector3(v)`),
-use the component setters `node.position`/`node.rotation`/`node.scale`, or edit the raw matrix
-through `node.mutateLocalTransform((m) => m.translateByVector3(v))`, which dirties the cache for you.
-
-**[0.22.0 catches this]** Debug builds throw a `StateError` naming the node and the fix, both for an
-in-place `localTransform` edit and for editing a copy returned by `position`/`rotation`/`scale`.
-
----
-
-## 2. Passing a normal or metallic-roughness map as `TextureContent.color`
-
-**Mistake.** `material.normalTexture = await Texture2D.fromAsset('brick_normal.png')` without
-`content: TextureContent.normal`. The `content` parameter defaults to `color`.
-
-**Symptom.** The base mip is fine, so it looks right up close and progressively wrong with distance:
-normals flatten and skew, roughness reads too smooth at range, specular shimmers. A
-distance-dependent symptom is nearly the worst case for screenshot-driven iteration.
-
-**Do instead.** Build non-color maps with the right content: `Texture2D.fromAsset(path, content:
-TextureContent.normal)` for normal maps, `TextureContent.data` for metallic-roughness, AO, and other
-linear data. Still silent, so this is on you.
-
----
-
-## 3. Non-uniform scale on a lit mesh
-
-**Mistake.** Any non-uniform scale on the node or an ancestor, e.g. `node.scale = Vector3(1, 3, 1)`.
-
-**Symptom.** Lighting, specular, and reflections are wrong across the whole mesh. It reads as a
-shading or material bug, so it sends you into the materials, never the transform.
-
-**Do instead.** Use a uniform scale, or bake the non-uniform scale into the geometry with
-`MeshData.transformed(matrix)` and build a fresh `MeshGeometry` from it. Still silent.
-
----
-
-## 4. Moving a skinned mesh node
-
-**Mistake.** `skinnedNode.localTransform = Matrix4.translation(v)` on a node that carries a `Skin`.
-
-**Symptom.** The mesh does not move. Worse, it *does* move when the node you transformed happens to
-be an ancestor of the skeleton's joints, so it looks intermittent across models.
-
-**Do instead.** glTF requires a skinned mesh node's own transform to be ignored, so the engine passes
-identity. Move the skeleton root (the common ancestor of `skin.joints`) instead, or parent both the
-mesh node and the skeleton under a shared node and move that. Still silent.
-
----
-
-## 5. Replacing the transform of a runtime-imported model root
-
-**Mistake.**
-```dart
-final model = await Node.fromGlbAsset('assets/ship.glb');
-model.localTransform = Matrix4.translation(v); // wipes the handedness flip
-```
-
-**Symptom.** The model renders mirrored through Z (asymmetric geometry reversed, text backwards) with
-normals and IBL wrong for the mirrored orientation. It still draws and is not obviously broken.
-
-**Do instead.** The runtime glTF importer synthesizes a root carrying a `scale(1, 1, -1)` handedness
-flip. Do not overwrite it. Parent that node under a new `Node` and transform the parent, or
-pre-multiply your transform by the existing `localTransform`. (The offline `.fscene`/`loadScene` path
-bakes handedness into the vertices, so its roots are identity and do not have this trap.) Still
-silent.
-
----
-
-## 6. `EnvironmentMap.fromGpuTextures` with a raw panorama
-
-**Mistake.**
-```dart
-final tex = await gpuTextureFromAsset('assets/panorama.png');
-scene.environment = EnvironmentMap.fromGpuTextures(prefilteredRadiance: tex);
-```
-
-**Symptom.** Every reflective surface (and the sky) shows the top 1/8 of the panorama stretched 8x
-vertically, cross-fading between slices as roughness varies. Diffuse is fully black.
-
-**Do instead.** `fromGpuTextures` expects an already-prefiltered radiance atlas, not a plain image.
-Run the source through `prefilterEquirectRadiance()` first, or just use
-`EnvironmentMap.fromEquirectImageAsset(assetPath: ...)`/`fromUIImages`, which prefilter and project
-SH for you. Still silent.
-
----
-
-## 7. `ShaderMaterial(cullingMode: none)` for a double-sided custom material
-
-**Mistake.** `ShaderMaterial(cullingMode: gpu.CullMode.none)`, or setting `doubleSided = true` on a
-`ShaderMaterial`. The two do not agree.
-
-**Symptom.** `cullingMode.none` draws back faces in color but leaves them out of the depth prepass, so
-SSAO, SSR, and contact shadows sample the wrong surface exactly where a back face shows: dark halos,
-wrong reflections, occlusion bleeding through a leaf card. `doubleSided = true` does the opposite: the
-color pass still culls while the prepass draws the extra faces.
-
-**Do instead.** For a truly double-sided `ShaderMaterial`, understand that the color cull and the
-prepass cull are driven separately today; keep the mesh single-sided where the depth-based effects
-need to match, or split it. Still silent.
-
----
-
-## 8. Caller-supplied `bounds` that do not cover the geometry
-
-**Mistake.** `MeshGeometry.fromArrays(positions: p, bounds: someAabb)` where the AABB does not contain
-every position, or `setLocalBounds` with a guessed or stale box.
-
-**Symptom.** The mesh pops out of existence at some camera angles and reappears at others; part of a
-large mesh vanishes; shadows disappear before the caster does. Intermittent and view-dependent, so a
-single screenshot can look fine.
-
-**Do instead.** Widen the bounds to cover every vertex, or just omit the `bounds` argument and let the
-constructor scan the positions. An absent bounds is safe (it means always-visible); only a wrong one
-is dangerous. Still silent.
-
----
-
-## 9. Binding a mipless texture to a material
-
-**Mistake.** `material.baseColorTexture = GpuTextureSource(await gpuTextureFromAsset('brick.png'))`.
-The helper's own doc even recommends this.
-
-**Symptom.** Severe minification aliasing: crawling and shimmering on any surface at an angle or
-distance, sparkling on a metallic-roughness map. `Texture2D.fromAsset` on the same file looks fine, so
-two seemingly equivalent APIs disagree visually.
-
-**Do instead.** Use `Texture2D.fromAsset`/`fromImage`/`fromPixels`, which generate a mip chain, or
-supply a texture you mipped yourself. Reserve `gpuTextureFromAsset` for non-material uses. Still
-silent.
-
----
-
-## 10. `RenderView.layerMask`/`Node.layers` mismatch, or a zero mask
-
-**Mistake.** Putting a node on a non-default layer and forgetting the view side (or vice versa), or
-`layerMask: 0`, or `Node.layers = 2` meaning to select "layer 2" (which is actually `1 << 2 == 4`).
-
-**Symptom.** A node, a group, or the whole scene is simply absent, with no hint a mask is involved. A
-`layerMask` of 0 renders nothing.
-
-**Do instead.** `Node.layers` is a bitmask and is NOT inherited by children, so set it on each node
-you want the view to see. Use `kRenderLayerAll` to see everything, or a bitmask like `(1 << 2)`.
-Match the view's `layerMask` to the nodes' `layers`. Still silent (but see #23 for the
-draws-nothing diagnostic).
-
----
-
-## 11. Mutating a `TextureTransform` in place
-
-**Mistake.** `material.baseColorTextureTransform.offset.x = 0.5` instead of assigning a fresh
-`TextureTransform`. Same shape as trap #1, for materials.
-
-**Symptom.** UV scroll/rotation animation freezes at the first value, but ONLY for materials on the
-physical-variant path (any material with clearcoat/sheen/transmission/etc). The identical code works
-on a plain PBR material, so it reads as a shader bug.
-
-**Do instead.** Assign a new transform each frame: `material.baseColorTextureTransform =
-TextureTransform(offset: ...)`. Still silent.
-
----
-
-## 12. Environment image that is not 2:1 equirectangular
-
-**Mistake.** Passing a cube cross, a 1:1 angular light probe, or a cropped panorama to any environment
-entry point. HDRI downloads are not reliably 2:1.
-
-**Symptom.** The scene is lit from wildly wrong directions, reflections show mirrored or duplicated
-content, the sky is smeared.
-
-**Do instead.** Re-project the source to a 2:1 latitude-longitude panorama before loading. Cube
-crosses and angular probes are not supported. Still silent.
-
----
-
-## 13. Hand-built triangles wound clockwise
-
-**Mistake.** Generating triangles with clockwise winding instead of the standard Counter-Clockwise (CCW)
-right-handed convention when feeding `MeshGeometry.fromArrays` or `GeometryBuilder`.
-
-**Symptom.** The mesh is invisible from outside and visible from inside; a closed shape looks hollow
-or inside-out; lighting is inverted where it shows. ("See-through faces.")
-
-**Do instead.** flutter_scene's front faces wind COUNTER-CLOCKWISE (CCW) in model space, matching glTF
-and standard 3D conventions. Ensure triangle indices wind CCW around the outward face normal, or omit
-`normals` and let `GeometryBuilder` derive them from your winding. Still silent.
-
----
-
-## 14. Out-of-range indices in `fromArrays`
-
-**Mistake.** `MeshGeometry.fromArrays(positions: p /* 100 verts */, indices: [0, 1, 100])`, e.g. from
-an off-by-one or an index list built against a different vertex array.
-
-**Symptom.** Stray triangles stretching to the origin or infinity, holes, flicker. On some backends
-the fetch is clamped and on others it reads adjacent memory, so the symptom differs per backend.
-
-**Do instead.** Keep every index in `0 .. vertexCount - 1`. (`GeometryBuilder.addTriangle` range-checks
-for you and throws; the `fromArrays` index path does not.) Still silent on the `fromArrays` path.
-
----
-
-## 15. A `vertexCount` that does not match the buffer in `setVertices`
-
-**Mistake.** `geometry.setVertices(bufferView, vertexCount)` where `vertexCount` is a byte count, a
-float count, or a triangle count rather than a vertex count.
-
-**Symptom.** Too small: part of the mesh is missing. Too large: the draw reads past the buffer, giving
-stray geometry or a dropped draw depending on backend. The buffer is fine, so the investigation goes
-to the packing code.
-
-**Do instead.** `vertexCount` is a count of vertices. Prefer `uploadVertexData` (which validates the
-stride, see #17) or `fromArrays` over the caller-managed `setVertices` path unless you really own the
-GPU buffer. Still silent.
-
----
-
-## 16. Oversized texture on a low-end device
-
-**Mistake.** `EnvironmentMap.fromEquirectImageAsset(assetPath: 'pano_16k.hdr', maxWidth: 16384)` or
-`EnvironmentMap.radianceCubeSize = 4096` on a device whose max texture size is lower.
-
-**Symptom.** A completely black environment: no IBL, no reflections, black sky. Works on the dev
-machine, black on a phone.
-
-**Do instead.** Keep environment and texture sizes within the device limit; lower `maxWidth` or
-`radianceCubeSize`. Test on the lowest-end target you support. Still silent.
-
----
-
-## 17. Hand-packing vertex bytes at the wrong stride
-
-**Mistake.** `SkinnedGeometry()..uploadVertexData(bytes, vertexCount, indices)` with the wrong stride
-(a common one is 96 bytes having forgotten UV1, or the legacy 80-byte layout).
-
-**Symptom.** Washed-out colors, see-through faces, geometry smeared toward the origin.
-
-**Do instead.** Unskinned vertices are 72 bytes (position 3, normal 3, tex_coords 2, tex_coords_1 2,
-color 4, tangent 4, all float32), skinned are 104 (+ joints 4, weights 4). Better, do not hand-pack:
-use `MeshGeometry.fromArrays`, `fromMeshData`, or `GeometryBuilder`.
-
-**[0.22.0 catches this]** `uploadVertexData` on both `SkinnedGeometry` and `UnskinnedGeometry` now
-throws an `ArgumentError` when the byte length does not match `vertexCount * stride`, naming the
-expected layout.
-
----
-
-## 18. Custom attribute length not matching the vertex count
-
-**Mistake.** `geometry.setCustomAttribute('a_wind', data, components: 3)` where `data` has the wrong
-length, or set before uploading vertices, or not re-set after a `rebuild` changed the count.
-
-**Symptom.** The attribute is read at the wrong stride, so every vertex gets a neighbor's value: a
-displacement shader shears the mesh, a color attribute smears. Nearly right, so hard to spot.
-
-**Do instead.** `data.length` must equal `vertexCount * components`. Set the attribute after uploading
-vertices, and re-set it after any rebuild. Also note custom attributes are not fetched by depth/shadow
-passes, so an attribute-driven displacement will not show in shadows.
-
-**[0.22.0 catches this]** `setCustomAttribute` now throws an `ArgumentError` on a length mismatch
-(once the vertex count is known).
-
----
-
-## 19. `UnlitMaterial` with `AlphaMode.mask`
-
-**Mistake.** `UnlitMaterial(colorTexture: foliage)..alphaMode = AlphaMode.mask` for cutout foliage.
-
-**Symptom.** No alpha test. Cutout edges render soft and blended, the material goes through the
-translucent pass, writes no depth, sorts badly against itself, and casts no cutout shadow.
-
-**Do instead.** `UnlitMaterial` does not implement `mask` (it behaves as `blend`). Use
-`PhysicallyBasedMaterial` for cutouts, or a `.fmat` unlit material that discards below your cutoff.
-Still silent.
-
----
-
-## 20. `vertexColorWeight` on a material that took a physical variant
-
-**Mistake.**
-```dart
-final m = PhysicallyBasedMaterial()..vertexColorWeight = 0.0;
-m.clearcoat = 1.0; // or sheen/transmission/anisotropy/ior != 1.5/any extension texture
-```
-
-**Symptom.** Vertex colors snap back to full strength the moment an unrelated extension is enabled.
-On a vertex-colored import, an abrupt tint change with no plausible cause. (The same gap silently
-drops `specularAntiAliasingVariance` and `specularAntiAliasingThreshold` on the variant path.)
-
-**Do instead.** Leave `vertexColorWeight` at 1.0 when using any advanced PBR feature, or drop the
-extension. Still silent.
-
----
-
-## 21. Vertex-stage binding on a `ShaderMaterial` with no vertex shader
-
-**Mistake.**
-```dart
-final m = ShaderMaterial(fragmentShader: frag);
-m.setUniformBlock('WaveInfo', bytes, stage: ShaderStage.vertex); // never set a vertex shader
-```
-
-**Symptom.** The vertex-stage parameter has no effect; geometry stays undisplaced while the fragment
-stage looks right. Reads as "my vertex shader is not running."
-
-**Do instead.** Pass a `vertexShader` (and `skinnedVertexShader`/`depthVertexShader` for those mesh
-kinds) to the constructor before binding vertex-stage blocks, or bind the block on
-`ShaderStage.fragment`. Still silent.
-
----
-
-## 22. A `ShaderMaterial` vertex shader on line/trail/polyline geometry
-
-**Mistake.** Attaching a `ShaderMaterial` that supplies a vertex shader to a `LineSegmentsGeometry`, a
-trail, or a polyline.
-
-**Symptom.** Lines vanish or explode into garbage. The unskinned vertex shader is paired with the
-line-segments instanced layout and never does the ribbon expansion.
-
-**Do instead.** These geometries do their vertex expansion in the engine's own shader; a material
-vertex shader cannot be used with them. Drop the vertex shader for line/trail/polyline geometry, or
-use a mesh geometry. Still silent.
-
----
-
-## 23. Four different causes of a blank frame
-
-**Mistake.** Any of: a degenerate camera (target equals position, or `up` parallel to the view
-direction, e.g. a top-down camera left at the default `up`), a field of view passed in degrees
-(`fovRadiansY: 60`), an inverted or zero frustum, `layerMask: 0`, a zero-area draw region, or
-rendering before `Scene.isReadyToRender`.
-
-**Symptom.** The entire scene is empty. Every one of these looks identical, so it is easy to "fix"
-lighting, materials, and geometry for many iterations before suspecting the camera or the mask.
-
-**Do instead.** For a top-down/bottom-up camera set `up` to `Vector3(0, 0, 1)` or `Vector3(0, 0,
--1)`, not the default `(0, 1, 0)`. Pass FOV in radians (`60 * degrees2Radians`). Keep `near > 0` and
-`far > near`. Give the view a non-zero `layerMask` and a non-empty draw region.
-
-**[0.22.0 catches most of this]** Degenerate cameras (zero view direction, parallel `up`, degrees-valued
-FOV, degenerate near/far) assert in debug. And a frame that issues zero draw calls now prints once in
-debug naming the likely cause (not ready, empty region, no views, no visible meshes, or a layer mask
-matching nothing).
-
----
-
-## 24. Missing bounds after swapping a primitive's geometry
-
-**Mistake.** `mesh.primitives[0].geometry = newGeometry` for hand LOD, a rebuilt procedural mesh, or a
-variant swap.
-
-**Symptom.** The new geometry is culled against the old geometry's bounds; if it is larger or
-displaced, it pops in and out exactly like trap #8.
-
-**Do instead.** Nothing extra is needed anymore.
-
-**[0.22.0 catches this]** A `Mesh` now recomputes its bounds on its own when a primitive's geometry
-identity changes, so the manual `markLocalBoundsDirty()` is no longer required.
-
----
-
-## 25. A `.fmat` material that overruns the 15-sampler budget
-
-**Mistake.** A `lit` or `physical` `.fmat` declaring several `sampler2d` parameters plus
-`engine_inputs: [scene_color, scene_depth]`, on top of the lit framework's own textures.
-
-**Symptom.** Geometry disappears on a mid-range Android device while everything is correct on Metal,
-with no build-time signal. The draw is rejected on GLES drivers reporting the 16-unit minimum.
-
-**Do instead.** The lit fragment shader budgets 15 fragment samplers. Pack channels into one texture
-(an ORM-style atlas), drop an `engine_input`, or make the material unlit. Still silent (fails at
-runtime on the device, not at build).
-
----
-
-## 26. `RenderView.viewport` with a `target` set
-
-**Mistake.** `RenderView(camera: cam, target: myRenderTexture, viewport: Rect.fromLTWH(0, 0, 0.5, 1))`
-expecting a half-width render into the texture.
-
-**Symptom.** The view fills the entire render texture; the passed `viewport` is ignored. Reads as
-"my viewport math is off."
-
-**Do instead.** `viewport` is ignored when `target` is set. Size the `RenderTexture` to the region you
-want, or drop the target to render a sub-rect of the screen. Still silent.
-
----
-
-## 27. Scaled or mirrored camera node
-
-**Mistake.** Attaching a `CameraComponent` to a scaled node, or parenting a camera node under a scaled
-one.
-
-**Symptom.** A uniform scale rescales the world in view; a negative scale mirrors the view, so every
-surface goes back-facing and the scene renders inside out. The camera's reported `forward`/`up` look
-correct, which makes it hard.
-
-**Do instead.** A camera node must carry only rotation and translation, and no ancestor may be scaled.
-Still silent.
-
----
-
-## 28. Hand-built `Skin` with mismatched joints and inverse-bind matrices
-
-**Mistake.** `skin.joints.add(n)` without a matching `skin.inverseBindMatrices.add(...)` (both are
-plain mutable lists).
-
-**Symptom.** Extra inverse bind matrices are silently ignored and the mesh deforms wrongly. (Too few
-throws a `RangeError`, so only the extra-matrices direction is silent.)
-
-**Do instead.** Keep the two lists parallel: one inverse bind matrix per joint (`Matrix4.identity()`
-if the joint's rest pose is the mesh's model space). Imported skins are validated; hand-built ones are
-not. Still silent.
-
----
-
-## 29. Cloning a mesh node whose skeleton is a sibling
-
-**Mistake.** `meshNode.clone()` when the skeleton lives outside the cloned subtree.
-
-**Symptom.** The clone renders collapsed or in bind-pose garbage. There is a `debugPrint`, but it says
-only "Index path formation failed" and names neither the skin nor the consequence.
-
-**Do instead.** Clone the common ancestor of the mesh node and its skeleton, not the mesh node alone.
-Still effectively silent.
-
----
-
-## 30. `updateInstanceTransforms(recomputeWinding: false)` with a mirroring edit
-
-**Mistake.** Editing an instance transform to a negative determinant while asking the engine to skip
-the parity refresh.
-
-**Symptom.** Those instances render inside out (front faces culled, back faces lit).
-
-**Do instead.** Drop `recomputeWinding: false`, or keep every instance edit orientation-preserving
-(no negative/mirrored scale). Still silent.
-
----
-
-## 31. Flipbook frame count vs atlas grid mismatch
-
-**Mistake.** A `FlipbookModule(frameCount: 16)` without `emitter.flipbookColumns = 4;
-emitter.flipbookRows = 4`.
-
-**Symptom.** Particles sample the wrong atlas cells, or only the first cell; the effect animates but
-shows the wrong art.
-
-**Do instead.** Set `flipbookColumns * flipbookRows` equal to the module's `frameCount`. Still silent.
-
----
-
-## 32. `LodComponent` blend bands overlapping
-
-**Mistake.** A `blendRange` larger than the gap between adjacent LOD thresholds.
-
-**Symptom.** An object sits permanently in the wrong cross-fade pair, dither-blending two levels that
-should not blend, or skipping a level.
-
-**Do instead.** Keep `blendRange` smaller than the smallest gap between adjacent `screenSize`
-thresholds. Still silent.
-
----
-
-## 33. `TextureAtlas` grid not matching its texture
-
-**Mistake.** `TextureAtlas(columns: 16, rows: 16, tileSize: 32, padding: 2, baseColor: eightBySix)`
-where the grid does not match the image, or an out-of-range tile `index`.
-
-**Symptom.** Every UV points at the wrong tile. With the default `repeat` addressing, an out-of-range
-index in release wraps to a different valid-looking tile rather than failing.
-
-**Do instead.** Make the grid parameters produce exactly the texture's dimensions
-(`columns * (tileSize + 2*padding)` etc), keep tile indices in range, and set
-`TextureSampling.maxMipmapLevels` so tiles do not bleed across the padding gutter at high mips. Still
-silent.
-
----
-
-## 34. `useEnvironment` sky with no cube-radiance variant
-
-**Mistake.** `ShaderSkySource(fragmentShader: myShader, useEnvironment: true)` with
-`radianceCubeFragmentShader` left null.
-
-**Symptom.** The sky contributes no image-based specular on any backend that builds the cube layout
-(the default nearly everywhere), so the scene loses its reflections.
-
-**Do instead.** Supply `radianceCubeFragmentShader`, the entry built with
-`FLUTTER_SCENE_RADIANCE_CUBE`. Debug builds warn about this at bind; release builds are silent, so do
-not rely on the warning.
-
----
-
-## 35. `radianceCubeFragmentShader` that is not the cube build
-
-**Mistake.** `ShaderMaterial(fragmentShader: f, radianceCubeFragmentShader: f)` (the same shader
-twice), or naming the non-cube entry as the cube twin.
-
-**Symptom.** The engine binds a cubemap into a shader whose sampler is a `sampler2D`: nothing on some
-backends, garbage specular on others.
-
-**Do instead.** The cube variant must be the entry compiled with `FLUTTER_SCENE_RADIANCE_CUBE`, whose
-`prefiltered_radiance` sampler is a `samplerCube`. Pass the distinct `...Cube` entry from your bundle.
-(The engine can only catch the identical-shader case, and only in debug.) Still effectively silent.
-
----
-
-## 36. Reading `int`/`bool`/`uint` shader members through `setUniformBlockFromFloats`
-
-**Mistake.** `setUniformBlockFromFloats('FragInfo', [1.0, 0.5])` where the shader declares `int mode;
-float amount;`.
-
-**Symptom.** The shader reads `mode` as the float bit pattern of `1.0` (a huge integer), so every
-`if (mode == 1)` branch misses and the material takes its fallback path. The block size is correct, so
-nothing complains.
-
-**Do instead.** Pack integer members with `ByteData.setInt32` at the member's offset, or use a `.fmat`
-material whose `MaterialParameters` type-checks every assignment. Unenforceable at runtime.
-
----
-
-## 37. A custom fragment shader that tone-maps or writes straight alpha
-
-**Mistake.** Ending a `ShaderMaterial`/`ShaderSkySource`/`beforeTonemap` `PostEffect` fragment
-shader with `frag_color = vec4(color, alpha)` (straight alpha) or `pow(color, vec3(1.0/2.2))`
-(gamma-encoded).
-
-**Symptom.** Straight alpha gives edge halos and over-bright overlaps. sRGB output is tone-mapped and
-EOTF-encoded a second time by the resolve pass, giving washed-out low-contrast color that looks like a
-bad exposure.
-
-**Do instead.** Output linear HDR premultiplied by alpha. Exposure, tone mapping, and the display
-encode are applied later by the full-screen resolve pass. When sampling an sRGB texture, linearize
-first. `.fmat` materials get the premultiply for free. Unenforceable at runtime.
-
----
-
-## 38. `MaterialParameters.copyStateFrom` across a changed layout
-
-**Mistake.** Applying a re-realized material onto a live instance whose shader layout changed (an
-editor hot reload where the `.fmat` gained or lost a parameter).
-
-**Symptom.** Every parameter reverts to its sidecar default while `assignedValues` still reports your
-overrides, so the inspector shows the right numbers and the render shows the wrong ones.
-
-**Do instead.** `copyStateFrom` needs both sides to come from the same compiled shader entry; remap by
-name through `updateFromMetadata` across a layout change instead. Still silent.
-
----
-
-## 39. Environment or widget textures with sub-255 alpha
-
-**Mistake.** Passing an equirect image carrying alpha below 255 (an unfilled sky dome, a masked
-panorama) to `fromUIImages`/`fromEquirectImageAsset`. Or, for `WidgetTexture`/`WidgetComponent`,
-simply using a widget with anti-aliased or translucent edges.
-
-**Symptom.** For environments, diffuse ambient comes out darker than the specular reflections of the
-same environment, so objects look lit by two environments. For widget textures, dark halos around
-anti-aliased text and rounded corners on the zero-copy path (correct on the web readback path, so it
-reads as a platform quirk).
-
-**Do instead.** Use opaque (alpha 255) environment sources. The widget-alpha double-multiply is a
-backend difference you cannot fully control from the API; keep widget content opaque where you can.
-Still silent.
-
----
-
-## Now caught by the engine, in one place
-
-For quick reference, these traps became loud in 0.22.0. If you hit one you get an error, not silent
-wrong pixels:
-
-- In-place edit of `localTransform`/`position`/`rotation`/`scale` -> throws in debug (#1).
-- Degenerate camera and a frame that draws nothing -> asserts/prints once in debug (#23).
-- `uploadVertexData` and `setCustomAttribute` length mismatches -> throw always (#17, #18).
-- A `Mesh` whose primitive geometry is swapped -> recomputes bounds itself (#24).
-- An `AnimationClip` binding zero of its channels -> asserts in debug naming the wanted nodes.
-- A web-backend bind to a shader uniform/texture name the shader does not declare -> throws (matches
- native), instead of silently sampling whatever was bound last.
-- Also fixed outright: `Node.clone()` sharing the original's matrix, the skinning joints texture being
- too narrow for small joint counts, and `ParticleSystem.reset()` not restarting its random stream.
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/what-exists.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/what-exists.md
deleted file mode 100644
index b610d0c..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-idioms/references/what-exists.md
+++ /dev/null
@@ -1,474 +0,0 @@
-# What exists in flutter_scene
-
-Complete public API inventory (package version 0.22.0). flutter_scene has lights, shadows, PBR
-materials, instancing, LOD, skeletal animation, and a full post-processing stack. If you think a
-feature is missing, it is almost certainly here under the name below. Look before you hand-roll.
-
-The public surface is the explicit `show` lists in `lib/scene.dart` (plus the separate barrels
-`gpu.dart`, `fscene.dart`, `build_hooks.dart`, `physics.dart`, `audio.dart`). Nothing under
-`lib/src` is public unless a barrel shows it.
-
-Import:
-```dart
-import 'package:flutter_scene/scene.dart';
-import 'package:vector_math/vector_math.dart' as vm; // NOT vector_math_64
-```
-
----
-
-## Node and scene graph
-
-`Node` (`base class Node implements SceneGraph`). Construct `Node({String name = '', Matrix4?
-localTransform, Mesh? mesh})`. A non-null `mesh` is wrapped in a `MeshComponent`.
-
-Transform API (0.22.0 added the component properties; older docs say only `localTransform` exists):
-
-| Member | Type | Notes |
-| --- | --- | --- |
-| `position` | `Vector3` get/set | Getter returns a copy; editing the copy in place throws in debug. Assign to move. |
-| `rotation` | `Quaternion` get/set | Same copy rule. |
-| `scale` | `Vector3` get/set | Same copy rule. |
-| `localTransform` | `Matrix4` get/set | Getter returns the LIVE matrix; in-place edit throws in debug on next read. Assign a fresh matrix. |
-| `mutateLocalTransform(void Function(Matrix4) edit)` | method | Edits in place AND dirties the cache. Correct raw-matrix path. |
-| `globalTransform` | `Matrix4` get/set | Cached world transform; setter solves for the needed local. |
-| `lookAt(target, {up})` | method | Orients the node's forward axis (local +Z) at a world-space target; preserves world position and scale. |
-| `lookAtFrom(eye, target, {up})` | method | Positions at `eye` and aims +Z at `target` in one call (the imperative camera one-liner). |
-| `Node.lookAtTransform(eye, target, {up})` | static -> `Matrix4` | The `lookAt` basis as a local transform, for `Node(localTransform:)` and declarative `transform:`. |
-
-+Z is the forward axis engine-wide (cameras, directional/spot lights, imported models), so the
-lookAt helpers aim any of them. Compose a plain matrix with `vm.Matrix4.translation(v)`,
-`vm.Matrix4.rotationY(a)`, `vm.Matrix4.compose(t, q, s)`. There is no `translate`/`rotateX` on Node.
-
-Hierarchy (`SceneGraph` is a mixin): `add`, `addAll`, `addMesh`, `remove`, `removeAll`. `add` throws
-if the child already has a parent. `parent`, `children`, `detach()`, `getRoot()`, `getDepth()`.
-
-Lookup: `getChildByName(name, {excludeAnimationPlayers})`, `getChildByNamePath`,
-`getChildByIndexPath`, static `getNamePath`/`getIndexPath`, `meshNodes`, `clone({recursive = true})`.
-
-Per-node flags: `visible` (true), `frustumCulled` (true), `layers` (`kRenderLayerDefault`, a 32-bit
-mask, NOT inherited), `castsShadows` (true, not inherited), `shadowStatic` (false), `raycastable`
-(true), `highlightColor` (`Vector4?`), `skin` (`Skin?`, set by importers).
-
-Bounds: `combinedLocalBounds`, `combinedWorldBounds`, `markBoundsDirty()`, `isVisibleTo(camera,
-size)`. A `null` bounds means always-visible.
-
-Loading models (see Assets): `Node.fromGlbAsset`, `Node.fromGlbBytes`, `Node.fromGltfBytes`.
-
-Geometry readback: `extractMeshData({Matrix4? transform})` flattens the subtree to one `MeshData`.
-Throws on instanced meshes, non-triangle primitives, caller-managed geometry, or an empty subtree.
-
-`Scene` (`base class Scene implements SceneGraph`, cannot be subclassed). See the render/lighting/
-post sections. `Mesh(geometry, material)`/`Mesh.primitives({primitives})`; `MeshPrimitive(geometry,
-material)`; `Mesh.clone()` (shallow, shares geometry+material); `Mesh.localBounds`,
-`Mesh.markLocalBoundsDirty()`.
-
-### Camera
-
-- `PerspectiveCamera({double fovRadiansY = 45 * degrees2Radians, Vector3? position /*(0,0,-5)*/,
- Vector3? target /*(0,0,0)*/, Vector3? up /*(0,1,0)*/, double fovNear = 0.1, double fovFar =
- 1000.0})`. Field names are `fovNear`/`fovFar`, NOT `near`/`far`.
-- `PerspectiveCamera.framing(Aabb3 bounds, {direction, fovRadiansY, up, margin = 1.1})`.
-- `PerspectiveProjection({fovRadiansY, near = 0.1, far = 1000.0})` and abstract `CameraProjection`,
- `Camera`. Camera helpers: `screenPointToRay`, `worldToScreen`, `getViewMatrix`, `getFrustum`.
-- There is NO `OrthographicCamera`. Implement `CameraProjection`/`Camera` for other projections.
-- Node-driven: `CameraComponent({CameraProjection? projection, activateOnMount = false})` ->
- `toCamera()` gives a `NodeCamera`. Camera node must not be scaled.
-- Interactive cameras: `CameraController` components attached to the camera node. `OrbitCameraController`
- (turntable around `target`; `orbitBy`/`dollyBy`/`panBy`/`frame`), `FlyCameraController` (WASD + drag
- free flight; `moveVertical: false` = grounded first-person; `look`), `FollowCameraController`
- (third-person easing behind `followTarget` node; `orbitBy`/`dollyBy`). All ease with frame-rate
- independent `smoothing` (settle seconds), clamp pitch short of vertical, and write the node via
- `lookAtFrom`. Wire input with the `CameraControls({required controller, enabled, autofocus, child})`
- widget (Focus + gestures + wheel); `SceneView` has no camera-input params by design.
-
----
-
-## Components
-
-`abstract class Component`. Lifecycle hooks (exact names): `onAttach`, `onLoad` (async), `onMount`,
-`update(double deltaSeconds)` (NOT `onUpdate`), `fixedUpdate(double)`, `onUnmount`, `onDetach`,
-`cloneFor(Node)`. Node side: `addComponent`, `removeComponent`, `getComponent()`,
-`getComponents()`.
-
-| Component | Constructor/notes |
-| --- | --- |
-| `MeshComponent` | `MeshComponent(mesh)`; `mesh` get/set, `refreshMaterials()` |
-| `InstancedMeshComponent` | `InstancedMeshComponent(instancedMesh)` |
-| `LodComponent` | `LodComponent(List, {lodBias = 1.0, hysteresis = 0.1, blendRange = 0.0})`; extends MeshComponent |
-| `CameraComponent`, `NodeCamera` | see Camera |
-| `DirectionalLightComponent` | `(light)` aims down node local +Z; `.aimed(light, localDir)`; `.fromLightDirection(light)` |
-| `PointLightComponent` | `(light)`; `worldPosition` |
-| `SpotLightComponent` | `(light)`; `worldPosition`, `worldDirection` |
-| `RectAreaLightComponent` | `(light)`; `worldPosition`, `worldRight`, `worldUp` |
-| `EnvironmentVolumeComponent` | `({required settings, shape = box, extents, radius = 5.0, blendDistance = 1.0, priority = 0.0, weight = 1.0})` |
-| `ReflectionProbeComponent` | `({extents = Vector3.all(5), blendDistance = 1.0, priority = 10.0, weight = 1.0, faceResolution = 128, captureOnActivate = true})`; parallax-corrected local reflections in the box; `requestCapture()` re-captures |
-| `MaterialsVariantsComponent` | No public ctor. `MaterialsVariantsComponent.of(root)`/`.allOf(root)`, then `select(name)`, `variants`, `selected` |
-| `SemanticsComponent` | `({label, value, hint, button, onTap, ... boundsOverride, properties})` |
-| `WidgetComponent` | `({required Widget child, required Size size, pixelRatio = 1.0, worldHeight = 1.0, update = everyFrame, input = automatic, ...})`; `.bindOnly(...)` |
-| `SplatComponent` | `SplatComponent(GaussianSplats)`; `opacity`, `splatScale`, `tint`, `shDegree`, `cropBox`, `cropMode` |
-| `ParticleEmitterComponent` | `({required system, SpriteMaterial? material})`; `facing`, `flipbookColumns/Rows/Blend`, `paused` |
-| `MeshParticleEmitterComponent` | `({required system, required List geometries, required material, facing = tumble})` |
-| `TrailComponent` | `({width = 0.25, lifetime = 0.6, minVertexDistance = 0.05, maxPoints = 48, ...})`; `emitting`, `clear()` |
-
-`SemanticsComponent`, `SplatComponent`, particle emitters, `TrailComponent`, `WidgetInput`,
-`MeshParticleFacing`, `LodLevel`, `EnvironmentVolumeShape` are all exported.
-
----
-
-## Geometry
-
-### Primitives (`primitives.dart`, all factory constructors, all `extends MeshGeometry`)
-
-| Class | Constructor | Facing/notes |
-| --- | --- | --- |
-| `CuboidGeometry` | `CuboidGeometry(Vector3 extents, {debugColors = false})` | positional extents; box `-extents/2..+extents/2`; debugColors off |
-| `WedgeGeometry` | `WedgeGeometry(Vector3 size)` | triangular prism; base on `y=0`, not Y-centered |
-| `PlaneGeometry` | `({width = 1.0, depth = 1.0, segmentsX = 1, segmentsZ = 1})` | XZ plane, faces +Y; no collisionShape |
-| `SphereGeometry` | `({radius = 0.5, segments = 32, rings = 16})` | UV sphere |
-| `CylinderGeometry` | `({bottomRadius = 0.5, topRadius = 0.5, height = 1.0, radialSegments = 32, heightSegments = 1, bottomCap = true, topCap = true})` | topRadius 0 = cone |
-| `CapsuleGeometry` | `({radius = 0.5, height = 1.0, radialSegments = 32, capRings = 8})` | `height` is the mid-section; total Y = height + 2*radius |
-| `TorusGeometry` | `({radius = 0.5, tubeRadius = 0.2, radialSegments = 32, tubularSegments = 16})` | XZ plane |
-| `DiscGeometry` | `({radius = 0.5, segments = 32})` | XZ, faces +Y |
-| `RingGeometry` | `({innerRadius = 0.25, outerRadius = 0.5, segments = 32})` | annulus, XZ, +Y |
-| `IcosphereGeometry` | `({radius = 0.5, subdivisions = 2})` | subdivided icosahedron |
-
-Every primitive except `PlaneGeometry` has a `Shape get collisionShape`.
-
-### Swept/procedural (sweep a `ScenePath`; also `BezierPath`, `CatmullRomPath`, `PolylinePath`)
-
-- `RibbonGeometry(path, {width = 1.0, stations = 64, alignment = RibbonAlignment.ground, up, storage = fixed})`; `updatePath(path)`. `RibbonAlignment` = `ground` | `path`.
-- `TubeGeometry(path, {radius = 0.5, radialSegments = 12, stations = 64, caps = true, storage})`.
-- `ExtrudeGeometry(path, {required List profile, stations = 64, caps = true, storage})`.
-- `PolylineGeometry(List points, {width = 8.0, widthMode = screenPixels, cap = butt, dash, perVertexWidth, perVertexColor})`. INERT until `updateForCamera(camera, viewportSize)` is called every frame. `PolylineWidthMode` = `screenPixels` | `worldUnits`; `PolylineCap` = `butt` | `round`; `DashPattern({dashLength, gapLength, cap})`.
-- `LineSegmentsGeometry(LineSegmentData segments, {width = 0.01, normalOffset = 0.0})`. `extends Geometry`, GPU-expanded, no per-frame CPU work. For large independent-segment sets.
-- `BillboardGeometry({capacity = 256})`. `floatsPerInstance = 14`; `BillboardFacing` = `spherical` | `axisLocked` | `velocityStretched`.
-
-### MeshGeometry, GeometryBuilder, MeshData
-
-`MeshGeometry.fromArrays({required Float32List positions, Float32List? normals, texCoords,
-texCoords1, colors, tangents, List? indices, primitiveType = triangle, Aabb3? bounds, storage =
-fixed, GeometryBufferArena? bufferArena, retainCpuData = true})`. Components per vertex: positions 3,
-normals 3, texCoords/texCoords1 2, colors/tangents 4. Omitted normals on a triangle list are
-generated. Omitted indices need a vertex count divisible by 3. `bounds` skips the position scan (it
-must actually cover every vertex).
-
-`MeshGeometry.fromMeshData(MeshData data, {storage, bufferArena, retainCpuData})`.
-
-In-place update (require `GeometryStorage.updatable`, all take `{dirtyStart, dirtyCount}`):
-`updatePositions`, `updateNormals`, `updateTexCoords`, `updateTexCoords1`, `updateColors`,
-`updateTangents`. `rebuild({positions, normals, ...})` may change the vertex/index count.
-`applyMeshData(data)`. `GeometryStorage` = `fixed` | `updatable`.
-
-`GeometryBuilder({deduplicate = true})`: `normal(v)`, `texCoord(v)`, `texCoord1(v)`, `color(v)`,
-`tangent(v)`, `addVertex(Vector3) -> int`, `addTriangle(a, b, c)` (throws RangeError on bad index),
-`packVertices()`, `build({storage, bufferArena, retainCpuData})`. Attribute setters are STICKY.
-Calling `normal()` once disables generated normals for the whole mesh.
-
-`MeshData` (isolate-transferable, pure): `MeshData({required positions, required vertexCount,
-normals, ..., customAttributes})`, `MeshData.build({required positions, ...})` (derives vertexCount,
-generates normals). Derivations: `triangleCount`, `triangles`, `transformed(Matrix4)` (inverse
-transpose for normals; a mirror flips winding), `toTriMeshShape()` (hollow static collider),
-`toConvexHullShape()` (dynamic body), `unweld({attributes})`, `extractEdges({creaseAngleDegrees})`,
-static `merge(parts)`. `MeshAttributeData(data, {components})`; `UnweldAttribute` = `centroid` |
-`seed` | `triangleIndex` | `barycentric`; `LineSegmentData({positions, normals})`.
-
-`Geometry` base: `primitiveType`, `localBounds`, `localBoundingSphere`, `setLocalBounds(aabb,
-sphere)`, `setVertices(BufferView, vertexCount)`, `setIndices(BufferView, indexType)`,
-`setCustomAttribute(name, Float32List, {required components})` (1..4; not fetched by depth passes so
-it does not affect shadows), `uploadVertexData(ByteData, vertexCount, ByteData? indices, {indexType =
-int16})`, `isReadable`, `extractMeshData()`, `setVertexShader`/`setVertexShaderName`,
-`setVertexLayout(descriptor, {bindsModelTransform = true})`, `draw(pass, {instanceCount = 1})`.
-`SkinnedGeometry`/`UnskinnedGeometry` subclasses. `GeometryBufferArena({blockSizeInBytes = 16MB})`.
-
-Vertex layout: unskinned 72 bytes/18 floats = position(3) normal(3) texture_coords(2)
-texture_coords_1(2) color(4) tangent(4). Skinned 104 bytes/26 floats = + joints(4) weights(4). Do
-not hand-pack; use `fromArrays`/`fromMeshData`/`GeometryBuilder`.
-
-### Instancing and LOD
-
-`InstancedMesh({required geometry, required material, cullInstances = false,
-sortTransparentInstances = true})`: `instanceCount`, `addInstance(Matrix4, {Vector4? color}) -> int`
-(clones the matrix), `setInstanceTransform(i, m)`, `updateInstanceTransforms(update,
-{recomputeWinding = true})`, `setInstanceColor(i, color)`, `removeInstanceAt(i)`, `clearInstances()`.
-Attach via `InstancedMeshComponent`.
-
-`LodLevel({required geometry, required material, required double screenSize})` (screenSize = projected
-bounding-sphere diameter as a fraction of viewport height, descending, last is the cull floor).
-Attach via `LodComponent`. Shadow/depth passes always draw level 0.
-
----
-
-## Materials and textures
-
-`Material` (abstract): `name`, `doubleSided` (false), `depthBias` (0.0), `setFragmentShader`,
-`setFragmentShaderName(name, {cubeName})`, `setRadianceCubeFragmentShader`, `isOpaque()`.
-
-### UnlitMaterial
-
-`UnlitMaterial({TextureSource? colorTexture})`. `baseColorTexture` (field name differs from the ctor
-arg), `baseColorTextureTransform`, `baseColorTextureTexCoord` (0), `alphaMode` (`opaque`; `mask` not
-implemented, behaves as blend), `baseColorFactor` (white), `vertexColorWeight` (1.0). Fog applies.
-
-### PhysicallyBasedMaterial
-
-`PhysicallyBasedMaterial({baseColorTexture, metallicRoughnessTexture, normalTexture, emissiveTexture,
-occlusionTexture, EnvironmentMap? environment})`. Every texture slot is a `TextureSource?` with a
-`TextureTransform` and `TextureTexCoord`.
-
-Core: `baseColorFactor` (white), `vertexColorWeight` (1.0), `metallicFactor` (1.0), `roughnessFactor`
-(1.0), `normalScale` (1.0), `emissiveFactor` (`Vector4.zero()`), `emissiveStrength` (1.0),
-`occlusionStrength` (1.0), `environment` (null, falls back to `Scene.environment`), `alphaMode`
-(opaque), `alphaCutoff` (0.5), `specularAntiAliasingVariance` (0.15), `specularAntiAliasingThreshold`
-(0.2).
-
-Advanced KHR_materials_* (setting any flips onto an internal physical-variant shader; each has a
-`Texture`): `specular` (1.0), `specularColor`, `ior` (1.5), `clearcoat` (0.0),
-`clearcoatRoughness` (0.0), `clearcoatNormalScale`, `sheenColor` (zero), `sheenRoughness` (0.0),
-`transmission` (0.0), `diffuseTransmission`, `diffuseTransmissionColor`, `thickness` (0.0),
-`attenuationDistance` (inf), `attenuationColor`, `dispersion` (0.0), `iridescence` (0.0),
-`iridescenceIor` (1.3), `iridescenceThicknessMinimum` (100.0), `iridescenceThicknessMaximum` (400.0),
-`anisotropy` (0.0), `anisotropyRotation` (0.0).
-
-`isOpaque()` is false when `transmission > 0`, `alphaMode == blend`, or `baseColorFactor.a < 1.0`.
-
-`AlphaMode` = `opaque` | `mask` | `blend`. `TextureTransform({offset, scale, rotation})` (glTF
-KHR_texture_transform order).
-
-### SpriteMaterial
-
-`SpriteMaterial({TextureSource? colorTexture})`: `colorTexture`, `tint` (white), `blendMode`
-(`SpriteBlendMode.alpha` | `additive`), `softDepthFade` (0.0), `cameraNearFade` (0.0), `sampler`.
-Always non-opaque, always cull none.
-
-### ShaderMaterial (raw GLSL escape hatch)
-
-`ShaderMaterial({gpu.Shader? fragmentShader, radianceCubeFragmentShader, vertexShader,
-skinnedVertexShader, depthVertexShader, useEnvironment = false, cullingMode = backFace, windingOrder =
-counterClockwise, isOpaqueOverride = true})`. `setVertexShader(shader, {variant = unskinned})`,
-`vertexShaderFor(variant)`, `setUniformBlock(name, ByteData?, {stage = fragment})`,
-`setUniformBlockFromFloats(name, List, {stage})`, `getUniformBlock`, `uniformBlockNames`,
-`setTexture(name, texture, {sampler, stage})` (accepts `gpu.Texture`/`Texture2D`/`RenderTexture`),
-`getTexture`, `textureNames`. `ShaderStage` = `vertex` | `fragment`; `MeshVariant` = `unskinned` |
-`skinned` | `depth`.
-
-Fragment shaders MUST output linear HDR premultiplied by alpha (exposure, tone mapping, and the
-display encode are applied later by the resolve pass). Same contract for `ShaderSkySource` and
-`PostInsertion.beforeTonemap` effects. std140 packing is by hand.
-
-### .fmat (declarative, recommended over ShaderMaterial)
-
-`loadFmatMaterial(sourcePath) -> PreprocessedMaterial`, `loadFmatSky(...) -> PreprocessedSky`.
-`PreprocessedMaterial`: `parameters` (`MaterialParameters`), `shadingModel`, `environment`.
-`MaterialParameters` (typed, reflection-backed, throws on wrong type/name): `setFloat`, `setInt`,
-`setVec2/3/4`, `setMat4`, `setColor(name, Color)`, `setTexture(name, gpu.Texture, {sampler})`,
-`operator []=`, `parameterNames`, `samplerNames`, `hasUniformBlock`.
-
-### Textures
-
-`TextureSource` (interface): implementers are `Texture2D`, `RenderTexture`, `GpuTextureSource`. Every
-built-in material slot takes a `TextureSource`, not a raw `gpu.Texture`.
-
-`Texture2D` (factories, generates a mip chain): `Texture2D.fromPixels(Uint8List, w, h, {content =
-color, sampling})`, `fromImage(ui.Image, {...})`, `fromAsset(String, {content = color, sampling,
-bundle})`. `TextureContent` = `color` (sRGB) | `data` (linear, e.g. metallic-roughness/AO) | `normal`
-(vector-averaged). `TextureSampling({mipmaps = true, maxMipmapLevels, minFilter = linear, magFilter =
-linear, mipFilter = linear, maxAnisotropy = 8, addressMode = repeat})`.
-
-`GpuTextureSource(gpu.Texture, {sampler})` adapts a raw texture. Barrel helpers:
-`gpuTextureFromImage`, `gpuTextureFromAsset` (mipless, aliases on materials), `imageFromAsset`,
-`imageFromBytes`. Cooked `.fstex`: `loadTexture(sourcePath, {package, bundle, sampling}) ->
-TextureSource`, `releaseTexture`, `clearTextureCache`.
-
-Custom-shader GPU barrel (`package:flutter_scene/gpu.dart`): `Shader`, `ShaderLibrary`,
-`loadShaderLibraryAsync` (use this, not `ShaderLibrary.fromAsset` which throws on web),
-`resolveShaderBundleKey`, `Texture`, `SamplerOptions`, `MinMagFilter`, `MipFilter`,
-`SamplerAddressMode`, `IndexType`, `VertexFormat`, `VertexStepMode`.
-
----
-
-## Lighting and environment
-
-Lights (all in `light.dart`, all fields mutable):
-
-- `DirectionalLight({direction /*(-0.3,-1,-0.2)*/, color, intensity = 3.0, priority = 0, castsShadow
- = false, cacheStaticShadows = true, shadowFadeRange = 2.0, shadowSoftness = 0.08, shadowCascadeCount
- = 4, shadowMaxDistance = 150.0, shadowCascadeSplitLambda = 0.6, shadowMapResolution = 1024,
- shadowDepthBias = 0.02, shadowNormalBias = 0.02, shadowAmbientStrength = 0.0, shadowFilter =
- rotatedPoisson, shadowCasterFaces = front, contactShadows = false, contactShadowDistance = 0.3,
- angularRadius = 0.005})`.
-- `PointLight({color, intensity = 1.0, range = 0.0, falloffExponent = 2.0})`. No shadows.
-- `SpotLight({color, intensity = 1.0, range = 0.0, falloffExponent = 2.0, direction /*(0,-1,0)*/,
- innerConeAngle = 0.0, outerConeAngle = pi/4, castsShadow = false, ...})`.
-- `RectAreaLight({color, intensity = 1.0, width = 1.0, height = 1.0, range = 0.0})`. Local XY plane,
- emits along +Z, no shadows.
-- `SunLight(SunSky source, {castsShadow = true, ...})` drives `Scene.directionalLight` from a sky.
-
-`ShadowCasterFaces` = `front` | `back` | `both`. `DirectionalShadowFilter` = `rotatedPoisson` |
-`fixedPcf` | `pcss`. `ShadowCascade`, `Lighting` (per-draw state) are exported.
-
-Scene lighting: `Scene.directionalLight` (`DirectionalLight?`, null = IBL only; honors `direction`;
-highest-priority one gets cascaded shadows), `Scene.sunLight`, `Scene.environment` (`EnvironmentMap?`,
-null falls back to `EnvironmentMap.studio()`; for genuinely no IBL use `EnvironmentMap.empty()`),
-`Scene.environmentIntensity` (1.0), `Scene.environmentTransform` (`Matrix3.identity()`),
-`Scene.skybox` (`Skybox?`, null = transparent), `Scene.skyEnvironment`.
-
-### EnvironmentMap
-
-Carries a prefiltered specular radiance atlas AND SH-9 diffuse coefficients (one texture path, no
-separate radiance/irradiance). Factories: `.empty()`, `.constantDiffuse(ambientRadiance)`,
-`.fromGpuTextures({required prefilteredRadiance, diffuseSphericalHarmonics, diffuseShTexture})` (the
-texture must already be prefiltered), `.fromUIImages({required radianceImage, ...})`,
-`.fromEquirectHdr({required Float32List linearPixels, w, h, ...})`,
-`.fromEquirectImageAsset({required assetPath, maxWidth = 4096, ...})` (auto-detects .hdr/.exr/LDR),
-`.fromEquirectImageBytes(...)`, `.fromSky(SkySource, {...})`, `.studio()` (zero-config default).
-Deprecated: `.fromAssets` (use `.fromEquirectImageAsset`). Env images must be equirect 2:1.
-`prefilterEquirectRadiance` is exported. `Scene.loadEnvironment(assetPath, {showSkybox = true,
-skyBlur = 0.0, intensity, exposure, rotationY, maxWidth = 4096, bundle})` is one-call setup.
-
-### Skybox/sky sources
-
-`Skybox(SkySource source, {intensity = 1.0})`. `SkySource` implementers: `EnvironmentSkySource({blurriness
-= 0.0})`, `ShaderSkySource({fragmentShader, fragmentShaderName, radianceCubeFragmentShader,
-useEnvironment = false})`, `GradientSkySource({zenithColor, horizonColor, groundColor, sunDirection,
-sunColor, sunSharpness = 400.0})`, `PhysicalSkySource({sunDirection, sunAngularRadius = 0.0175,
-rayleighCoefficient = 2.0, mieCoefficient = 0.005, turbidity = 10.0, energy = 1.0, ...})`.
-`SkyEnvironment(ShaderSkySource, {refresh = manual, interval, faceResolution = 128, equirectWidth =
-512})`; `SkyEnvironmentRefresh` = `manual` | `interval` | `everyFrame`.
-
-### Exposure and tone mapping
-
-`Scene.exposure` (1.0; not 2.0), `Scene.toneMapping` (`ToneMappingMode.pbrNeutral`; also `aces`,
-`reinhard`, `linear`, `agx`), `Scene.agxWhite` (16.29), `Scene.agxContrast` (1.25). Static
-`Scene.physicalCameraExposure({required aperture, shutterSpeed, iso})` returns a multiplier to assign
-to `exposure`.
-
----
-
-## Post-processing
-
-Every effect is a settings object on `Scene`, off by default, turned on with `enabled`. Environment
-looks blend via `EnvironmentSettings` (snapshot/lerp of the whole look) and `EnvironmentVolume` /
-`EnvironmentVolumeComponent` (spatial).
-
-| Scene field | Type | Key fields (default) | Requires |
-| --- | --- | --- | --- |
-| `ambientOcclusion` | `AmbientOcclusionSettings` | `method` (obscurance/`groundTruth`), `radius` (0.33), `intensity` (1.0), `power` (1.5), `bentNormals` (false), `halfResolution` (true), `indirectLight` (0.0 = SSGI), `specularMode` | perspective camera |
-| `screenSpaceReflections` | `ScreenSpaceReflectionsSettings` | `intensity` (1.0), `maxDistance` (24.4), `thickness` (0.46), `stride` (9.0), `maxSteps` (90), `blur` (0.3), `debugView` | perspective camera |
-| `fog` | `Fog` | `mode` (`FogMode.exponential`; also none/linear/exponentialSquared), `color`, `density` (0.02), `start`/`end`, needs both `enabled` AND non-none `mode` | any camera |
-| `godRays` | `GodRaysSettings` | `intensity` (1.0), `density` (0.5), `anisotropy` (0.7), `stepCount` (24), `maxDistance` (200), `color` | shadow-casting DirectionalLight + perspective camera |
-| `depthOfField` | `DepthOfField` | `focusDistance` (10.0), `fStop` (2.8), `focalLength`, `sensorHeight` (0.024), `bladeCount`, `quality` (low/medium/high) | perspective camera |
-| `autoExposure` | `AutoExposureSettings` | `strength` (0.55), `compensation`, `minEv` (-4), `maxEv` (4), `speedUp` (3.0), `speedDown` (1.0); multiplies on top of `exposure` | none |
-| `postProcess` | `PostProcessSettings` | see below | none |
-
-`AmbientOcclusionMethod` = `obscurance` (McGuire SAO) | `groundTruth` (GTAO). `SpecularAmbientOcclusionMode`
-= `none` | `simple` | `bentCone`. `SsrDebugView` = composite/reflectedUv/hitMask/normal/confidence/depth.
-
-`PostProcessSettings` (all sub-settings off by default; mutate the nested objects):
-- `colorGrading` (`ColorGradingSettings`): `brightness` (1.0), `contrast` (1.0), `saturation` (1.0),
- `temperature`, `tint`, `lift`/`gamma`/`gain`, `lut` (`ColorLut?`, applies after tone mapping,
- independent of `enabled`), `lutBlend` (1.0).
-- `chromaticAberration` (`intensity` 0.2), `vignette` (`intensity` 0.5, `radius` 0.75, `smoothness`
- 0.5), `filmGrain` (`intensity` 0.3), `bloom` (`threshold` 1.0, `intensity` 0.15, `scatter` 0.7,
- and `lensFlare`: `enabled` false, `intensity` 1.0, `ghostCount` 4, `ghostSpacing` 0.3, `haloRadius`
- 0.35, `haloIntensity` 1.0, `chromaticAberration` 0.005; rides the bloom, needs bloom enabled).
-- `customEffects` (`List`).
-
-`ColorLut.fromCubeString`/`.fromCubeAsset` (Adobe `.cube`, edge 2..64).
-
-Custom post: `PostEffect({gpu.Shader? fragmentShader, insertion = beforeTonemap, enabled = true,
-useFrameInfo = false})`, added to `scene.postProcess.customEffects`. Engine binds `uniform sampler2D
-input_color` at `in vec2 v_uv`. `PostInsertion` = `beforeTonemap` (linear HDR premultiplied) |
-`afterTonemap` (display-referred).
-
----
-
-## Scene, render, and widgets
-
-`Scene()` (no args; calls `initializeStaticResources()`, needs a live Flutter GPU context). Methods:
-`add`, `addAll`, `addMesh`, `remove`, `removeAll`, `update(dt)` (optional), `render(camera, canvas,
-{viewport, pixelRatio})`, `renderViews(views, canvas, {region, pixelRatio})`, `warmUp(views,
-{includeOffscreen})`, `raycast(ray, {maxDistance, layerMask, where, includeInvisible})`,
-`raycastAll(...)`, `addRenderPass`/`removeRenderPass`, `captureRenderGraph({viewIndex, request,
-timeout})`, `captureEnvironment({required position, faceResolution = 128, equirectWidth = 512,
-layerMask})` -> `EnvironmentMap` (one-shot static capture; use `ReflectionProbeComponent` for a
-node-anchored, parallax-corrected, auto-blended probe). Statics: `Scene.initializeStaticResources()`,
-`Scene.isReadyToRender`, `Scene.physicalCameraExposure`, `Scene.isAntiAliasingModeSupported`,
-`Scene.effectiveAntiAliasingMode`.
-
-`Scene.antiAliasingMode` (`AntiAliasingMode.auto` -> msaa or fxaa; also `none`, `msaa`, `fxaa`, `smaa`),
-`Scene.renderScale` (1.0), `Scene.filterQuality` (`FilterQuality.medium`), `Scene.views`
-(`List` for RenderTexture targets).
-
-`RenderView({required Camera camera, RenderTexture? target, Rect? viewport /*normalized 0..1,
-ignored when target set*/, int layerMask = kRenderLayerAll, order = 0, AntiAliasingMode?
-antiAliasingMode, double? renderScale, FilterQuality? filterQuality, List cullingPlanes})`.
-`kRenderLayerDefault = 1`, `kRenderLayerAll = 0xFFFFFFFF`.
-
-`RenderTexture`, `RenderTextureSampling`, `RenderTextureUpdate`, `RenderTextureView(renderTexture,
-{fit = contain, filterQuality = medium, followLayout = false})`.
-
-Widgets:
-- `SceneView(Scene scene, {Camera? camera, SceneCameraBuilder? cameraBuilder, SceneViewsBuilder?
- viewsBuilder, autoTick = true, pixelRatio, onTick, loading, loadingBuilder, revealMinDuration,
- warmUp = false, children})`. App-owned scene; does not write scene properties. `camera`,
- `cameraBuilder`, `viewsBuilder` are mutually exclusive.
-- `SceneView.declarative({environment, environmentIntensity = 1.0, exposure = 1.0, toneMapping =
- pbrNeutral, camera, cameraBuilder, viewsBuilder, children, ...})`. View-owned scene.
-- `SceneViewsBuilder` is exported as of 0.22.0 (older docs list it as a trap; it is public now).
-- Declarative widgets: `SceneNode`, `SceneMesh`, `SceneModel`, `SceneSubtree`, `SceneNodeHost`,
- `SceneNodeController`, `SceneModelSource`, `AssetModelSource`, `MemoryModelSource`,
- `SceneAnimationSpec`. `WidgetTexture`, `WidgetTextureController`, `WidgetUpdatePolicy`. `SceneScope`.
-- Camera resolution precedence: `camera` -> `cameraBuilder(elapsed)` -> `scene.camera` (or first
- mounted `CameraComponent`) -> default `PerspectiveCamera()`.
-
-`CustomRenderPass`, `RenderInput`, `RenderPassContext`, `RenderStage`, `TransientWriter`,
-`NodeFilter`, `HighlightStyle`, render-graph capture types (`CapturedPass`, `CapturedResource`,
-`RenderGraphCaptureRequest`, `RenderGraphCaptureResult`) are all exported.
-
----
-
-## Assets and animation
-
-Setup: `flutter pub add flutter_scene` then `dart run flutter_scene:init`. Enable Flutter GPU with
-`flutter run --enable-flutter-gpu` (native only; nothing for web). Requires Flutter 3.47 stable+, NOT
-master. Impeller is default; do not pass `--enable-impeller`. Never pass
-`--enable-experiment=native-assets` (breaks the build on Dart 3.10+).
-
-Two model-loading paths, do not conflate:
-
-- Pipeline (preferred, needs the `buildScenes` hook): `loadScene(sourcePath, {package, bundle,
- registry, onReload, applyStageTo}) -> Future`. `sourcePath` is the SOURCE path relative to
- the package root (e.g. `'assets/level.glb'`), NOT a generated name. Companions:
- `loadSceneSubtree`, `releaseScene`, `clearSceneTemplateCache`.
-- Runtime glTF (no hook, parses every load): `Node.fromGlbAsset(assetPath)`,
- `Node.fromGlbBytes(bytes)`, `Node.fromGltfBytes(gltfJson, {required resolveUri})`. Each synthesizes
- a root node.
-
-Sibling loaders: `loadTexture` (`.fstex`), `loadFmatMaterial`/`loadFmatSky` (`.fmat`).
-
-Build hooks (`package:flutter_scene/build_hooks.dart`): `buildScenes({buildInput, buildOutput,
-inputFilePaths, discoveryRoot = 'assets/', assetMode = generatedTree, compressTextures = false})`,
-`buildMaterials({...})`, `buildTextures({..., required textures, contents})`, `buildEngineAssets`,
-`buildTargetShaderBundleJson`. Outputs land in `flutter_scene_generated/` (never commit
-`.fsceneb`/`.shaderbundle`/`.fmat.json`/`.fstex`). Removed 0.21.0: `legacyOnly`,
-`dataAssetsIfAvailable`, `outputDirectory`.
-
-Animation (`Animation`, `AnimationClip`, `AnimationPlayer` exported):
-
-- Off a loaded model: `node.parsedAnimations`, `node.findAnimationByName(name)`,
- `node.createAnimationClip(animation)`, `node.removeAnimationClip(clip)`. Clips start paused at t=0;
- call `play()`.
-- `AnimationClip`: `playbackTime` (assignment is seek), `playbackTimeScale` (1; negative reverses),
- `weight` (0..1), `playing`, `loop`. `play()`, `pause()`, `stop()`, `replay()`, `gotoAndPlay(t)`,
- `seek(t)`, `advance(dt)`, `rebind(newTarget, {animation})`. Channels bind by node NAME; channels
- whose node is absent from the subtree are dropped (0.22.0 asserts in debug when ALL channels drop).
-- `AnimationPlayer`: `createAnimationClip(animation, bindTarget)` (a second call with the same
- `Animation.name` replaces), `getClipByName`, `rebind`, `update(dt)` (auto-driven per frame).
-- `Animation({name, channels})`, `AnimationChannel`, `BindKey({required nodeName, property =
- translation})`, `AnimationProperty` = `translation` | `rotation` | `scale`.
-- Declarative: `SceneModel(assetPath, animations: [SceneAnimationSpec(name, {playing = true, loop =
- true, weight = 1.0, speed = 1.0})])`. Note `SceneModel` loads via the runtime glTF path.
-
-The engine-agnostic scene-document core is a separate package `scene` (0.2.0), re-exported through
-`package:flutter_scene/fscene.dart`. `flutter_scene_importer` and `flutter_gpu_shim` no longer exist
-(folded in). Physics and audio are separate barrels (`physics.dart`, `audio.dart`).
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-kit/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-kit/SKILL.md
deleted file mode 100644
index 7ec5d93..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-kit/SKILL.md
+++ /dev/null
@@ -1,146 +0,0 @@
----
-name: flutter_scene-kit
-version: 3
-description: Build interactive 3D gameplay, character controllers, camera rigs, dynamic day/night cycles, water surfaces, audio, pooling, and debug overlays in flutter_scene. Use when creating game mechanics, camera controls, NPC behaviors, atmospheric environments, or diagnostic HUDs.
----
-
-# Gameplay, camera, and atmosphere kit in flutter_scene
-
-Flutter Scene provides high-level gameplay components and ergonomic building blocks in `package:flutter_scene/kit.dart` so games and interactive experiences do not need to re-implement standard mechanics from scratch.
-
-When choosing components, consider existing engine alternatives:
-- For physics-driven character navigation with collider capsules, wall sliding, and autostep, use `KinematicCharacterController` from `package:flutter_scene/physics.dart`.
-- For interactive mouse/touch orbit cameras with inertia, use `OrbitCameraController` or `FollowCameraController`.
-- For framing a standalone `PerspectiveCamera`, use `PerspectiveCamera.framing`. Use `BoundsFraming` when computing a transform for a `NodeCamera` mounted in the scene graph.
-
-## Imports
-
-```dart
-import 'package:flutter_scene/scene.dart';
-import 'package:flutter_scene/kit.dart';
-import 'package:vector_math/vector_math.dart' as vm;
-```
-
-## Camera rigs and smoothing
-
-### SpringArmComponent
-
-`SpringArmComponent` attaches to a target character node and mounts a camera node at the arm's socket. It casts rays against the scene hierarchy to prevent geometry clipping, smoothly pulling the camera inward when colliding with walls.
-
-Note on offsets: `targetOffset` is applied in world space from the character node's origin, and `socketOffset` acts in the camera socket's local plane along X (right) and Y (up).
-
-```dart
-final characterNode = Node();
-final cameraNode = Node();
-final cameraArm = SpringArmComponent(
- targetLength: 5.0,
- targetOffset: vm.Vector3(0, 1.6, 0), // Eye height
- socketOffset: vm.Vector3(0.5, 0, 0), // Over-the-shoulder
- enablePositionLag: true,
- positionLagSpeed: 8.0,
- cameraNode: cameraNode,
-);
-
-characterNode.addComponent(cameraArm);
-scene.root.add(characterNode);
-scene.root.add(cameraNode);
-```
-
-### CameraShake
-
-`CameraShake` implements a trauma-decay model driven by deterministic simplex noise for organic multi-axis camera shake (explosions, footsteps, hits).
-
-```dart
-final shake = CameraShake(decayRate: 1.2, frequency: 25.0);
-
-// Add trauma on hit
-shake.addTrauma(0.6);
-
-// Inside game loop
-final offset = shake.update(deltaSeconds);
-cameraNode.localTransform = baseTransform * offset.toMatrix4();
-```
-
-## Character movement and steering
-
-### ThirdPersonControllerComponent
-
-`ThirdPersonControllerComponent` handles kinematic movement, sprint multipliers, turn smoothing, ground snapping with raycasts, slope sliding, coyote time, and buffered jumps. Input expects `+Y` as forward in 3D.
-
-```dart
-final playerNode = Node();
-final controller = ThirdPersonControllerComponent(
- walkSpeed: 4.5,
- runMultiplier: 1.8,
- jumpVelocity: 7.0,
- groundPlaneHeight: 0.0, // Optional fallback floor
-);
-playerNode.addComponent(controller);
-
-// When using VirtualJoystick (where up is -Y in screen space), invert Y:
-// controller.setMoveInput(vm.Vector2(joystickDir.x, -joystickDir.y), isRunning: isSprinting);
-if (jumpPressed) controller.jump();
-```
-
-### Autonomous Steering Behaviors
-
-`Steering` provides math helpers for NPC navigation, flocking, and crowd dynamics.
-
-```dart
-// Seek target
-final seekForce = Steering.seek(npcPos, npcVel, targetPos, maxSpeed: 4.0);
-
-// Arrive smoothly
-final arriveForce = Steering.arrive(npcPos, npcVel, targetPos, slowingRadius: 3.0);
-
-// Flocking separation
-final sepForce = Steering.separation(npcPos, npcVel, neighborPositions, desiredDistance: 1.5);
-```
-
-## Dynamic environments and atmosphere
-
-### DayNightCycleComponent
-
-`DayNightCycleComponent` moves the sun along a realistic solar arc given latitude and time of day, evaluating sun colors, intensities, and ambient lighting transitions.
-
-```dart
-final sunLight = DirectionalLight();
-final sunNode = Node()..addComponent(DirectionalLightComponent(sunLight));
-scene.root.add(sunNode);
-
-final skyCycle = DayNightCycleComponent(
- timeOfDay: 14.5, // 2:30 PM
- timeSpeed: 0.1, // Progress 0.1 hours per second
- latitude: 34.0,
- sunLightNode: sunNode,
-);
-scene.root.addComponent(skyCycle);
-```
-
-### WaterSurfaceComponent
-
-`WaterSurfaceComponent` evaluates multi-harmonic Gerstner trochoidal waves for water surfaces and floating buoyancy queries.
-
-```dart
-final water = WaterSurfaceComponent();
-final surface = water.evaluateAt(vm.Vector2(playerPos.x, playerPos.z));
-final waterHeight = surface.displacement.y;
-final waterNormal = surface.normal;
-```
-
-## Immediate-mode debug visualization
-
-`DebugDraw` provides static immediate-mode line, ray, box, sphere, and axis drawing utilities for physics debugging and AI visualizers.
-
-```dart
-DebugDraw.line(startPos, endPos, color: vm.Vector4(1, 0, 0, 1));
-DebugDraw.box(aabb, color: vm.Vector4(0, 1, 0, 1));
-DebugDraw.sphere(center, 1.0, color: vm.Vector4(0, 0, 1, 1));
-DebugDraw.axes(node.globalTransform, size: 2.0);
-
-// Render debug lines
-final debugMesh = DebugDraw.flushMesh();
-if (debugMesh != null) {
- debugNode.mesh = Mesh(debugMesh, UnlitMaterial());
-}
-```
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/SKILL.md
deleted file mode 100644
index 63fb1ea..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/SKILL.md
+++ /dev/null
@@ -1,166 +0,0 @@
----
-name: flutter_scene-looks
-version: 4
-description: Give a flutter_scene render a deliberate, polished look. Use this whenever a scene looks flat, dull, or washed out, or whenever the ask is to make it look good, because a good look is lighting plus post-processing, not geometry. Ships copy-paste EnvironmentSettings presets that configure the whole stack coherently.
----
-
-# Making flutter_scene look good
-
-The single most common reason a flutter_scene render looks amateur is that the post-processing stack was left at defaults. flutter_scene has a deep lit-and-post pipeline (image-based lighting, tone mapping, bloom, lens flares, ambient occlusion, screen-space reflections, fog, god rays, depth of field, color grading, vignette, film grain). Out of the box almost all of it is off, so a bare scene is technically correct and visually flat.
-
-**The insight: a polished look is lighting and post-processing, not geometry.** Better meshes will not fix a flat render. Do not spend effort on modeling detail when the scene reads dull; spend it on the look. And a look is a *coherent* set of choices, not twelve knobs turned independently. Turning bloom, AO, SSR, fog, grain, and grading up one at a time, each by feel, lands in muddy incoherent territory. Pick one deliberate preset and paste it whole.
-
-## Apply a look in one line
-
-Every scene-wide look field lives on one aggregate value, `EnvironmentSettings`, assigned through a single setter:
-
-```dart
-scene.environmentSettings = EnvironmentSettings(/* fields below */);
-```
-
-That one assignment configures tone mapping, exposure, image-based-lighting intensity, and the entire post stack, **including** fog, god rays, depth of field, and auto exposure. You do not need to touch `scene.fog`, `scene.godRays`, `scene.depthOfField`, or `scene.autoExposure` separately; those are the live per-effect objects, but `EnvironmentSettings` carries all of their fields and applies them for you. Every effect is off by default, so a preset only names the fields it turns on.
-
-`EnvironmentSettings` is also a blendable snapshot. Read the current look with `scene.environmentSettings`, and cross-fade two looks with `EnvironmentSettings.lerp(a, b, t)` driven from an animation. That is how you transition day to night or ramp an effect in.
-
-## Lights are separate, and still matter
-
-`EnvironmentSettings` covers image-based lighting (the `environment` map) and the whole post stack, but **direct lights are not part of it.** Shadows, god rays, and any strong key light come from the scene's lights, set separately:
-
-```dart
-scene.directionalLight = DirectionalLight(
- direction: vm.Vector3(-0.4, -1.0, -0.3),
- intensity: 4.0,
- castsShadow: true, // shadows are off until you ask
-);
-```
-
-An unset `scene.environment` still resolves to a default studio IBL, so a `PhysicallyBasedMaterial` is always lit. But a scene with no direct light is soft and shadowless. God rays require a shadow-casting `DirectionalLight`; shadows require `castsShadow: true` on the light.
-
-## The four looks
-
-Paste one whole. Each is a real `EnvironmentSettings` literal; import `package:vector_math/vector_math.dart as vm` for the `Vector3` color fields. AO, SSR, god rays, and depth of field require a `PerspectiveCamera` (the only built-in camera).
-
-### showcase
-
-Clean, bright product-viz beauty. Punchy tone mapping, soft bloom on highlights, grounded contact occlusion, and real reflections. The default reach-for-it look.
-
-```dart
-scene.environmentSettings = EnvironmentSettings(
- toneMapping: ToneMappingMode.aces,
- exposure: 1.0,
- bloomEnabled: true,
- bloomThreshold: 1.1,
- bloomIntensity: 0.2,
- bloomScatter: 0.7,
- ambientOcclusionEnabled: true,
- ambientOcclusionMethod: AmbientOcclusionMethod.groundTruth,
- ambientOcclusionBentNormals: true,
- ambientOcclusionSpecularMode: SpecularAmbientOcclusionMode.bentCone,
- ambientOcclusionIntensity: 1.0,
- screenSpaceReflectionsEnabled: true,
- screenSpaceReflectionsIntensity: 1.0,
- vignetteEnabled: true,
- vignetteIntensity: 0.25,
-);
-```
-
-### stylized
-
-Vivid and graphic. Saturated, slightly warm, glowing, flatter shading (no heavy occlusion or reflections). For playful or illustrative scenes.
-
-```dart
-scene.environmentSettings = EnvironmentSettings(
- toneMapping: ToneMappingMode.aces,
- colorGradingEnabled: true,
- saturation: 1.25,
- contrast: 1.1,
- brightness: 1.05,
- temperature: 0.1,
- bloomEnabled: true,
- bloomThreshold: 0.9,
- bloomIntensity: 0.28,
- bloomScatter: 0.8,
- vignetteEnabled: true,
- vignetteIntensity: 0.2,
-);
-```
-
-### moody
-
-Dark, cinematic, atmospheric. Lower exposure, cool graded, foggy, heavy vignette, subtle grain and aberration, deep occlusion. God rays if the scene has a shadow-casting sun. Use a cool horizon-colored fog.
-
-```dart
-scene.environmentSettings = EnvironmentSettings(
- toneMapping: ToneMappingMode.aces,
- exposure: 0.8,
- colorGradingEnabled: true,
- contrast: 1.15,
- saturation: 0.9,
- temperature: -0.1,
- fogEnabled: true,
- fogMode: FogMode.exponential,
- fogColor: vm.Vector3(0.05, 0.06, 0.09),
- fogDensity: 0.03,
- ambientOcclusionEnabled: true,
- ambientOcclusionMethod: AmbientOcclusionMethod.groundTruth,
- ambientOcclusionIntensity: 1.2,
- ambientOcclusionPower: 1.8,
- vignetteEnabled: true,
- vignetteIntensity: 0.6,
- vignetteRadius: 0.6,
- filmGrainEnabled: true,
- filmGrainIntensity: 0.25,
- chromaticAberrationEnabled: true,
- chromaticAberrationIntensity: 0.15,
- godRaysEnabled: true, // needs scene.directionalLight with castsShadow: true
- godRaysIntensity: 1.0,
- godRaysDensity: 0.6,
- godRaysColor: vm.Vector3(1.0, 0.95, 0.85),
-);
-```
-
-### clean
-
-Neutral and honest. Minimal post, no grading, no bloom, no vignette, just correct tone mapping and gentle grounding occlusion. The right look for an editor, an inspector, a UI-embedded viewer, or anywhere you want an accurate read of the actual material.
-
-```dart
-scene.environmentSettings = EnvironmentSettings(
- toneMapping: ToneMappingMode.pbrNeutral, // the engine default
- exposure: 1.0,
- ambientOcclusionEnabled: true,
- ambientOcclusionIntensity: 0.8,
- ambientOcclusionHalfResolution: true,
-);
-```
-
-## Tuning from a preset
-
-Start from the nearest look, then move one field at a time.
-
-- **Too dim or too bright overall.** Change `exposure` (default 1.0), not per-light intensity. Or turn on `autoExposureEnabled: true` to let the scene meter itself.
-- **Highlights not glowing.** Lower `bloomThreshold` toward 1.0 or below, or raise `bloomIntensity`. `bloomScatter` widens the glow.
-- **Want a lens flare off a bright source.** Turn on `lensFlareEnabled` (needs `bloomEnabled`). Keep `lensFlareIntensity` modest and drop `lensFlareHaloIntensity` first if the flare washes the frame; the halo is the broad wash, the ghosts are the crisp chain.
-- **Reads flat and ungrounded.** `ambientOcclusionEnabled: true`. Use `AmbientOcclusionMethod.groundTruth` for quality, keep `ambientOcclusionHalfResolution: true` for cost.
-- **Colors feel wrong.** Turn on `colorGradingEnabled` and reach for `saturation`, `contrast`, `temperature`, `tint` before anything else.
-- **Wrong tone-map feel.** `ToneMappingMode.aces` is contrasty and filmic, `pbrNeutral` (default) preserves hue and saturation, `agx` is the most neutral highlight rolloff. Do not reintroduce an `exposure: 2.0` hack; that was an artifact of an older renderer.
-
-## Micro-surface metrics and anti-waxiness tuning
-
-When tuning procedural materials or custom shaders, use surface metrics and exposure discipline to eliminate waxiness, plastic reads, or harsh aliasing:
-
-- **Surface reads like smooth plastic.** Increase high-frequency micro-scale variation. Low 1-pixel luminance gradients `(|dL/dx| + |dL/dy|) / 2` indicate untextured or overly smooth surfaces.
-- **Blotchy macro clouds.** Balance high-frequency and low-frequency energy ratios (`hf/lf`). High variance with low fine detail indicates large-scale noise patches without adequate surface grain.
-- **Harsh normal-map glitter or torn noise.** Check terminator crossing behavior under low grazing light angles (sun low on the horizon). Over-amplified normal maps flip adjacent pixels between full light and full shadow.
-- **Colors look washed out in bright areas.** Tone mapping curves compress channel differences near the shoulder. A surface reading high brightness with low saturation is often over-exposed rather than under-pigmented; reduce exposure to restore natural material saturation.
-
-## Look tools that are not on EnvironmentSettings
-
-Anti-aliasing, reflection probes, and planar reflectors affect the look but are set outside `EnvironmentSettings`, so a preset does not turn them on.
-
-- **Anti-aliasing** is a `Scene` field. The default `AntiAliasingMode.auto` already picks `msaa` where the backend supports it and `fxaa` otherwise, so edges are handled. Reach for `scene.antiAliasingMode = AntiAliasingMode.smaa` when `fxaa` looks mushy (it blurs texture detail) and `msaa` is unavailable; SMAA keeps edges clean without the blur, at ~3x fxaa cost.
-- **Reflection probes** capture true local reflections that SSR cannot, because SSR only reflects what is on screen. Attach a `ReflectionProbeComponent` to a node placed at the reflective spot (a room, a mirror ball); it captures the surroundings into a parallax-corrected box and blends with the environment. The capture renders the scene six times, so it happens once on activate (or on an explicit `requestCapture()`), never per frame.
-- **Planar reflectors** render a true per-frame mirror for one flat surface (a mirror, a glossy floor), which neither SSR nor a probe can produce. Attach a `PlanarReflectorComponent` to the mirror node and give the surface a `.fmat` material declaring `engine_inputs: [ planar_reflection ]`; the component renders the scene once more per frame from the reflected camera and the material samples it with `GetPlanarReflection()`. That extra scene render is the cost, so bound it with `resolutionScale` (default 0.5) and `layerMask`. See `references/looks.md` for all three.
-
-## Cost
-
-The post stack is not free. AO, SSR, depth of field, and god rays each add screen-space passes, and mobile and web are the budget. Keep `ambientOcclusionHalfResolution: true`, prefer `DepthOfFieldQuality.low` on mobile, and do not stack SSR plus god rays plus depth of field on a low-end target without profiling. The `clean` look is nearly free; `moody` is the heaviest. See `references/looks.md` for the full per-effect knob reference, defaults, and cost notes.
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/references/looks.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/references/looks.md
deleted file mode 100644
index 86823c8..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-looks/references/looks.md
+++ /dev/null
@@ -1,290 +0,0 @@
-# The look stack, knob by knob
-
-Every field here is a constructor argument on `EnvironmentSettings` (`lib/src/environment_settings.dart`). Assigning `scene.environmentSettings = EnvironmentSettings(...)` applies all of them at once. Defaults are the constructor defaults; a preset only names what it changes. Direct lights (`scene.directionalLight` and friends) are separate and not covered by `EnvironmentSettings`.
-
-Color fields are `vm.Vector3` (import `package:vector_math/vector_math.dart as vm`).
-
----
-
-## Base look
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `toneMapping` | `ToneMappingMode.pbrNeutral` | HDR to display operator. `pbrNeutral` preserves hue/saturation, `aces` is contrasty and filmic, `agx` has the gentlest highlight rolloff, `reinhard`/`linear` are simpler references. |
-| `exposure` | `1.0` | Linear scene exposure multiplier. The one knob for overall brightness. Do not use `2.0`; that was an old-renderer hack. |
-| `environmentIntensity` | `1.0` | Scales image-based lighting (the environment map) contribution. |
-| `agxWhite` | `16.29` | AgX white point, only meaningful with `ToneMappingMode.agx`. |
-| `agxContrast` | `1.25` | AgX contrast, only with `agx`. |
-
-`environment` (an `EnvironmentMap?`) and the sky fields are also on `EnvironmentSettings`, but building environment maps is the domain of the idioms skill; a null environment resolves to a default studio IBL.
-
-## Auto exposure (`autoExposure*`)
-
-Meters the frame and multiplies on top of `exposure`. Off by default.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `autoExposureEnabled` | `false` | Turn metering on. |
-| `autoExposureStrength` | `0.55` | How fully it drives toward the metered target (0 = none, 1 = full). |
-| `autoExposureCompensation` | `0.0` | EV bias applied after metering. |
-| `autoExposureMinEv` / `autoExposureMaxEv` | `-4.0` / `4.0` | Clamp range for the adaptation. |
-| `autoExposureSpeedUp` / `autoExposureSpeedDown` | `3.0` / `1.0` | Adaptation rate brightening vs darkening. |
-
-## Bloom (`bloom*`)
-
-Blooms bright pixels. Off by default. The cheapest way to make a scene feel lit rather than rendered.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `bloomEnabled` | `false` | Turn bloom on. |
-| `bloomThreshold` | `1.0` | Brightness above which a pixel blooms. Lower for more glow. |
-| `bloomIntensity` | `0.15` | Strength of the added glow. |
-| `bloomScatter` | `0.7` | Spread of the glow, wider values feel dreamier. |
-
-### Lens flares (`lensFlare*`)
-
-Ghost chains and a halo ring off the bloom pyramid, for bright emissive sources and sun disks. Rides the bloom chain, so `bloomEnabled` must be on and the flare scales with `bloomIntensity`. Off by default. A little goes a long way; a strong source with high intensity/halo washes the frame.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `lensFlareEnabled` | `false` | Turn flares on. Needs `bloomEnabled`. |
-| `lensFlareIntensity` | `1.0` | Strength of the flare features relative to the bloom. |
-| `lensFlareGhostCount` | `4` | Internal-reflection ghosts along the line through the screen center (clamped to 8 at render). |
-| `lensFlareGhostSpacing` | `0.3` | Spacing between ghosts, as a fraction of the distance to the center. |
-| `lensFlareHaloRadius` | `0.35` | Halo ring radius in screen UV units. |
-| `lensFlareHaloIntensity` | `1.0` | Halo strength relative to the ghosts. `0` disables the halo. The halo is what washes the whole frame, so tame it first. |
-| `lensFlareChromaticAberration` | `0.005` | Radial color dispersion of the flare features. |
-
-## Color grading (`colorGrading*`)
-
-Post-tone-map color shaping. Off by default. Reach here to change the *mood* of the color rather than the exposure.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `colorGradingEnabled` | `false` | Turn grading on. |
-| `brightness` | `1.0` | Multiplicative brightness. |
-| `contrast` | `1.0` | Contrast around mid-gray. |
-| `saturation` | `1.0` | Color saturation. Above 1 is vivid, below 1 desaturates toward gray. |
-| `temperature` | `0.0` | Warm (positive) to cool (negative) white balance. |
-| `tint` | `0.0` | Green to magenta balance. |
-| `lift` / `gamma` / `gain` | `Vector3(0)` / `Vector3(1)` / `Vector3(1)` | Per-channel shadow/mid/highlight color control (lift-gamma-gain). |
-| `colorGradingLut` | `null` | A `ColorLut` (from a `.cube` file) applied after tone mapping. Independent of `colorGradingEnabled`. |
-| `colorGradingLutBlend` | `1.0` | LUT mix amount. |
-
-## Vignette (`vignette*`)
-
-Darkens the frame edges. Off by default. Small amounts read as cinematic; large amounts as a peephole.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `vignetteEnabled` | `false` | Turn vignette on. |
-| `vignetteIntensity` | `0.5` | Darkening strength at the edge. |
-| `vignetteRadius` | `0.75` | How far in the darkening starts (smaller = tighter, more closed-in). |
-| `vignetteSmoothness` | `0.5` | Falloff softness of the edge. |
-
-## Chromatic aberration (`chromaticAberration*`)
-
-Splits color channels toward the edges. Off by default. A touch adds a lens feel; too much looks broken.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `chromaticAberrationEnabled` | `false` | Turn it on. |
-| `chromaticAberrationIntensity` | `0.2` | Channel-separation strength. |
-
-## Film grain (`filmGrain*`)
-
-Adds animated grain. Off by default. Sells a moody or analog look and hides banding in dark gradients.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `filmGrainEnabled` | `false` | Turn it on. |
-| `filmGrainIntensity` | `0.3` | Grain strength. |
-
-## Ambient occlusion (`ambientOcclusion*`)
-
-Screen-space contact darkening. Off by default. Requires a `PerspectiveCamera`. The single biggest upgrade for "grounded" vs "floating". Half-resolution by default to stay affordable.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `ambientOcclusionEnabled` | `false` | Turn AO on. |
-| `ambientOcclusionMethod` | `AmbientOcclusionMethod.obscurance` | `obscurance` is the cheap default; `groundTruth` (GTAO) is higher quality and needed for bent normals. |
-| `ambientOcclusionIntensity` | `1.0` | Darkening strength. |
-| `ambientOcclusionRadius` | `0.33` | World-space sampling radius. |
-| `ambientOcclusionPower` | `1.5` | Contrast of the occlusion curve. |
-| `ambientOcclusionBias` | `0.07` | Self-occlusion rejection. |
-| `ambientOcclusionBentNormals` | `false` | Compute bent normals (needs `groundTruth`); improves indirect lighting direction and enables `bentCone` specular AO. |
-| `ambientOcclusionSpecularMode` | `SpecularAmbientOcclusionMode.none` | `simple` occludes reflections cheaply; `bentCone` is directional and needs bent normals. |
-| `ambientOcclusionHalfResolution` | `true` | Compute at half res. Keep on unless AO edges look too coarse. |
-| `ambientOcclusionIndirectLight` | `0.0` | Above 0 turns on screen-space global illumination (SSGI) bounce; expensive. Its radiance history reprojects, so the bounce stays put under camera motion (object motion still lags). |
-| `ambientOcclusionMultiBounce` | `0.0` | Approximate multi-bounce darkening recovery. |
-| `ambientOcclusionSampleCount` | `16` | Samples for the `obscurance` method. |
-| `ambientOcclusionSliceCount` / `ambientOcclusionStepsPerSlice` | `3` / `3` | GTAO slice sampling. |
-| `ambientOcclusionDetail` | `0.5` | Fine-detail term weight (obscurance). |
-| `ambientOcclusionHorizonAngle` | `0.06` | Horizon rejection angle. |
-| `ambientOcclusionThickness` / `ambientOcclusionThicknessHeuristic` | `0.5` / `0.004` | Depth thickness assumptions for occlusion. |
-| `ambientOcclusionDirectLightAffect` | `0.0` | How much AO also dims direct light. |
-| `ambientOcclusionVisibilityBitmask` | `false` | Bitmask visibility estimator. |
-| `ambientOcclusionDepthMipChain` | `false` | Build a depth mip chain for wide-radius sampling. |
-
-## Screen-space reflections (`screenSpaceReflections*`)
-
-Reflects on-screen geometry. Off by default. Requires a `PerspectiveCamera`. Adds realism to floors, water, and glossy surfaces, but only reflects what is on screen.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `screenSpaceReflectionsEnabled` | `false` | Turn SSR on. |
-| `screenSpaceReflectionsIntensity` | `1.0` | Reflection strength. |
-| `screenSpaceReflectionsMaxDistance` | `24.4` | Max world-space ray distance. |
-| `screenSpaceReflectionsThickness` | `0.46` | Assumed surface thickness for hit tests. |
-| `screenSpaceReflectionsStride` | `9.0` | March step size (larger = faster, coarser). |
-| `screenSpaceReflectionsMaxSteps` | `90` | Ray-march step budget. |
-| `screenSpaceReflectionsBlur` | `0.3` | Roughness-based blur of the reflection. |
-| `screenSpaceReflectionsDistanceFadeStart` | `0.0` | Where reflections start fading with distance. |
-| `screenSpaceReflectionsResolutionScale` | `1.0` | Compute resolution scale; drop below 1 to save cost. |
-
-## Fog (`fog*`)
-
-Distance and height fog, evaluated in linear HDR before tone mapping. Off by default. Needs both `fogEnabled` and a non-`none` `fogMode`. Applies to lit and unlit materials; the skybox is left unfogged, so set `fogColor` to your horizon color for distant blending.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `fogEnabled` | `false` | Turn fog on. |
-| `fogMode` | `FogMode.exponential` | `none`, `linear`, `exponential`, `exponentialSquared`. Must be non-`none` to render. |
-| `fogColor` | `Vector3(0.6, 0.7, 0.8)` | Fog tint. Match your sky/horizon. |
-| `fogDensity` | `0.02` | Density for the exponential modes. |
-| `fogStart` / `fogEnd` | `0.0` / `200.0` | Near/far bounds for `linear` mode. |
-| `fogSkyColorInfluence` | `0.0` | Blend fog color toward the sky color. |
-| `fogMaxOpacity` | `1.0` | Cap on how opaque fog gets. |
-| `fogHeight` / `fogHeightFalloff` | `0.0` / `0.0` | Height-fog band and falloff. |
-| `fogSunInScatter` / `fogSunInScatterExponent` | `0.0` / `8.0` | Sun in-scatter glow through the fog. |
-| `fogCutoffDistance` | `0.0` | Distance beyond which fog stops accumulating. |
-
-## God rays (`godRays*`)
-
-Volumetric light shafts. Off by default. Requires a shadow-casting `DirectionalLight` and a `PerspectiveCamera`; without a shadow-casting sun there is nothing to shaft.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `godRaysEnabled` | `false` | Turn shafts on. |
-| `godRaysIntensity` | `1.0` | Shaft strength. |
-| `godRaysDensity` | `0.5` | Medium density the light scatters through. |
-| `godRaysAnisotropy` | `0.7` | Forward-scatter bias (higher = tighter shafts toward the sun). |
-| `godRaysStepCount` | `24` | March steps; higher is smoother and costlier. |
-| `godRaysMaxDistance` | `200.0` | Max shaft distance. |
-| `godRaysJitter` | `1.0` | Dither to hide banding. |
-| `godRaysColor` | `Vector3(1)` | Shaft tint. |
-
-## Depth of field (`depthOfField*`)
-
-Physically parameterized lens blur. Off by default. Requires a `PerspectiveCamera`. Great for a hero shot, wasteful for a full interactive scene.
-
-| Field | Default | What it does |
-| --- | --- | --- |
-| `depthOfFieldEnabled` | `false` | Turn DoF on. |
-| `depthOfFieldFocusDistance` | `10.0` | World distance in sharp focus. |
-| `depthOfFieldFStop` | `2.8` | Aperture; lower = shallower focus, more blur. |
-| `depthOfFieldFocalLength` | `0.0` | Lens focal length (0 derives from FOV). |
-| `depthOfFieldSensorHeight` | `0.024` | Sensor height in meters (35mm-ish). |
-| `depthOfFieldBlurScale` | `1.0` | Overall blur multiplier. |
-| `depthOfFieldMaxForegroundBlur` / `depthOfFieldMaxBackgroundBlur` | `24.0` / `32.0` | Blur radius caps. |
-| `depthOfFieldBladeCount` | `0` | Aperture blades for bokeh shape (0 = round). |
-| `depthOfFieldBladeRotation` / `depthOfFieldBladeCurvature` | `0.0` / `0.0` | Bokeh blade shaping. |
-| `depthOfFieldQuality` | `DepthOfFieldQuality.medium` | `low` (16 taps, mobile/web), `medium` (32 taps + postfilter), `high` (48 taps). |
-
----
-
-## Not on `EnvironmentSettings`
-
-Two look-affecting settings live outside the `EnvironmentSettings` snapshot: anti-aliasing is a `Scene` field, and reflection probes are a scene-graph component. Set them directly.
-
-### Anti-aliasing (`Scene.antiAliasingMode`)
-
-Edge anti-aliasing. `AntiAliasingMode.auto` is the default: it picks `msaa` where the backend supports it and `fxaa` otherwise. Set it directly, not through `EnvironmentSettings`.
-
-```dart
-scene.antiAliasingMode = AntiAliasingMode.smaa;
-```
-
-| Mode | What it does |
-| --- | --- |
-| `none` | No anti-aliasing; native-resolution edges. |
-| `msaa` | 4x MSAA on the scene pass, the best geometry-edge quality and cheap on mobile GPUs, but not supported on every Flutter GPU backend (falls back to `fxaa`). Check `Scene.isAntiAliasingModeSupported` and read `Scene.effectiveAntiAliasingMode`. |
-| `fxaa` | One post pass over the tone-mapped image, supported everywhere, but softens all high-contrast edges including texture detail. |
-| `smaa` | SMAA 1x, three post passes, supported everywhere. Reconstructs edge shapes so edges are cleaner than `fxaa` with far less texture blurring, at roughly 3x the `fxaa` cost. Reach for it when `fxaa` looks mushy and `msaa` is unavailable. |
-| `auto` | `msaa` where supported, else `fxaa`. |
-
-### Reflection probes (`ReflectionProbeComponent`)
-
-SSR only reflects what is currently on screen. A reflection probe captures the surroundings from a point into a local, parallax-corrected environment, so off-screen geometry reflects correctly inside a bounded box (a mirror ball, a glossy floor in a room). It is a `Component` attached to a `Node`, not an `EnvironmentSettings` field.
-
-```dart
-final probe = Node()
- ..localTransform = vm.Matrix4.translation(vm.Vector3(0, 1, 0)); // reflective spot
-probe.addComponent(ReflectionProbeComponent(
- extents: vm.Vector3(4, 3, 4), // box half-extents (the influence + parallax volume)
-));
-scene.add(probe);
-```
-
-| Constructor arg | Default | What it does |
-| --- | --- | --- |
-| `extents` | `Vector3.all(5.0)` | Half-extents of the world-axis-aligned box that is both the influence volume and the parallax proxy. |
-| `blendDistance` | `1.0` | Distance over which the probe cross-fades with the environment at the box edge. |
-| `priority` | `10.0` | Which probe wins where several overlap. |
-| `weight` | `1.0` | Contribution scale in the blend. |
-| `faceResolution` | `128` | Cubemap face resolution of the capture. |
-| `captureOnActivate` | `true` | Capture once when the probe joins the scene. Call `requestCapture()` to re-capture after the scene changes; the capture is a static snapshot otherwise. |
-
-For a one-shot environment capture with no node or parallax (e.g. to hand a captured `EnvironmentMap` to another material), `Scene.captureEnvironment(position: ...)` returns an `EnvironmentMap` directly.
-
-### Planar reflectors (`PlanarReflectorComponent`)
-
-A true mirror for one flat surface, re-rendered every frame the surface is visible: the engine renders the scene from the view camera reflected across the surface's plane (near plane clamped to the mirror, so nothing behind it leaks in) and hands the capture to the surface's material. Use it for mirrors and glossy floors where SSR's on-screen-only reflections or a probe's static capture are not enough.
-
-Two pieces pair up. The component goes on the mirror node (the plane is the node's local `+Y` through its transform, or an explicit `localNormal`):
-
-```dart
-final mirror = Node(mesh: Mesh(PlaneGeometry(width: 10, depth: 10), mirrorMaterial))
- ..addComponent(PlanarReflectorComponent());
-scene.add(mirror);
-```
-
-And the surface's material is a `.fmat` that declares the `planar_reflection` engine input and samples `GetPlanarReflection()` (mirrored scene color in rgb, `a` 1 while a capture is bound; fall back to the environment reflection at `a == 0`). A worked mirror lives at `examples/flutter_app/assets/planar_mirror.fmat`.
-
-| Constructor arg | Default | What it does |
-| --- | --- | --- |
-| `resolutionScale` | `0.5` | Capture resolution relative to the view (clamped `0.1..1.0`). The fragment-cost lever. |
-| `layerMask` | all layers | What renders into the capture. The draw-cost lever. |
-| `reflectionGroupId` | `-1` | Co-planar surfaces sharing a non-negative id share one capture per frame; `-1` means an own capture. |
-| `clipBias` | `1e-3` | World-space offset of the clip plane in front of the mirror, keeping the surface itself out of the capture. |
-| `localNormal` | local `+Y` | The mirror plane's facing direction in node space. |
-
-The capture is a second scene submission per reflection group per frame: its CPU and draw-call cost scales with scene complexity, not just resolution. It reuses the frame's shadow atlas and runs without screen-space post; reflectors seen inside a capture draw their base look, so captures never recurse.
-
----
-
-## Cost and budget
-
-Post effects are screen-space passes; they cost per output pixel, not per triangle. Rough order, cheapest first:
-
-- **Nearly free.** Tone mapping, exposure, color grading, vignette, chromatic aberration, film grain, bloom (with lens flares). The `clean` and `stylized` looks live here.
-- **Moderate.** Ambient occlusion (keep `ambientOcclusionHalfResolution: true`), fog, `fxaa`, `smaa` (~3x `fxaa`). `msaa` is nearly free on mobile GPUs but costs more elsewhere.
-- **Expensive.** SSR, god rays, depth of field, and especially SSGI (`ambientOcclusionIndirectLight > 0`). Each adds ray-marching or gather passes. A reflection probe's capture renders the scene six times, so capture on activate or an occasional `requestCapture()`, never per frame.
-
-Budget guidance:
-
-- **Mobile and web are the ceiling.** A look that is smooth on desktop can tank a phone. Profile the target, do not assume.
-- **Keep AO at half resolution** unless the coarse edges actually show. Full-res AO rarely earns its cost.
-- **Do not stack the expensive effects blindly.** SSR plus god rays plus depth of field together on a low-end device needs profiling; drop one or lower its resolution/step budget (`screenSpaceReflectionsResolutionScale`, `godRaysStepCount`, `DepthOfFieldQuality.low`).
-- **Depth of field is a hero-shot tool.** It reads as intentional on a framed still and as a smear on a free-moving interactive camera. Prefer it where the camera is controlled.
-- **Bloom and grading buy the most look per cost.** When you need "flashier" cheaply, reach for these before the screen-space passes.
-
-## Why each look is built the way it is
-
-**showcase** aims for a clean, believable, flattering render, the default for showing a model off. `aces` tone mapping gives contrast and pop; soft bloom (threshold just above 1.0) lights the highlights without haze; GTAO with bent normals and `bentCone` specular AO grounds the object and tightens reflections in its cavities; SSR adds real floor and surface reflections; a light vignette focuses the eye. Every choice supports "look at this object", nothing calls attention to itself.
-
-**stylized** trades realism for graphic punch. It leans on color (saturation up, a warm push, contrast up) and glow (a lower bloom threshold so more of the frame blooms) and deliberately *omits* AO and SSR, because heavy occlusion and reflection read as realism and fight the flatter, poppier intent. Cheap to run, since it is all near-free passes.
-
-**moody** is the atmospheric, cinematic end. Lower exposure and cool grading set a somber base; exponential fog with a dark horizon color adds depth and hides the far plane; strong AO deepens the shadows; a tight heavy vignette closes the frame; film grain and a touch of chromatic aberration add texture and a lens feel; god rays (given a shadow-casting sun) add drama. It is the most expensive look; on a tight budget, drop god rays first, then SSR if present.
-
-**clean** is the honest look, for when the render must show the *actual* material and lighting without editorializing: an editor viewport, an inspector, a UI-embedded preview. Default `pbrNeutral` tone mapping preserves hue and saturation, and the only effect on is gentle half-res AO for grounding. No bloom, grading, or vignette, because each of those changes what the color and brightness actually are, which is exactly what an accurate preview must not do. Also the cheapest look by far.
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/SKILL.md
deleted file mode 100644
index c833f8e..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/SKILL.md
+++ /dev/null
@@ -1,52 +0,0 @@
----
-name: flutter_scene-performance
-version: 2
-description: Make a flutter_scene app hit frame budget. Use whenever a scene janks, stutters, or drops frames, or when the ask is to make it faster or run on mobile or web, because code-driven scenes are reliably slow and the fix depends on which thread is over budget, not on a guessed poly count.
----
-
-# Making flutter_scene fast
-
-A code-driven flutter_scene scene is reliably slow, and the usual reason is that nothing told you the budget or what to fix first, so optimization starts as guesswork. Guessing wastes iterations and often makes the wrong thread slower. This skill replaces the guessing with a budget, a way to measure it, and a fixed order to apply fixes in.
-
-**The one thing to internalize: measure the actual frame, find which of the two threads is over budget, then fix that thread. Do not target a triangle or draw-call number from memory.** There is no built-in poly budget, and the same scene can be fast on desktop and jank on a phone. The number that matters is milliseconds per frame on the real target.
-
-## The budget
-
-A frame has a fixed wall-clock budget set by the refresh rate.
-
-- **60 fps is 16.6 ms per frame. 120 fps is 8.3 ms.** Miss it and the frame janks.
-- That budget is split across **two threads**, and either one blowing it drops the frame:
- - **UI thread** runs your Dart. flutter_scene walks the scene graph, culls, updates components, and builds the render here.
- - **Raster thread** is where Impeller draws the built frame on the GPU. The whole post-processing stack (ambient occlusion, reflections, depth of field, god rays, bloom) lands here.
-- **Mobile and web are the real constraint.** Desktop GPUs hide a lot; a scene that runs smooth on a laptop can miss budget badly on a phone or in a browser. Profile on the lowest target you must support.
-
-## Measure first (do not skip this)
-
-flutter_scene has **no built-in stats API**. There is no `scene.frameTime`, no draw-call counter. The editor MCP `get_app_state` reports only lifecycle (launching/running), not frame timing. So measurement is Flutter's own tooling.
-
-1. **Run in profile mode.** `flutter run --profile --enable-flutter-gpu`. Debug-mode timings are meaningless for performance (assertions, no JIT-to-AOT optimization, extra checks), so never judge speed in debug.
-2. **Read the frame chart.** Open DevTools, go to the Performance view, and read **per-frame UI time vs raster time**. The jank frames are flagged. This single view tells you which thread is over budget, which decides everything below.
-3. **Or use the performance overlay** for a quick in-app read of the two thread graphs without DevTools.
-4. **Stopwatch as a coarse fallback.** A `Stopwatch` around the per-frame work gives a rough UI-thread number when you cannot open DevTools. It sees nothing on the raster thread.
-
-Which thread is over budget names the fix. UI over budget means too much graph/CPU work (steps a, b, c below). Raster over budget means too much GPU work (steps d, e). See `references/performance.md` for the symptom to thread diagnosis.
-
-## The fixed remediation order
-
-Apply top-down. Each step lists the real API. Do the measured-over-budget thread's steps first, but the order within is deliberate, the earlier fixes are the bigger wins.
-
-1. **Instancing** (UI thread). Many copies of one mesh collapse into one draw and one cull test. Build an `InstancedMesh(geometry:, material:)`, add a transform per copy with `addInstance(matrix, {color})`, and mount it with `InstancedMeshComponent`. The single biggest win for repeated geometry (foliage, crowds, tiles, debris). Per-instance frustum culling is off by default (`cullInstances: false`), so the batch is one cull test as a unit. See the `flutter_scene-procedural` skill for the full scatter pattern.
-
-2. **Level of detail** (both threads). `LodComponent(List)` swaps cheaper meshes as an object shrinks on screen. Each `LodLevel(geometry:, material:, screenSize:)` gives a threshold (projected size as a fraction of viewport height, highest detail first, last is the cull floor). Fewer triangles for distant objects on the GPU, and nothing drawn below the floor. Note the shadow and depth passes always draw the highest-detail level, so LOD does not lighten shadow cost.
-
-3. **Culling** (UI thread). `Node.frustumCulled` (default true) skips off-screen subtrees; leave it on. Set it `false` only where the cached bound is known-stale or unbounded (procedural terrain you regenerate). For whole sets, `RenderView.cullingPlanes` adds extra clip planes, and `node.layers` (default `kRenderLayerDefault`) against `RenderView.layerMask` (default `kRenderLayerAll`) skips entire layers a view should not draw.
-
-4. **Shrink the post stack** (raster thread). This is usually where raster time goes. Turn off effects you do not need via the `EnvironmentSettings` `*Enabled` flags, keep `ambientOcclusionHalfResolution: true`, lower `depthOfFieldQuality` toward `DepthOfFieldQuality.low`, drop `RenderView.renderScale` (or `Scene.renderScale`) below `1.0` to render fewer pixels, and step `Scene.antiAliasingMode` down (`msaa` to `fxaa` to `none`). See the `flutter_scene-looks` skill for what each knob does to the look.
-
-5. **Texture and material consolidation** (raster thread). Fewer distinct textures and materials means fewer state changes and binds per frame. `TextureAtlas` (with `generateSolidColorAtlasPixels` for placeholders) packs many tiles into one texture so one material covers them all; share a single `Material` instance across many nodes instead of constructing one per node; use `MaterialsVariantsComponent` to switch a model between named material sets rather than duplicating materials.
-
-6. **Static shadows** (raster thread). `Node.shadowStatic = true` promises a caster's geometry, material coverage, and world transform will not change while mounted, so the engine renders it into cached shadow-map tiles reused across frames instead of re-encoding every caster every frame. A large static world becomes dramatically cheaper to shadow. Flag only genuinely static content; a static node that moves shows stale shadows until its render item re-registers.
-
-## More depth
-
-`references/performance.md` expands each step with the full API and when it helps, the UI-vs-raster symptom map (what a wrong frame time points to), and the honest note on what measurement tooling actually exists.
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/references/performance.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/references/performance.md
deleted file mode 100644
index 51cd859..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-performance/references/performance.md
+++ /dev/null
@@ -1,128 +0,0 @@
-# flutter_scene performance reference
-
-Companion to the `flutter_scene-performance` skill. The skill states the budget, the measure-first rule, and the fixed remediation order. This file expands each step with the real API, when it helps and when it does not, the diagnosis that maps a wrong frame time to a thread, and an honest account of the measurement tooling.
-
-Verify any symbol here against `lib/src` before relying on it; the inventory in the `flutter_scene-idioms` skill (`references/what-exists.md`) is the fuller API map.
-
-## The two threads, concretely
-
-A frame is built on the UI thread and drawn on the raster thread, and they overlap across frames (frame N rasters while frame N+1 builds). Either thread over budget drops the frame.
-
-- **UI thread work** is Dart. flutter_scene walks the scene graph, computes world transforms, runs frustum culling, ticks every component's `update`, and encodes the draw list. Cost scales with node count, component count, and how much per-frame Dart you run in `onTick` or component `update`.
-- **Raster thread work** is the GPU. Impeller executes the encoded passes, the shadow pass, the main color pass, and every enabled screen-space post-processing pass. Cost scales with pixels drawn, overdraw, shadow-map resolution, and how many post passes are on.
-
-## Diagnosis, symptom to thread
-
-Read the two thread graphs in DevTools (or the performance overlay) and match the over-budget one to a cause.
-
-| Observation | Over-budget thread | Likely cause | Go to step |
-| --- | --- | --- | --- |
-| UI time high, raster fine | UI | Too many nodes/draws or heavy per-frame Dart | 1 instancing, 3 culling |
-| UI time scales with object count | UI | Thousands of separate nodes for one repeated mesh | 1 instancing |
-| UI time high with a huge static world | UI | No culling; whole graph walked every frame | 3 culling |
-| Raster time high, UI fine | Raster | Too many pixels or post passes | 4 post stack, 5 consolidation |
-| Raster time high, and it tracks resolution | Raster | Fill-bound; too many pixels | 4 `renderScale`, AA |
-| Raster spikes only when shadows are on | Raster | Every caster re-encoded per frame | 6 static shadows |
-| Raster time tracks the number of distinct materials | Raster | State-change churn from per-node materials/textures | 5 consolidation |
-| Jank only on phone or web, smooth on desktop | Whichever is over budget there | Desktop GPU was hiding it | measure on the real target |
-
-If both threads are near budget, fix the UI thread first (steps 1 to 3); a lighter draw list also lightens the raster thread.
-
-## Measurement tooling, honestly
-
-There is **no built-in frame-stats API in flutter_scene**. No `scene.frameTime`, no draw-call count, no visible-triangle count. Do not invent one or claim one exists.
-
-- **Profile mode is mandatory.** `flutter run --profile --enable-flutter-gpu`. Debug builds carry assertions and skip AOT optimization, so their timings do not reflect a release build. A number taken in debug mode is not a performance number.
-- **DevTools Performance view** is the primary tool. It shows per-frame UI time and raster time as two tracks, flags janky frames, and lets you expand a frame's timeline. This is what tells you which thread is over budget.
-- **The performance overlay** gives the same two thread graphs in-app for a quick read without attaching DevTools.
-- **A `Stopwatch`** around the per-frame work (the `onTick` body, or a component `update`) is a coarse UI-thread fallback. It cannot see the raster thread at all, so a good stopwatch number does not clear a raster-bound jank.
-- **Editor MCP.** `get_app_state` reports lifecycle only (launching/running), not timing. The one place per-pass GPU timings surface is a render-graph capture (`Scene.captureRenderGraph`, or the editor MCP capture tool), whose result carries per-pass timing and lets you see which post pass is expensive. That is per-pass GPU detail, not a whole-frame counter, and the editor MCP is not connected in every project. When it is not, the DevTools loop above is fully sufficient.
-
-## Step 1, instancing
-
-`InstancedMesh` holds one `geometry`/`material` pair and one transform per copy. The whole set encodes as a single draw and, by default, a single frustum cull test.
-
-```dart
-final mesh = InstancedMesh(
- geometry: someGeometry,
- material: sharedMaterial, // one material for the whole batch
-);
-for (final placement in placements) {
- mesh.addInstance(placement.transform, color: placement.tint); // matrix is cloned
-}
-scene.add(Node()..addComponent(InstancedMeshComponent(mesh)));
-```
-
-- `addInstance(Matrix4, {Vector4? color})` returns an index; the matrix is cloned, so mutating your copy afterward is safe. Per-instance `color` is a linear RGBA multiplier.
-- Edit later with `setInstanceTransform(i, m)`, `setInstanceColor(i, color)`, `removeInstanceAt(i)`, `clearInstances()`, or move the whole batch in one pass with `updateInstanceTransforms((list) { ... })`.
-- **Culling default.** `cullInstances` defaults to `false`, so the batch is culled as one unit against its combined bounds, not per instance. Turn `cullInstances: true` on only for a batch spread across a large area where many instances are off-screen, since per-instance culling adds CPU work.
-- **When it helps.** Repeated geometry, foliage, crowds, tiles, debris, particles-as-meshes. It is the largest UI-thread win available, because N separate nodes become one. It does nothing for a scene of distinct meshes.
-- **Winding trap.** A mirrored (negative-determinant) instance edited with `updateInstanceTransforms(recomputeWinding: false)` renders inside-out. Keep instance edits orientation-preserving, or let winding recompute.
-
-See the `flutter_scene-procedural` skill for the full scatter-on-terrain pattern.
-
-## Step 2, level of detail
-
-`LodComponent(List)` draws one of several mesh variants per frame, chosen from how large the object appears on screen.
-
-```dart
-node.addComponent(LodComponent([
- LodLevel(geometry: high, material: mat, screenSize: 0.4),
- LodLevel(geometry: mid, material: mat, screenSize: 0.15),
- LodLevel(geometry: low, material: mat, screenSize: 0.04), // set 0.0 to never cull
-]));
-```
-
-- `screenSize` is the projected bounding-sphere diameter as a fraction of viewport height. Levels are highest detail first, strictly descending. The engine draws the highest-detail level whose threshold the object still meets, and draws nothing below the last threshold (the cull floor).
-- Selection is screen-size based, so it is field-of-view aware and resolution independent, and it is per view (a split-screen frame can pick different levels per view).
-- `LodComponent(levels, {lodBias = 1.0, hysteresis = 0.1, blendRange = 0.0})`. `lodBias` above `1` keeps detail farther away; `hysteresis` is a dead-band so an object on a boundary does not flip-flop; `blendRange` above `0` dither-cross-fades adjacent levels to remove the pop (honored by the built-in lit and unlit materials).
-- **Limitation that matters for shadows.** The shadow and depth-prepass passes always draw the highest-detail level and ignore the LOD cull. So LOD lightens the color pass, not shadow or depth cost. A shadow-heavy scene needs step 6, not LOD.
-- **Not for instanced draws.** A `LodComponent` draws a single mesh and picks one level for the whole node; it does not combine with hardware instancing.
-
-## Step 3, culling
-
-Skip work for things the camera cannot see.
-
-- **`Node.frustumCulled`** (default `true`) skips a subtree whose `combinedLocalBounds` do not intersect the camera frustum. Leave it on. Set it `false` only where the cached bound is known-stale or misleading (procedural geometry you regenerate, large terrain pieces). A subtree that reports no bound (skinned content, geometry without a computable bound) is treated as always visible regardless of the flag.
-- **`RenderView.cullingPlanes`** (`List`, default empty) adds extra clip planes beyond the frustum, for portal or region culling.
-- **Layers.** `node.layers` (a 32-bit mask, default `kRenderLayerDefault` which is layer 0, not inherited by children) against `RenderView.layerMask` (default `kRenderLayerAll`) decides whether a view draws a node at all, when `node.layers & view.layerMask != 0`. Put editor gizmos, an inset viewport's contents, or a minimap's set on their own layer and give each view the mask it needs, so a view skips whole sets cheaply.
-- **When it helps.** Large worlds where much of the graph is off-screen each frame. Culling is a UI-thread win (fewer nodes encoded) that also lightens the raster thread (fewer draws).
-
-## Step 4, shrink the post stack
-
-Every screen-space effect is a raster-thread pass. Turning off what you do not need is the most direct raster win. All the scene-wide look fields live on `EnvironmentSettings` (see the `flutter_scene-looks` skill); each effect has an `*Enabled` flag, off by default.
-
-- **Turn effects off.** `ambientOcclusionEnabled`, `screenSpaceReflectionsEnabled`, `godRaysEnabled`, `depthOfFieldEnabled`, `bloomEnabled`, and the rest default `false`. A preset the app copied may have turned several on; drop the ones the scene does not visibly need. Ambient occlusion, screen-space reflections, god rays, and depth of field are the heavy ones.
-- **Half-resolution AO.** `ambientOcclusionHalfResolution` defaults `true`; keep it. Full-resolution AO roughly doubles that pass's cost for little visible gain on most content.
-- **Cheaper depth of field.** `depthOfFieldQuality` (`DepthOfFieldQuality.low`/`medium`/`high`, default `medium`) trades gather taps and cleanup passes for time. Step it down to `low` on mobile.
-- **Render fewer pixels.** `Scene.renderScale` (default `1.0`), or per-view `RenderView.renderScale`, renders the scene at a fraction of resolution and upscales. Dropping to `0.75` cuts fill cost by nearly half and is often barely visible after anti-aliasing. This is the biggest lever for a fill-bound (resolution-tracking) raster time.
-- **Step anti-aliasing down.** `Scene.antiAliasingMode`. `AntiAliasingMode.auto` picks `msaa` where supported else `fxaa`. `msaa` is cheap on mobile tilers and highest quality; `fxaa` is a single post pass on every backend; `smaa` is cleaner than `fxaa` but three post passes (~3x its cost), so step it down to `fxaa` or `none` on a raster-bound target; `none` is free. Read what actually runs with `Scene.effectiveAntiAliasingMode`.
-- **Do not re-capture reflection probes per frame.** A `ReflectionProbeComponent` capture (or `Scene.captureEnvironment`) renders the scene six times, a large one-frame spike. Let it capture once on activate and only call `requestCapture()` when the scene visibly changes, never every frame.
-- **When it helps.** Any raster-bound scene. The `clean` look in the looks skill is nearly free; a full `moody` stack (AO plus SSR plus god rays plus DoF plus grain) is the heaviest. Do not stack all of those on a low-end target without profiling.
-
-## Step 5, texture and material consolidation
-
-Every distinct material and texture is a potential state change and bind on the raster thread. Fewer of them means a shorter, cheaper draw list.
-
-- **`TextureAtlas`** packs many equally sized tiles (voxel faces, sprite sheets, terrain tiles) into one texture, so a single material and draw call cover every tile. Resolve a tile's UV box with `tileBounds(index)` or map a within-tile coordinate with `tileUv(index, u, v)`, write those into the mesh's texture coordinates, and build the bound material with `toMaterial()`. `generateSolidColorAtlasPixels(tileColors:, columns:, tileSize:, padding:)` builds placeholder pixels to bring the atlas path up before real art exists.
-- **Share one `Material` instance** across many nodes rather than constructing a new one per node. Identical materials that are separate objects still churn binds; the same object does not. Build the material once and reuse the reference.
-- **`MaterialsVariantsComponent`** switches an imported model between its named `KHR_materials_variants` sets in place (`MaterialsVariantsComponent.of(model)?.select('name')`), instead of duplicating a model per look. Read `variants` for the declared names; `select(null)` restores defaults.
-- **When it helps.** Scenes whose raster time tracks the count of distinct materials or textures, tile-based worlds, and models shown in several finishes.
-
-## Step 6, static shadows
-
-Shadow casting re-encodes every caster into the shadow map each frame by default. `Node.shadowStatic = true` promises a caster will not change and lets the engine cache its shadow-map tiles across frames.
-
-```dart
-staticWorldNode.shadowStatic = true; // set per mesh-bearing node; not inherited
-```
-
-- **The contract.** The node's geometry, material coverage, and world transform must not change while mounted. In return, the engine renders it into cached shadow-map tiles reused across frames instead of re-encoding it every frame. Dynamic nodes (the default) still cast per-frame shadows on top of the cache, so a moving character over a static world works.
-- **Not inherited.** Set it on each mesh-bearing node, not once on a root.
-- **Stale-shadow caveat.** A static node that does change (moves, remeshes, edits material coverage) shows stale shadows until its render item re-registers. Flag only genuinely static content.
-- **Displacement caveat.** A material with a `vertex { }` displacement stage should stay dynamic, since its cached shadow would not follow a camera-dependent displacement.
-- **When it helps.** Large static worlds with a shadow-casting `DirectionalLight`. The win scales with how many static casters you have; a mostly static level with a few moving actors is the ideal case.
-
-## Order and stopping
-
-Fix the measured over-budget thread first, top-down within it, and re-measure after each change so cause and effect stay legible. Stop when the frame chart clears budget on the real target; there is no reason to keep optimizing a thread that is already under budget while the other one janks. The whole point of measuring first is to avoid spending a step's effort on the thread that was never the problem.
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/SKILL.md
deleted file mode 100644
index 417af29..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/SKILL.md
+++ /dev/null
@@ -1,134 +0,0 @@
----
-name: flutter_scene-procedural
-version: 2
-description: Build flutter_scene content from code instead of asset files. Use when generating terrain, scattering vegetation or crowds, building oceans and Gerstner waves, setting up procedural skies and trees, assembling modular kits, or driving a scene from noise and instancing rather than loading a .glb.
----
-
-# Procedural content in flutter_scene
-
-A lot of 3D work does not need an artist's `.glb` at all. Terrain, scattered foliage, debris fields, crowds, and modular buildings are cheaper and more flexible built from code, and flutter_scene has the whole path in the box. Reach for it before wiring up an asset pipeline.
-
-**The insight: for a code-driven scene, generate geometry and draw it instanced. That path is more reliable than loading external assets, because it has no import step, no coordinate-conversion traps, no missing-file failure modes, and one draw call for thousands of copies.** Three pieces cover almost everything:
-
-- **`GeometryBuilder`** (and the built-in primitives and swept paths) build custom meshes without a model file.
-- **`FastNoiseLite`** drives heightmaps, placement, and displacement deterministically.
-- **`InstancedMesh`** draws thousands of copies of one mesh as a single render item.
-
-Do not hand-pack a `ByteData` vertex buffer. The vertex layout is fixed (72 bytes unskinned, a specific attribute order) and a wrong stride fails silently with washed-out or see-through geometry. `GeometryBuilder` and `MeshGeometry.fromArrays` interleave the layout for you.
-
-## Imports
-
-Geometry and instancing live in the main barrel. **Noise is a separate barrel** and is easy to forget:
-
-```dart
-import 'package:flutter_scene/scene.dart'; // GeometryBuilder, MeshGeometry, InstancedMesh, ...
-import 'package:flutter_scene/noise.dart'; // FastNoiseLite, bakeNoiseTexture, noiseCurl3
-import 'package:vector_math/vector_math.dart' as vm; // NOT vector_math_64
-```
-
-## Terrain from a noise heightmap
-
-Sample `FastNoiseLite` on a grid, add each vertex, and wind the two triangles per cell so the lit surface faces up. Omitting normals lets the builder derive them from the actual face slopes, which is what you want for terrain.
-
-```dart
-MeshGeometry buildTerrain({int cols = 128, int rows = 128, double spacing = 0.5}) {
- final noise = FastNoiseLite(seed: 1337)
- ..noiseType = NoiseType.openSimplex2
- ..fractalType = FractalType.fbm // stack octaves for natural detail
- ..octaves = 5
- ..frequency = 0.02; // world units are multiplied by this
-
- final builder = GeometryBuilder();
-
- // One vertex per grid point. getNoise2 returns roughly -1..1.
- for (var r = 0; r < rows; r++) {
- for (var c = 0; c < cols; c++) {
- final x = c * spacing;
- final z = r * spacing;
- final height = noise.getNoise2(x, z) * 6.0;
- builder.addVertex(vm.Vector3(x, height, z));
- }
- }
-
- // Two triangles per cell, wound Counter-Clockwise (CCW) so the front face points +Y (up).
- for (var r = 0; r < rows - 1; r++) {
- for (var c = 0; c < cols - 1; c++) {
- final v00 = r * cols + c;
- final v10 = v00 + 1;
- final v01 = v00 + cols;
- final v11 = v01 + 1;
- builder
- ..addTriangle(v00, v01, v10)
- ..addTriangle(v10, v01, v11);
- }
- }
-
- return builder.build();
-}
-```
-
-Attach it like any mesh:
-
-```dart
-final terrain = Node(mesh: Mesh(buildTerrain(), PhysicallyBasedMaterial()..roughnessFactor = 1.0));
-scene.add(terrain);
-```
-
-If a hand-built surface renders inside-out (visible only from below, dark where lit), reverse each triangle's index order. flutter_scene's front faces wind Counter-Clockwise (CCW) in model space, matching glTF and standard conventions; never fix orientation with a per-triangle flip on an imported model, but for geometry you author yourself the winding is yours to set.
-
-## Natural formations and landscape recipes
-
-To achieve documentary realism rather than generic procedural lumps:
-
-1. **Footpaths are scoured trenches, not flat stripes.** A real trail is the lowest line across terrain because water and foot traffic erode it downwards. When generating heightfields, cut the trail path profile down into the terrain with banks rising away on both sides.
-2. **Ridged noise for valley walls and cliffs.** Standard `FractalType.fbm` makes rolling mounds. Use `FractalType.ridged` for valley walls, mountain spurs, and cliffs to produce sharp erosion creases.
-3. **Free-end Worley rock cracks.** Standard Worley noise (`F2 - F1`) creates closed polygonal loops like bathroom tile. To produce weathered rock fractures with natural free ends, multiply the cell border by a low-frequency region mask and a high-frequency grain breaker.
-4. **Noise-modulated pitting.** A constant threshold radius across Worley cells places a pit in every cell, producing an artificial grid lattice. Modulate the threshold radius with an underlying Perlin field so pores vary in size and only appear in exposed weathering pockets.
-5. **Macro massing for scattered gravel.** Soil wears in 0.5m to 2m zones. Modulate multi-scale pebble instances with a low-frequency massing field so gravel clusters into realistic water scour lines rather than uniform sandpaper noise.
-6. **Sunk block settling and ground contact staining.** Place boulders and masonry courses 1/3 to 2/3 submerged into the sampled ground height. Use vertex colors or shader ground distance to stain the bottom 20cm of rock near the soil boundary, creating a smooth moisture transition.
-7. **Oceans and Gerstner waves.** Sum 4 to 8 directional Gerstner trochoidal waves that pull vertices horizontally toward crests, producing sharp peaks and wide flat troughs. Use Beer-Lambert depth absorption (exp(-sigma_a * d)) via scene depth for turquoise to deep navy transitions, Jacobian folding for peak foam, and darken/smooth tidal sand within the shoreline wash.
-8. **Trees and foliage translucency.** Extrude branch splines using `TubeGeometry` or `ExtrudeGeometry`, conserving cross-sectional area across splits (d_parent^2 = sum d_child^2). Set `Material.doubleSided = true` and add diffuse transmission in custom leaf shaders so backlit canopies glow. Apply quadratic cantilever displacement (delta_p proportional to h^2) for organic wind sway.
-9. **Procedural skies and IBL synchronization.** Use `PhysicalSkySource` (`lib/src/sky_sources.dart`) with analytic Rayleigh and Mie scattering. Assign `SkyEnvironment` to `Scene.skyEnvironment` or call `EnvironmentMap.fromSky` to bake prefiltered radiance and SH-9 diffuse coefficients into the scene's IBL automatically, and assign the source to `Scene.skybox` for matching background visuals.
-10. **Islands and coastal erosion.** Multiply radial distance falloff with domain-warped FBM to form organic bays, sandbars, and lagoons. Use analytical surface slopes to strip topsoil on steep cliffs while depositing golden sand and reef shoals on shallow coastal planes.
-
-## Scattering thousands of copies
-
-`InstancedMesh` holds one geometry/material pair and a transform per copy. The whole set is one pipeline and one cull test. Place instances by sampling the same terrain height so they sit on the ground.
-
-```dart
-final rng = math.Random(7);
-final scatter = InstancedMesh(
- geometry: CylinderGeometry(bottomRadius: 0.0, topRadius: 0.15, height: 1.2), // a cone
- material: PhysicallyBasedMaterial()..baseColorFactor = vm.Vector4(0.2, 0.5, 0.15, 1),
-);
-
-for (var i = 0; i < 4000; i++) {
- final x = rng.nextDouble() * 64;
- final z = rng.nextDouble() * 64;
- final y = noise.getNoise2(x, z) * 6.0; // same field as the terrain
- final transform = vm.Matrix4.translation(vm.Vector3(x, y, z))
- ..rotateY(rng.nextDouble() * math.pi * 2);
- scatter.addInstance(transform); // the matrix is cloned; mutating it later is safe
-}
-
-// InstancedMesh rides on a component, not Node(mesh:).
-final node = Node()..addComponent(InstancedMeshComponent(scatter));
-scene.add(node);
-```
-
-`addInstance(matrix, {color})` returns an index; edit later with `setInstanceTransform(i, m)` or move the whole batch at once through `updateInstanceTransforms((list) { ... })`. Per-instance `color` is a linear RGBA multiplier. Keep instance edits orientation-preserving, a mirrored (negative-determinant) instance edited with `updateInstanceTransforms(recomputeWinding: false)` renders inside-out.
-
-## The web noise trap
-
-The Dart `FastNoiseLite` relies on 32-bit integer math. On the web (dart2js) a Dart `int` is a JavaScript double, exact only to 53 bits, so the hash loses its low bits and 3D noise can overflow, producing wrong values. This is silent, you get a plausible-looking but incorrect field, and only on web.
-
-For web targets:
-
-- Prefer the **GLSL side** (`#include ` in a `.fmat` block), which is correct on every backend including WebGL2 and matches the Dart algorithms table-for-table.
-- Or **bake** the field once with `bakeNoiseTexture(noise, width: ..., height: ...)` at build time or in a native isolate, then sample the texture. `bakeNoisePixels` is pure CPU with no engine imports, so it runs in a build hook or background isolate.
-
-Native platforms are unaffected. `noiseHash2`/`noiseHash3` are the bit-exact CPU/GPU-agreeing integer path for decisions that must never disagree (world generation, placement), but they carry the same web-overflow caveat, so make the decision once and share it rather than re-deriving it on both sides.
-
-## More depth
-
-`references/procedural.md` has the full `GeometryBuilder` and `MeshData` API (including off-isolate meshing), the complete `FastNoiseLite` config reference, natural rock, ocean, tree, sky, and island formation recipes, the instancing API in full, modular-kit assembly from the built-in primitives, and the web-noise caveat expanded.
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/references/procedural.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/references/procedural.md
deleted file mode 100644
index ee68638..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-procedural/references/procedural.md
+++ /dev/null
@@ -1,499 +0,0 @@
-# Procedural content, the full API
-
-Everything for building flutter_scene content from code, custom meshes, noise, instancing, and modular kits. All symbols verified against the package source. Import geometry and instancing from the main barrel, noise from its own barrel:
-
-```dart
-import 'package:flutter_scene/scene.dart';
-import 'package:flutter_scene/noise.dart';
-import 'package:vector_math/vector_math.dart' as vm; // NOT vector_math_64
-```
-
----
-
-## GeometryBuilder
-
-The incremental way to build a custom triangle mesh. You add vertices one at a time, each carrying whatever attributes are currently set, then reference them by returned index to form triangles.
-
-```dart
-class GeometryBuilder {
- GeometryBuilder({bool deduplicate = true});
-
- // Sticky attribute setters (return `this`, so cascade or chain them).
- GeometryBuilder normal(vm.Vector3 value);
- GeometryBuilder texCoord(vm.Vector2 value);
- GeometryBuilder texCoord1(vm.Vector2 value); // secondary UV set
- GeometryBuilder color(vm.Vector4 value); // linear RGBA
- GeometryBuilder tangent(vm.Vector4 value); // xyz + handedness in w
-
- int addVertex(vm.Vector3 position); // returns the vertex index
- GeometryBuilder addTriangle(int a, int b, int c); // throws RangeError on a bad index
-
- int get vertexCount;
- int get triangleCount;
-
- Uint8List packVertices(); // pure, no GPU context needed
- MeshGeometry build({
- GeometryStorage storage = GeometryStorage.fixed,
- GeometryBufferArena? bufferArena,
- bool retainCpuData = true,
- });
-}
-```
-
-### The sticky-attribute model
-
-The attribute setters do not apply to one vertex, they set state that every following `addVertex` inherits until you change it. This makes flat-shaded faces and per-region colors natural:
-
-```dart
-final geometry = (GeometryBuilder()
- ..color(vm.Vector4(1, 0, 0, 1)) // every vertex below is red...
- ..addVertex(vm.Vector3(0, 0, 0))
- ..addVertex(vm.Vector3(1, 0, 0))
- ..color(vm.Vector4(0, 1, 0, 1)) // ...until this changes it to green
- ..addVertex(vm.Vector3(0, 1, 0))
- ..addTriangle(0, 1, 2))
- .build();
-```
-
-### Normals, generated or authored
-
-If you never call `normal()`, the builder generates area-weighted vertex normals from the faces you wound. That is the right default for most procedural geometry, terrain, extrusions, anything where the surface shape defines the normal.
-
-**Calling `normal()` even once opts the whole mesh out of generated normals.** After that, any vertex you add without an explicit normal keeps the default `(0, 0, 1)`, which is almost never what you want. So either author a normal for every vertex, or author none and let generation run. Do not mix.
-
-### Deduplication
-
-With `deduplicate: true` (the default), `addVertex` merges a vertex equal to one already added and returns the existing index, so a shared grid corner is stored once. Pass `deduplicate: false` when you want every call to produce a distinct vertex (flat shading with per-face normals, or per-vertex data that must not collapse).
-
-### Winding
-
-flutter_scene's front faces wind **counter-clockwise in model space**, matching glTF and standard conventions. For a surface that should face +Y (a heightmap, a floor), match the built-in plane's winding: for a cell with corners `v00`(x,z), `v10`(x+1,z), `v01`(x,z+1), `v11`(x+1,z+1), emit `addTriangle(v00, v01, v10)` and `addTriangle(v10, v01, v11)`.
-
-If a mesh renders inside-out (invisible from the front, visible and inverted-lit from behind), reverse each triangle's index order. Because generated normals follow the winding, fixing the winding fixes the normals too. This freedom is only for geometry you author. Never apply a per-triangle winding flip to an imported model to correct its orientation, that leaves its normals and image-based lighting wrong.
-
-### From builder to scene
-
-`build()` returns a `MeshGeometry`, which is a `Geometry`. Wrap it in a `Mesh` with a material, hang the mesh on a `Node`, add the node:
-
-```dart
-final node = Node(mesh: Mesh(geometry, PhysicallyBasedMaterial()));
-scene.add(node);
-```
-
----
-
-## MeshGeometry.fromArrays, the bulk path
-
-When you already have attributes as flat arrays (a generator that fills typed lists), skip the per-vertex calls and hand `MeshGeometry.fromArrays` structure-of-arrays data directly. Same result, less overhead for large meshes.
-
-```dart
-MeshGeometry.fromArrays({
- required Float32List positions, // 3 floats/vertex, required
- Float32List? normals, // 3/vertex; omitted -> generated for triangle lists
- Float32List? texCoords, // 2/vertex; omitted -> (0, 0)
- Float32List? texCoords1, // 2/vertex
- Float32List? colors, // 4/vertex; omitted -> opaque white
- Float32List? tangents, // 4/vertex
- List? indices, // omitted -> vertex count must be a multiple of 3
- gpu.PrimitiveType primitiveType = gpu.PrimitiveType.triangle,
- Aabb3? bounds, // skips the position scan; MUST cover every vertex
- GeometryStorage storage = GeometryStorage.fixed,
- GeometryBufferArena? bufferArena,
- bool retainCpuData = true,
-});
-```
-
-Notes that bite:
-
-- Every supplied optional array must match the vertex count implied by `positions`.
-- Out-of-range `indices` are **not** validated here (unlike `GeometryBuilder.addTriangle`), and produce stray triangles or holes silently. Keep every index in `0..vertexCount-1`.
-- A `bounds` you pass that does not enclose every vertex makes the mesh over-cull and pop out of view at some angles. Omit it (the constructor scans positions) unless you computed it correctly off-thread.
-- `retainCpuData: false` drops the CPU copy after upload, saving memory, but then the mesh cannot be raycast or read back with `extractMeshData`.
-
-### Updatable geometry (animated meshes)
-
-Pass `storage: GeometryStorage.updatable` to get a mesh you can mutate in place each frame without reallocating. The in-place updaters replace one attribute when the vertex count is unchanged:
-
-```dart
-final water = MeshGeometry.fromArrays(positions: p, storage: GeometryStorage.updatable);
-// later, per frame:
-water.updatePositions(newPositions); // also updateNormals/TexCoords/Colors/Tangents
-// or replace everything (may change the count):
-water.rebuild(positions: p2, indices: i2);
-```
-
-An updatable mesh fixes its indexed-or-not state at construction, if you built it with `indices`, `rebuild` requires them thereafter, and vice versa. To start empty and fill later, pass a zero-length `positions` with `updatable`. Updatable geometry must retain CPU data and cannot use a buffer arena.
-
-`GeometryBufferArena({int blockSizeInBytes = 16 * 1024 * 1024})` lets many fixed meshes share immutable GPU buffer blocks, worth it when you build a large number of small static meshes.
-
----
-
-## MeshData, meshing off the render isolate
-
-Heavy generation (remeshing a voxel chunk, a large marching-cubes surface) should not block the render isolate. `MeshData` is a pure, isolate-transferable snapshot, build it on a background isolate with `compute`, send it back, upload it there.
-
-```dart
-factory MeshData.build({
- required Float32List positions,
- Float32List? normals, // omitted -> generated for triangle lists (the win here)
- Float32List? texCoords,
- Float32List? texCoords1,
- Float32List? colors,
- Float32List? tangents,
- List? indices,
- gpu.PrimitiveType primitiveType = gpu.PrimitiveType.triangle,
- Map customAttributes = const {},
-});
-```
-
-Recipe:
-
-```dart
-// Top-level or static, runs on the background isolate.
-MeshData buildChunk(ChunkInput input) {
- final positions = /* your generator */;
- final indices = /* ... */;
- return MeshData.build(positions: positions, indices: indices);
-}
-
-// On the render isolate:
-final data = await compute(buildChunk, input);
-final geometry = MeshGeometry.fromMeshData(data);
-// or, to feed an existing updatable mesh in place:
-existing.applyMeshData(data);
-```
-
-The normal generation is the expensive part, and running it inside `MeshData.build` is exactly the work you moved off the render isolate.
-
-Pure derivations on a `MeshData` (all off-isolate safe): `transformed(Matrix4)` (moves positions, carries normals by the inverse transpose so a non-uniform scale stays correct, reverses winding on a mirror), `unweld({attributes})`, `extractEdges({creaseAngleDegrees})`, static `MeshData.merge(parts)`, plus `triangleCount`/`triangles`. `Geometry.extractMeshData()` reads a loaded mesh back into one.
-
----
-
-## FastNoiseLite
-
-One configurable object evaluating several noise algorithms, sampled with `getNoise2`/`getNoise3`. Output is roughly in `[-1, 1]`.
-
-```dart
-final noise = FastNoiseLite(seed: 1337)
- ..frequency = 0.01 // coords are multiplied by this before eval
- ..noiseType = NoiseType.openSimplex2
- ..fractalType = FractalType.fbm
- ..octaves = 5;
-
-final h = noise.getNoise2(x, z); // 2D
-final d = noise.getNoise3(x, y, z); // 3D
-```
-
-### Config reference
-
-| Field | Default | Meaning |
-| --- | --- | --- |
-| `seed` | 1337 | Seed for every noise type. |
-| `frequency` | 0.01 | Input coordinates are scaled by this. Bigger = finer features. |
-| `noiseType` | `openSimplex2` | Base algorithm (see below). |
-| `fractalType` | `none` | How octaves layer (see below). |
-| `octaves` | 3 | Number of fractal layers. More detail, more cost. |
-| `lacunarity` | 2.0 | Frequency multiplier between octaves. |
-| `gain` | 0.5 | Amplitude multiplier between octaves. |
-| `weightedStrength` | 0.0 | Biases octave amplitude toward stronger detail. |
-| `pingPongStrength` | 2.0 | Warp strength for `FractalType.pingPong`. |
-| `cellularDistanceFunction` | `euclideanSq` | Distance metric for `NoiseType.cellular`. |
-| `cellularReturnType` | `distance` | What cellular returns. |
-| `cellularJitterModifier` | 1.0 | Cell-point jitter; above 1 causes artifacts. |
-| `domainWarpType` | `openSimplex2` | Warp algorithm for `domainWarp2`/`domainWarp3`. |
-| `domainWarpAmp` | 1.0 | Max warp distance. |
-| `domainWarpFractalType` | `none` | Octave layering for domain warp. |
-
-Enums:
-
-- `NoiseType` = `openSimplex2` | `openSimplex2S` | `cellular` | `perlin` | `value`.
-- `FractalType` = `none` | `fbm` (classic layered fractal, the usual terrain choice) | `ridged` (sharp ridges, mountains) | `pingPong`.
-- `CellularDistanceFunction` = `euclidean` | `euclideanSq` | `manhattan` | `hybrid`.
-- `CellularReturnType` = `cellValue` | `distance` | `distance2` | `distance2Add` | `distance2Sub` | `distance2Mul` | `distance2Div`.
-- `DomainWarpType` = `openSimplex2` | `openSimplex2Reduced` | `basicGrid`.
-- `DomainWarpFractalType` = `none` | `progressive` | `independent`.
-
-### Domain warp
-
-`domainWarp2`/`domainWarp3` distort the input coordinates before sampling, breaking up the regular look of raw fractal noise. The reference version mutates in place, this port returns the warped position for you to feed back in:
-
-```dart
-final w = noise.domainWarp2(x, z); // ({double x, double y})
-final v = noise.getNoise2(w.x, w.y);
-```
-
-### Curl noise
-
-`noiseCurl3(x, y, z, {int seed = 1337, double epsilon = 0.25})` returns a divergence-free 3D vector `({x, y, z})` from a seeded potential field, for advecting particles so they swirl without clumping. Coordinates are taken pre-scaled (no frequency parameter), matching the GLSL `NoiseCurl3`. Advect by adding `curl * speed * dt`. A smaller `epsilon` sharpens the field and amplifies CPU/GPU divergence.
-
-### Baking noise to a texture
-
-Sampling many octaves per fragment is expensive. When the field is static, bake it once and sample the texture instead:
-
-```dart
-Texture2D bakeNoiseTexture(
- FastNoiseLite noise, {
- required int width,
- required int height,
- double originX = 0.0,
- double originY = 0.0,
- double cellSize = 1.0,
- TextureSampling sampling = const TextureSampling(),
-});
-```
-
-It bakes `getNoise2` over a `width` x `height` grid into a grayscale `Texture2D` (content is linear `data`, so mipmaps average cleanly) ready to bind as a material sampler. It must run where GPU resources are created (the raster thread). The CPU half, `bakeNoisePixels(noise, {width, height, originX, originY, cellSize})`, returns `Uint8List` RGBA and has no engine imports, so it runs in a build hook or a background isolate, then `Texture2D.fromPixels` uploads the result.
-
----
-
-### Natural formations: rocks, cliffs, trails, and scatter recipes
-
-Composing raw noise into realistic natural terrain and geology requires specific math patterns to avoid telltale procedural artifacts.
-
-### 1. Free-end Worley rock cracks (avoiding closed cell loops)
-
-Standard cellular Worley distance (`F2 - F1`) creates a continuous polygon network like bathroom tile or dry mud. To create natural weathering cracks with free ends, mask the cell borders with a low-frequency macro patch and a high-frequency grain breaker:
-
-```glsl
-// GLSL shader bake or .fmat surface
-// Cellular Worley noise returning F2 - F1 distance
-float cwl = NoiseCellular2(p * 3.0, 1337, kNoiseCellularEuclidean, kNoiseCellularDistance2Sub, 1.0);
-float net = smoothstep(0.08, -0.80, cwl);
-float region = smoothstep(-0.2, 0.4, NoiseFbm2(p * 1.0, 1338, 3, 2.0, 0.5));
-float breaker = smoothstep(-0.4, 0.2, NoiseFbm2(p * 6.0, 1339, 2, 2.0, 0.5));
-float crack = net * region * breaker; // produces isolated segments with natural start/end points
-```
-
-### 2. Noise-modulated pitting (avoiding regular dot lattices)
-
-Thresholding Worley noise at a constant radius places a pit in every single cell, creating an artificial grid lattice. Modulate the threshold radius with an underlying Perlin field so pores vary in size and only appear in exposed weathering pockets:
-
-```glsl
-float sizeVar = (NoiseFbm2(p * 2.5, 1337, 3, 2.0, 0.5) + 1.0) * 0.5;
-float pw = NoiseCellular2(p * 5.0, 1338, kNoiseCellularEuclidean, kNoiseCellularDistance, 1.0);
-float pit = smoothstep(0.05 + 0.24 * sizeVar * sizeVar, 0.005, pw)
- * smoothstep(-0.1, 0.5, NoiseFbm2(p * 1.5, 1339, 3, 2.0, 0.5));
-```
-
-### 3. Incised trail heightfields (scours vs flat stripes)
-
-Footpaths are formed by water and foot traffic compressing and eroding soil downwards. Sample the path polyline once (`trail.sample(n, evenlySpaced: true)`) and compute the minimum point-to-segment distance to cut the path profile into the terrain heightfield with raised spoil banks:
-
-```dart
-double computeTerrainHeight(double x, double z, FastNoiseLite noise, List trailPoints) {
- final baseHeight = noise.getNoise2(x, z) * 8.0;
-
- // Find minimum distance from (x, z) to the sampled 2D path segments
- var minDist = double.infinity;
- final p = vm.Vector2(x, z);
- for (var i = 0; i < trailPoints.length - 1; i++) {
- final a = vm.Vector2(trailPoints[i].x, trailPoints[i].z);
- final b = vm.Vector2(trailPoints[i + 1].x, trailPoints[i + 1].z);
- final ab = b - a;
- final t = ((p - a).dot(ab) / ab.length2).clamp(0.0, 1.0);
- final dist = (p - (a + ab * t)).length;
- if (dist < minDist) minDist = dist;
- }
-
- const pathWidth = 1.8;
- const pathDepth = 0.45;
- const bermHeight = 0.25;
-
- // Carve central path trough
- final trench = (1.0 - (minDist / pathWidth).clamp(0.0, 1.0)) * pathDepth;
- // Build gentle spoil berm along the verge
- final verge = ((minDist - pathWidth * 0.8) / (pathWidth * 0.8)).clamp(0.0, 1.0);
- final berm = math.sin(verge * math.pi) * bermHeight;
-
- return baseHeight - trench + berm;
-}
-```
-
-### 4. Macro-massed pebble scatter (avoiding uniform sandpaper noise)
-
-Gravel and pebbles cluster into water-washed scour lines rather than spreading evenly over an entire level. Gate multi-scale pebble instances with a low-frequency macro massing field and key the hash off integer cell coordinates:
-
-```dart
-void scatterPebbles(InstancedMesh finePebbles, InstancedMesh largeStones, FastNoiseLite terrainNoise) {
- final macroNoise = FastNoiseLite(seed: 42)..frequency = 0.05;
- const step = 0.8;
- const cells = 80; // 64m / 0.8m
- for (var ix = 0; ix < cells; ix++) {
- for (var iz = 0; iz < cells; iz++) {
- final x = ix * step;
- final z = iz * step;
- // Deterministic coordinate jitter keyed off integer cell indices
- final h = noiseHash2(1337, ix, iz);
- final jx = x + ((h & 0xFF) / 255.0 - 0.5) * 0.6;
- final jz = z + (((h >> 8) & 0xFF) / 255.0 - 0.5) * 0.6;
-
- final mass = (macroNoise.getNoise2(jx, jz) + 1.0) * 0.5;
- if (mass > 0.65) {
- final y = terrainNoise.getNoise2(jx, jz) * 8.0;
- final matrix = vm.Matrix4.translation(vm.Vector3(jx, y, jz));
- if (((h >> 16) & 0xFF) > 180) {
- largeStones.addInstance(matrix);
- } else {
- finePebbles.addInstance(matrix);
- }
- }
- }
- }
-}
-```
-
-### 5. Oceans and Gerstner waves
-
-Trochoidal Gerstner waves pull vertices horizontally toward wave peaks, creating sharp crests and wide flat troughs. Sum multiple directional waves and compute normals analytically:
-
-```glsl
-// GLSL Gerstner wave displacement
-struct Wave { vec2 dir; float amp; float freq; float speed; float steepness; };
-
-// Caller seeds accumulators with tangent = vec3(1.0, 0.0, 0.0) and binormal = vec3(0.0, 0.0, 1.0).
-vec3 evaluateGerstner(vec2 pos, float time, Wave w, float numWaves, inout vec3 tangent, inout vec3 binormal) {
- vec2 d = normalize(w.dir);
- float phase = dot(d, pos) * w.freq + time * w.speed;
- float c = cos(phase);
- float s = sin(phase);
- float q = w.steepness / (w.amp * w.freq * numWaves);
-
- tangent += vec3(-q * d.x * d.x * w.amp * w.freq * s,
- d.x * w.amp * w.freq * c,
- -q * d.x * d.y * w.amp * w.freq * s);
- binormal += vec3(-q * d.x * d.y * w.amp * w.freq * s,
- d.y * w.amp * w.freq * c,
- -q * d.y * d.y * w.amp * w.freq * s);
-
- return vec3(q * w.amp * d.x * c,
- w.amp * s,
- q * w.amp * d.y * c);
-}
-```
-
-For shallow water transitions and shorelines:
-- **Beer-Lambert Depth Extinction**: Declare `engine_inputs: [ depth ]` in the `.fmat` to sample linear opaque scene depth (`RenderInput.depth`). Compute water depth `d = sceneDepth - surfaceDepth` and attenuate color with `C = C_deep + (C_shallow - C_deep) * exp(-sigma_a * d)`.
-- **Tidal Wet Sand**: Reduce sand roughness to 0.15 and multiply albedo by 0.6 within the wave wash zone to produce glistening wet shorelines.
-
-### 6. Trees, branching splines, and backlit foliage
-
-Trunk and branch structures follow Leonardo da Vinci's rule: total cross-sectional area is conserved across splits (d_parent^2 = sum d_child^2). Extrude branches along swept spline tubes using `TubeGeometry` (sweeping a round cross-section along a `ScenePath`) or `ExtrudeGeometry`:
-
-- **Backlit Leaf Translucency**: Set `Material.doubleSided = true` for two-sided rendering. In a custom leaf shader, add a diffuse transmission term so backlit foliage glows rather than rendering as a dark silhouette:
-```glsl
-// In custom leaf shader
-float NdotL = dot(normal, lightDir);
-float backLight = max(0.0, -NdotL) * leafTransmissionFactor;
-vec3 litColor = albedo * (max(0.0, NdotL) + backLight * leafTranslucentColor);
-```
-- **Quadratic Cantilever Wind**: Displace leaf and branch vertices in world space proportional to height squared (delta_p = windVec * (h / h_max)^2 * sin(omega * t - k * p)) so tips sway vigorously while roots remain anchored.
-
-### 7. Procedural skies and runtime IBL synchronization
-
-Use `PhysicalSkySource` (`lib/src/sky_sources.dart`) with analytic Rayleigh and Mie scattering. Assign `SkyEnvironment` to `Scene.skyEnvironment` or call `EnvironmentMap.fromSky` to bake prefiltered radiance and SH-9 diffuse coefficients into the scene's IBL automatically, and assign the source to `Scene.skybox` for matching background visuals.
-
-### 8. Islands, coastal bays, and sand dunes
-
-To form natural island topographies:
-- **Domain-Warped Island Mask**: Multiply a radial distance falloff (1.0 - (r / R)^2) with domain-warped FBM to form organic bays, sandbars, and peninsulas rather than symmetrical circular cones.
-- **Slope-Based Sediment Stripping**: Compute heightfield slope sqrt((dh/dx)^2 + (dh/dz)^2). Steep cliffs strip topsoil to expose rock strata, while gentle coastal planes accumulate golden beach sand.
-- **Anisotropic Wind Dune Ripples**: Layer 8:1 anisotropically stretched noise perpendicular to the prevailing wind direction to generate fine ripple crests across sand surfaces.
-
----
-
-## The web noise caveat, expanded
-
-The Dart `FastNoiseLite` port relies on 32-bit integer arithmetic. On native platforms this is exact. On the web (dart2js), a Dart `int` is a JavaScript double, exact only to 53 bits, so the integer hash loses its low bits and 3D noise can overflow. The result is a plausible-looking but wrong field, silent, and web-only. A web-safe integer multiply for the Dart side is a planned follow-up.
-
-The GLSL half of the module is unaffected, it is correct on every backend including WebGL2, and implements the same algorithms with the same tables and seeds, so a field sampled on the CPU (native) and evaluated in a shader agree. The agreement has two tiers:
-
-- **Bit-exact**: `noiseHash2`/`noiseHash3` (and GLSL `NoiseHash2`/`NoiseHash3`) are pure integer math and match bit for bit across backends. Use them for decisions that must never disagree between machines (world generation, deterministic placement).
-- **Float-close**: the float noise functions match within a small tolerance (float32 rounding differs per GPU), imperceptible visually. Do not re-derive a hard threshold from float noise on both the CPU and GPU sides, make the decision once and share the result.
-
-Both tiers carry the web-overflow caveat on the Dart side. Strategy by target:
-
-- **Native only**: use the Dart `FastNoiseLite` freely, on the render isolate or a background one.
-- **Web, per-fragment noise**: move it to the GLSL side (`#include ` in a `.fmat` block).
-- **Web, a static field**: bake it with `bakeNoiseTexture` (or `bakeNoisePixels` in a build hook / native isolate) and sample the texture. This sidesteps the overflow because the baking happens where `int` is 64-bit.
-
----
-
-## InstancedMesh, thousands of copies for one draw
-
-One geometry/material pair drawn many times, each placed by its own model transform. The whole set is one render item, one pipeline, one cull test. This is how you scatter foliage, crowds, debris, or a grid of the same prop without a node per copy.
-
-```dart
-class InstancedMesh {
- InstancedMesh({
- required Geometry geometry,
- required Material material,
- bool cullInstances = false, // per-instance cull after the aggregate pass
- bool sortTransparentInstances = true,
- });
-
- int get instanceCount;
-
- int addInstance(vm.Matrix4 transform, {vm.Vector4? color}); // matrix is CLONED; returns index
- void setInstanceTransform(int index, vm.Matrix4 transform);
- void updateInstanceTransforms(
- void Function(List transforms) update, {
- bool recomputeWinding = true,
- });
- void setInstanceColor(int index, vm.Vector4 color); // linear RGBA multiplier
- void removeInstanceAt(int index); // shifts later indices down
- void clearInstances();
-}
-```
-
-Attach it to a node with an `InstancedMeshComponent` (it does not go on `Node(mesh:)`):
-
-```dart
-final mesh = InstancedMesh(geometry: geo, material: mat);
-for (final placement in placements) {
- mesh.addInstance(placement); // a Matrix4 in the instanced mesh's local space
-}
-final node = Node()..addComponent(InstancedMeshComponent(mesh));
-scene.add(node);
-```
-
-Practical notes:
-
-- `addInstance` clones the matrix, so reusing one scratch `Matrix4` across the loop is fine.
-- The node the component is on transforms the entire batch. Instance transforms compose under it.
-- To animate all instances cheaply, use `updateInstanceTransforms`, which invalidates the batch once instead of per call. Mutate the matrices in the callback list; do not add, remove, or replace entries.
-- `updateInstanceTransforms(recomputeWinding: false)` skips the parity refresh. Only pass it when no edit changes a transform's winding. A mirrored (negative-determinant) edit under it renders those instances inside-out.
-- `cullInstances: true` pays for per-instance culling, worth it for a large spatial spread whose instances enter view at different times; leave it off for a small compact clump that the single aggregate cull already handles.
-- Set `cullInstances` per instanced mesh based on that trade; it is not a global.
-
----
-
-## Modular kits from the built-in primitives
-
-Before authoring a mesh, remember the ten primitives assemble a surprising amount by composition, no builder needed. Each is a `Geometry`, so each goes on its own `Node`, and a parent node groups a kit piece you can clone and place.
-
-| Class | Constructor | Notes |
-| --- | --- | --- |
-| `CuboidGeometry` | `CuboidGeometry(vm.Vector3 extents)` | Box from `-extents/2` to `+extents/2`. Positional. |
-| `SphereGeometry` | `SphereGeometry({radius = 0.5, segments = 32, rings = 16})` | UV sphere. |
-| `IcosphereGeometry` | `IcosphereGeometry({radius = 0.5, subdivisions = 2})` | Even triangle distribution. |
-| `CylinderGeometry` | `CylinderGeometry({bottomRadius = 0.5, topRadius = 0.5, height = 1.0, ...})` | `topRadius: 0` makes a cone; different radii make a frustum. |
-| `CapsuleGeometry` | `CapsuleGeometry({radius = 0.5, height = 1.0, ...})` | `height` is the mid-section; total Y is `height + 2*radius`. |
-| `TorusGeometry` | `TorusGeometry({radius = 0.5, tubeRadius = 0.2, ...})` | Lies in XZ. |
-| `PlaneGeometry` | `PlaneGeometry({width = 1.0, depth = 1.0, segmentsX = 1, segmentsZ = 1})` | XZ plane, faces +Y. |
-| `DiscGeometry` | `DiscGeometry({radius = 0.5, segments = 32})` | Filled circle, XZ, faces +Y. |
-| `RingGeometry` | `RingGeometry({innerRadius = 0.25, outerRadius = 0.5, segments = 32})` | Annulus, XZ, +Y. |
-| `WedgeGeometry` | `WedgeGeometry(vm.Vector3 size)` | Triangular prism; base on `y = 0` (not Y-centered). |
-
-Because a cone is just `CylinderGeometry(topRadius: 0)`, a tree is a green cone on a brown cylinder, a fence is repeated thin cuboids, a table is a plane on four cylinders. Assemble each piece as a parented `Node` subtree, then `clone()` and place it, or feed the placements to an `InstancedMesh` when the same piece repeats many times.
-
-Every primitive except `PlaneGeometry` exposes a `Shape get collisionShape` for the physics package, so a code-built kit gets colliders for free.
-
-### Swept geometry for shapes primitives cannot make
-
-For paths, tubes, and profiles, sweep a `ScenePath` (`BezierPath`, `CatmullRomPath`, `PolylinePath`):
-
-- `TubeGeometry(path, {radius = 0.5, radialSegments = 12, stations = 64, caps = true})` for pipes, cables, vines.
-- `ExtrudeGeometry(path, {required List profile, stations = 64, caps = true})` sweeps a 2D profile along the path (railings, moldings, extruded logos).
-- `RibbonGeometry(path, {width = 1.0, stations = 64, alignment = RibbonAlignment.ground})` for flat strips (roads, trails).
-
-These build detailed shapes from a curve and a few parameters, often replacing an imported model outright.
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/SKILL.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/SKILL.md
deleted file mode 100644
index f553708..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/SKILL.md
+++ /dev/null
@@ -1,65 +0,0 @@
----
-name: flutter_scene-verification-loop
-version: 2
-description: Close the visual-iteration loop when building or debugging a flutter_scene 3D app so you see your own output and self-correct. Use whenever a change affects what renders (geometry, materials, lighting, shaders, post-processing) or a frame looks wrong (black, washed-out, see-through, missing geometry).
----
-
-# Verifying flutter_scene visually
-
-flutter_scene renders 3D. A rendering change you cannot see is a guess, and guessing at pixels is the single biggest waste of iterations. The highest-value habit is a closed visual loop plus judgment that does not drift. This skill is that loop.
-
-**The one thing to internalize: run it, let it settle, look at the frame AND the console, localize before you edit, repeat.** Do not change code off a hypothesis you did not confirm from the actual output.
-
-## The loop
-
-1. **Run.** Launch the app with `flutter run --enable-flutter-gpu` (native; the flag is mandatory and it is the only run flag, see the idioms skill). Where the editor MCP is connected, `run_project` launches the managed session instead.
-2. **Settle.** A live frame is a moving target. Auto-exposure is still ramping, particles have a random phase, an animation is mid-clip, IBL re-bakes after the first present. Let the scene reach steady state (a few frames, or until the image stops changing) before you trust a capture. Do not screenshot the first frame and reason from it.
-3. **Capture the frame AND read the console.** A screenshot alone hides errors that print; the console alone hides wrong pixels. Take both every time. Baseline path is a screenshot plus the run log; with the editor MCP it is `screenshot_viewport` plus `get_console`.
-4. **Localize before editing.** When something is wrong, find where it goes wrong before you touch code. Read an intermediate buffer, read a single pixel's exact value, or scan for non-finite values. A NaN or Inf propagates silently into black or garbage downstream, so the first pass that produced it is the culprit, not the pass where you see the black. `references/loop.md` has the tool table and a symptom to action map.
-5. **Correct, repeat.** Make one change, run the loop again. One change per iteration keeps cause and effect legible.
-
-## The readiness gate (do not debug through it)
-
-Rendering is gated on `Scene.initializeStaticResources()`. Until that Future completes, every frame is skipped and the engine prints exactly:
-
-```
-Flutter Scene is not ready to render. Skipping frame.
-```
-
-If you see that line, the scene is not broken, it is not ready. Wait for readiness (build geometry and materials inside `initializeStaticResources().then(...)`, gate the widget on `Scene.isReadyToRender`) before you diagnose anything else. A black frame while that line prints is the gate, not your code.
-
-## Judge blind, never self-score (the load-bearing rule)
-
-When deciding whether a change improved the look, **do not assign the frame a quality score.** Self-assigned scores drift upward, because the model is grading its own trajectory and wants to have made progress. That drift is how a session convinces itself a regression is an improvement.
-
-Instead, **compare two frames and return a binary pick.** Put the new frame next to a reference (a known-good target) or the previous frame, and answer only "which of these two is better", A or B. No number, no "8/10", no "looks pretty good now". A blind pairwise pick does not inflate the way a solo score does. This applies to every visual review, including the ones that feel obvious.
-
-If you have no reference at all, say so and describe the concrete difference between the two frames (this one is brighter here, that one has an artifact there) rather than inventing a score.
-
-## Empirical verification rules
-
-These rules prevent false diagnoses, hollow passes, and measurement traps during visual iteration:
-
-1. **Liveness before ablation.** A negative result is evidence only if the removed or modified term was actually live in the draw pass. Diff pixels for liveness before trusting an ablation.
-2. **Tools must fail loud on empty measurements.** A tool that measures an empty population, zero pixels, or non-finite data must fail loud rather than returning a default passing number.
-3. **Population discipline.** Always quote the population window, crop rectangle, brightness threshold, and rendering resolution beside any color or lighting figure.
-4. **Baselines expire quickly.** Two captures taken hours apart in a changing tree reflect multiple edits; isolate paired A/B captures with temporary snapshots or git worktrees.
-5. **Look at the raw frame before quoting numbers.** Inspect the actual captured frame before taking numbers off it; metrics can yield valid-looking numbers on corrupt frames.
-6. **Negative control requirement.** A metric that returns the same score for positive and negative control populations cannot serve as evidence for either.
-7. **Explanations are hypotheses, not evidence.** Before adopting an explanatory mechanism, identify the specific numerical observation that would differ if the mechanism were false.
-8. **Attribution must reach the triangle.** When diagnosing geometry defects, trace down to the specific triangle indices and edge lengths rather than stopping at the mesh component.
-9. **Symmetric domain clamping.** Clamping parametric lookup domains must be verified at both boundaries, and derived slope or heading accessors must clamp both sample points.
-10. **Multi-band octave tables for tiling.** Two-band ratios like `hf/lf` are blind to regular mid-frequency patterns; inspect multi-octave energy tables to detect periodic tiling.
-11. **Physical discrimination over naming.** A class-discriminating physical observation (such as missing shadow terminators) outranks code comments or variable names.
-12. **Grazing light terminator crossing.** Under low-angle grazing lighting, prioritize terminator-crossing fractions over slope RMS to detect normal map over-amplification.
-13. **Resolution scaling.** High-frequency energy (`hf/lf`) and relative contrast scale with resolution; compare them only at equal pixel resolutions.
-14. **Paired capture isolation.** Snapshot source files or freeze environment state when capturing before/after pairs so background modifications cannot corrupt the comparison.
-
-## Be honest about tooling
-
-The richest observation tooling lives behind the editor MCP (`flutter_scene_mcp`): screenshots, console, NaN scans, render-graph capture, per-pass and per-pixel readback, viewport debug modes. A general-purpose observation server for an arbitrary running game is still being built, so do not assume those tools exist for every project. When the MCP is not connected, the baseline loop is still fully usable: `flutter run --enable-flutter-gpu`, read the console, take a screenshot. Do not claim a capability you cannot reach in the current project.
-
-## More depth
-
-- `references/loop.md` for the full editor-MCP tool table, the symptom to action map (black frame, washed-out, see-through, missing geometry), and the settle details.
-- The `flutter_scene-idioms` skill (`references/traps.md`) for the underlying mistakes each symptom points back to (wrong vertex layout, hand-rolled winding flip, transform-in-place, the blank-frame causes).
diff --git a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/references/loop.md b/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/references/loop.md
deleted file mode 100644
index 2ea7278..0000000
--- a/sanctification-tcg/flutter-scene-spike/.claude/skills/flutter_scene-verification-loop/references/loop.md
+++ /dev/null
@@ -1,145 +0,0 @@
-# The verification loop in detail
-
-The core loop, the readiness gate, and the blind-judgment rule are in `SKILL.md`. This file has the
-tool table (what exists where), the settle details, and the symptom to action map.
-
----
-
-## Two tooling tiers
-
-### Baseline (any project, no MCP)
-
-This always works and needs nothing installed beyond the package setup.
-
-- Launch: `flutter run --enable-flutter-gpu` (native; add `-d chrome` for web). The flag is mandatory.
-- Console: read the run log. The readiness line, `debugPrint` output, asserts, and the 0.22.0
- blank-frame diagnostic all land here.
-- Frame: take a screenshot of the running app after it settles.
-
-That is the whole loop when there is no editor. Run, settle, screenshot, read the log, correct.
-
-### Editor MCP (`flutter_scene_mcp`, when connected)
-
-The editor exposes richer observation. Tool names below are exact. Do not assume they exist unless
-the MCP is actually connected for the current project.
-
-| Tool | What it does | Reach for it when |
-| --- | --- | --- |
-| `run_project` | Launch the editor-managed Play session (a managed `flutter run`). | Starting a session under the editor. |
-| `build_project` | Start the selected build config; output streams to the console. | You want a build without launching. |
-| `stop_project` | Stop the running session. | Ending or restarting cleanly. |
-| `hot_reload` | Hot reload the running debug session. | A Dart-only change, fastest turnaround. |
-| `hot_restart` | Hot restart the session. | State or startup changed, or reload did not take. |
-| `get_console` | The build/run console tail plus building/running flags. | EVERY iteration, paired with a screenshot. |
-| `screenshot_viewport` | The viewport as a PNG, what the user sees. | EVERY iteration, paired with the console. |
-| `describe_scene` | The scene-graph tree (ids, paths, names, component types). | Confirming a node/mesh is actually in the scene. |
-| `scan_for_nans` | Capture a frame and scan every float render target for NaN/Inf in pass order. | A black or garbage frame with no error. Find where non-finite values start. |
-| `capture_render_graph` | Capture the next frame's graph with thumbnails. | You need to see intermediate buffers. |
-| `list_render_passes` | The executed passes in order with CPU timings and the buffer keys each read/wrote, plus target formats and sizes. No images. | Learning which pass owns which buffer, and the key names to read. |
-| `get_pass_output` | Render one captured buffer (a key like `scene_color`, `linear_depth`) as a PNG. NaN paints magenta, Inf yellow, negative blue. | Eyeballing an intermediate buffer to see which stage broke. |
-| `read_pass_pixel` | One pixel's exact float RGBA from a captured buffer, with NaN/Inf flags. | Confirming an exact value (is this really 0, or NaN, or negative). |
-| `list_viewport_debug_modes` | The available debug outputs (final, HDR color, linear depth, normals, AO, shadow atlas, ...) and which is active. | Seeing what debug views exist. |
-| `set_viewport_debug_mode` | Render one debug output full-viewport. Set `final` to restore. | Inspecting depth/normals/AO live, paired with `screenshot_viewport`. |
-
-Render-graph capture (`capture_render_graph`, `list_render_passes`, `get_pass_output`,
-`read_pass_pixel`, `scan_for_nans`) is gated on `Scene.debugAllowRenderGraphCapture`. It is a debug
-opt-in, so a release build or a scene that never armed it returns nothing. The editor arms it for you;
-outside the editor, set `Scene.debugAllowRenderGraphCapture = true` and call
-`Scene.captureRenderGraph(...)` directly.
-
----
-
-## Settle, do not seed
-
-Frames differ from run to run for benign reasons. That is normal, not a bug to eliminate.
-
-- **Auto-exposure** (`Scene.autoExposure`) ramps toward the target over `speedUp`/`speedDown` seconds,
- so the first second is darker or brighter than the settled image.
-- **Particles and trails** carry a random phase, so a `ParticleSystem` looks different every launch.
-- **Animations** are mid-clip unless you seek them, so a screenshot lands on an arbitrary frame.
-- **Image-based lighting** re-bakes after the first present on some paths, so reflections dim in for
- a frame before they are correct.
-
-So let the scene settle before you trust a capture. Watch until the image stops changing, or advance
-a fixed few frames, then screenshot. Judge the settled frame, not the first one.
-
-**Seeding is a different job.** Strict determinism (a fixed random seed, a pinned animation time, a
-frozen exposure) is what you set up for pixel-exact regression comparison, where two runs must be
-byte-identical. You do not need it for ordinary observation. For "does this change look right", settle
-and look. Reserve the seeding work for when you are building a golden or diffing two runs at the pixel
-level.
-
----
-
-## Symptom to action map
-
-Localize before editing. Each row says what to capture first and the mistakes it usually points back
-to. The mistakes are detailed in the `flutter_scene-idioms` skill's `references/traps.md`; this map
-routes a symptom to the right one.
-
-### Entirely black frame
-
-1. Read the console FIRST. If `Flutter Scene is not ready to render. Skipping frame.` is printing, it
- is the readiness gate, not your scene. Wait for `Scene.initializeStaticResources()`. Stop here.
-2. In 0.22.0 a frame that issues zero draws prints once in debug naming the likely cause (not ready,
- empty region, no views, no visible meshes, or a layer mask matching nothing). Read that line.
-3. If draws are happening but the image is black, `scan_for_nans`. A NaN or Inf anywhere upstream
- collapses the final image to black, and the scan names the first offending pass. Then
- `get_pass_output` on that pass's buffer (NaN shows magenta) to confirm.
-4. Common non-NaN causes: a degenerate camera (target equals position, `up` parallel to the view
- direction on a top-down camera, FOV passed in degrees not radians), `layerMask: 0`, an oversized
- environment texture that failed to allocate on the device. See traps #23 and #16.
-
-### Washed-out, low-contrast, or too-bright color
-
-1. `screenshot_viewport` after settling, and check whether auto-exposure has finished ramping (a
- too-bright first second is just the ramp).
-2. If it persists, suspect a shader-output contract break. A custom `ShaderMaterial`/`PostEffect`/sky
- shader must output linear HDR premultiplied by alpha. Tone-mapping or gamma-encoding in the shader
- gets applied a second time by the resolve pass, giving exactly this washed-out look. See traps #37
- and the root `MATERIALS.md`.
-3. Also check for a non-color texture bound as color (a normal or metallic-roughness map without the
- right `TextureContent`), which reads wrong and distance-dependent. Trap #2.
-4. Hand-packed vertex data at the wrong stride also washes out color (the color attribute lands at the
- wrong offset). `describe_scene` plus trap #17.
-
-### See-through or inside-out faces
-
-1. `set_viewport_debug_mode` to normals (or `get_pass_output` on the normals buffer) and look at the
- orientation. Inverted normals confirm a winding problem.
-2. Cause is almost always clockwise hand-built triangles. flutter_scene front faces wind
- COUNTER-CLOCKWISE (CCW) in model space, matching glTF and standard conventions. Ensure triangle
- indices wind CCW around the outward face normal, or omit normals and let the constructor derive
- them. NEVER fix orientation with a per-triangle winding flip on an imported model; that leaves
- normals and IBL wrong. Traps #13 and #17.
-3. For an imported model rendered mirrored, check you did not overwrite the runtime importer's
- `scale(1, 1, -1)` handedness root. Trap #5.
-
-### Missing or popping geometry
-
-1. `describe_scene` to confirm the node is actually in the graph. If it is absent, it is a scene-build
- bug, not a render bug.
-2. If it is present but invisible, check the layer mask (`Node.layers` is a bitmask, NOT inherited,
- and must match the view's `layerMask`; `layers = 2` means `1 << 1`, not "layer 2"). Trap #10.
-3. If it appears and disappears with camera angle, the bounds do not cover the geometry (a
- caller-supplied `bounds` or `setLocalBounds` that is too small, or a swapped primitive geometry on
- an older version). Widen or omit the bounds. Traps #8 and #24.
-4. A moved skinned mesh that will not move is the skinned-node transform being ignored by design; move
- the skeleton root instead. Trap #4.
-
-### A value looks numerically wrong (not visually)
-
-Use `read_pass_pixel` on the relevant buffer to read the exact float RGBA at a coordinate, with NaN/Inf
-flags. This settles "is this pixel actually 0.5, or is it NaN, or negative" without eyeballing a PNG
-that the display remap has already clamped.
-
----
-
-## Judgment, restated
-
-The blind-pairwise rule from `SKILL.md` is the part most likely to be skipped, so it bears repeating
-here. When you have a before and an after, put them side by side and pick the better one as a binary
-A-or-B choice against a reference or the previous frame. Do not narrate a score. A solo score climbs
-on its own because you are grading your own progress; a blind pick between two concrete frames does
-not. Every visual review runs through a pairwise pick, including the ones that feel too obvious to
-bother with.
diff --git a/sanctification-tcg/flutter-scene-spike/.gitignore b/sanctification-tcg/flutter-scene-spike/.gitignore
deleted file mode 100644
index 36905b4..0000000
--- a/sanctification-tcg/flutter-scene-spike/.gitignore
+++ /dev/null
@@ -1,48 +0,0 @@
-# Miscellaneous
-*.class
-*.log
-*.pyc
-*.swp
-.DS_Store
-.atom/
-.build/
-.buildlog/
-.history
-.svn/
-.swiftpm/
-migrate_working_dir/
-
-# IntelliJ related
-*.iml
-*.ipr
-*.iws
-.idea/
-
-# The .vscode folder contains launch configuration and tasks you configure in
-# VS Code which you may wish to be included in version control, so this line
-# is commented out by default.
-#.vscode/
-
-# Flutter/Dart/Pub related
-**/doc/api/
-**/ios/Flutter/.last_build_id
-.dart_tool/
-.flutter-plugins-dependencies
-.pub-cache/
-.pub/
-/build/
-/coverage/
-
-# Symbolication related
-app.*.symbols
-
-# Obfuscation related
-app.*.map.json
-
-# Android Studio will place build artifacts here
-/android/app/debug
-/android/app/profile
-/android/app/release
-
-# Widget Preview related
-.widget_preview/
diff --git a/sanctification-tcg/flutter-scene-spike/.metadata b/sanctification-tcg/flutter-scene-spike/.metadata
deleted file mode 100644
index 7fdd725..0000000
--- a/sanctification-tcg/flutter-scene-spike/.metadata
+++ /dev/null
@@ -1,30 +0,0 @@
-# This file tracks properties of this Flutter project.
-# Used by Flutter tool to assess capabilities and perform upgrades etc.
-#
-# This file should be version controlled and should not be manually edited.
-
-version:
- revision: "d3b14c876900e553bc736ca19295fc09e3853e8e"
- channel: "stable"
-
-project_type: app
-
-# Tracks metadata for the flutter migrate command
-migration:
- platforms:
- - platform: root
- create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
- base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
- - platform: web
- create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
- base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
-
- # User provided section
-
- # List of Local paths (relative to this file) that should be
- # ignored by the migrate tool.
- #
- # Files that are not part of the templates will be ignored by default.
- unmanaged_files:
- - 'lib/main.dart'
- - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/sanctification-tcg/flutter-scene-spike/README.md b/sanctification-tcg/flutter-scene-spike/README.md
deleted file mode 100644
index 28327c6..0000000
--- a/sanctification-tcg/flutter-scene-spike/README.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# Flutter Scene Card Spike
-
-Bounded comparison of Flutter Scene against the approved Three.js
-`runtime-look-v4-2026-09-07` card renderer.
-
-## Pinned toolchain
-
-- Flutter 3.47.2 stable
-- Dart 3.13.2
-- `flutter_scene` 0.23.0
-
-The Flutter SDK used during the spike is installed at:
-
-```text
-C:\Users\dakovale\development\flutter
-```
-
-## Scope
-
-- Procedural rounded card geometry
-- David Paper + Holographic baseline
-- Reveal-safe universal back
-- Front, Grazing, Edge, and Back poses
-- One-finger rotation, pinch zoom, flip, reset, and sweep
-- Web execution over the local network
-
-The full Three.js laboratory is intentionally not being duplicated.
-
-## Run
-
-The validated release build can be served to the local network with:
-
-```powershell
-python -m http.server 5180 --bind 0.0.0.0 --directory build\web
-```
-
-Open `http://10.0.0.193:5180/` from the current network.
-
-For Flutter debug iteration:
-
-```powershell
-$flutter = "$HOME\development\flutter\bin\flutter.bat"
-& $flutter run -d web-server --web-hostname 0.0.0.0 --web-port 5180
-```
-
-For a native target after its platform tooling is installed:
-
-```powershell
-& $flutter run --enable-flutter-gpu
-```
-
-Android-native validation is currently blocked because this machine does not
-have Java, the Android SDK, or `adb` installed. The web comparison does not
-depend on that tooling.
-
-## Validate
-
-```powershell
-& $flutter analyze
-& $flutter test
-& $flutter build web
-```
-
-Source assets in `assets\` are immutable copies of the approved Three.js
-fixture. Scene's initialized build hook compiles loose textures and `.fmat`
-materials into `flutter_scene_generated\`.
diff --git a/sanctification-tcg/flutter-scene-spike/analysis_options.yaml b/sanctification-tcg/flutter-scene-spike/analysis_options.yaml
deleted file mode 100644
index 035aeda..0000000
--- a/sanctification-tcg/flutter-scene-spike/analysis_options.yaml
+++ /dev/null
@@ -1,33 +0,0 @@
-# This file configures the analyzer, which statically analyzes Dart code to
-# check for errors, warnings, and lints.
-#
-# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
-# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
-# invoked from the command line by running `flutter analyze`.
-
-# The following line activates a set of recommended lints for Flutter apps,
-# packages, and plugins designed to encourage good coding practices.
-include: package:flutter_lints/flutter.yaml
-
-analyzer:
- exclude:
- - build/**
- - web/**
-
-linter:
- # The lint rules applied to this project can be customized in the
- # section below to disable rules from the `package:flutter_lints/flutter.yaml`
- # included above or to enable additional rules. A list of all available lints
- # and their documentation is published at https://dart.dev/lints.
- #
- # Instead of disabling a lint rule for the entire project in the
- # section below, it can also be suppressed for a single line of code
- # or a specific dart file by using the `// ignore: name_of_lint` and
- # `// ignore_for_file: name_of_lint` syntax on the line or in the file
- # producing the lint.
- rules:
- # avoid_print: false # Uncomment to disable the `avoid_print` rule
- # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
-
-# Additional information about this file can be found at
-# https://dart.dev/guides/language/analysis-options
diff --git a/sanctification-tcg/flutter-scene-spike/assets/card-back.png b/sanctification-tcg/flutter-scene-spike/assets/card-back.png
deleted file mode 100644
index caa4df8..0000000
Binary files a/sanctification-tcg/flutter-scene-spike/assets/card-back.png and /dev/null differ
diff --git a/sanctification-tcg/flutter-scene-spike/assets/card_holographic.fmat b/sanctification-tcg/flutter-scene-spike/assets/card_holographic.fmat
deleted file mode 100644
index f3ee5bc..0000000
--- a/sanctification-tcg/flutter-scene-spike/assets/card_holographic.fmat
+++ /dev/null
@@ -1,79 +0,0 @@
-material {
- name: "SanctificationCardHolographic",
- shading_model: physical,
- blending: opaque,
- culling: back,
- parameters: [
- { type: sampler2d, name: artwork_texture, hint: default_white },
- { type: sampler2d, name: finish_mask_texture, hint: default_black },
- { type: vec3, name: light_position, default: [2.2, 2.5, 4.4] },
- { type: float, name: finish_strength, default: 0.6 },
- { type: float, name: roughness, default: 0.23 },
- { type: float, name: surface_detail, default: 0.14 },
- ],
-}
-
-fragment {
- vec3 Spectrum(float phase) {
- return 0.52 + 0.48 * cos(
- 6.2831853 * (phase + vec3(0.0, 0.33, 0.67))
- );
- }
-
- float Hash(vec2 p) {
- return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
- }
-
- void Surface(inout MaterialInputs material) {
- vec2 uv = GetUV(0);
- vec3 artwork = SRGBToLinear(texture(artwork_texture, uv).rgb);
- float mask = texture(finish_mask_texture, uv).r;
- vec3 normal = GetWorldNormal();
- vec3 view_direction = GetViewDirection();
- vec3 light_direction = normalize(
- material_params.light_position - GetWorldPosition()
- );
-
- float paper = Hash(floor(uv * vec2(230.0, 322.0)));
- artwork *= 0.985 + (paper - 0.5) *
- material_params.surface_detail * 0.08;
-
- float view_phase = dot(view_direction, vec3(0.43, 0.24, 0.87));
- float light_phase = dot(light_direction, vec3(-0.31, 0.66, 0.68));
- float phase = uv.x * 1.7 + uv.y * 0.9 +
- view_phase * 0.78 + light_phase * 0.34;
- vec3 spectrum = Spectrum(phase);
- float grazing = pow(
- 1.0 - clamp(dot(normal, view_direction), 0.0, 1.0),
- 0.72
- );
- float coating = clamp(
- mask * material_params.finish_strength *
- (0.34 + grazing * 1.28),
- 0.0,
- 0.92
- );
- vec3 spectral_ink = artwork * mix(vec3(0.88), spectrum, 0.72);
- vec3 coated = mix(artwork, mix(artwork, spectral_ink, 0.48), coating);
- coated += spectrum * coating * grazing * 0.12;
-
- material.base_color = vec4(coated, 1.0);
- material.metallic = coating * 0.18;
- material.roughness = clamp(
- mix(max(material_params.roughness, 0.56), 0.22, coating),
- kMinRoughness,
- 1.0
- );
- material.normal = normal;
- material.specular_weight = 1.0;
- material.specular_color = vec3(1.0);
- material.ior = 1.5;
- material.clearcoat = coating * 0.58;
- material.clearcoat_roughness = 0.24;
- material.clearcoat_normal = normal;
- material.iridescence = coating * 0.42;
- material.iridescence_ior = 1.3;
- material.iridescence_thickness = mix(180.0, 430.0, spectrum.r);
- PrepareMaterial(material);
- }
-}
diff --git a/sanctification-tcg/flutter-scene-spike/assets/david-finish-mask.png b/sanctification-tcg/flutter-scene-spike/assets/david-finish-mask.png
deleted file mode 100644
index 0e92846..0000000
Binary files a/sanctification-tcg/flutter-scene-spike/assets/david-finish-mask.png and /dev/null differ
diff --git a/sanctification-tcg/flutter-scene-spike/assets/david-front.png b/sanctification-tcg/flutter-scene-spike/assets/david-front.png
deleted file mode 100644
index 9eb7cd0..0000000
Binary files a/sanctification-tcg/flutter-scene-spike/assets/david-front.png and /dev/null differ
diff --git a/sanctification-tcg/flutter-scene-spike/flutter_scene_generated/.gitignore b/sanctification-tcg/flutter-scene-spike/flutter_scene_generated/.gitignore
deleted file mode 100644
index c1cf5c3..0000000
--- a/sanctification-tcg/flutter-scene-spike/flutter_scene_generated/.gitignore
+++ /dev/null
@@ -1,4 +0,0 @@
-# Written by flutter_scene's build hook. Generated assets are tied to the
-# Flutter engine that built them, so they are never committed.
-*
-!.gitignore
diff --git a/sanctification-tcg/flutter-scene-spike/hook/build.dart b/sanctification-tcg/flutter-scene-spike/hook/build.dart
deleted file mode 100644
index d2b1dce..0000000
--- a/sanctification-tcg/flutter-scene-spike/hook/build.dart
+++ /dev/null
@@ -1,28 +0,0 @@
-import 'package:flutter_scene/build_hooks.dart';
-import 'package:hooks/hooks.dart';
-
-void main(List args) async {
- await build(args, (input, output) async {
-// flutter_scene:init:start
- // Import .glb and .fscene sources under assets/, loadable by source path
- // with loadScene (and hot-reloadable). A no-op when there are no scenes.
- buildScenes(buildInput: input, buildOutput: output);
- // Compile .fmat materials under assets/, loadable by source path with
- // loadFmatMaterial (and hot-reloadable). A no-op when there are none.
- await buildMaterials(buildInput: input, buildOutput: output);
-// flutter_scene:init:end
- buildTextures(
- buildInput: input,
- buildOutput: output,
- textures: const [
- 'assets/david-front.png',
- 'assets/david-finish-mask.png',
- 'assets/card-back.png',
- ],
- contents: const {
- 'assets/david-finish-mask.png': TextureContent.data,
- },
- alignForCompression: true,
- );
- });
-}
diff --git a/sanctification-tcg/flutter-scene-spike/lib/card_geometry.dart b/sanctification-tcg/flutter-scene-spike/lib/card_geometry.dart
deleted file mode 100644
index c188ca1..0000000
--- a/sanctification-tcg/flutter-scene-spike/lib/card_geometry.dart
+++ /dev/null
@@ -1,117 +0,0 @@
-import 'dart:math' as math;
-import 'dart:typed_data';
-
-import 'package:flutter_scene/scene.dart';
-import 'package:vector_math/vector_math.dart' as vm;
-
-const cardWidth = 2.8571428571;
-const cardHeight = 4.0;
-const cardThickness = 0.0181405896;
-const cardCornerRadius = 0.1269841270;
-const _cornerSegments = 12;
-
-List _roundedPerimeter() {
- final points = [];
- final halfWidth = cardWidth / 2;
- final halfHeight = cardHeight / 2;
- final centers = [
- vm.Vector2(halfWidth - cardCornerRadius, halfHeight - cardCornerRadius),
- vm.Vector2(-halfWidth + cardCornerRadius, halfHeight - cardCornerRadius),
- vm.Vector2(-halfWidth + cardCornerRadius, -halfHeight + cardCornerRadius),
- vm.Vector2(halfWidth - cardCornerRadius, -halfHeight + cardCornerRadius),
- ];
-
- for (var corner = 0; corner < centers.length; corner++) {
- final startAngle = corner * math.pi / 2;
- for (var step = 0; step <= _cornerSegments; step++) {
- final angle = startAngle + step / _cornerSegments * math.pi / 2;
- points.add(
- centers[corner] +
- vm.Vector2(math.cos(angle), math.sin(angle)) * cardCornerRadius,
- );
- }
- }
- return points;
-}
-
-MeshGeometry buildCardFaceGeometry({
- required bool front,
-}) {
- final perimeter = _roundedPerimeter();
- final z = front ? -cardThickness / 2 : cardThickness / 2;
- final positions = [0, 0, z];
- final normals = [0, 0, front ? -1 : 1];
- final texCoords = [0.5, 0.5];
-
- for (final point in perimeter) {
- positions.addAll([point.x, point.y, z]);
- normals.addAll([0, 0, front ? -1 : 1]);
- final u = front
- ? point.x / cardWidth + 0.5
- : 0.5 - point.x / cardWidth;
- texCoords.addAll([u, 0.5 - point.y / cardHeight]);
- }
-
- final indices = [];
- for (var index = 0; index < perimeter.length; index++) {
- final current = index + 1;
- final next = (index + 1) % perimeter.length + 1;
- if (front) {
- indices.addAll([0, next, current]);
- } else {
- indices.addAll([0, current, next]);
- }
- }
-
- return MeshGeometry.fromArrays(
- positions: Float32List.fromList(positions),
- normals: Float32List.fromList(normals),
- texCoords: Float32List.fromList(texCoords),
- indices: indices,
- );
-}
-
-MeshGeometry buildCardEdgeGeometry() {
- final perimeter = _roundedPerimeter();
- final positions = [];
- final normals = [];
- final indices = [];
-
- for (var index = 0; index < perimeter.length; index++) {
- final current = perimeter[index];
- final next = perimeter[(index + 1) % perimeter.length];
- final normal = (current + next).normalized();
- final base = positions.length ~/ 3;
- positions.addAll([
- current.x,
- current.y,
- -cardThickness / 2,
- current.x,
- current.y,
- cardThickness / 2,
- next.x,
- next.y,
- cardThickness / 2,
- next.x,
- next.y,
- -cardThickness / 2,
- ]);
- for (var vertex = 0; vertex < 4; vertex++) {
- normals.addAll([normal.x, normal.y, 0]);
- }
- indices.addAll([
- base,
- base + 2,
- base + 1,
- base,
- base + 3,
- base + 2,
- ]);
- }
-
- return MeshGeometry.fromArrays(
- positions: Float32List.fromList(positions),
- normals: Float32List.fromList(normals),
- indices: indices,
- );
-}
diff --git a/sanctification-tcg/flutter-scene-spike/lib/main.dart b/sanctification-tcg/flutter-scene-spike/lib/main.dart
deleted file mode 100644
index 453be00..0000000
--- a/sanctification-tcg/flutter-scene-spike/lib/main.dart
+++ /dev/null
@@ -1,391 +0,0 @@
-import 'dart:math' as math;
-
-import 'package:flutter/material.dart';
-import 'package:flutter_scene/scene.dart';
-import 'package:vector_math/vector_math.dart' as vm;
-
-import 'card_geometry.dart';
-
-const spikeTitle = 'FLUTTER SCENE CARD SPIKE';
-
-void main() {
- runApp(const SceneSpikeApp());
-}
-
-class SceneSpikeApp extends StatelessWidget {
- const SceneSpikeApp({super.key});
-
- @override
- Widget build(BuildContext context) {
- return const MaterialApp(
- debugShowCheckedModeBanner: false,
- home: CardSceneView(),
- );
- }
-}
-
-class CardSceneView extends StatefulWidget {
- const CardSceneView({super.key});
-
- @override
- State createState() => _CardSceneViewState();
-}
-
-class _CardSceneViewState extends State {
- static const _frontPitch = -0.06;
- static const _frontYaw = 0.12;
- static const _minCameraDistance = 5.0;
- static const _maxCameraDistance = 12.0;
-
- final Scene scene = Scene();
- final Node cardRoot = Node(name: 'CARD_ROOT');
- final vm.Vector3 lightPosition = vm.Vector3(2.2, 2.5, 4.4);
-
- bool ready = false;
- String? error;
- double pitch = _frontPitch;
- double yaw = _frontYaw;
- double targetPitch = _frontPitch;
- double targetYaw = _frontYaw;
- double cameraDistance = 8.2;
- double defaultCameraDistance = 8.2;
- double gestureStartDistance = 8.2;
- Offset lastFocalPoint = Offset.zero;
- bool scriptedMotion = false;
- Duration latestElapsed = Duration.zero;
- double scriptStartSeconds = 0;
- double scriptStartPitch = _frontPitch;
- double scriptStartYaw = _frontYaw;
-
- @override
- void initState() {
- super.initState();
- _initializeScene();
- }
-
- @override
- void didChangeDependencies() {
- super.didChangeDependencies();
- final size = MediaQuery.sizeOf(context);
- if (size.isEmpty) return;
- final verticalTangent = math.tan(34 * math.pi / 360);
- final verticalFit = cardHeight / 2 / verticalTangent;
- final horizontalFit =
- cardWidth / 2 / (verticalTangent * size.aspectRatio);
- final nextDefault = math.max(8.2, math.max(verticalFit, horizontalFit) * 1.08);
- if ((cameraDistance - defaultCameraDistance).abs() < 0.001) {
- cameraDistance = nextDefault;
- }
- defaultCameraDistance = nextDefault;
- }
-
- Future _initializeScene() async {
- try {
- debugPrint('Scene spike: initializing static resources');
- await Scene.initializeStaticResources();
- debugPrint('Scene spike: loading textures');
- final artwork = await loadTexture('assets/david-front.png');
- final finishMask = await loadTexture('assets/david-finish-mask.png');
- final backArtwork = await loadTexture('assets/card-back.png');
- debugPrint('Scene spike: loading materials');
- final frontMaterial = await loadFmatMaterial(
- 'assets/card_holographic.fmat',
- );
- final backMaterial = await loadFmatMaterial(
- 'assets/card_holographic.fmat',
- );
-
- frontMaterial.parameters
- ..setTexture(
- 'artwork_texture',
- artwork.sampledTexture!,
- sampler: artwork.sampledSampler,
- )
- ..setTexture(
- 'finish_mask_texture',
- finishMask.sampledTexture!,
- sampler: finishMask.sampledSampler,
- )
- ..setVec3('light_position', lightPosition)
- ..setFloat('finish_strength', 0.6)
- ..setFloat('roughness', 0.23)
- ..setFloat('surface_detail', 0.14);
- backMaterial.parameters
- ..setTexture(
- 'artwork_texture',
- backArtwork.sampledTexture!,
- sampler: backArtwork.sampledSampler,
- )
- ..setTexture(
- 'finish_mask_texture',
- finishMask.sampledTexture!,
- sampler: finishMask.sampledSampler,
- )
- ..setVec3('light_position', lightPosition)
- ..setFloat('finish_strength', 0)
- ..setFloat('roughness', 0.42)
- ..setFloat('surface_detail', 0);
-
- final edgeMaterial = PhysicallyBasedMaterial()
- ..baseColorFactor = vm.Vector4(0.33, 0.27, 0.16, 1)
- ..roughnessFactor = 0.62
- ..metallicFactor = 0.02;
-
- cardRoot.addAll([
- Node(
- name: 'CARD_FRONT',
- mesh: Mesh(buildCardFaceGeometry(front: true), frontMaterial),
- ),
- Node(
- name: 'CARD_BACK',
- mesh: Mesh(buildCardFaceGeometry(front: false), backMaterial),
- ),
- Node(
- name: 'CARD_EDGE',
- mesh: Mesh(buildCardEdgeGeometry(), edgeMaterial),
- ),
- ]);
- scene.add(cardRoot);
- scene.add(
- Node(
- name: 'STUDIO_LIGHT',
- localTransform: vm.Matrix4.translation(lightPosition),
- )..addComponent(
- PointLightComponent(
- PointLight(
- color: vm.Vector3(1.0, 0.87, 0.63),
- intensity: 42,
- range: 20,
- falloffExponent: 1.7,
- ),
- ),
- ),
- );
- scene.environmentSettings = EnvironmentSettings(
- toneMapping: ToneMappingMode.aces,
- exposure: 1,
- );
- _applyCardRotation();
- debugPrint('Scene spike: ready');
- if (mounted) {
- setState(() {
- ready = true;
- });
- }
- } catch (exception) {
- if (mounted) {
- setState(() {
- error = exception.toString();
- });
- }
- }
- }
-
- void _applyCardRotation() {
- cardRoot.rotation =
- vm.Quaternion.axisAngle(vm.Vector3(0, 1, 0), yaw) *
- vm.Quaternion.axisAngle(vm.Vector3(1, 0, 0), pitch);
- }
-
- void _setPose(double nextPitch, double nextYaw) {
- scriptedMotion = false;
- pitch = nextPitch;
- yaw = nextYaw;
- targetPitch = nextPitch;
- targetYaw = nextYaw;
- _applyCardRotation();
- setState(() {});
- }
-
- void _reset() {
- scriptedMotion = false;
- cameraDistance = defaultCameraDistance;
- _setPose(_frontPitch, _frontYaw);
- }
-
- void _flip() {
- scriptedMotion = false;
- targetPitch = pitch;
- targetYaw = yaw + math.pi;
- }
-
- void _toggleSweep(Duration elapsed) {
- if (scriptedMotion) {
- scriptedMotion = false;
- targetPitch = pitch;
- targetYaw = yaw;
- } else {
- scriptedMotion = true;
- scriptStartSeconds = elapsed.inMicroseconds / 1000000;
- scriptStartPitch = pitch;
- scriptStartYaw = yaw;
- }
- setState(() {});
- }
-
- void _onScaleStart(ScaleStartDetails details) {
- scriptedMotion = false;
- lastFocalPoint = details.focalPoint;
- gestureStartDistance = cameraDistance;
- }
-
- void _onScaleUpdate(ScaleUpdateDetails details) {
- if (details.pointerCount >= 2) {
- cameraDistance = (gestureStartDistance / details.scale).clamp(
- _minCameraDistance,
- _maxCameraDistance,
- );
- setState(() {});
- return;
- }
- final delta = details.focalPoint - lastFocalPoint;
- yaw += delta.dx * 0.008;
- pitch = (pitch + delta.dy * 0.008).clamp(-1.25, 1.25);
- targetPitch = pitch;
- targetYaw = yaw;
- lastFocalPoint = details.focalPoint;
- _applyCardRotation();
- }
-
- void _tick(Duration elapsed, double deltaSeconds) {
- latestElapsed = elapsed;
- final frameDelta = deltaSeconds.clamp(0, 0.1);
- if (scriptedMotion) {
- final seconds = elapsed.inMicroseconds / 1000000;
- final progress = ((seconds - scriptStartSeconds) / 5).clamp(0, 1);
- final eased = progress * progress * (3 - 2 * progress);
- final wave = math.sin(eased * math.pi);
- pitch = scriptStartPitch - 0.12 * wave;
- yaw = scriptStartYaw + math.pi / 7.5 * wave;
- if (progress >= 1) {
- scriptedMotion = false;
- targetPitch = scriptStartPitch;
- targetYaw = scriptStartYaw;
- }
- } else {
- final damping = 1 - math.exp(-9 * frameDelta);
- pitch += (targetPitch - pitch) * damping;
- yaw += (targetYaw - yaw) * damping;
- }
- _applyCardRotation();
- }
-
- PerspectiveCamera _camera() {
- return PerspectiveCamera(
- position: vm.Vector3(0, 0, -cameraDistance),
- target: vm.Vector3.zero(),
- fovRadiansY: 34 * math.pi / 180,
- fovNear: 0.1,
- fovFar: 100,
- );
- }
-
- Widget _controlButton(String label, VoidCallback onPressed) {
- return FilledButton.tonal(
- onPressed: onPressed,
- style: FilledButton.styleFrom(
- foregroundColor: const Color(0xFFF3EFE5),
- backgroundColor: const Color(0xFF171B23),
- side: const BorderSide(color: Color(0x554B5360)),
- ),
- child: Text(label),
- );
- }
-
- @override
- Widget build(BuildContext context) {
- return Scaffold(
- backgroundColor: const Color(0xFF080A0E),
- body: Stack(
- children: [
- if (ready)
- Positioned.fill(
- child: GestureDetector(
- behavior: HitTestBehavior.opaque,
- onScaleStart: _onScaleStart,
- onScaleUpdate: _onScaleUpdate,
- child: SceneView(
- scene,
- cameraBuilder: (_) => _camera(),
- onTick: _tick,
- ),
- ),
- )
- else
- Center(
- child: error == null
- ? const CircularProgressIndicator()
- : Padding(
- padding: const EdgeInsets.all(24),
- child: Text(
- error!,
- style: const TextStyle(color: Colors.redAccent),
- ),
- ),
- ),
- SafeArea(
- child: Padding(
- padding: const EdgeInsets.all(14),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const Text(
- spikeTitle,
- style: TextStyle(
- color: Color(0xFFD6C078),
- fontSize: 11,
- fontWeight: FontWeight.w700,
- letterSpacing: 1.4,
- ),
- ),
- const SizedBox(height: 3),
- const Text(
- 'David · Paper · Holographic',
- style: TextStyle(
- color: Color(0xFFF3EFE5),
- fontFamily: 'serif',
- fontSize: 20,
- ),
- ),
- const Spacer(),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- _controlButton('Front', () {
- _setPose(_frontPitch, _frontYaw);
- }),
- _controlButton('Grazing', () {
- _setPose(-0.14, 52 * math.pi / 180);
- }),
- _controlButton('Edge', () {
- _setPose(-0.05, math.pi / 2);
- }),
- _controlButton('Back', () {
- _setPose(_frontPitch, math.pi + _frontYaw);
- }),
- _controlButton('Flip', _flip),
- _controlButton('Reset', _reset),
- _controlButton(
- scriptedMotion ? 'Stop sweep' : 'Play sweep',
- () => _toggleSweep(latestElapsed),
- ),
- ],
- ),
- const SizedBox(height: 8),
- const Text(
- 'Drag to rotate · pinch to zoom',
- style: TextStyle(
- color: Color(0xFF9DA3AD),
- fontSize: 12,
- ),
- ),
- ],
- ),
- ),
- ),
- ],
- ),
- );
- }
-}
diff --git a/sanctification-tcg/flutter-scene-spike/pubspec.lock b/sanctification-tcg/flutter-scene-spike/pubspec.lock
deleted file mode 100644
index 08d0dd1..0000000
--- a/sanctification-tcg/flutter-scene-spike/pubspec.lock
+++ /dev/null
@@ -1,378 +0,0 @@
-# Generated by pub
-# See https://dart.dev/tools/pub/glossary#lockfile
-packages:
- archive:
- dependency: transitive
- description:
- name: archive
- sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
- url: "https://pub.dev"
- source: hosted
- version: "4.2.0"
- args:
- dependency: transitive
- description:
- name: args
- sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
- url: "https://pub.dev"
- source: hosted
- version: "2.7.0"
- async:
- dependency: transitive
- description:
- name: async
- sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
- url: "https://pub.dev"
- source: hosted
- version: "2.13.1"
- boolean_selector:
- dependency: transitive
- description:
- name: boolean_selector
- sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
- url: "https://pub.dev"
- source: hosted
- version: "2.1.2"
- characters:
- dependency: transitive
- description:
- name: characters
- sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
- url: "https://pub.dev"
- source: hosted
- version: "1.4.1"
- clock:
- dependency: transitive
- description:
- name: clock
- sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
- url: "https://pub.dev"
- source: hosted
- version: "1.1.3"
- code_assets:
- dependency: transitive
- description:
- name: code_assets
- sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
- url: "https://pub.dev"
- source: hosted
- version: "1.2.1"
- collection:
- dependency: transitive
- description:
- name: collection
- sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
- url: "https://pub.dev"
- source: hosted
- version: "1.19.1"
- crypto:
- dependency: transitive
- description:
- name: crypto
- sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
- url: "https://pub.dev"
- source: hosted
- version: "3.0.7"
- cupertino_icons:
- dependency: "direct main"
- description:
- name: cupertino_icons
- sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.9"
- data_assets:
- dependency: transitive
- description:
- name: data_assets
- sha256: "8bfdbf25ec8a0f4b5a3c993042c4ab9996ba354f0b03d40f4e360f3730d71cae"
- url: "https://pub.dev"
- source: hosted
- version: "0.20.0"
- fake_async:
- dependency: transitive
- description:
- name: fake_async
- sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
- url: "https://pub.dev"
- source: hosted
- version: "1.3.3"
- ffi:
- dependency: transitive
- description:
- name: ffi
- sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
- url: "https://pub.dev"
- source: hosted
- version: "2.2.0"
- flat_buffers:
- dependency: transitive
- description:
- name: flat_buffers
- sha256: "7c1de2d6eb5f3e61e5c50040841109f509deaaf2b12ec0d57b92456d9ea50345"
- url: "https://pub.dev"
- source: hosted
- version: "25.9.23"
- flutter:
- dependency: "direct main"
- description: flutter
- source: sdk
- version: "0.0.0"
- flutter_gpu:
- dependency: transitive
- description: flutter
- source: sdk
- version: "0.0.0"
- flutter_gpu_shaders:
- dependency: transitive
- description:
- name: flutter_gpu_shaders
- sha256: "1e8c5438c24e79629f5688c7b5dfd2b7cf09b36b443a806bc8c083cd8c04644a"
- url: "https://pub.dev"
- source: hosted
- version: "0.5.2"
- flutter_lints:
- dependency: "direct dev"
- description:
- name: flutter_lints
- sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
- url: "https://pub.dev"
- source: hosted
- version: "6.0.0"
- flutter_scene:
- dependency: "direct main"
- description:
- name: flutter_scene
- sha256: "1cc32b5ed0d05296c1f7958e63168a750c05c2373d6769ad9b9eaa973b2f97fe"
- url: "https://pub.dev"
- source: hosted
- version: "0.23.0"
- flutter_test:
- dependency: "direct dev"
- description: flutter
- source: sdk
- version: "0.0.0"
- hooks:
- dependency: "direct main"
- description:
- name: hooks
- sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f
- url: "https://pub.dev"
- source: hosted
- version: "2.2.0"
- image:
- dependency: transitive
- description:
- name: image
- sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
- url: "https://pub.dev"
- source: hosted
- version: "4.9.2"
- leak_tracker:
- dependency: transitive
- description:
- name: leak_tracker
- sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
- url: "https://pub.dev"
- source: hosted
- version: "11.0.2"
- leak_tracker_flutter_testing:
- dependency: transitive
- description:
- name: leak_tracker_flutter_testing
- sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
- url: "https://pub.dev"
- source: hosted
- version: "3.0.10"
- leak_tracker_testing:
- dependency: transitive
- description:
- name: leak_tracker_testing
- sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
- url: "https://pub.dev"
- source: hosted
- version: "3.0.2"
- lints:
- dependency: transitive
- description:
- name: lints
- sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
- url: "https://pub.dev"
- source: hosted
- version: "6.1.0"
- logging:
- dependency: transitive
- description:
- name: logging
- sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
- url: "https://pub.dev"
- source: hosted
- version: "1.3.0"
- matcher:
- dependency: transitive
- description:
- name: matcher
- sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
- url: "https://pub.dev"
- source: hosted
- version: "0.12.20"
- material_color_utilities:
- dependency: transitive
- description:
- name: material_color_utilities
- sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
- url: "https://pub.dev"
- source: hosted
- version: "0.13.0"
- meta:
- dependency: transitive
- description:
- name: meta
- sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
- url: "https://pub.dev"
- source: hosted
- version: "1.19.0"
- path:
- dependency: transitive
- description:
- name: path
- sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
- url: "https://pub.dev"
- source: hosted
- version: "1.9.1"
- posix:
- dependency: transitive
- description:
- name: posix
- sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
- url: "https://pub.dev"
- source: hosted
- version: "6.5.2"
- pub_semver:
- dependency: transitive
- description:
- name: pub_semver
- sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
- url: "https://pub.dev"
- source: hosted
- version: "2.2.1"
- record_use:
- dependency: transitive
- description:
- name: record_use
- sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2"
- url: "https://pub.dev"
- source: hosted
- version: "1.1.1"
- scene:
- dependency: transitive
- description:
- name: scene
- sha256: f14dd23ea3858041b644ec4824eca5cae817b42aca03c5ed2efcf1afa003d554
- url: "https://pub.dev"
- source: hosted
- version: "0.3.0"
- sky_engine:
- dependency: transitive
- description: flutter
- source: sdk
- version: "0.0.0"
- source_span:
- dependency: transitive
- description:
- name: source_span
- sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
- url: "https://pub.dev"
- source: hosted
- version: "1.10.2"
- stack_trace:
- dependency: transitive
- description:
- name: stack_trace
- sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
- url: "https://pub.dev"
- source: hosted
- version: "1.12.2"
- stream_channel:
- dependency: transitive
- description:
- name: stream_channel
- sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
- url: "https://pub.dev"
- source: hosted
- version: "2.1.4"
- string_scanner:
- dependency: transitive
- description:
- name: string_scanner
- sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
- url: "https://pub.dev"
- source: hosted
- version: "1.4.1"
- term_glyph:
- dependency: transitive
- description:
- name: term_glyph
- sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
- url: "https://pub.dev"
- source: hosted
- version: "1.2.2"
- test_api:
- dependency: transitive
- description:
- name: test_api
- sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
- url: "https://pub.dev"
- source: hosted
- version: "0.7.12"
- typed_data:
- dependency: transitive
- description:
- name: typed_data
- sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
- url: "https://pub.dev"
- source: hosted
- version: "1.4.0"
- vector_math:
- dependency: "direct main"
- description:
- name: vector_math
- sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
- url: "https://pub.dev"
- source: hosted
- version: "2.4.2"
- vm_service:
- dependency: transitive
- description:
- name: vm_service
- sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
- url: "https://pub.dev"
- source: hosted
- version: "15.3.0"
- web:
- dependency: transitive
- description:
- name: web
- sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
- url: "https://pub.dev"
- source: hosted
- version: "1.1.1"
- yaml:
- dependency: transitive
- description:
- name: yaml
- sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
- url: "https://pub.dev"
- source: hosted
- version: "3.1.4"
- yaml_edit:
- dependency: transitive
- description:
- name: yaml_edit
- sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488"
- url: "https://pub.dev"
- source: hosted
- version: "2.2.4"
-sdks:
- dart: ">=3.13.2 <4.0.0"
- flutter: ">=3.47.0"
diff --git a/sanctification-tcg/flutter-scene-spike/pubspec.yaml b/sanctification-tcg/flutter-scene-spike/pubspec.yaml
deleted file mode 100644
index b390178..0000000
--- a/sanctification-tcg/flutter-scene-spike/pubspec.yaml
+++ /dev/null
@@ -1,89 +0,0 @@
-name: flutter_scene_spike
-description: "A new Flutter project."
-# The following line prevents the package from being accidentally published to
-# pub.dev using `flutter pub publish`. This is preferred for private packages.
-publish_to: 'none' # Remove this line if you wish to publish to pub.dev
-
-# The following defines the version and build number for your application.
-# A version number is three numbers separated by dots, like 1.2.43
-# followed by an optional build number separated by a +.
-# Both the version and the builder number may be overridden in flutter
-# build by specifying --build-name and --build-number, respectively.
-# In Android, build-name is used as versionName while build-number used as versionCode.
-# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
-# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
-# Read more about iOS versioning at
-# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
-# In Windows, build-name is used as the major, minor, and patch parts
-# of the product and file versions while build-number is used as the build suffix.
-version: 1.0.0+1
-
-environment:
- sdk: ^3.13.2
-
-# Dependencies specify other packages that your package needs in order to work.
-# To automatically upgrade your package dependencies to the latest versions
-# consider running `flutter pub upgrade --major-versions`. Alternatively,
-# dependencies can be manually updated by changing the version numbers below to
-# the latest version available on pub.dev. To see which dependencies have newer
-# versions available, run `flutter pub outdated`.
-dependencies:
- flutter:
- sdk: flutter
-
- # The following adds the Cupertino Icons font to your application.
- # Use with the CupertinoIcons class for iOS style icons.
- cupertino_icons: ^1.0.8
- flutter_scene: ^0.23.0
- vector_math: ^2.4.2
- hooks: ^2.2.0
-
-dev_dependencies:
- flutter_test:
- sdk: flutter
-
- # The "flutter_lints" package below contains a set of recommended lints to
- # encourage good coding practices. The lint set provided by the package is
- # activated in the `analysis_options.yaml` file located at the root of your
- # package. See that file for information about deactivating specific lint
- # rules and activating additional ones.
- flutter_lints: ^6.0.0
-flutter:
-
- # The following line ensures that the Material Icons font is
- # included with your application, so that you can use the icons in
- # the material Icons class.
- assets:
- - flutter_scene_generated/
- uses-material-design: true
-
- # To add assets to your application, add an assets section, like this:
- # assets:
- # - images/a_dot_burr.jpeg
- # - images/a_dot_ham.jpeg
-
- # An image asset can refer to one or more resolution-specific "variants", see
- # https://flutter.dev/to/resolution-aware-images
-
- # For details regarding adding assets from package dependencies, see
- # https://flutter.dev/to/asset-from-package
-
- # To add custom fonts to your application, add a fonts section here,
- # in this "flutter" section. Each entry in this list should have a
- # "family" key with the font family name, and a "fonts" key with a
- # list giving the asset and other descriptors for the font. For
- # example:
- # fonts:
- # - family: Schyler
- # fonts:
- # - asset: fonts/Schyler-Regular.ttf
- # - asset: fonts/Schyler-Italic.ttf
- # style: italic
- # - family: Trajan Pro
- # fonts:
- # - asset: fonts/TrajanPro.ttf
- # - asset: fonts/TrajanPro_Bold.ttf
- # weight: 700
- #
- # For details regarding fonts from package dependencies,
- # see https://flutter.dev/to/font-from-package
diff --git a/sanctification-tcg/flutter-scene-spike/test/configuration_test.dart b/sanctification-tcg/flutter-scene-spike/test/configuration_test.dart
deleted file mode 100644
index 5e5df38..0000000
--- a/sanctification-tcg/flutter-scene-spike/test/configuration_test.dart
+++ /dev/null
@@ -1,8 +0,0 @@
-import 'package:flutter_test/flutter_test.dart';
-import 'package:flutter_scene_spike/main.dart';
-
-void main() {
- test('uses the expected spike label', () {
- expect(spikeTitle, 'FLUTTER SCENE CARD SPIKE');
- });
-}
diff --git a/sanctification-tcg/flutter-scene-spike/web/favicon.png b/sanctification-tcg/flutter-scene-spike/web/favicon.png
deleted file mode 100644
index 8aaa46a..0000000
Binary files a/sanctification-tcg/flutter-scene-spike/web/favicon.png and /dev/null differ
diff --git a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-192.png b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-192.png
deleted file mode 100644
index b749bfe..0000000
Binary files a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-192.png and /dev/null differ
diff --git a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-512.png b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-512.png
deleted file mode 100644
index 88cfd48..0000000
Binary files a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-512.png and /dev/null differ
diff --git a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-192.png b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-192.png
deleted file mode 100644
index eb9b4d7..0000000
Binary files a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-192.png and /dev/null differ
diff --git a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-512.png b/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-512.png
deleted file mode 100644
index d69c566..0000000
Binary files a/sanctification-tcg/flutter-scene-spike/web/icons/Icon-maskable-512.png and /dev/null differ
diff --git a/sanctification-tcg/flutter-scene-spike/web/index.html b/sanctification-tcg/flutter-scene-spike/web/index.html
deleted file mode 100644
index 563b907..0000000
--- a/sanctification-tcg/flutter-scene-spike/web/index.html
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- flutter_scene_spike
-
-
-
-
-
-
-
diff --git a/sanctification-tcg/flutter-scene-spike/web/manifest.json b/sanctification-tcg/flutter-scene-spike/web/manifest.json
deleted file mode 100644
index 03b3d43..0000000
--- a/sanctification-tcg/flutter-scene-spike/web/manifest.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "name": "flutter_scene_spike",
- "short_name": "flutter_scene_spike",
- "start_url": ".",
- "display": "standalone",
- "background_color": "#0175C2",
- "theme_color": "#0175C2",
- "description": "A new Flutter project.",
- "orientation": "portrait-primary",
- "prefer_related_applications": false,
- "icons": [
- {
- "src": "icons/Icon-192.png",
- "sizes": "192x192",
- "type": "image/png"
- },
- {
- "src": "icons/Icon-512.png",
- "sizes": "512x512",
- "type": "image/png"
- },
- {
- "src": "icons/Icon-maskable-192.png",
- "sizes": "192x192",
- "type": "image/png",
- "purpose": "maskable"
- },
- {
- "src": "icons/Icon-maskable-512.png",
- "sizes": "512x512",
- "type": "image/png",
- "purpose": "maskable"
- }
- ]
-}
diff --git a/sanctification-tcg/functional-spec-v2.md b/sanctification-tcg/functional-spec-v2.md
deleted file mode 100644
index 349b647..0000000
--- a/sanctification-tcg/functional-spec-v2.md
+++ /dev/null
@@ -1,1097 +0,0 @@
-# Christian Habit Tracker TCG - Functional Spec v2
-
-I think a good name for the application would still be **Sanctification TCG**; as the objective is to grow in Christ and move towards holiness.
-
-Habit trackers are super common place and incredibly overdone. I find the same issue with every single one of them - tracking your habits needs to be a habit itself. Many applications try to add a "carrot" but nothing actually seems to provide a good sense to really reel you back in.
-
-On the opposite front, bad habits also tend to just be a number or a streak. If you fail, the app resets the number and that is about it. I want this to feel more human than that and I especially do not want failure to become a reason to avoid opening the app again.
-
-One thing that has become hugely popular is TCGs - more importantly card openings. This idea that you can collect the cards, try to obtain rare instances, collect wide variations of the same card, trade with friends, and just enjoy the experience of opening something you earned.
-
-All these card games are typically built in their own universe - but I want to do it Christian themed.
-
-The collection system is not really secondary to the habit tracker. It is the thing that makes the habit tracker unique. If I just wanted to track habits I could use pen and paper or download one of the thousand other habit trackers that already exist. The cards are the hook that should make me want to come back and keep using it.
-
-Below breaks this down into the larger functional pieces of the project, the things I think are already decided, and the unknowns that still need to be explored.
-
----
-
-## Product Principles
-
-### Core Purpose
-
-Ultimately this is a tool to help build essential habits with God. The collecting, trading, opening animations, social aspects, etc. are all there to make that process fun enough that someone actually wants to keep coming back.
-
-I think four concepts are equally important here:
-
-* Completion - actually doing the habits you said you wanted to do.
-* Honesty - being truthful with yourself about whether you actually did them.
-* Consistency - building something sustainable over time rather than obsessing over a perfect streak.
-* Returning from failure - if you fall off for a day, week, month, or longer, the application should make you want to come back instead of making you feel like everything was lost.
-
-### Honor System
-
-How or whether someone actually is doing what they are tracking is between them and God - and with accountability partners if they choose to do that.
-
-What it means to complete a task is also between the user and God. If prayer for them means 5 minutes or 1 hour, that is fine. I think we should outline during onboarding that trying to say a 5 second prayer before entering the app is not really qualification. It is a matter of the heart - not trying to min/max the game.
-
-At the same time, the app should not try to grade the quality of someone's prayer, Bible reading, church attendance, etc.
-
-Five minutes of prayer should not give a worse pack than sixty minutes. A whole day of prayer should not give more rewards than an earnest five minutes. Habit completion is binary from the application's perspective.
-
-### Honesty vs. the Economy
-
-I don't think we should build this like an anti-cheat system.
-
-The rarity and economy should be modeled around the assumption that everyone earnestly completes every task and reaches the consistency targets available to them. That is basically the maximum expected supply.
-
-If people miss days or do not complete everything, the economy simply develops more slowly than that baseline.
-
-If someone lies and clicks every task anyway, that is technically just reaching the baseline we already modeled for. It is between them and God, and I do not want the app to turn into a verification system for prayer or Christian practice.
-
-Because of that, rewards should also never scale based on self-reported duration or difficulty. We should not create an incentive to claim that a five minute prayer was a two hour prayer because it gives better cards.
-
-### Privacy
-
-Habit tracking is private by default.
-
-This becomes especially important with negative habits because the user may be putting extremely sensitive information into the application.
-
-I want sensitive customer content to be encrypted in a way where the server cannot casually read it. Statistics can still exist where possible, but content such as a user's sins, struggles, custom habit names, notes, etc. should not just be plaintext sitting in the database for a server administrator to inspect.
-
-Exactly how we accomplish this is more of a technical-spec question. Client-side encryption and potentially some kind of BYOK design are worth exploring. The important functional requirement is that private content should be private even from us as much as reasonably possible.
-
-Accountability sharing should also be obscure by default. A user should be able to say something like:
-
-> Daniel needs your prayer today.
-
-without automatically telling the accountability partner exactly what happened.
-
-The user can explicitly choose to share more detail if they want to.
-
----
-
-## Christianity
-
-At the core foundation, what we consider Christianity for the purposes of this application is currently founded in the words and logic of the Nicene Creed.
-
-This is the standard **for now** and can be revisited later if needed.
-
-> We believe in one God the Father Almighty, Maker of heaven and earth, and of all things visible and invisible.
->
-> And in one Lord Jesus Christ, the only-begotten Son of God, begotten of the Father before all worlds, God of God, Light of Light, Very God of Very God, begotten, not made, being of one substance with the Father by whom all things were made; who for us men, and for our salvation, came down from heaven, and was incarnate by the Holy Spirit of the Virgin Mary, and was made man, and was crucified also for us under Pontius Pilate. He suffered and was buried, and the third day he rose again according to the Scriptures, and ascended into heaven, and sitteth on the right hand of the Father. And he shall come again with glory to judge both the quick and the dead, whose kingdom shall have no end.
->
-> And we believe in the Holy Spirit, the Lord and Giver of Life, who proceedeth from the Father, who with the Father and the Son together is worshiped and glorified, who spoke by the prophets. And we believe in one holy catholic and apostolic Church. We acknowledge one baptism for the remission of sins. And we look for the resurrection of the dead, and the life of the world to come. Amen.
-
-### Shared Core Plus Traditions
-
-Rather than treating Catholic, Orthodox, Protestant, etc. as completely separate decks, I think it makes more sense to have a shared Christian core and then tradition-specific collections around it.
-
-The shared core can contain things such as:
-
-* Scripture
-* Biblical people
-* Biblical places
-* Biblical events
-* Early church history
-* Major ecumenical councils
-* Core Christian doctrines
-* Things generally shared within Nicene Christianity
-
-Tradition-specific collections can then include:
-
-* Catholic
-* Orthodox
-* Protestant
-* Potentially more specific traditions later
-
-A user does **not** pick one tradition and get locked into it. A Catholic-specific card can be visible to and collected by an Orthodox or Protestant user, and vice versa.
-
-I want people to be able to collect every tradition because part of the point is also learning what other Christians actually believe and where traditions differ.
-
-Maybe the user can choose a UI/theme that matches their tradition at some point, but that would be visual and not limit the cards they can collect.
-
-### Controversies / Non-Nicene Movements
-
-I still want historical heresies and theological controversies represented because they are genuinely interesting and useful to learn about. I just don't think a generic "Heretic Deck" is the best structure.
-
-This can include things such as:
-
-* Arianism
-* Gnosticism
-* Pelagianism
-* Nestorian controversies
-* Mormonism / Latter-day Saint theology
-* Jehovah's Witnesses
-* Other non-Nicene or historically disputed movements
-
-The card should explain what the person/movement taught, why it became controversial, and how it differs from the Nicene standard we are using for the application.
-
-The card cannot contain all the information so we can also make sure to have a panel for more information or resource links to learn more about it. It can help explain the position, why it matters historically/theologically, and how it compares to the Nicene standard. They aren't insult cards.
-
-This should be educational rather than just putting a big "HERETIC" stamp on people and calling it a day.
-
-### The Trinity Card
-
-I no longer think The Father, The Son, and The Holy Spirit should be the highest collectible rarity tier.
-
-I do still really like the idea of the Trinity having a unique place in the application though.
-
-One idea is that during the tutorial - or immediately after skipping the tutorial - every user is given **The Trinity** as their first and permanent card.
-
-This card would be different from every other card in the system:
-
-* It is given, not earned.
-* It cannot be traded.
-* It cannot be destroyed.
-* It is always considered mint condition.
-* The user can customize the finish/material presentation however they like without affecting rarity or the economy.
-* It does not participate in normal rarity or population mechanics.
-
-The symbolism I like here is that the most important thing in the collection is something you did not grind for or earn. It was given to you and you cannot lose it.
-
-The exact theological messaging obviously needs some thought because traditions differ on some of the implications there, but I think the overall meaning is good.
-
-**Initial verse thought:** John 3:16. It seems like a pretty decent fit for the idea of something given rather than earned.
-
-The artwork for this card also needs special consideration. I do not want us to accidentally make some weird literal depiction of the Father / Son / Holy Spirit just because we need card art.
-
----
-
-## Core Application Loop
-
-The rough loop is:
-
-1. Open the application.
-2. See today's habits and current pack state.
-3. Complete and report habits honestly.
-4. Improve the day's reward / pack.
-5. Open it now or save it for later.
-6. Collect cards and variants.
-7. Inspect, organize, learn from, and eventually trade cards.
-8. Come back tomorrow.
-
-The exact pack reward model still needs to be worked through.
-
----
-
-## Habit Tracking
-
-Ultimately this is still a habit tracker at its base.
-
-### Positive Habit Tracking
-
-I think initially we will start with a couple of base good habits to build on:
-
-* Prayer
-* Bible reading
-* Weekly church attendance
-* Study - something like a devotional, a chapter of Christian literature, digging into theology, etc.
-* Volunteering ?* - I am still not sure how to track this cadence. It doesn't feel like a daily thing but it is also obviously a category that builds Christian values.
-
-Custom habits can come later or be included early depending on how much work it ends up being.
-
-You can log up to the day prior and still receive whatever reward that day qualified for.
-
-You can always log older days/weeks of progress toward habits for your own records and consistency statistics, but they should not generate retroactive packs indefinitely.
-
-### Consistency Instead of Streaks
-
-I don't think positive habits should revolve around streaks anymore.
-
-A streak makes one missed day disproportionately destructive. Going from 93 days to "0" is a terrible way to represent someone who completed something 93 out of the last 94 days.
-
-Consistency is a much stronger metric.
-
-We can track things such as:
-
-* 7-day consistency
-* 30-day consistency
-* 90-day consistency
-* Lifetime completion rate
-* Total completions
-* Returning after time away
-
-The exact windows and which ones affect rewards are still subject to change.
-
-This also gives us more interesting achievement opportunities without making the user feel like one missed Tuesday erased three months of work.
-
-### Negative Habit Tracking
-
-I want there to be an option to track bad habits that someone may have. These can be custom input although we can provide some baseline categories/examples like lust, addiction, smoking, drinking, anger, etc.
-
-This tracks separately from the positive habits the user is trying to build.
-
-For negative habits, I think there are two useful measurements:
-
-#### Abstinence
-
-How long has it been since the behavior occurred?
-
-This can still naturally be represented as a streak because the elapsed time itself is meaningful here.
-
-#### Honesty / Engagement
-
-Is the user continuing to truthfully track, return, and work on the problem?
-
-This should **not** reset when someone fails.
-
-If someone falls after 100 days and reports it honestly, we should not turn that into:
-
-> You failed. Everything is gone. Start over.
-
-The abstinence count resets because that is simply factual, but the user's larger journey does not.
-
-### Recovery Milestones
-
-After a fall, I like the idea of recovery milestones:
-
-* First day back
-* Three days back
-* One week back
-* Other meaningful points we decide on
-
-Every milestone should be celebrated, but **not with card packs or rarity rewards**. I do not want to accidentally create some weird side incentive where failure becomes part of an optimal reward strategy.
-
-The celebration can be messaging, visual acknowledgement, Scripture, encouragement, etc.
-
-### Failure Messaging
-
-Every fall needs to be met with grace and good messaging.
-
-The experience I want the morning after someone fails badly is basically:
-
-> We are glad you're back. You are still loved. Christ died for our sins. Repent, get back up, and keep moving forward. Every journey begins again with the first step.
-
-The exact wording will need theological/content review later, but that is the tone.
-
-The application should never make the user feel like they should avoid opening it because they are ashamed of what happened.
-
-### Voluntary Card Burning
-
-I still like the visual and ceremonial idea of burning a card. I just don't want it attached to mandatory punishment for honestly reporting a failure.
-
-Maybe card burning becomes voluntary.
-
-Possible uses:
-
-* A user voluntarily burns a card as part of some personal ceremony / reset.
-* Burning duplicates becomes part of the card economy.
-* Cards can be consumed in future crafting/trade-up systems.
-
-The exact implementation is still open.
-
----
-
-## TCG / Collection
-
-The whole premise of the "game" is collection. This is the carrot that makes the habits more interesting and hopefully gives the application a real community around it.
-
-There is no actual card combat system planned. The fun is collecting, opening, inspecting, organizing, trading, learning, and hunting for rare variants.
-
-### Collection Completion
-
-I still like the general idea that collecting one of every base card by yourself should take something like at least a year of very consistent usage.
-
-That number is very subject to change once we actually model the economy and start testing it.
-
-If someone completes the full base compendium, regardless of finish/material/etc., I think some kind of prestige system could be cool.
-
-Maybe it decorates the profile or binder rather than just resetting everything.
-
-### Binders
-
-Everyone gets a binder that holds their entire compendium that they can look through.
-
-Other people can look through a user's public collection/binder if that user allows it.
-
-Later we can have different binder designs and different ways to organize cards. This would let someone make dedicated collections such as:
-
-* All metal cards
-* All holographic cards
-* All foil cards
-* All Catholic cards
-* All church-history cards
-* Whatever other collection they want to show off
-
-I previously mentioned microtransactions here, but there is currently no monetization plan. If this somehow becomes large enough that monetization matters, cosmetic binder/profile customization could be revisited later.
-
-### Packs
-
-I still need to deliberate on exactly how packs are awarded, how many cards each pack contains, and all of the statistics behind them.
-
-#### Working Daily-Pack Idea
-
-I still like having some kind of login pack because it establishes the initial hook.
-
-The user opens the app and sees the pack, which can immediately remind them:
-
-> Oh right. I need to actually accomplish my tasks today.
-
-One idea I like more than simply giving one pack per task is that the daily pack **upgrades as tasks are completed**.
-
-For example:
-
-* Login / start of day - very weak pack or only a couple cards.
-* Complete one task - improve the pack.
-* Complete additional tasks - improve odds, card count, or pack quality.
-* Complete everything for the day - reach that day's maximum pack state.
-
-This keeps the login hook without making simply opening the application equivalent to actually doing the habits.
-
-I am not sure yet whether task completion should:
-
-* Improve rarity odds.
-* Increase the number of cards in the pack.
-* Improve expected wear / quality.
-* Upgrade the pack type itself.
-* Use some combination of the above.
-
-I particularly like the idea that the initial login pack might only have something like two cards and completing tasks adds additional cards to it. That may feel less casino-like than constantly manipulating rarity odds, but we need to test it.
-
-#### Saving Packs
-
-Users can choose to save earned packs and open them later.
-
-Some people may want to open every day. Other people may want to save ten or twenty packs and have one larger opening session.
-
-Once a day's pack is finalized, saving it should not continue to improve it later unless we intentionally design a mechanic around that.
-
-#### Pack Odds
-
-Pack probabilities should initially be globally fixed.
-
-We should still wire the probability variables in a way where they can be adjusted later if testing shows that the economy is developing too quickly or too slowly.
-
-I do **not** want probabilities dynamically changing per user behind the scenes just to manipulate engagement.
-
-#### Pack Types
-
-I still think different types of packs could be fun, with different expected card qualities or presentation.
-
-Initial placeholder ideas:
-
-* Common / normal
-* Gold
-* Illuminescent
-
-Names and exact meaning are still TBD.
-
-Different packs can have different opening animations.
-
-### Pack Opening Experience
-
-Opening cards is one of the most important parts of the entire application, so I think this is an area where we can intentionally be a little excessive.
-
-Possible opening modes:
-
-* Cards in a row, clicking each one to flip.
-* Cards stacked front-to-back and revealed one at a time.
-* Auto-open / open all for when the user does not care about the full animation.
-
-Rarity can affect anticipation without going full casino.
-
-For example, higher-rarity cards can have different pacing, lighting, sound, or reveal animation, but I do not want the application screaming flashing slot-machine effects at people.
-
-The reveal should feel special because the card is special, not because we are trying to mimic gambling psychology.
-
-Pack-opening presentation should be implemented by the active runtime renderer rather than depending on pre-rendered videos or Blender-authored animation clips. The sequence needs to respond to the cards actually awarded, support user interaction, scale across device capabilities, and remain skippable.
-
-The wrapper can be generated from a subdivided procedural mesh with controlled seams, tear paths, peel zones, and spring- or cloth-like deformation. Wrinkles, metallic reflections, particles, lighting, and sound can provide additional realism. A controlled and deterministic simulation is preferable to unrestricted cloth physics because it produces repeatable results, is easier to synchronize, and can degrade gracefully on lower-power devices.
-
-Blender is not required even if we pursue convincing wrapper tearing or crumpling. Runtime physics libraries, renderer extensions, skeletal deformation, morph targets, or custom shader deformation may be used where appropriate. Blender or another digital content creation tool remains optional for visual exploration or unusually complex assets.
-
-### Rarity
-
-I still want different tiers, although I want to find better Biblical/thematic names for them eventually.
-
-Placeholder tiers:
-
-* Common
-* Uncommon
-* Rare
-* Extraordinary
-* Legendary
-
-Rarity should represent **prominence within the collection**, not spiritual worth, holiness, or importance to God.
-
-I also really like the idea that different sets can have independent rarity structures.
-
-For example, rarity within a Biblical People set does not necessarily need to mean the exact same thing as rarity within a Church History set or a Catholic collection.
-
-This needs more work when we start building the actual card catalog.
-
-### What Makes a Card Unique
-
-At a high level, I think a card instance is made up of things such as:
-
-* Card Identity
-* Artwork
-* Rarity
-* Finish
-* Printing
-* Material
-* Wear
-* Potential imperfections
-* Provenance / who originally opened it and when
-
-There should still be a clean distinction between the base **card definition** and an individual **card instance**.
-
-For example:
-
-```text
-Card Definition
- David - Card 001
- Artwork A
- Legendary
- Biblical People Set
-
-Card Instance
- David - Card 001
- Holographic
- Borderless
- Metal
- 97.3% condition
- Opened by Daniel
- Opened on
-```
-
-That general contract should be renderer-independent. The database should not care whether Three.js, Flutter Scene, or some future engine is displaying the card.
-
-### Finish
-
-Possible finishes:
-
-* Matte
-* Glossy
-* Textured
-* Holographic
-* Foil
-* Potentially multiple kinds of holographic/foil patterns later
-
-Different finishes should be visually obvious when interacting with the card. A holographic card should not just have a little "Holographic" tag underneath it.
-
-### Printing
-
-Placeholder printing types:
-
-* Normal
-* Borderless
-* Textless
-* Boundless
-
-These can materially alter the card artwork/layout rather than just being metadata.
-
-### Material
-
-Possible materials:
-
-* Paper
-* Plastic
-* Wood
-* Linen
-* Metal
-
-Material should affect the way the card looks and reacts to light.
-
-### Wear / Condition
-
-I still like every normal card instance having a wear/condition value between `0.00` and `1.00`, but I am reconsidering what the low end actually looks like.
-
-`1.00` would be essentially pristine.
-
-`0.00` does **not** need to mean the card looks like it went through a washing machine. It could still be clearly readable and attractive, just visibly scratched / worn / damaged compared to a mint card.
-
-We can expose condition to users as percentages and named bands.
-
-Very rough example:
-
-* Mint: `1.00 - 0.95`
-* Minimal Wear: `0.95 - 0.75`
-* Additional bands: TBD
-
-The actual ranges need to be modeled and tested rather than guessed here.
-
-The wear value should affect visible rendering where practical:
-
-* Edge whitening
-* Surface scratches
-* Small scuffs
-* Roughness changes
-* Other subtle damage
-
-Even poor-condition cards should still look like something someone wants to collect.
-
-### Weight
-
-I still like the completely unnecessary physical-card idea that cards have simulated weight.
-
-Cards might have a baseline around 100g with slight variance, while material/finish can add or subtract minor amounts.
-
-Eventually there could be an achievement or unlockable digital scale that lets the user weigh an unopened pack and try to infer what might be inside.
-
-This is very much a future fun feature and not core functionality.
-
-### Imperfections
-
-This is still a maybe.
-
-We could generate some number of masks for things like:
-
-* Fingerprints
-* Hair
-* Minor print artifacts
-* Surface marks
-
-Then an individual card instance could have a very small chance of receiving one based on a deterministic seed.
-
-This would make otherwise-identical variants slightly more interesting.
-
-### Population / Provenance
-
-Cards should retain metadata about when they were opened and who originally opened them.
-
-I also like tracking population for meaningful combinations such as:
-
-```text
-Card ID + Finish + Printing + Material
-```
-
-That way someone can see that their metallic holographic borderless Adam is 1 of 1, while another combination may already have 1,000 copies.
-
-I don't think wear, weight, and individual imperfections should count toward this population grouping because then effectively everything becomes a fake 1-of-1.
-
-### Card Economy / Destruction
-
-This is one area I want to work on more specifically.
-
-One mechanic that might be useful is some kind of trade-up / crafting system similar in spirit to Counter-Strike trade-ups.
-
-For example:
-
-> Consume 20 Common cards to generate 1 Uncommon card.
-
-The exact number and output obviously need to be modeled.
-
-I like this because it gives duplicates a use while also permanently removing cards from circulation.
-
-Questions we need to answer later:
-
-* Is the output guaranteed to be the next rarity?
-* Does finish/material influence the result?
-* Can users choose which set the resulting card comes from?
-* Are some cards protected from trade-ups?
-* Does a burned/traded-up card remain in provenance history as destroyed?
-
-I think actual destruction can become an important sink in the economy, but it should mostly be voluntary rather than punishment.
-
-### Content
-
-I want to eventually have a very large repository of cards.
-
-Possible categories include:
-
-* People
-* History
-* Icons
-* Items
-* Places
-* Events
-* Theologians
-* Theology
-* Apologetics / arguments
-* Councils
-* Traditions
-* Controversies
-* Probably many more once we actually start cataloging things
-
-Unless it is a specific printing such as textless, every card should have meaningful text associated with it.
-
-Priority for card text:
-
-1. Scripture where directly relevant.
-2. Quote where directly relevant.
-3. Historical / theological fact.
-
-There should also be a way to click through and learn more or understand why the text/artwork is associated with that card.
-
-The educational side is important. Pulling something unfamiliar should be an invitation to learn what it is rather than just seeing a rarity number.
-
-### Art Style
-
-I still have not decided how I want the cards themselves to look.
-
-This is something where I will probably lean heavily on LLM/image-generation tooling and MCP servers to help explore art direction since I am a software engineer, not an artist or 3D modeler.
-
-I think we should generate a bunch of visual directions before committing to one common design language.
-
-Tradition/set flavor can affect things such as:
-
-* Colors
-* Borders
-* Typography
-* Card backs
-* Ornamentation
-* Maybe 3D material treatment
-
----
-
-## Visual Experience
-
-The visual experience is not just decoration for this application. Opening and physically inspecting the collectible is part of the reward.
-
-### Card Interaction
-
-Cards should be represented as tactile physical objects rather than static collectible images wherever practical.
-
-When inspecting a card, the user must be able to:
-
-* Rotate the card freely.
-* Flip between front and back.
-* Zoom in to inspect artwork and physical characteristics.
-* Observe material properties responding dynamically to lighting.
-* Observe wear and imperfections where applicable.
-* Return quickly to the originating collection/binder context.
-
-Card finish and material must have visually distinguishable properties. For example, foil, holographic, paper, linen, and metal cards should respond differently to lighting.
-
-Lower-power devices must be able to fall back to simplified visual representations without affecting gameplay.
-
-### Where 3D Is Used
-
-I do **not** think every card in every screen should be an actively rendered 3D object.
-
-The primary 3D use cases should be:
-
-* Pack openings
-* Full card inspection
-* Potentially special collection/showcase views later
-
-Normal binder pages, search, trading lists, etc. can use static or pre-rendered thumbnails until the user chooses to inspect a card.
-
-This should keep the application responsive while still making the moments that matter visually interesting.
-
-### Visual Properties Should Matter
-
-If we add a collectible property, the user should ideally be able to see or interact with it somehow.
-
-For example:
-
-```text
-Finish -> visible
-Material -> visible
-Wear -> visible
-Imperfections -> visible
-Printing -> visible
-Weight -> indirectly observable
-Provenance -> inspectable metadata
-Population -> inspectable metadata
-```
-
-Otherwise there is not much point in storing increasingly complicated metadata that never changes the actual experience.
-
----
-
-## Social Interaction
-
-The other important aspect of card collections is trading and having some kind of community around them.
-
-Users can have friends and accountability partners. Those are intentionally different concepts because someone may want to trade/show collections to a lot of people while keeping accountability very private.
-
-### Initial Social Functionality
-
-Things I think would be great:
-
-* View another user's public compendium / binder.
-* Offer trades.
-* Add friends.
-* Add explicit accountability partners.
-* Send predetermined messages of encouragement.
-* Send Scripture / verses.
-* Send generic prayer requests without revealing why.
-
-### No General Chat
-
-I do not want general chat integration.
-
-That creates a massive moderation problem and can very quickly turn into the application becoming a social network instead of the thing I actually want to build.
-
-Predetermined messages and controlled interactions should cover most of what is useful here without adding an entire moderation product.
-
-### Accountability
-
-All habit tracking is private unless explicitly opened to accountability partners.
-
-Even then, the default shared state should be obscure rather than detailed.
-
-For example:
-
-> Daniel asked for prayer today.
-
-rather than:
-
-> Daniel failed Habit X at 9:42 PM.
-
-The user can choose to share exact habits/details with a specific accountability partner if they want to.
-
-This avoids both hubris and shame - neither of those is the point of the project.
-
----
-
-## Monetization
-
-I genuinely have no real plan to make money off this right now.
-
-My current plan is to build it, use it myself, and try it with friends.
-
-If it somehow blows up in popularity, then infrastructure costs and monetization can be figured out later.
-
-One thing I do want to make an explicit product principle now:
-
-**Absolutely no paid loot boxes or buying better pack odds.**
-
-Gambling already ruins lives and building that psychology into an application that is supposed to help people grow in Christ would be anathema to what I am trying to accomplish.
-
-If monetization ever becomes necessary, things like purely cosmetic binder designs, profile customization, or other non-random features can be considered separately.
-
-Money should not buy spiritual-habit rewards or manipulate rarity odds.
-
----
-
-## Integrations
-
-I still think API and webhook support would be useful eventually so the application can integrate into other things.
-
-Examples:
-
-* Fluxer bot
-* Discord bot
-* Other automation / personal tooling
-
-Exactly what gets exposed and when is a technical/API-spec question.
-
-This is not a priority for the first MVP.
-
----
-
-## MVP
-
-There is not really a formal deliverable or deadline right now. This is a project I want to build because the product is interesting and some of the technical problems are fun.
-
-Since I am a software engineer and not an artist or 3D modeler, I expect to lean on LLMs, MCP servers, image generation, and other tooling to help with those parts.
-
-I think the MVP should intentionally focus on the difficult / interesting pieces rather than spending months filling out content.
-
-### Initial MVP Scope
-
-Something like:
-
-* Web client first.
-* Works on desktop and mobile browsers.
-* Basic account/authentication.
-* Positive habit tracking.
-* Negative habit tracking with abstinence + honesty/engagement concepts.
-* Consistency metrics instead of positive-habit streaks.
-* Daily pack/reward prototype.
-* Ability to save packs.
-* Around 50 real cards.
-* Multiple rarity levels.
-* Multiple finishes/materials/printings.
-* Wear rendering.
-* Binder / compendium.
-* Full 3D card inspection.
-* 3D pack-opening flow.
-* Basic population/provenance model.
-* Enough economy simulation to begin testing rarity math.
-
-I would rather have **50 cards where all of the difficult rendering/material/variant systems actually work** than 500 cards that are basically static images.
-
-The rendering canvas, shaders/materials, and card pipeline are some of the more interesting technical challenges to me.
-
-Wiring up a backend to a client, normal CRUD, auth, API calls, etc. are much more familiar problems and can be fleshed out separately in the technical spec.
-
-### Not Required for the First MVP
-
-Likely later:
-
-* Native Android/iOS applications.
-* Full trading economy.
-* Advanced social functionality.
-* API/webhook ecosystem.
-* Huge card catalog.
-* Digital pack scale / weight mechanics.
-* Deep achievement/prestige systems.
-* Monetization.
-
-### MVP art-direction rules
-
-Seen in [Art Direction](./art-direction.md)
-
-### Sample MVP Collection
-
-#### Permanent starter
-
-| ID | Card | Type | Notes |
-| ----- | --------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| S-001 | **The Trinity** | Foundation | Permanent, nontradeable, cannot be burned or consumed, always mint. User can choose/customize finish/material/printing. Suggested verse: Matthew 28:19. |
-
-
-#### Others
-
-| # | Card | Category / Set | Working Rarity | Content / Visual Hook |
-| --: | --------------------------------- | --------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------- |
-| 001 | **Adam** | Scripture · Person | Rare | Genesis 2–3. Excellent portrait/environment card; Eden imagery. |
-| 002 | **Eve** | Scripture · Person | Rare | Genesis 2–3. Companion visually to Adam without making them a paired requirement. |
-| 003 | **Noah** | Scripture · Person | Uncommon | Genesis 6–9. Rain, ark, rainbow imagery. |
-| 004 | **Abraham** | Scripture · Person | Extraordinary | Genesis 12–22. Stars/sky imagery could make an excellent foil. |
-| 005 | **Sarah** | Scripture · Person | Uncommon | Genesis 18, 21. |
-| 006 | **Moses** | Scripture · Person | Legendary | Burning bush / Sinai gives us several strong artwork directions. |
-| 007 | **Ruth** | Scripture · Person | Common | Book of Ruth. Wheat-field imagery; intentionally beautiful despite Common rarity. |
-| 008 | **David** | Scripture · Person | Legendary | Shepherd/king imagery. Could support multiple artwork printings eventually. |
-| 009 | **Elijah** | Scripture · Person | Rare | 1 Kings 17–19. Fire-heavy card effects would be useful renderer testing. |
-| 010 | **Isaiah** | Scripture · Person | Rare | Isaiah 6 / prophetic imagery. |
-| 011 | **Jonah** | Scripture · Person | Common | Jonah. Strong visual identity even at Common. |
-| 012 | **Mary** | Scripture · Person | Extraordinary | Luke 1–2. Could eventually have tradition-specific artwork variants. |
-| 013 | **Joseph of Nazareth** | Scripture · Person | Uncommon | Matthew 1–2. |
-| 014 | **John the Baptist** | Scripture · Person | Rare | Matthew 3 / John 1. Water/wilderness imagery. |
-| 015 | **Peter** | Scripture · Person | Legendary | Gospels / Acts. Keys could appear in some tradition-specific printings without defining the base card around them. |
-| 016 | **Paul** | Scripture · Person | Legendary | Acts 9 + epistles. |
-| 017 | **Mary Magdalene** | Scripture · Person | Rare | John 20. Resurrection witness gives the card a clear textual focus. |
-| 018 | **Stephen** | Scripture · Person | Common | Acts 6–7. |
-| 019 | **Timothy** | Scripture · Person | Common | Acts 16 / Timothy. |
-| 020 | **Gabriel** | Scripture · Angel | Rare | Luke 1. Visually lets us test a non-human person card. |
-| 021 | **Creation** | Scripture · Event | Extraordinary | Genesis 1. Very different composition from portrait cards. |
-| 022 | **The Flood** | Scripture · Event | Uncommon | Genesis 6–9. Water/rain shader experimentation. |
-| 023 | **The Exodus** | Scripture · Event | Extraordinary | Exodus 12–14. Sea/fire/cloud imagery. |
-| 024 | **The Ten Commandments** | Scripture · Event | Rare | Exodus 20. Stone/tablet materials make this particularly useful visually. |
-| 025 | **The Nativity** | Scripture · Event | Extraordinary | Luke 2. |
-| 026 | **The Baptism of Jesus** | Scripture · Event | Rare | Matthew 3. |
-| 027 | **The Last Supper** | Scripture · Event | Rare | Luke 22 / 1 Corinthians 11. |
-| 028 | **The Crucifixion** | Scripture · Event | Legendary | Central subject, but rarity represents prominence in this collection rather than "holiness." |
-| 029 | **The Resurrection** | Scripture · Event | Legendary | Excellent candidate for one of the MVP's most visually elaborate cards. |
-| 030 | **Pentecost** | Scripture · Event | Extraordinary | Acts 2. Fire/light effects. |
-| 031 | **The Ark of the Covenant** | Scripture · Item | Rare | Exodus 25. Excellent first metal/gold-material showcase. |
-| 032 | **Bethlehem** | Scripture · Place | Common | Micah 5:2 / Luke 2. |
-| 033 | **Jerusalem** | Scripture · Place | Rare | Lets us establish how location cards differ visually from person/event cards. |
-| 034 | **The Sea of Galilee** | Scripture · Place | Common | Strong environmental artwork; water reflection testing. |
-| 035 | **The Empty Tomb** | Scripture · Place | Extraordinary | Resurrection-associated without duplicating the Resurrection event card. |
-| 036 | **The Nicene Creed** | Core · Doctrine/History | Legendary | Basically the thesis statement for the app's doctrinal baseline. |
-| 037 | **The Council of Nicaea** | Church History · Event | Extraordinary | AD 325. Also introduces historical-event cards outside Scripture. |
-| 038 | **Athanasius of Alexandria** | Church History · Theologian | Rare | Natural connection to Nicaea and the Incarnation. |
-| 039 | **The Incarnation** | Core · Theology | Extraordinary | John 1:14 provides a strong scriptural anchor. |
-| 040 | **The Resurrection of the Dead** | Core · Theology | Uncommon | 1 Corinthians 15 / Nicene Creed. |
-| 041 | **The Great Commission** | Core · Teaching | Uncommon | Matthew 28:18–20. |
-| 042 | **The Lord's Prayer** | Core · Teaching | Common | Matthew 6:9–13. Good example of a Common card that everyone should still want. |
-| 043 | **The Sermon on the Mount** | Core · Teaching | Uncommon | Matthew 5–7. |
-| 044 | **The Rosary** | Catholic · Item/Practice | Uncommon | Tests explicitly tradition-specific educational material. |
-| 045 | **Christ Pantocrator** | Orthodox · Icon | Rare | Gives the MVP an actual icon card and a very different visual format. |
-| 046 | **Martin Luther** | Protestant · Theologian | Rare | Reformation history; explicitly Protestant-tagged while collectible by everyone. |
-| 047 | **The Book of Common Prayer** | Protestant · Item/History | Uncommon | Anglican-specific, which also starts proving that "Protestant" doesn't need to be treated as one monolithic tradition. |
-| 048 | **Arianism** | Controversies · Theology | Uncommon | Explain what Arius taught, why Nicaea responded, and what the Nicene position is. |
-| 049 | **The Latter-day Saint Movement** | Controversies · Movement | Uncommon | Educational treatment; explain where its doctrine of God differs from the app's Nicene standard. |
-| 050 | **Jehovah's Witnesses** | Controversies · Movement | Uncommon | Same approach: describe rather than mock, then clearly compare with the Nicene baseline. |
-
-
-I'd choose maybe 10 "hero cards" and deliberately make them exercise the most difficult rendering features:
-
-| Hero card | Rendering experiment |
-| --------------------- | ---------------------------------------- |
-| Moses | emissive/fire |
-| Elijah | foil + fire |
-| Creation | borderless + large environmental artwork |
-| Exodus | animated/specular water |
-| Resurrection | premium holographic treatment |
-| Pentecost | emissive/fire |
-| Ark of the Covenant | metallic gold |
-| Nicene Creed | text-heavy card |
-| Christ Pantocrator | textured/icon treatment |
-| Book of Common Prayer | linen/paper/embossing |
-
-
----
-
-## Initial Technical Direction
-
-The functional requirements should stay mostly engine-independent, but the way the cards look and behave is important enough that we need to consider rendering architecture early.
-
-### High-Level Architecture
-
-The web client is the MVP because it gets us something usable on desktop and mobile devices immediately.
-
-Native Android/iOS are still the north star if the project proves worth continuing.
-
-```text
- Backend / API
- |
- Shared Card Model
- |
- +----------+----------+
- | |
- Web Client Flutter Client
- (MVP) (Android / iOS)
- | |
- +------+-------+ +------+-------+
- | | | |
- Normal UI 3D Renderer Flutter UI 3D Renderer
-```
-
-The important part is that the **shared card model and backend contracts are renderer-independent**.
-
-### Card Rendering Contract
-
-The general idea is that the renderer receives a contract describing what the card is rather than the database storing engine-specific details.
-
-Very rough example:
-
-```json
-{
- "cardId": "david-001",
- "artworkId": "david-a",
- "finish": "holographic",
- "material": "metal",
- "printing": "borderless",
- "wear": 0.973,
- "imperfectionSeed": 81251
-}
-```
-
-Then the active renderer interprets that contract.
-
-That gives us room to use different renderers on different clients without touching the card economy or rewriting the backend model.
-
-### Asset Pipeline
-
-Current direction:
-
-```text
-Card definition + artwork + masks
- |
- v
- Shared visual specification
- (dimensions, radius, thickness, UVs)
- |
- +-------+-------+
- | |
- v v
- Three.js renderer Native renderer
-```
-
-The runtime renderer should own the standard card geometry, materials, lighting, wear, inspection interactions, card flips, and pack-opening animation. Standard card geometry is simple and parameterized enough to generate directly from the shared visual specification rather than requiring a `.blend` file or GLB export.
-
-Artwork, masks, card metadata, and finish/material selections should remain engine-independent inputs. Each renderer interprets those inputs using its own runtime materials and shaders. Holographic, foil, substrate, embossing, wear, and other view-dependent effects must therefore have explicit runtime implementations rather than relying on Blender material-node export.
-
-Blender is not a production dependency for cards or pack openings. It can remain an optional tool for visual references, promotional renders, material experiments, or future bespoke 3D assets that are not practical to construct procedurally. If such an asset is introduced, it may be exported through glTF/GLB without changing the standard runtime-owned card pipeline.
-
-### Renderer Technical Spike - Next Step
-
-The next major technical question should be a renderer spike comparing:
-
-#### Three.js
-
-Likely strongest candidate for the web MVP and probably via React Three Fiber if the web application is React-based.
-
-Things to test:
-
-* Holographic materials
-* Foil
-* Metal
-* Wear masks
-* Lighting
-* Rotation/zoom/touch controls
-* Pack-opening animation
-* Controlled wrapper tearing, peeling, and crumpling
-* Mobile browser performance
-
-#### Flutter Scene / Flutter GPU
-
-Worth testing because native Android/iOS are the eventual goal and a good result here could let us build a native renderer without embedding the web renderer forever.
-
-Things to test should be the same so we are comparing real output rather than toy demos.
-
-#### Prototype the Hard Card
-
-Instead of testing with a plain paper Common card, the technical spike should deliberately build something absurd:
-
-> Legendary + borderless + metal + holographic + embossed + visible wear
-
-If both renderers can handle the hardest card nicely, normal cards should be straightforward.
-
-The spike should compare:
-
-* Visual fidelity
-* Shader/material flexibility
-* Performance on desktop
-* Performance on mobile
-* Touch interaction quality
-* Developer ergonomics
-* Asset pipeline complexity
-* How much renderer code can realistically be shared between web and future native clients
-
-We can decide the rendering engine after that rather than locking ourselves in beforehand.
-
----
-
-## Open Questions / Areas to Workshop
-
-### Pack Economy
-
-* How many cards are in the initial login pack?
-* Does each completed task add cards, improve rarity, improve condition, upgrade the pack, or some mixture?
-* How large should the difference be between an untouched login pack and a fully upgraded daily pack?
-* How do weekly habits such as church attendance affect the pack?
-* Are there separate weekly/monthly packs even though we are moving away from streaks?
-* How quickly should one person be able to complete the base compendium?
-
-### Rarity / Trade-Ups
-
-* Exact rarity probabilities.
-* Exact expected supply assuming perfect habit completion.
-* How set-specific rarity works.
-* How many lower-rarity cards are required for a trade-up.
-* Whether finish/material/printing affect trade-up outputs.
-* What happens to destroyed-card provenance.
-
-### Wear
-
-* Exact condition bands.
-* Lowest acceptable visual condition.
-* Whether higher rarity cards have condition floors.
-* How heavily wear should affect collectability/value.
-
-### Trinity Card
-
-* Final name.
-* John 3:16 or another verse.
-* Artwork that communicates the idea without bad or irreverent depiction.
-* How customizable finishes/materials should look.
-
-### Christian Content
-
-* Who ultimately reviews doctrinal/content accuracy?
-* How deep tradition-specific collections go.
-* Exact taxonomy for controversies / non-Nicene movements.
-* How citations/sources are stored and displayed on cards.
-
-### Rendering
-
-* Three.js vs Flutter Scene technical spike.
-* Exact web framework.
-* Shader/material design.
-* Runtime asset-generation workflow and optional digital content creation tooling.
-* Static thumbnail generation from the same card contract.
-
-### Social / Trading
-
-* When trading belongs in the roadmap.
-* Whether population numbers are public by default.
-* How offers work.
-* Whether destroyed cards remain visible in historical collection records.
-* What parts of a user's binder/profile are private versus public.
-
-### Privacy
-
-* Exact client-side encryption model.
-* Whether BYOK is practical for normal users.
-* Account recovery when encryption keys are lost.
-* What anonymous/statistical data can be collected without weakening the privacy model.
-* Exact accountability-sharing permissions.
diff --git a/sanctification-tcg/legendary.png b/sanctification-tcg/legendary.png
deleted file mode 100644
index 9eb7cd0..0000000
Binary files a/sanctification-tcg/legendary.png and /dev/null differ