Compare commits
18 Commits
main
...
M1-dev-rev4
| Author | SHA1 | Date | |
|---|---|---|---|
| 8749b07611 | |||
| 2d5ff95441 | |||
| 2bc7eecd7c | |||
| f00c4a7460 | |||
| 0d06e6f7b7 | |||
| a83c9239ff | |||
| 88b9c47200 | |||
| eca306e869 | |||
| 509e2e82a3 | |||
| 25b1650a8a | |||
| b124450d0c | |||
| 507865e30b | |||
| 0284a2211a | |||
| 7e2a1845cf | |||
| dd011207a0 | |||
| 93666320ef | |||
| ba8e5f72f6 | |||
| 92d1a7ec5e |
@@ -0,0 +1,454 @@
|
||||
|
||||
// this file is generated — do not edit it
|
||||
|
||||
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
|
||||
/**
|
||||
* This module provides access to environment variables that are injected _statically_ into your bundle at build time and are limited to _private_ access.
|
||||
*
|
||||
* | | Runtime | Build time |
|
||||
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
||||
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
||||
*
|
||||
* Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
|
||||
*
|
||||
* **_Private_ access:**
|
||||
*
|
||||
* - This module cannot be imported into client-side code
|
||||
* - This module only includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
|
||||
*
|
||||
* For example, given the following build time environment:
|
||||
*
|
||||
* ```env
|
||||
* ENVIRONMENT=production
|
||||
* PUBLIC_BASE_URL=http://site.com
|
||||
* ```
|
||||
*
|
||||
* With the default `publicPrefix` and `privatePrefix`:
|
||||
*
|
||||
* ```ts
|
||||
* import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/private';
|
||||
*
|
||||
* console.log(ENVIRONMENT); // => "production"
|
||||
* console.log(PUBLIC_BASE_URL); // => throws error during build
|
||||
* ```
|
||||
*
|
||||
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
|
||||
*/
|
||||
declare module '$env/static/private' {
|
||||
export const LESSOPEN: string;
|
||||
export const TMUX: string;
|
||||
export const HERMES_DELEGATED_CHILD_CONTEXT: string;
|
||||
export const timezone: string;
|
||||
export const CUDACXX: string;
|
||||
export const BROWSERBASE_PROXIES: string;
|
||||
export const HERMES_RESTART_DRAIN_TIMEOUT: string;
|
||||
export const _HERMES_GATEWAY: string;
|
||||
export const AI_AGENT: string;
|
||||
export const USER: string;
|
||||
export const SSH_CLIENT: string;
|
||||
export const npm_config_user_agent: string;
|
||||
export const TERMINAL_CONTAINER_CPU: string;
|
||||
export const AGENT_BROWSER_EXECUTABLE_PATH: string;
|
||||
export const TERMINAL_DOCKER_ENV: string;
|
||||
export const TERMINAL_CWD: string;
|
||||
export const HERMES_REAL_HOME: string;
|
||||
export const XDG_SESSION_TYPE: string;
|
||||
export const npm_node_execpath: string;
|
||||
export const SHLVL: string;
|
||||
export const HERMES_PROFILE: string;
|
||||
export const npm_config_noproxy: string;
|
||||
export const HOME: string;
|
||||
export const OLDPWD: string;
|
||||
export const TERMINAL_DOCKER_FORWARD_ENV: string;
|
||||
export const TERM_PROGRAM_VERSION: string;
|
||||
export const BROWSER_SESSION_TIMEOUT: string;
|
||||
export const HERMES_KANBAN_BOARD: string;
|
||||
export const SSH_TTY: string;
|
||||
export const HERMES_HOME: string;
|
||||
export const npm_package_json: string;
|
||||
export const TERMINAL_DOCKER_EXTRA_ARGS: string;
|
||||
export const HF_HUB_DISABLE_XET: string;
|
||||
export const IM_CONFIG_ENTRY: string;
|
||||
export const TERMINAL_DOCKER_IMAGE: string;
|
||||
export const PAGER: string;
|
||||
export const TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE: string;
|
||||
export const TERMINAL_CONTAINER_MEMORY: string;
|
||||
export const SSL_CERT_FILE: string;
|
||||
export const TERMINAL_CONTAINER_PERSISTENT: string;
|
||||
export const npm_config_userconfig: string;
|
||||
export const npm_config_local_prefix: string;
|
||||
export const HERMES_TURN_LEASE_TIMEOUT: string;
|
||||
export const HERMES_MEDIA_DELIVERY_STRICT: string;
|
||||
export const TERMINAL_MODAL_IMAGE: string;
|
||||
export const DBUS_SESSION_BUS_ADDRESS: string;
|
||||
export const COLORTERM: string;
|
||||
export const TERMINAL_DOCKER_NETWORK: string;
|
||||
export const file_read_max_chars: string;
|
||||
export const COLOR: string;
|
||||
export const DEBUGINFOD_URLS: string;
|
||||
export const TERMINAL_DOCKER_VOLUMES: string;
|
||||
export const TERMINAL_ENV: string;
|
||||
export const TERMINAL_VERCEL_RUNTIME: string;
|
||||
export const LOGNAME: string;
|
||||
export const HERMES_KANBAN_DB: string;
|
||||
export const IMAGE_TOOLS_DEBUG: string;
|
||||
export const _: string;
|
||||
export const npm_config_prefix: string;
|
||||
export const npm_config_npm_version: string;
|
||||
export const group_sessions_per_user: string;
|
||||
export const HERMES_KANBAN_WORKSPACES_ROOT: string;
|
||||
export const HERMES_SINGLE_QUERY_SESSION: string;
|
||||
export const XDG_SESSION_CLASS: string;
|
||||
export const BROWSERBASE_ADVANCED_STEALTH: string;
|
||||
export const paste_collapse_threshold: string;
|
||||
export const TERM: string;
|
||||
export const TERMINAL_DAYTONA_IMAGE: string;
|
||||
export const XDG_SESSION_ID: string;
|
||||
export const npm_config_cache: string;
|
||||
export const TERMINAL_TEMP_DIR: string;
|
||||
export const TERMINAL_LIFETIME_SECONDS: string;
|
||||
export const TERMINAL_TIMEOUT: string;
|
||||
export const HERMES_GATEWAY_BUSY_INPUT_MODE: string;
|
||||
export const paste_collapse_char_threshold: string;
|
||||
export const TERMINAL_DOCKER_SHARED_CONTAINER_KEY: string;
|
||||
export const npm_config_node_gyp: string;
|
||||
export const PATH: string;
|
||||
export const HERMES_AGENT_TIMEOUT: string;
|
||||
export const NODE: string;
|
||||
export const TERMINAL_DOCKER_SHM_SIZE: string;
|
||||
export const HERMES_KANBAN_WORKSPACE: string;
|
||||
export const XDG_RUNTIME_DIR: string;
|
||||
export const TERMINAL_PERSISTENT_SHELL: string;
|
||||
export const LANG: string;
|
||||
export const HERMES_QUIET: string;
|
||||
export const MOA_TOOLS_DEBUG: string;
|
||||
export const HERMES_MEDIA_TRUST_RECENT_FILES: string;
|
||||
export const LS_COLORS: string;
|
||||
export const HERMES_MEDIA_TRUST_RECENT_SECONDS: string;
|
||||
export const HERMES_MAX_ITERATIONS: string;
|
||||
export const TERM_PROGRAM: string;
|
||||
export const npm_lifecycle_script: string;
|
||||
export const HERMES_SESSION_ID: string;
|
||||
export const HERMES_AGENT_TIMEOUT_WARNING: string;
|
||||
export const prefill_messages_file: string;
|
||||
export const SHELL: string;
|
||||
export const HERMES_AGENT_NOTIFY_INTERVAL: string;
|
||||
export const TERMINAL_DEGRADED_MODE: string;
|
||||
export const TERMINAL_MODAL_MODE: string;
|
||||
export const npm_lifecycle_event: string;
|
||||
export const WEB_TOOLS_DEBUG: string;
|
||||
export const VISION_TOOLS_DEBUG: string;
|
||||
export const LESSCLOSE: string;
|
||||
export const TERMINAL_CONTAINER_DISK: string;
|
||||
export const TERMINAL_DOCKER_RUN_AS_HOST_USER: string;
|
||||
export const GIT_PAGER: string;
|
||||
export const paste_collapse_threshold_fallback: string;
|
||||
export const npm_config_globalconfig: string;
|
||||
export const npm_config_init_module: string;
|
||||
export const PWD: string;
|
||||
export const HERMES_INTERACTIVE: string;
|
||||
export const _config_version: string;
|
||||
export const HERMES_AUTO_CONTINUE_FRESHNESS: string;
|
||||
export const npm_execpath: string;
|
||||
export const SSH_CONNECTION: string;
|
||||
export const XDG_DATA_DIRS: string;
|
||||
export const npm_config_global_prefix: string;
|
||||
export const hooks_auto_accept: string;
|
||||
export const npm_command: string;
|
||||
export const TMUX_PLUGIN_MANAGER_PATH: string;
|
||||
export const BROWSER_INACTIVITY_TIMEOUT: string;
|
||||
export const HERMES_REDACT_SECRETS: string;
|
||||
export const HERMES_CUSTOM_192_168_1_203_8084_API_KEY: string;
|
||||
export const TERMINAL_SINGULARITY_IMAGE: string;
|
||||
export const TMUX_PANE: string;
|
||||
export const TERMINAL_HOME_MODE: string;
|
||||
export const TERMINAL_DOCKER_SNAP_COMPAT: string;
|
||||
export const HERMES_AGENT: string;
|
||||
export const HERMES_SESSION_SOURCE: string;
|
||||
export const INIT_CWD: string;
|
||||
export const EDITOR: string;
|
||||
export const TEST: string;
|
||||
export const VITEST: string;
|
||||
export const NODE_ENV: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This module provides access to environment variables that are injected _statically_ into your bundle at build time and are _publicly_ accessible.
|
||||
*
|
||||
* | | Runtime | Build time |
|
||||
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
||||
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
||||
*
|
||||
* Static environment variables are [loaded by Vite](https://vitejs.dev/guide/env-and-mode.html#env-files) from `.env` files and `process.env` at build time and then statically injected into your bundle at build time, enabling optimisations like dead code elimination.
|
||||
*
|
||||
* **_Public_ access:**
|
||||
*
|
||||
* - This module _can_ be imported into client-side code
|
||||
* - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
|
||||
*
|
||||
* For example, given the following build time environment:
|
||||
*
|
||||
* ```env
|
||||
* ENVIRONMENT=production
|
||||
* PUBLIC_BASE_URL=http://site.com
|
||||
* ```
|
||||
*
|
||||
* With the default `publicPrefix` and `privatePrefix`:
|
||||
*
|
||||
* ```ts
|
||||
* import { ENVIRONMENT, PUBLIC_BASE_URL } from '$env/static/public';
|
||||
*
|
||||
* console.log(ENVIRONMENT); // => throws error during build
|
||||
* console.log(PUBLIC_BASE_URL); // => "http://site.com"
|
||||
* ```
|
||||
*
|
||||
* The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values.
|
||||
*/
|
||||
declare module '$env/static/public' {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This module provides access to environment variables set _dynamically_ at runtime and that are limited to _private_ access.
|
||||
*
|
||||
* | | Runtime | Build time |
|
||||
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
||||
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
||||
*
|
||||
* Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
|
||||
*
|
||||
* **_Private_ access:**
|
||||
*
|
||||
* - This module cannot be imported into client-side code
|
||||
* - This module includes variables that _do not_ begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) _and do_ start with [`config.kit.env.privatePrefix`](https://svelte.dev/docs/kit/configuration#env) (if configured)
|
||||
*
|
||||
* > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
|
||||
*
|
||||
* > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
|
||||
* >
|
||||
* > ```env
|
||||
* > MY_FEATURE_FLAG=
|
||||
* > ```
|
||||
* >
|
||||
* > You can override `.env` values from the command line like so:
|
||||
* >
|
||||
* > ```sh
|
||||
* > MY_FEATURE_FLAG="enabled" npm run dev
|
||||
* > ```
|
||||
*
|
||||
* For example, given the following runtime environment:
|
||||
*
|
||||
* ```env
|
||||
* ENVIRONMENT=production
|
||||
* PUBLIC_BASE_URL=http://site.com
|
||||
* ```
|
||||
*
|
||||
* With the default `publicPrefix` and `privatePrefix`:
|
||||
*
|
||||
* ```ts
|
||||
* import { env } from '$env/dynamic/private';
|
||||
*
|
||||
* console.log(env.ENVIRONMENT); // => "production"
|
||||
* console.log(env.PUBLIC_BASE_URL); // => undefined
|
||||
* ```
|
||||
*/
|
||||
declare module '$env/dynamic/private' {
|
||||
export const env: {
|
||||
LESSOPEN: string;
|
||||
TMUX: string;
|
||||
HERMES_DELEGATED_CHILD_CONTEXT: string;
|
||||
timezone: string;
|
||||
CUDACXX: string;
|
||||
BROWSERBASE_PROXIES: string;
|
||||
HERMES_RESTART_DRAIN_TIMEOUT: string;
|
||||
_HERMES_GATEWAY: string;
|
||||
AI_AGENT: string;
|
||||
USER: string;
|
||||
SSH_CLIENT: string;
|
||||
npm_config_user_agent: string;
|
||||
TERMINAL_CONTAINER_CPU: string;
|
||||
AGENT_BROWSER_EXECUTABLE_PATH: string;
|
||||
TERMINAL_DOCKER_ENV: string;
|
||||
TERMINAL_CWD: string;
|
||||
HERMES_REAL_HOME: string;
|
||||
XDG_SESSION_TYPE: string;
|
||||
npm_node_execpath: string;
|
||||
SHLVL: string;
|
||||
HERMES_PROFILE: string;
|
||||
npm_config_noproxy: string;
|
||||
HOME: string;
|
||||
OLDPWD: string;
|
||||
TERMINAL_DOCKER_FORWARD_ENV: string;
|
||||
TERM_PROGRAM_VERSION: string;
|
||||
BROWSER_SESSION_TIMEOUT: string;
|
||||
HERMES_KANBAN_BOARD: string;
|
||||
SSH_TTY: string;
|
||||
HERMES_HOME: string;
|
||||
npm_package_json: string;
|
||||
TERMINAL_DOCKER_EXTRA_ARGS: string;
|
||||
HF_HUB_DISABLE_XET: string;
|
||||
IM_CONFIG_ENTRY: string;
|
||||
TERMINAL_DOCKER_IMAGE: string;
|
||||
PAGER: string;
|
||||
TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE: string;
|
||||
TERMINAL_CONTAINER_MEMORY: string;
|
||||
SSL_CERT_FILE: string;
|
||||
TERMINAL_CONTAINER_PERSISTENT: string;
|
||||
npm_config_userconfig: string;
|
||||
npm_config_local_prefix: string;
|
||||
HERMES_TURN_LEASE_TIMEOUT: string;
|
||||
HERMES_MEDIA_DELIVERY_STRICT: string;
|
||||
TERMINAL_MODAL_IMAGE: string;
|
||||
DBUS_SESSION_BUS_ADDRESS: string;
|
||||
COLORTERM: string;
|
||||
TERMINAL_DOCKER_NETWORK: string;
|
||||
file_read_max_chars: string;
|
||||
COLOR: string;
|
||||
DEBUGINFOD_URLS: string;
|
||||
TERMINAL_DOCKER_VOLUMES: string;
|
||||
TERMINAL_ENV: string;
|
||||
TERMINAL_VERCEL_RUNTIME: string;
|
||||
LOGNAME: string;
|
||||
HERMES_KANBAN_DB: string;
|
||||
IMAGE_TOOLS_DEBUG: string;
|
||||
_: string;
|
||||
npm_config_prefix: string;
|
||||
npm_config_npm_version: string;
|
||||
group_sessions_per_user: string;
|
||||
HERMES_KANBAN_WORKSPACES_ROOT: string;
|
||||
HERMES_SINGLE_QUERY_SESSION: string;
|
||||
XDG_SESSION_CLASS: string;
|
||||
BROWSERBASE_ADVANCED_STEALTH: string;
|
||||
paste_collapse_threshold: string;
|
||||
TERM: string;
|
||||
TERMINAL_DAYTONA_IMAGE: string;
|
||||
XDG_SESSION_ID: string;
|
||||
npm_config_cache: string;
|
||||
TERMINAL_TEMP_DIR: string;
|
||||
TERMINAL_LIFETIME_SECONDS: string;
|
||||
TERMINAL_TIMEOUT: string;
|
||||
HERMES_GATEWAY_BUSY_INPUT_MODE: string;
|
||||
paste_collapse_char_threshold: string;
|
||||
TERMINAL_DOCKER_SHARED_CONTAINER_KEY: string;
|
||||
npm_config_node_gyp: string;
|
||||
PATH: string;
|
||||
HERMES_AGENT_TIMEOUT: string;
|
||||
NODE: string;
|
||||
TERMINAL_DOCKER_SHM_SIZE: string;
|
||||
HERMES_KANBAN_WORKSPACE: string;
|
||||
XDG_RUNTIME_DIR: string;
|
||||
TERMINAL_PERSISTENT_SHELL: string;
|
||||
LANG: string;
|
||||
HERMES_QUIET: string;
|
||||
MOA_TOOLS_DEBUG: string;
|
||||
HERMES_MEDIA_TRUST_RECENT_FILES: string;
|
||||
LS_COLORS: string;
|
||||
HERMES_MEDIA_TRUST_RECENT_SECONDS: string;
|
||||
HERMES_MAX_ITERATIONS: string;
|
||||
TERM_PROGRAM: string;
|
||||
npm_lifecycle_script: string;
|
||||
HERMES_SESSION_ID: string;
|
||||
HERMES_AGENT_TIMEOUT_WARNING: string;
|
||||
prefill_messages_file: string;
|
||||
SHELL: string;
|
||||
HERMES_AGENT_NOTIFY_INTERVAL: string;
|
||||
TERMINAL_DEGRADED_MODE: string;
|
||||
TERMINAL_MODAL_MODE: string;
|
||||
npm_lifecycle_event: string;
|
||||
WEB_TOOLS_DEBUG: string;
|
||||
VISION_TOOLS_DEBUG: string;
|
||||
LESSCLOSE: string;
|
||||
TERMINAL_CONTAINER_DISK: string;
|
||||
TERMINAL_DOCKER_RUN_AS_HOST_USER: string;
|
||||
GIT_PAGER: string;
|
||||
paste_collapse_threshold_fallback: string;
|
||||
npm_config_globalconfig: string;
|
||||
npm_config_init_module: string;
|
||||
PWD: string;
|
||||
HERMES_INTERACTIVE: string;
|
||||
_config_version: string;
|
||||
HERMES_AUTO_CONTINUE_FRESHNESS: string;
|
||||
npm_execpath: string;
|
||||
SSH_CONNECTION: string;
|
||||
XDG_DATA_DIRS: string;
|
||||
npm_config_global_prefix: string;
|
||||
hooks_auto_accept: string;
|
||||
npm_command: string;
|
||||
TMUX_PLUGIN_MANAGER_PATH: string;
|
||||
BROWSER_INACTIVITY_TIMEOUT: string;
|
||||
HERMES_REDACT_SECRETS: string;
|
||||
HERMES_CUSTOM_192_168_1_203_8084_API_KEY: string;
|
||||
TERMINAL_SINGULARITY_IMAGE: string;
|
||||
TMUX_PANE: string;
|
||||
TERMINAL_HOME_MODE: string;
|
||||
TERMINAL_DOCKER_SNAP_COMPAT: string;
|
||||
HERMES_AGENT: string;
|
||||
HERMES_SESSION_SOURCE: string;
|
||||
INIT_CWD: string;
|
||||
EDITOR: string;
|
||||
TEST: string;
|
||||
VITEST: string;
|
||||
NODE_ENV: string;
|
||||
[key: `PUBLIC_${string}`]: undefined;
|
||||
[key: `${string}`]: string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This module provides access to environment variables set _dynamically_ at runtime and that are _publicly_ accessible.
|
||||
*
|
||||
* | | Runtime | Build time |
|
||||
* | ------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
* | Private | [`$env/dynamic/private`](https://svelte.dev/docs/kit/$env-dynamic-private) | [`$env/static/private`](https://svelte.dev/docs/kit/$env-static-private) |
|
||||
* | Public | [`$env/dynamic/public`](https://svelte.dev/docs/kit/$env-dynamic-public) | [`$env/static/public`](https://svelte.dev/docs/kit/$env-static-public) |
|
||||
*
|
||||
* Dynamic environment variables are defined by the platform you're running on. For example if you're using [`adapter-node`](https://github.com/sveltejs/kit/tree/main/packages/adapter-node) (or running [`vite preview`](https://svelte.dev/docs/kit/cli)), this is equivalent to `process.env`.
|
||||
*
|
||||
* **_Public_ access:**
|
||||
*
|
||||
* - This module _can_ be imported into client-side code
|
||||
* - **Only** variables that begin with [`config.kit.env.publicPrefix`](https://svelte.dev/docs/kit/configuration#env) (which defaults to `PUBLIC_`) are included
|
||||
*
|
||||
* > [!NOTE] In `dev`, `$env/dynamic` includes environment variables from `.env`. In `prod`, this behavior will depend on your adapter.
|
||||
*
|
||||
* > [!NOTE] To get correct types, environment variables referenced in your code should be declared (for example in an `.env` file), even if they don't have a value until the app is deployed:
|
||||
* >
|
||||
* > ```env
|
||||
* > MY_FEATURE_FLAG=
|
||||
* > ```
|
||||
* >
|
||||
* > You can override `.env` values from the command line like so:
|
||||
* >
|
||||
* > ```sh
|
||||
* > MY_FEATURE_FLAG="enabled" npm run dev
|
||||
* > ```
|
||||
*
|
||||
* For example, given the following runtime environment:
|
||||
*
|
||||
* ```env
|
||||
* ENVIRONMENT=production
|
||||
* PUBLIC_BASE_URL=http://example.com
|
||||
* ```
|
||||
*
|
||||
* With the default `publicPrefix` and `privatePrefix`:
|
||||
*
|
||||
* ```ts
|
||||
* import { env } from '$env/dynamic/public';
|
||||
* console.log(env.ENVIRONMENT); // => undefined, not public
|
||||
* console.log(env.PUBLIC_BASE_URL); // => "http://example.com"
|
||||
* ```
|
||||
*
|
||||
* ```
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
declare module '$env/dynamic/public' {
|
||||
export const env: {
|
||||
[key: `PUBLIC_${string}`]: string | undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// See https://svelte.dev/docs/kit/environment-variables for more information
|
||||
@@ -0,0 +1,38 @@
|
||||
// in dev, this makes Vite inject its client as this module's first dependency,
|
||||
// so that global constant replacements are installed before any other module
|
||||
// (including user hooks) evaluates. In build it's inert.
|
||||
import.meta.hot;
|
||||
|
||||
|
||||
|
||||
|
||||
export { matchers } from './matchers.js';
|
||||
|
||||
export const nodes = [
|
||||
() => import('./nodes/0'),
|
||||
() => import('./nodes/1')
|
||||
];
|
||||
|
||||
export const server_loads = [];
|
||||
|
||||
export const dictionary = {
|
||||
|
||||
};
|
||||
|
||||
export const hooks = {
|
||||
handleError: (({ error }) => { console.error(error) }),
|
||||
|
||||
reroute: (() => {}),
|
||||
transport: {}
|
||||
};
|
||||
|
||||
export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
|
||||
export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode]));
|
||||
|
||||
export const hash = false;
|
||||
|
||||
export const decode = (type, value) => decoders[type](value);
|
||||
|
||||
export { default as root } from '../root.js';
|
||||
|
||||
export const get_error_template = () => import('../shared/error-template.js').then(m => m.default);
|
||||
@@ -0,0 +1 @@
|
||||
export const matchers = {};
|
||||
@@ -0,0 +1 @@
|
||||
export { default as component } from "../../../../frontend/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/layout.svelte";
|
||||
@@ -0,0 +1 @@
|
||||
export { default as component } from "../../../../frontend/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte";
|
||||
@@ -0,0 +1 @@
|
||||
export default ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n";
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"$app/types": [
|
||||
"./types/index.d.ts"
|
||||
]
|
||||
},
|
||||
"rootDirs": [
|
||||
"..",
|
||||
"./types"
|
||||
],
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"lib": [
|
||||
"esnext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"module": "esnext",
|
||||
"noEmit": true,
|
||||
"target": "esnext"
|
||||
},
|
||||
"include": [
|
||||
"ambient.d.ts",
|
||||
"env.d.ts",
|
||||
"non-ambient.d.ts",
|
||||
"./types/**/$types.d.ts",
|
||||
"../vite.config.js",
|
||||
"../vite.config.ts",
|
||||
"../src/**/*.js",
|
||||
"../src/**/*.ts",
|
||||
"../src/**/*.svelte",
|
||||
"../test/**/*.js",
|
||||
"../test/**/*.ts",
|
||||
"../test/**/*.svelte",
|
||||
"../tests/**/*.js",
|
||||
"../tests/**/*.ts",
|
||||
"../tests/**/*.svelte"
|
||||
],
|
||||
"exclude": [
|
||||
"../node_modules/**",
|
||||
"../src/service-worker.js",
|
||||
"../src/service-worker/**/*.js",
|
||||
"../src/service-worker.ts",
|
||||
"../src/service-worker/**/*.ts",
|
||||
"../src/service-worker.d.ts",
|
||||
"../src/service-worker/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# M1-6 UAT Deploy — run these on 192.168.1.202
|
||||
|
||||
# 1. Copy the repo to .202 (from THIS machine, /home/ty):
|
||||
rsync -az --exclude node_modules --exclude .svelte-kit --exclude build \
|
||||
/home/ty/SlabUploader/ ty@192.168.1.202:SlabUploader/
|
||||
|
||||
# 2. Build the image on .202:
|
||||
ssh ty@192.168.1.202 "cd SlabUploader/frontend && docker build -t slabuploader-m1 . && \
|
||||
docker rm -f slabuploader-m1 2>/dev/null; \
|
||||
docker run -d --name slabuploader-m1 --restart unless-stopped -p 18080:80 slabuploader-m1 && \
|
||||
echo 'Container running at http://192.168.1.202:18080'"
|
||||
|
||||
# 3. Verify from your phone:
|
||||
# Open http://192.168.1.202:18080 in Safari/Chrome
|
||||
# Flow: Add photo → Mask (green sheet, sensitivity) → Approve → Axis → Approve → Measure → Calculate
|
||||
@@ -0,0 +1,36 @@
|
||||
# SlabUploader M1 — shared build context (for :202 worker tasks)
|
||||
|
||||
REPO: /home/ty/SlabUploader — work ONLY inside /home/ty/SlabUploader/frontend.
|
||||
Branch: M1-dev-rev4 (already checked out; DO NOT switch branches, DO NOT push).
|
||||
This is Milestone 1 (Capture) of a slab-measuring web app. Browser-only, NO backend,
|
||||
NO inference, NO OpenCV/ONNX. Pure JS (not TS). Svelte 5 + SvelteKit + adapter-node.
|
||||
|
||||
TEST COMMANDS (run these, show real output):
|
||||
unit: cd /home/ty/SlabUploader/frontend && npx vitest run
|
||||
e2e: npx playwright test --list (collection only; full e2e needs the M1-6 container)
|
||||
|
||||
EXISTING (already committed, green, DO NOT break):
|
||||
src/lib/core/mask.js -> keySheet(data,{sheet,sensitivity}), maskStats(mask)
|
||||
src/lib/core/math.js -> scale, sqft, bdft, roundHalfUp, sampledWidths,
|
||||
sampledWidthsFrom, snapToEighth
|
||||
tests/unit/mask.test.js, tests/unit/math.test.js (24 tests, all pass)
|
||||
src/routes/+page.svelte (placeholder home — M1-3 replaces it)
|
||||
src/routes/+layout.svelte, src/routes/api/health/+server.js
|
||||
|
||||
TDD IS MANDATORY (test-driven-development skill):
|
||||
1. Write the failing test(s) FIRST.
|
||||
2. Run them and WATCH them fail for the right reason (feature missing, not a typo).
|
||||
3. Write the MINIMAL code to pass.
|
||||
4. Run them and confirm they pass.
|
||||
5. Run the FULL unit suite (npx vitest run) and confirm no regressions + pristine output.
|
||||
6. Only then are you done. Commit is done by the coordinator — you do NOT commit.
|
||||
|
||||
CONVENTIONS:
|
||||
- Tab indentation (the repo uses tabs).
|
||||
- Pure logic lives in src/lib/core/*.js (DOM-free, unit-testable under node).
|
||||
- Svelte components live under src/lib/ (e.g. src/lib/components/).
|
||||
- Every new exported function needs a unit test that you SAW fail first.
|
||||
- No console.log left in production code.
|
||||
- Do not add dependencies.
|
||||
- Report at the end: files created/changed, the exact test command output, and
|
||||
any place you had to make a judgment call.
|
||||
@@ -1,4 +1,7 @@
|
||||
# Host
|
||||
|
||||
Record the machine and URL when a container is started.
|
||||
Do not put a host name in the product spec.
|
||||
- Machine: ubuDual5060 (192.168.1.202)
|
||||
- Container: slabuploader-m1 (image sha256:98d11c23, restart unless-stopped)
|
||||
- URL: http://192.168.1.202:18080
|
||||
- Port: 18080 (LAN interface, bound 0.0.0.0)
|
||||
- Started: 2026-09-14 (M1 UAT)
|
||||
|
||||
@@ -6,3 +6,12 @@ Where the container runs is chosen at runtime.
|
||||
No FastAPI and no Woo client in this milestone.
|
||||
|
||||
`docker-compose.yml`, `Caddyfile.fragment`, and `.env.example` describe an older two-service stack. They are not the Milestone 1 contract.
|
||||
|
||||
## Milestone 1 UAT — deployment POR
|
||||
|
||||
- **One container.** Build a single Docker image that serves the SvelteKit capture app. No FastAPI, no Woo client, no Caddy TLS edge in M1. (Caddy/compose/Woo are Milestone 3.)
|
||||
- **Host chosen at runtime.** Record the machine and URL in `INVENTORY.md` when the container starts. Do not put a host name in the product spec.
|
||||
- **LAN-reachable UAT URL.** For UAT the container binds a port on the LAN interface (not only `127.0.0.1`), so other machines on the LAN — e.g. a phone — reach the app at `http://<host-lan-ip>:<port>`.
|
||||
- **No backend.** The app is browser-only in M1 (see `docs/CAPTURE.md`). There is no `/api` route and no inference.
|
||||
|
||||
Reference for the current UAT host: see `INVENTORY.md` (filled in when the container starts).
|
||||
|
||||
@@ -45,6 +45,12 @@ Phone: iOS Safari, Android Chrome.
|
||||
PC: Chrome, Edge, Firefox.
|
||||
Other clients get a hard stop.
|
||||
|
||||
## Input photo
|
||||
|
||||
Accepted input: JPEG. Phones typically save JPEG; Android Chrome delivers JPEG by default.
|
||||
|
||||
If the file is HEIC and the browser cannot decode it, show a one-tap notice to retake the photo as JPEG. HEIC is never silently dropped and never decoded server-side in Milestone 1.
|
||||
|
||||
## States
|
||||
|
||||
One photo component is reused. Each state advances on approve. Back returns to the previous state. A device draft restores the last approved state.
|
||||
@@ -126,3 +132,5 @@ The first inventory photo is the measurement reference. Later inventory photos u
|
||||
|
||||
Source below that floor: warn and require a new photo. No upscale from an undersized source.
|
||||
Editor preview of alpha is a light gray checkerboard. The storefront uses the theme background.
|
||||
|
||||
File size: aim under 200KB per image (soft goal, not a hard gate in Milestone 1).
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Milestone 1 — Plan of Record (POR)
|
||||
|
||||
Decisions captured as they are made. This is the working plan for implementing and UAT-ing Milestone 1. It is the source of truth for scope, order, and dispatch; the spec detail lives in `docs/CAPTURE.md` and `docs/PRODUCT.md`.
|
||||
|
||||
## Scope
|
||||
|
||||
Implement **Milestone 1 — Capture** only (see `AGENTS.md`). Browser-only, inference-free, no backend, no Woo create. Milestones 2–4 are out of scope.
|
||||
|
||||
## Method — TDD (non-negotiable)
|
||||
|
||||
Every card is delivered **test-first** per the `test-driven-development` skill:
|
||||
|
||||
```
|
||||
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
|
||||
```
|
||||
|
||||
- **Red-Green-Refactor**, tracer-bullet: one behavior slice at a time (RED→GREEN→REFACTOR), not all-tests-then-all-code.
|
||||
- **Verify RED** (watch it fail for the right reason) and **Verify GREEN** (it passes + full suite stays green) are mandatory before the card is done.
|
||||
- Code written before a failing test is **deleted and redone** — not kept as "reference."
|
||||
- The deterministic core (math + PNG contract) is unit-tested in a browser-runnable JS suite; UI behaviors are tested with Playwright against the fixtures.
|
||||
- A card is not complete until: every new function has a test, each test was seen failing first, the full suite passes with pristine output.
|
||||
|
||||
## Card breakdown (implementation order)
|
||||
|
||||
Each card follows TDD. "Define" = write the failing tests (RED); "Build" = minimal code to pass (GREEN); "Refactor" = clean up keeping green.
|
||||
|
||||
1. **Scaffold + Dockerfile + test harness** — SvelteKit project with the browser-only M1 runtime (Web Worker, canvas/OffscreenCanvas, IndexedDB via `idb-keyval`), `frontend/Dockerfile`, and the test harness (JS unit runner + **Playwright** web).
|
||||
- *TDD note:* this card establishes the harness itself (a smoke test that runs and fails, then passes) so every later card has a place to be RED/GREEN.
|
||||
2. **Deterministic core** — the pure math + PNG-encode functions.
|
||||
- *RED:* unit tests for `scale`, `sqft`, `bdft`, width sampling, 1/8″ stepping, rounding, and the PNG contract (3:4, 8-bit RGBA, 80% fill, ≥1600px floor, undersized→retake). Run on the fixtures' calibration records.
|
||||
- *GREEN/REFACTOR:* implement to pass; core is authoritative and server-less.
|
||||
3. **Capture + Mask** — Add photo (JPEG; HEIC→"retake as JPEG" notice), green/black key, four knobs, debounced recompute, mask approve.
|
||||
- *RED:* Playwright — load fixture, key sheet, approve; empty-mask blocks approve; HEIC shows the notice.
|
||||
- *GREEN/REFACTOR:* build the Mask state to pass.
|
||||
4. **Length axis** — min-area bounding rectangle + long-side axis, user rotate, approve.
|
||||
- *RED:* Playwright — rect drawn from the approved mask, axis on long side, rotate, approve.
|
||||
- *GREEN/REFACTOR:* build the axis state.
|
||||
5. **Measure + PNG** — length/thickness (1/8″), compute sqft/bdft/widths, write the 3:4 inventory PNG. M1 stops here.
|
||||
- *RED:* Playwright — enter L/thickness → Calculate; assert PNG is 3:4 with alpha, and numbers equal the fixture's expected values.
|
||||
- *GREEN/REFACTOR:* build Measure + PNG encode.
|
||||
6. **M1 UAT** — serve the container per `deploy/README.md` §Milestone 1 UAT; run the full Playwright suite in the container; then the manual phone pass. Record host + URL in `deploy/INVENTORY.md`.
|
||||
|
||||
## Deployment (UAT)
|
||||
|
||||
See `deploy/README.md` §Milestone 1 UAT — deployment POR: one container, host chosen at runtime, LAN-reachable URL, no backend.
|
||||
|
||||
## Dev / QA worker dispatch
|
||||
|
||||
Two local inference endpoints are available as dispatchable workers for test / dev / QA:
|
||||
|
||||
- **`.202:8081`** — `gemma-4-12B-it-q4_k_m` (12B, Q4) — **completion only, no vision.** Use for lighter text: drafting, code review, summaries.
|
||||
- **`.202:8082`** — `Qwen3.8-27B-UD-IQ3_XXS` (27B, IQ3) — **completion + multimodal (vision).** The **vision worker** (any task that must look at a slab photo) and the stronger general-reasoning endpoint.
|
||||
|
||||
Coordinator: Hermes (me) — plans, decomposes, dispatches, verifies, and does work directly when a card is small or judgment-heavy.
|
||||
|
||||
### Calibrated delegation policy (2026-09-14)
|
||||
|
||||
- **Frame every prompt**: system role + "show each step" + explicit constraints. Small/low-quant models drop multi-step arithmetic in one-shot "reply JSON only" prompts but get it exactly right with scratch-space framing. Do not judge capability from under-framed probes.
|
||||
- **The deterministic core stays the coordinator's** (TDD'd, unit-tested). A model that's "usually right" on math is not good enough for the spec — math is cheap to test and must be exact.
|
||||
- **Verify before land**: any load-bearing model output is checked against tests/spec before entering the repo. Workers are assist, not autonomous builders.
|
||||
- **Output quirks**: gemma wraps JSON in ``` fences — strip before parsing. Vision calls run ~18s/image (text ~1s).
|
||||
- **One task per endpoint at a time** (no parallel on the same endpoint — backs up and overheats).
|
||||
- Both run low-quant (Q4/IQ3). Fine for framed assist; deep sustained reasoning is best done by the coordinator directly.
|
||||
|
||||
## Test plan (M1)
|
||||
|
||||
Three layers. The deterministic core is unit-tested; the end-to-end path is Playwright; a real phone is the final UAT gate.
|
||||
|
||||
### 1. Unit (deterministic core) — must pass before any UI consumes it
|
||||
- `bdft = sqft × thickness_in`; `sqft = (mask_px / scale²) / 144`; `scale = longest_axis_px / L`.
|
||||
- 1/8 in stepping; rounding (sqft/bdft 2 decimals).
|
||||
- Width sampling every 6 in along the approved axis.
|
||||
- PNG encode contract: 3:4 aspect, 8-bit RGBA, 80% fill, shorter side ≥ 1600px when source allows; undersized source → warn + retake (no upscale).
|
||||
- Mask empty → approve blocked.
|
||||
|
||||
### 2. Playwright (automated end-to-end, runs in the UAT container's browser)
|
||||
Walks the 5 states against a **sample photo**:
|
||||
- Add photo → Mask (set sheet + sensitivity) → approve → Length axis → approve → Measure (enter L, thickness) → Calculate.
|
||||
- Assert: PNG rendered, dimensions = 3:4, alpha present; sqft/bdft/widths equal the expected values for that fixture (within tolerance); numbers visible on screen.
|
||||
- HEIC input → "retake as JPEG" notice shown (not a crash).
|
||||
- Device draft: reload → Resume → Continue restores last approved state.
|
||||
|
||||
### 3. Manual phone UAT (final gate)
|
||||
On a real phone (iOS Safari / Android Chrome) over the LAN UAT URL: repeat the happy path with one real slab; confirm PNG + numbers on screen. Record host + URL in `deploy/INVENTORY.md`.
|
||||
|
||||
## Test fixtures (sample pics)
|
||||
|
||||
Real slab photos are the fixtures (supplied by Ty from `\\truenas.local\Business\Samples`, SMB user `SMBSVC`). Each is a DJI MIMO top-down JPEG, ~4000×2256, slab on a green/black sheet.
|
||||
|
||||
`tests/fixtures/`:
|
||||
- **sample_01 … sample_0N.JPG** — a representative handful of real slab photos (pulled on demand from the TrueNAS Samples share; do not bulk-copy the whole folder).
|
||||
- Each fixture gets a **calibration record** in `tests/fixtures/<name>.json`: the slab's true length and thickness (in inches), the sheet color, and the approved mask/axis reference — used to compute the expected `scale`, `sqft`, `bdft`, and sampled widths for assertions.
|
||||
|
||||
The real photos drive both **bg removal** (green/black key) and **rectangle/axis calibration**. Expected values come from the calibration records, so assertions stay deterministic even on real imagery.
|
||||
|
||||
**M1 fixture set (agreed, complete for M1):** three black-sheet slabs — one irregular (stress-tests the min-area rectangle/axis), two clean elongated. Green-sheet keying is deferred (not an M1 fixture). A 1 HEIC sample (optional) for the decode-fallback test.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- (none outstanding for M1 as of this plan; new decisions append here)
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -0,0 +1,18 @@
|
||||
# SlabUploader M1 — frontend only (no FastAPI/Woo). Multi-stage: build then serve.
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS run
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=build /app/build ./build
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
EXPOSE 80
|
||||
ENV PORT=80
|
||||
CMD ["node", "build/index.js"]
|
||||
@@ -0,0 +1,20 @@
|
||||
import { keySheet } from './src/lib/core/mask.js';
|
||||
import { sampleWidthsPx } from './src/lib/core/measure.js';
|
||||
const W = 200, H = 150;
|
||||
function slabPhoto(slabW, slabH, ox, oy) {
|
||||
const data = new Uint8ClampedArray(W * H * 4);
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = (y * W + x) * 4;
|
||||
const inSlab = x >= ox && x < ox + slabW && y >= oy && y < oy + slabH;
|
||||
if (inSlab) { data[i] = 139; data[i + 1] = 90; data[i + 2] = 43; }
|
||||
else { data[i] = 34; data[i + 1] = 139; data[i + 2] = 34; }
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
const d = slabPhoto(10, 10, 0, 0);
|
||||
const m = keySheet(d, { sheet: 'green', sensitivity: 50 });
|
||||
let kept = 0;
|
||||
for (let i = 0; i < m.length; i++) kept += m[i];
|
||||
console.log('kept:', kept, 'of', m.length);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { sampleWidthsPx } from './src/lib/core/measure.js';
|
||||
const W = 200, H = 150;
|
||||
const mask = new Uint8Array(W * H);
|
||||
// slab 336 x 192 px at (0,0) — extends beyond photo bounds (200x150)
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++)
|
||||
mask[y * W + x] = (x < 336 && y < 192) ? 1 : 0;
|
||||
const axis = { origin: {x:0, y:0}, dir: {x:1, y:0}, lengthPx: 336 };
|
||||
const w = sampleWidthsPx(mask, W, H, axis, 160, 6);
|
||||
console.log('widths:', w);
|
||||
// sample points at t=0, 96, 192, 288 px
|
||||
// perpendicular dir: (0,1) — scan down
|
||||
// at x=0: y from 0..149 all in slab (y<192) → width 150 (clamped by photo)
|
||||
// at x=96: same → 150
|
||||
// at x=192: same → 150
|
||||
// at x=288: x=288 > 200 (photo width) → out of bounds → 0? or skipped?
|
||||
console.log('expected: [150, 150, 150] or [150, 150, 150, 0]');
|
||||
@@ -0,0 +1,11 @@
|
||||
import { sampleWidthsPx } from './src/lib/core/measure.js';
|
||||
const W = 400, H = 250;
|
||||
const mask = new Uint8Array(W * H);
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++)
|
||||
mask[y * W + x] = 1; // whole photo is slab
|
||||
const axis = { origin: {x:0, y:0}, dir: {x:1, y:0}, lengthPx: 336 };
|
||||
const w = sampleWidthsPx(mask, W, H, axis, 160, 6);
|
||||
console.log('widths (full photo):', w);
|
||||
console.log('expected: [250, 250, 250] (t=0,96,192; t=288>336? no, 288<336 → also 250)');
|
||||
console.log('samples at t=0,96,192,288 → 4 samples, each width=250');
|
||||
@@ -0,0 +1,10 @@
|
||||
import { keySheet } from './src/lib/core/mask.js';
|
||||
const data = new Uint8ClampedArray(10 * 4);
|
||||
// slab brown
|
||||
data[0] = 139; data[1] = 90; data[2] = 43; data[3] = 255;
|
||||
// sheet green
|
||||
data[4] = 34; data[5] = 139; data[6] = 34; data[7] = 255;
|
||||
data[8] = 34; data[9] = 139; data[10] = 34; data[11] = 255;
|
||||
data[12] = 34; data[13] = 139; data[14] = 34; data[15] = 255;
|
||||
const m = keySheet(data, { sheet: 'green', sensitivity: 50 });
|
||||
console.log('mask for 4 pixels (1 slab + 3 sheet):', Array.from(m));
|
||||
@@ -0,0 +1,8 @@
|
||||
import { keySheet } from './src/lib/core/mask.js';
|
||||
const data = new Uint8ClampedArray(4 * 4);
|
||||
data[0]=139; data[1]=90; data[2]=43; data[3]=255;
|
||||
data[4]=34; data[5]=139; data[6]=34; data[7]=255;
|
||||
data[8]=34; data[9]=139; data[10]=34; data[11]=255;
|
||||
data[12]=34; data[13]=139; data[14]=34; data[15]=255;
|
||||
const m = keySheet(data, { sheet:'green', sensitivity:50 });
|
||||
console.log('mask for 4 px (1 slab, 3 sheet):', Array.from(m));
|
||||
@@ -0,0 +1,29 @@
|
||||
const data = new Uint8ClampedArray(4 * 4);
|
||||
data[0]=139; data[1]=90; data[2]=43; data[3]=255;
|
||||
data[4]=34; data[5]=139; data[6]=34; data[7]=255;
|
||||
data[8]=34; data[9]=139; data[10]=34; data[11]=255;
|
||||
data[12]=34; data[13]=139; data[14]=34; data[15]=255;
|
||||
|
||||
const SHEET_GREEN = { r: 0, g: 255, b: 0 };
|
||||
function greenDistance(r, g, b) {
|
||||
const dr = r - SHEET_GREEN.r;
|
||||
const dg = g - SHEET_GREEN.g;
|
||||
const db = b - SHEET_GREEN.b;
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
}
|
||||
function greenThreshold(s) { return 150 - s; }
|
||||
function blackCutoff(s) { return 40 + (s / 100) * 100; }
|
||||
const BLACK_SAT_CAP = 120;
|
||||
|
||||
const sensitivity = 50;
|
||||
const n = data.length / 4;
|
||||
const mask = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = i * 4;
|
||||
const r = data[j], g = data[j + 1], b = data[j + 2];
|
||||
const d = greenDistance(r, g, b);
|
||||
const t = greenThreshold(sensitivity);
|
||||
mask[i] = d > t ? 1 : 0;
|
||||
console.log(`px ${i}: r=${r} g=${g} b=${b} dist=${d.toFixed(1)} thresh=${t} kept=${mask[i]}`);
|
||||
}
|
||||
console.log('mask:', Array.from(mask));
|
||||
@@ -0,0 +1,20 @@
|
||||
import { keySheet } from './src/lib/core/mask.js';
|
||||
import { readFileSync } from 'fs';
|
||||
const fileSrc = readFileSync('./src/lib/core/mask.js', 'utf8');
|
||||
const match = fileSrc.match(/const SHEET_GREEN = \{([^}]*)\}/);
|
||||
console.log('SHEET_GREEN in file:', match ? match[0] : 'NOT FOUND');
|
||||
console.log('keySheet source:', keySheet.toString());
|
||||
const W = 20, H = 10;
|
||||
const data = new Uint8ClampedArray(W * H * 4);
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = (y * W + x) * 4;
|
||||
const inSlab = x >= 5 && x < 15 && y >= 2 && y < 8;
|
||||
if (inSlab) { data[i]=139; data[i+1]=90; data[i+2]=43; }
|
||||
else { data[i]=34; data[i+1]=139; data[i+2]=34; }
|
||||
data[i+3] = 255;
|
||||
}
|
||||
const maskResult = keySheet(data, { sheet: 'green', sensitivity: 50 });
|
||||
console.log('row 5:', Array.from(maskResult.slice(5*W, 6*W)));
|
||||
let kept = 0; for (let i = 0; i < maskResult.length; i++) kept += maskResult[i];
|
||||
console.log('kept:', kept, 'expected: 60');
|
||||
@@ -0,0 +1,10 @@
|
||||
const SHEET_GREEN = { r: 0, g: 255, b: 0 };
|
||||
function greenDistance(r, g, b) {
|
||||
const dr = r - SHEET_GREEN.r;
|
||||
const dg = g - SHEET_GREEN.g;
|
||||
const db = b - SHEET_GREEN.b;
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
}
|
||||
console.log('sheet green (34,139,34):', greenDistance(34, 139, 34).toFixed(1));
|
||||
console.log('brown (139,90,43):', greenDistance(139, 90, 43).toFixed(1));
|
||||
console.log('threshold at s=50:', 150 - 50);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { keySheet } from './src/lib/core/mask.js';
|
||||
const W = 20, H = 10;
|
||||
const data = new Uint8ClampedArray(W * H * 4);
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = (y * W + x) * 4;
|
||||
const inSlab = x >= 5 && x < 15 && y >= 2 && y < 8;
|
||||
if (inSlab) { data[i]=139; data[i+1]=90; data[i+2]=43; }
|
||||
else { data[i]=34; data[i+1]=139; data[i+2]=34; }
|
||||
data[i+3] = 255;
|
||||
}
|
||||
const m = keySheet(data, { sheet: 'green', sensitivity: 50 });
|
||||
console.log('row 5:', Array.from(m.slice(5*W, 6*W)));
|
||||
@@ -0,0 +1,23 @@
|
||||
import { keySheet } from './src/lib/core/mask.js';
|
||||
const W = 20, H = 10;
|
||||
const data = new Uint8ClampedArray(W * H * 4);
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = (y * W + x) * 4;
|
||||
const inSlab = x >= 5 && x < 15 && y >= 2 && y < 8;
|
||||
if (inSlab) { data[i]=139; data[i+1]=90; data[i+2]=43; }
|
||||
else { data[i]=34; data[i+1]=139; data[i+2]=34; }
|
||||
data[i+3] = 255;
|
||||
}
|
||||
const m = keySheet(data, { sheet: 'green', sensitivity: 50 });
|
||||
console.log('row 5:', Array.from(m.slice(5*W, 6*W)));
|
||||
console.log('row 5 length:', m.slice(5*W, 6*W).length);
|
||||
const arr = m.slice(5*W, 6*W);
|
||||
let all1 = true;
|
||||
for (let i = 0; i < W; i++) if (arr[i] !== 1) { all1 = false; console.log('x=', i, 'val=', arr[i]); }
|
||||
console.log('all 1s?', all1);
|
||||
// Check a specific pixel
|
||||
const px5 = m[5*W+10];
|
||||
console.log('px at (10,5):', px5);
|
||||
const px0 = m[0];
|
||||
console.log('px at (0,0):', px0);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { keySheet } from './src/lib/core/mask.js';
|
||||
const W = 20, H = 10;
|
||||
const data = new Uint8ClampedArray(W * H * 4);
|
||||
for (let y = 0; y < H; y++)
|
||||
for (let x = 0; x < W; x++) {
|
||||
const i = (y * W + x) * 4;
|
||||
const inSlab = x >= 5 && x < 15 && y >= 2 && y < 8;
|
||||
if (inSlab) { data[i]=139; data[i+1]=90; data[i+2]=43; }
|
||||
else { data[i]=34; data[i+1]=139; data[i+2]=34; }
|
||||
data[i+3] = 255;
|
||||
}
|
||||
console.log('px (0,0) =', [data[0], data[1], data[2]]);
|
||||
console.log('px (10,5) =', [data[(5*W+10)*4], data[(5*W+10)*4+1], data[(5*W+10)*4+2]]);
|
||||
const m = keySheet(data, { sheet: 'green', sensitivity: 50 });
|
||||
console.log('mask (0,0) =', m[0]);
|
||||
console.log('mask (10,5) =', m[5*W+10]);
|
||||
let kept = 0; for (let i = 0; i < m.length; i++) kept += m[i];
|
||||
console.log('kept:', kept, 'of', m.length);
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "slab-uploader-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test:unit": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.21.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"playwright": "^1.63.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^6.3.0",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Playwright config for M1 end-to-end tests.
|
||||
// base URL points at the LAN UAT container (card M1-6). Override with BASE_URL.
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 60_000,
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://192.168.1.202:18080'
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
:root {
|
||||
--bg: #1a1a1a;
|
||||
--fg: #f2f2f2;
|
||||
--accent: #4f9d69;
|
||||
--checker: repeating-conic-gradient(#e8e8e8 0% 25%, #f7f7f7 0% 50%) 0 0 / 24px 24px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 0.6rem 1rem;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #04140b;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,288 @@
|
||||
// M1-4 Length axis — pure functions + state machine, no DOM.
|
||||
//
|
||||
// Ground truth: docs/CAPTURE.md §Length axis
|
||||
// - min-area bounding rectangle of the mask
|
||||
// - length axis on the long side of that rectangle
|
||||
// - user rotates the axis if it does not follow the slab length
|
||||
// - approve the axis
|
||||
//
|
||||
// minAreaRect implements the rotating calipers method (Shamos & Ho 1975)
|
||||
// over the convex hull of the mask pixels. The convex hull is computed
|
||||
// with the monotone-chain algorithm (O(n log n)).
|
||||
|
||||
/**
|
||||
* Compute the minimum-area bounding rectangle of a set of 2D points.
|
||||
* Uses the rotating calipers method on the convex hull.
|
||||
*
|
||||
* @param {Array<{x:number, y:number}>} pts
|
||||
* @returns {{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}}
|
||||
* angle: rotation of the long side in radians (0..PI/2, CCW from +x).
|
||||
* width: length along the long side. height: length along the short side.
|
||||
*/
|
||||
function minAreaRectPoints(pts) {
|
||||
const n = pts.length;
|
||||
if (n === 0) return null;
|
||||
if (n === 1)
|
||||
return {
|
||||
center: { x: pts[0].x, y: pts[0].y },
|
||||
width: 0,
|
||||
height: 0,
|
||||
angle: 0,
|
||||
area: 0,
|
||||
};
|
||||
if (n === 2) {
|
||||
const dx = pts[1].x - pts[0].x;
|
||||
const dy = pts[1].y - pts[0].y;
|
||||
return {
|
||||
center: { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 },
|
||||
width: Math.sqrt(dx * dx + dy * dy),
|
||||
height: 0,
|
||||
angle: Math.atan2(dy, dx),
|
||||
area: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Convex hull (monotone chain).
|
||||
const hull = convexHull(pts);
|
||||
if (hull.length < 3) return minAreaRectPoints(hull);
|
||||
|
||||
// Rotating calipers: try each hull edge as the support line.
|
||||
let best = { area: Infinity, width: 0, height: 0, angle: 0, center: { x: 0, y: 0 } };
|
||||
const m = hull.length;
|
||||
|
||||
for (let i = 0; i < m; i++) {
|
||||
const p1 = hull[i];
|
||||
const p2 = hull[(i + 1) % m];
|
||||
const ex = p2.x - p1.x;
|
||||
const ey = p2.y - p1.y;
|
||||
const len = Math.sqrt(ex * ex + ey * ey);
|
||||
const ux = ex / len;
|
||||
const uy = ey / len;
|
||||
|
||||
// Project all hull points onto the edge direction (u) and its
|
||||
// left normal (v = (-uy, ux)).
|
||||
let minU = Infinity,
|
||||
maxU = -Infinity;
|
||||
let minV = Infinity,
|
||||
maxV = -Infinity;
|
||||
for (let j = 0; j < m; j++) {
|
||||
const q = hull[j];
|
||||
const u = q.x * ux + q.y * uy;
|
||||
const v = -q.x * uy + q.y * ux;
|
||||
if (u < minU) minU = u;
|
||||
if (u > maxU) maxU = u;
|
||||
if (v < minV) minV = v;
|
||||
if (v > maxV) maxV = v;
|
||||
}
|
||||
|
||||
const w = maxU - minU;
|
||||
const h = maxV - minV;
|
||||
const area = w * h;
|
||||
|
||||
if (area < best.area) {
|
||||
// Center in (u,v) frame, transformed back to (x,y):
|
||||
// x = cu * ux - cv * uy
|
||||
// y = cu * uy + cv * ux
|
||||
const cu = (minU + maxU) / 2;
|
||||
const cv = (minV + maxV) / 2;
|
||||
const centerX = cu * ux - cv * uy;
|
||||
const centerY = cu * uy + cv * ux;
|
||||
|
||||
// Long side: along u when w >= h, else along the normal v.
|
||||
let width, height, angle;
|
||||
if (w >= h) {
|
||||
width = w;
|
||||
height = h;
|
||||
angle = Math.atan2(uy, ux);
|
||||
} else {
|
||||
width = h;
|
||||
height = w;
|
||||
// Long side along v = (-uy, ux)
|
||||
angle = Math.atan2(ux, -uy);
|
||||
}
|
||||
// Normalize angle to [0, PI) — rectangle has 180-degree symmetry.
|
||||
if (angle < 0) angle += Math.PI;
|
||||
if (angle >= Math.PI) angle -= Math.PI;
|
||||
|
||||
best = {
|
||||
center: { x: centerX, y: centerY },
|
||||
width,
|
||||
height,
|
||||
angle,
|
||||
area,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotone-chain convex hull, keeping collinear boundary points.
|
||||
* Returns an array of {x,y} points in CCW order, no duplicate last point.
|
||||
*
|
||||
* Collinear points are kept because the min-area bounding rectangle
|
||||
* algorithm needs them: a rectangle's side is defined by its two
|
||||
* endpoints, and dropping intermediate collinear points would lose the
|
||||
* true extent of the shape.
|
||||
*
|
||||
* @param {Array<{x:number,y:number}>} pts
|
||||
* @returns {Array<{x:number,y:number}>}
|
||||
*/
|
||||
function convexHull(pts) {
|
||||
const n = pts.length;
|
||||
if (n <= 2) return pts.slice();
|
||||
|
||||
const sorted = pts.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
|
||||
|
||||
const cross = (O, A, B) =>
|
||||
(A.x - O.x) * (B.y - O.y) - (A.y - O.y) * (B.x - O.x);
|
||||
|
||||
const lower = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
while (
|
||||
lower.length >= 2 &&
|
||||
cross(lower[lower.length - 2], lower[lower.length - 1], sorted[i]) < 0
|
||||
) {
|
||||
lower.pop();
|
||||
}
|
||||
lower.push(sorted[i]);
|
||||
}
|
||||
|
||||
const upper = [];
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
while (
|
||||
upper.length >= 2 &&
|
||||
cross(upper[upper.length - 2], upper[upper.length - 1], sorted[i]) < 0
|
||||
) {
|
||||
upper.pop();
|
||||
}
|
||||
upper.push(sorted[i]);
|
||||
}
|
||||
|
||||
// Concatenate, dropping the last point of each (duplicates).
|
||||
const hull = lower.slice(0, -1).concat(upper.slice(0, -1));
|
||||
return hull;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum-area bounding rectangle of a binary mask.
|
||||
*
|
||||
* @param {Uint8Array} mask binary mask (1 = kept)
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @returns {null|{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}}
|
||||
*/
|
||||
export function minAreaRect(mask, width, height) {
|
||||
const pts = [];
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (mask[y * width + x]) {
|
||||
pts.push({ x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pts.length === 0) return null;
|
||||
if (pts.length === 1)
|
||||
return {
|
||||
center: { x: pts[0].x, y: pts[0].y },
|
||||
width: 1,
|
||||
height: 1,
|
||||
angle: 0,
|
||||
area: 1,
|
||||
};
|
||||
return minAreaRectPoints(pts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit vector along the long side of a min-area rectangle.
|
||||
*
|
||||
* @param {{width:number, height:number, angle:number}} rect
|
||||
* @returns {{x:number, y:number}}
|
||||
*/
|
||||
export function axisFromRect(rect) {
|
||||
if (rect.width >= rect.height) {
|
||||
// Long side is along the rect's angle direction.
|
||||
return { x: Math.cos(rect.angle), y: Math.sin(rect.angle) };
|
||||
} else {
|
||||
// Long side is along the normal (90 degrees CCW from the rect angle).
|
||||
return { x: -Math.sin(rect.angle), y: Math.cos(rect.angle) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a unit axis by an angle (radians, CCW positive).
|
||||
*
|
||||
* @param {{x:number, y:number}} axis
|
||||
* @param {number} angle radians
|
||||
* @returns {{x:number, y:number}}
|
||||
*/
|
||||
export function rotateAxis(axis, angle) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
return { x: axis.x * cos - axis.y * sin, y: axis.x * sin + axis.y * cos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Angle of a unit axis in radians (-PI..PI).
|
||||
*
|
||||
* @param {{x:number, y:number}} axis
|
||||
* @returns {number}
|
||||
*/
|
||||
export function axisAngle(axis) {
|
||||
return Math.atan2(axis.y, axis.x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit vector from an angle in radians.
|
||||
*
|
||||
* @param {number} angle radians
|
||||
* @returns {{x:number, y:number}}
|
||||
*/
|
||||
export function axisUnit(angle) {
|
||||
return { x: Math.cos(angle), y: Math.sin(angle) };
|
||||
}
|
||||
|
||||
// --- axis state machine -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a fresh axis state from an approved min-area rectangle.
|
||||
*
|
||||
* @param {{center:{x:number,y:number}, width:number, height:number, angle:number, area:number}} rect
|
||||
*/
|
||||
export function createAxisState(rect) {
|
||||
return {
|
||||
rect,
|
||||
axis: axisFromRect(rect),
|
||||
approved: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reducer for the axis state.
|
||||
* Actions:
|
||||
* { type: 'rotate', angle } — rotate the axis by `angle` radians (CCW).
|
||||
* Retracts any prior approval.
|
||||
* { type: 'approve' } — mark the axis as approved.
|
||||
*
|
||||
* @param {object} state
|
||||
* @param {object} action
|
||||
* @returns {object} new state
|
||||
*/
|
||||
export function reduceAxisState(state, action) {
|
||||
switch (action.type) {
|
||||
case 'rotate': {
|
||||
const angle = action.angle;
|
||||
if (angle === 0) return state;
|
||||
// Rotate the RECTANGLE, not a separate axis vector. The rect's angle
|
||||
// changes, and the axis is re-derived from the rotated rect.
|
||||
const newRect = { ...state.rect, angle: state.rect.angle + angle };
|
||||
return { ...state, rect: newRect, axis: axisFromRect(newRect), approved: false };
|
||||
}
|
||||
case 'approve': {
|
||||
return { ...state, approved: true };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* M1 UAT-3: Browser gate (hard stop).
|
||||
*
|
||||
* CAPTURE.md §Browsers:
|
||||
* Phone: iOS Safari, Android Chrome.
|
||||
* PC: Chrome, Edge, Firefox.
|
||||
* Other clients get a hard stop.
|
||||
*
|
||||
* Pure logic, DOM-free, unit-testable under node.
|
||||
*
|
||||
* Detection: each rule returns a browser name OR null (no match).
|
||||
* Rules are tried in order; the first non-null wins. Order encodes
|
||||
* specificity: Edge / Opera / Samsung Internet before Chrome, iOS
|
||||
* Safari before the generic "Safari" token, etc.
|
||||
*
|
||||
* PC: allow Chrome, Edge, Firefox; everything else is a hard stop.
|
||||
* Phone: allow iOS Safari, Android Chrome; everything else is a hard
|
||||
* stop.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} ua
|
||||
* @returns {string|null} browser name, or null when the rule does not match
|
||||
*/
|
||||
const RULES = [
|
||||
// Edge (desktop/Android) — its UA carries "Chrome/" too.
|
||||
(ua) => (/Edg\//.test(ua) ? 'Microsoft Edge' : null),
|
||||
// Opera — "OPR/" token, distinct from Chrome.
|
||||
(ua) => (/OPR\//.test(ua) ? 'Opera' : null),
|
||||
// Samsung Internet — "SamsungBrowser" token.
|
||||
(ua) => (/SamsungBrowser/.test(ua) ? 'Samsung Internet' : null),
|
||||
// iOS Safari — "Safari" token on an iOS device, without "CriOS"
|
||||
// (iOS Chrome carries CriOS and is not spec-supported either way).
|
||||
// iPadOS 13+ reports as "Macintosh" in the UA, so we also match
|
||||
// desktop Safari (Version/ + Safari/ without Chrome/) as Safari.
|
||||
(ua) =>
|
||||
/Safari/.test(ua) && /iPhone|iPad|iPod/.test(ua) && !/CriOS/.test(ua)
|
||||
? 'iOS Safari'
|
||||
: null,
|
||||
// Android Chrome — "Chrome" token on Android.
|
||||
(ua) => (/Chrome/.test(ua) && /Android/.test(ua) ? 'Android Chrome' : null),
|
||||
// Desktop Chrome — "Chrome" token, not on Android.
|
||||
(ua) => (/Chrome/.test(ua) && !/Android/.test(ua) ? 'Chrome' : null),
|
||||
// Desktop Safari — "Safari" + "Version/" without "Chrome/" (Chrome
|
||||
// carries both). iPadOS 13+ reports as Macintosh, so this catches
|
||||
// iPad Safari too.
|
||||
(ua) =>
|
||||
/Safari/.test(ua) && /Version\//.test(ua) && !/Chrome\//.test(ua)
|
||||
? 'Safari'
|
||||
: null,
|
||||
// Firefox.
|
||||
(ua) => (/Firefox/.test(ua) ? 'Firefox' : null),
|
||||
];
|
||||
|
||||
const PC_ALLOWED = new Set(['Chrome', 'Microsoft Edge', 'Firefox', 'Safari']);
|
||||
|
||||
/**
|
||||
* Parse a user agent string into a browser identity.
|
||||
* @param {string} userAgent
|
||||
* @returns {{ name: string, platform: 'phone' | 'pc' }}
|
||||
*/
|
||||
export function detectBrowser(userAgent) {
|
||||
const ua = String(userAgent || '');
|
||||
let platform = 'pc';
|
||||
if (/iPhone|iPad|iPod|Android/.test(ua)) {
|
||||
platform = 'phone';
|
||||
}
|
||||
for (const rule of RULES) {
|
||||
const name = rule(ua);
|
||||
if (name) {
|
||||
return { name, platform };
|
||||
}
|
||||
}
|
||||
return { name: 'Unknown', platform };
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this browser allowed by CAPTURE.md §Browsers?
|
||||
* @param {{ name: string, platform: 'phone' | 'pc' }} browser
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAllowed(browser) {
|
||||
if (browser.platform === 'phone') {
|
||||
return browser.name === 'iOS Safari' || browser.name === 'Android Chrome';
|
||||
}
|
||||
return PC_ALLOWED.has(browser.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to show the hard-stop gate.
|
||||
* @param {string} userAgent
|
||||
* @returns {{ supported: boolean, browser: { name: string, platform: 'phone' | 'pc' } }}
|
||||
*/
|
||||
export function checkBrowserGate(userAgent) {
|
||||
const browser = detectBrowser(userAgent);
|
||||
return { supported: isAllowed(browser), browser };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// M1 deterministic inventory generation — pure functions.
|
||||
//
|
||||
// Ground truth: docs/CAPTURE.md §Inventory PNG
|
||||
// Aspect | 3:4 portrait
|
||||
// Layout | Slab centered, 80% fill at widest and tallest points
|
||||
// Size | After crop, shorter side >= 1600px
|
||||
//
|
||||
// This module handles the logic for calculating the inventory metadata
|
||||
// and preparing the instructions for the browser-side PNG encoding.
|
||||
//
|
||||
// Note: The actual PNG encoding (canvas/OffscreenCanvas) is handled
|
||||
// by the frontend component/worker. This file provides the data.
|
||||
//
|
||||
/**
|
||||
* @typedef {Object} InventoryMetadata
|
||||
* @property {number} sqft
|
||||
* @property {number} bdft
|
||||
* @property {number[]} widths
|
||||
* @property {number} minWidth
|
||||
* @property {number} maxWidth
|
||||
* @property {number} avgWidth
|
||||
* @property {number} lengthIn
|
||||
* @property {number} thicknessIn
|
||||
* @property {number} scale
|
||||
*/
|
||||
//
|
||||
/**
|
||||
* Prepares the metadata for the inventory PNG.
|
||||
* @param {number} maskPx - Total area of the mask in pixels.
|
||||
* @param {number} scale - The px-per-inch scale.
|
||||
* @param {number} lengthIn - User-entered length in inches.
|
||||
* @param {number} thicknessIn - User-entered thickness in inches.
|
||||
* @param {number[]} sampledWidthsPx - The raw pixel widths sampled along the axis.
|
||||
* @returns {InventoryMetadata}
|
||||
*/
|
||||
export function prepareInventoryMetadata(
|
||||
maskPx,
|
||||
scale,
|
||||
lengthIn,
|
||||
thicknessIn,
|
||||
sampledWidthsPx
|
||||
) {
|
||||
// Re-use logic from math.js to ensure consistency
|
||||
const sqft = (maskPx / (scale * scale)) / 144;
|
||||
const bdft = sqft * thicknessIn;
|
||||
|
||||
// Widths are converted to inches
|
||||
const widths = sampledWidthsPx.map((w) => w / scale);
|
||||
const minWidth = Math.min(...widths);
|
||||
const maxWidth = Math.max(...widths);
|
||||
const avgWidth = widths.reduce((a, b) => a + b, 0) / widths.length;
|
||||
|
||||
return {
|
||||
sqft: Math.round(sqft * 100) / 100,
|
||||
bdft: Math.round(bdft * 100) / 100,
|
||||
widths,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
avgWidth,
|
||||
lengthIn,
|
||||
thicknessIn,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// M1-3 mask key geometry — pure functions, no canvas/DOM.
|
||||
//
|
||||
// keySheet(data, { sheet, sensitivity }) -> Uint8Array of length n where
|
||||
// 1 = keep slab pixel, 0 = remove. maskStats(mask) -> { kept, total, empty }.
|
||||
//
|
||||
// Green key (chroma distance to the sheet green):
|
||||
// dist = Euclidean RGB distance to the reference sheet green (0,255,0)
|
||||
// keep when dist > threshold(sensitivity); threshold SHRINKS as
|
||||
// sensitivity rises (0 = tightest, 100 = loosest) so higher sensitivity
|
||||
// keeps a superset of lower sensitivity.
|
||||
//
|
||||
// Black key (brightness above a cutoff):
|
||||
// keep when brightness > cutoff.
|
||||
//
|
||||
// Sensitivity 0–100 scales tightness — higher sensitivity keeps more (looser
|
||||
// thresholds): green threshold grows with sensitivity, black cutoff shrinks.
|
||||
// Auto picks whichever sheet yields more kept pixels.
|
||||
|
||||
/**
|
||||
* Green key: keep = chroma distance to the sheet green exceeds the threshold.
|
||||
* Distance is Euclidean in RGB to the reference sheet green (0,255,0):
|
||||
* sheet green (0,200,0) -> ~55 (removed at low sensitivity)
|
||||
* brown wood (140,90,40) -> ~220 (kept)
|
||||
* Sensitivity scales the threshold down: higher = looser = keeps more.
|
||||
*/
|
||||
// Reference sheet green: pure keying green. Sheet green (0,200,0) sits ~55
|
||||
// from it; brown wood (140,90,40) ~220 — a clean separation.
|
||||
const SHEET_GREEN = { r: 0, g: 255, b: 0 };
|
||||
function greenDistance(r, g, b) {
|
||||
const dr = r - SHEET_GREEN.r;
|
||||
const dg = g - SHEET_GREEN.g;
|
||||
const db = b - SHEET_GREEN.b;
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Greenness gate: a pixel must have a meaningfully green channel to be
|
||||
* considered "on a green sheet." Without this, a dark or neutral background
|
||||
* (e.g. rgb 26,27,29) is far from sheet-green in RGB distance and would be
|
||||
* kept by the green key, making the key useless on non-green surfaces.
|
||||
* A real green sheet has g >> r and g >> b. A dark surface has all channels
|
||||
* low and roughly equal.
|
||||
*/
|
||||
function isGreenish(r, g, b) {
|
||||
return g > 40 && g > r * 1.3 && g > b * 1.3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 0–100 sensitivity -> green distance threshold.
|
||||
* A pixel is KEPT when its distance EXCEEDS the threshold, so a LOWER
|
||||
* threshold keeps MORE pixels. Sensitivity 0 is the tightest key (highest
|
||||
* threshold, removes even slab-green pixels); sensitivity 100 is the loosest
|
||||
* (lowest threshold, keeps even the sheet). Monotone: higher sensitivity
|
||||
* keeps a superset of lower sensitivity.
|
||||
* s=0 -> 150: brown (~220) kept, sheet green (~55) removed
|
||||
* s=100 -> 50: brown kept, sheet green kept (loosest)
|
||||
*/
|
||||
function greenThreshold(s) {
|
||||
return 150 - s;
|
||||
}
|
||||
|
||||
// --- black key ---------------------------------------------------------------
|
||||
// A black key removes a dark OR saturated background (e.g. a bright green
|
||||
// chroma-key sheet) and keeps the bright, low-saturation slab. Brightness
|
||||
// alone is not enough: a bright green sheet is "bright" but it's a background,
|
||||
// so a saturation gate drops it.
|
||||
|
||||
/** Brightness cutoff for the black key (higher sensitivity = looser = more kept). */
|
||||
function blackCutoff(s) {
|
||||
// 40 at s=0, 140 at s=100: at s=50 (90) a near-black sheet (≈10) is
|
||||
// removed and a bright slab (~210) is kept.
|
||||
return 30 + (s / 100) * 100;
|
||||
}
|
||||
|
||||
/** Saturation gate for the black key: pixels more saturated than this are
|
||||
* treated as background (e.g. a green keying sheet, saturation ≈ 200) and
|
||||
* dropped, even if bright. The slab is low-saturation. */
|
||||
const BLACK_SAT_CAP = 120;
|
||||
|
||||
/**
|
||||
* Key the sheet out of a pixel buffer.
|
||||
* @param {Uint8ClampedArray|{r,g,b,a}} data RGBA pixels, length n*4.
|
||||
* @param {{sheet?:'auto'|'green'|'black', sensitivity?:number}} opts
|
||||
* sensitivity is 0–100 (0 = tightest key, 100 = loosest).
|
||||
* @returns {Uint8Array} length n, 1=keep slab pixel, 0=remove.
|
||||
*/
|
||||
export function keySheet(data, opts = {}) {
|
||||
const sheet = opts.sheet ?? 'auto';
|
||||
const sensitivity = Math.max(0, Math.min(100, opts.sensitivity ?? 50));
|
||||
const n = data.length / 4;
|
||||
const mask = new Uint8Array(n);
|
||||
let sheetMode;
|
||||
if (sheet === 'green' || sheet === 'black') sheetMode = sheet;
|
||||
else {
|
||||
// auto: run both keys, prefer the one that removes more (fewer kept)
|
||||
// as long as it is not empty. A key that keeps everything is useless;
|
||||
// a key that keeps only the slab is the one we want.
|
||||
const g = keySheet(data, { sheet: 'green', sensitivity });
|
||||
const b = keySheet(data, { sheet: 'black', sensitivity });
|
||||
let gk = 0,
|
||||
bk = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
gk += g[i];
|
||||
bk += b[i];
|
||||
}
|
||||
// Both empty: return green (tie-break, no slab found either way).
|
||||
if (gk === 0 && bk === 0) return g;
|
||||
// One empty: return the non-empty one.
|
||||
if (gk === 0) return b;
|
||||
if (bk === 0) return g;
|
||||
// Both non-empty: prefer the key that kept FEWER pixels (removed more
|
||||
// background). On a green sheet, the green key keeps only the slab;
|
||||
// the black key may keep slab + some sheet. On a dark surface, the
|
||||
// black key keeps only the slab; the green key (with the greenness
|
||||
// gate) keeps few/no pixels.
|
||||
return gk <= bk ? g : b;
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = i * 4;
|
||||
const r = data[j],
|
||||
g = data[j + 1],
|
||||
b = data[j + 2];
|
||||
if (sheetMode === 'green') {
|
||||
// Green key: keep pixels that are NOT on a green sheet.
|
||||
// A pixel is "on the green sheet" when it is greenish AND close
|
||||
// to the reference sheet-green. Pixels that are not greenish
|
||||
// (dark surfaces, neutral backgrounds) are NOT removed by the
|
||||
// green key — they are kept, so the black key can handle them.
|
||||
// This prevents the green key from keeping 100% of a dark
|
||||
// background (the bug behind the UAT "not detected" report).
|
||||
if (!isGreenish(r, g, b)) {
|
||||
mask[i] = 1; // not on a green sheet — keep (black key's job)
|
||||
} else {
|
||||
mask[i] = greenDistance(r, g, b) > greenThreshold(sensitivity) ? 1 : 0;
|
||||
}
|
||||
} else {
|
||||
const bright = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
const sat = Math.max(r, g, b) - Math.min(r, g, b);
|
||||
mask[i] =
|
||||
bright > blackCutoff(sensitivity) && sat < BLACK_SAT_CAP ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} mask
|
||||
* @returns {{kept:number, total:number, empty:boolean}} empty=true blocks approve.
|
||||
*/
|
||||
export function maskStats(mask) {
|
||||
let kept = 0;
|
||||
for (let i = 0; i < mask.length; i++) kept += mask[i];
|
||||
return { kept, total: mask.length, empty: kept === 0 };
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// M1-3 mask state — pure state machine + edge/feather geometry, no DOM.
|
||||
//
|
||||
// groundTruth: docs/CAPTURE.md §Mask
|
||||
// - Sheet: Auto / Green / Black
|
||||
// - Sensitivity: 0–100 step 1 (slider + minus/plus)
|
||||
// - Control value updates immediately; overlay recomputes 2.0 s after the
|
||||
// LAST sensitivity change (debounce). A sheet change may recompute on
|
||||
// the change.
|
||||
// - Approve is blocked when the mask has no slab pixels.
|
||||
// - Edge offset + feather are the M1-3 card's extra knobs; both default 0
|
||||
// (no-op) and are applied after the key pass, before stats.
|
||||
//
|
||||
// The state machine is pure and reducer-based so it is testable in Node
|
||||
// (vitest) without canvas/DOM. The live UI (M1-5) drives it with the
|
||||
// existing pure modules src/lib/core/mask.js (keySheet, maskStats).
|
||||
//
|
||||
// Edge offset (px): erode (positive) / dilate (negative) the binary mask.
|
||||
// Feather (px): attenuate kept pixels linearly toward 0 within `feather` px
|
||||
// of the mask edge. Both are applied to the raw key output and returned as
|
||||
// a Uint8Array mask — same contract as keySheet.
|
||||
|
||||
import { keySheet, maskStats } from './mask.js';
|
||||
|
||||
// --- edge offset -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Erode (positive px) or dilate (negative px) a binary mask by `n` iterations
|
||||
* of 4-neighbour erosion/dilation.
|
||||
* @param {Uint8Array} mask
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {number} n signed iterations (positive = erode, negative = dilate)
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function offsetMask(mask, width, height, n) {
|
||||
const iterations = Math.abs(Math.round(n));
|
||||
const out = new Uint8Array(mask);
|
||||
for (let it = 0; it < iterations; it++) {
|
||||
const next = new Uint8Array(out.length);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = y * width + x;
|
||||
if (n > 0) {
|
||||
// Erode: keep only if all 4-neighbours are kept.
|
||||
// Out-of-bounds counts as removed (not kept).
|
||||
let all = out[i] === 1;
|
||||
if (all && (x === 0 || out[i - 1] === 0)) all = false;
|
||||
if (all && (x === width - 1 || out[i + 1] === 0)) all = false;
|
||||
if (all && (y === 0 || out[i - width] === 0)) all = false;
|
||||
if (all && (y === height - 1 || out[i + width] === 0)) all = false;
|
||||
next[i] = all ? 1 : 0;
|
||||
} else {
|
||||
// Dilate: keep if any 4-neighbour is kept.
|
||||
let any = out[i] === 1;
|
||||
if (!any && x > 0 && out[i - 1] === 1) any = true;
|
||||
if (!any && x < width - 1 && out[i + 1] === 1) any = true;
|
||||
if (!any && y > 0 && out[i - width] === 1) any = true;
|
||||
if (!any && y < height - 1 && out[i + width] === 1) any = true;
|
||||
next[i] = any ? 1 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.set(next);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// NOTE: featherMask returns a Float32Array weight map (1.0 = fully kept,
|
||||
// 0.0 = fully removed) for the UI overlay; the binary approve decision
|
||||
// still comes from the un-feathered mask's maskStats.
|
||||
/**
|
||||
* Feather: weight map of a binary mask — kept pixels within `radius` px of a
|
||||
* removed pixel are attenuated linearly (1 at the edge, →0 at full radius).
|
||||
* @param {Uint8Array} mask binary
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {number} radius px (0 = no-op, returns identity weights)
|
||||
* @returns {Float32Array} length w*h, 1 = kept, 0 = removed
|
||||
*/
|
||||
export function featherMask(mask, width, height, radius) {
|
||||
const r = Math.round(radius);
|
||||
const out = new Float32Array(width * height);
|
||||
for (let i = 0; i < mask.length; i++) out[i] = mask[i] ? 1 : 0;
|
||||
if (r <= 0) return out;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = y * width + x;
|
||||
if (out[i] !== 1) continue;
|
||||
let dist = r + 1;
|
||||
for (let dy = -r; dy <= r && dist > 0; dy++) {
|
||||
const ny = y + dy;
|
||||
if (ny < 0 || ny >= height) continue;
|
||||
for (let dx = -r; dx <= r && dist > 0; dx++) {
|
||||
const nx = x + dx;
|
||||
if (nx < 0 || nx >= width) continue;
|
||||
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
if (d === 0 || d >= dist) continue;
|
||||
if (mask[ny * width + nx] === 0) dist = d;
|
||||
}
|
||||
}
|
||||
if (dist <= r) {
|
||||
out[i] = (r - dist + 1) / (r + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- pure mask pipeline -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run the full mask pipeline on an RGBA buffer:
|
||||
* keySheet(data, {sheet, sensitivity}) → offsetMask(…, edgeOffset) →
|
||||
* featherMask(…, feather) → maskStats
|
||||
*
|
||||
* `mask` is the binary approve/footprint mask (edgeOffset applied, feather
|
||||
* NOT — feather is display-only). `weights` is the display weight map
|
||||
* (feather applied to the binary mask). Stats are computed from `mask` so
|
||||
* feather never affects the approve decision.
|
||||
* @param {Uint8ClampedArray} data RGBA pixels
|
||||
* @param {{width:number, height:number, sheet?:string, sensitivity?:number,
|
||||
* edgeOffset?:number, feather?:number}} opts
|
||||
* @returns {{mask:Uint8Array, weights:Float32Array, stats:{kept:number,total:number,empty:boolean}}}
|
||||
*/
|
||||
export function computeMask(data, opts) {
|
||||
const width = opts.width;
|
||||
const height = opts.height;
|
||||
const sheet = opts.sheet ?? 'auto';
|
||||
const sensitivity = Math.max(0, Math.min(100, opts.sensitivity ?? 50));
|
||||
const edgeOffset = opts.edgeOffset ?? 0;
|
||||
const feather = opts.feather ?? 0;
|
||||
|
||||
let mask = keySheet(data, { sheet, sensitivity });
|
||||
if (edgeOffset !== 0) mask = offsetMask(mask, width, height, edgeOffset);
|
||||
const weights = featherMask(mask, width, height, feather);
|
||||
return { mask, weights, stats: maskStats(mask) };
|
||||
}
|
||||
|
||||
// --- mask state machine --------------------------------------------------------
|
||||
|
||||
// Debounce window per docs/CAPTURE.md §Mask: 2.0 s after the last
|
||||
// sensitivity change. Exported so tests can assert the constant.
|
||||
export const RECOMPUTE_DELAY_MS = 2000;
|
||||
|
||||
/**
|
||||
* Create a fresh mask state.
|
||||
* @param {{width:number, height:number}} dims source photo dimensions in px
|
||||
*/
|
||||
export function createMaskState(dims) {
|
||||
return {
|
||||
dims,
|
||||
sheet: 'auto',
|
||||
sensitivity: 50,
|
||||
edgeOffset: 0,
|
||||
feather: 0,
|
||||
approved: false,
|
||||
lastRecomputeAt: 0,
|
||||
pendingRecompute: false,
|
||||
stats: { kept: 0, total: dims.width * dims.height, empty: true },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reducer for the mask state.
|
||||
* Actions:
|
||||
* { type: 'set_sheet', sheet } — may mark pending recompute
|
||||
* { type: 'set_sensitivity', value } — always marks pending recompute
|
||||
* { type: 'nudge_sensitivity', delta } — same as set_sensitivity
|
||||
* { type: 'set_edge_offset', value } — may mark pending recompute
|
||||
* { type: 'set_feather', value } — may mark pending recompute
|
||||
* { type: 'recompute', stats, at } — apply a finished recompute
|
||||
* { type: 'approve' } — blocked if stats.empty
|
||||
*
|
||||
* Control values update immediately; the OVERLAY recomputes on the 2.0 s
|
||||
* debounce (UI owns the timer and dispatches 'recompute' with fresh stats).
|
||||
* @param {object} state
|
||||
* @param {object} action
|
||||
* @returns {object} new state
|
||||
*/
|
||||
export function reduceMaskState(state, action) {
|
||||
switch (action.type) {
|
||||
case 'set_sheet': {
|
||||
const sheet = action.sheet;
|
||||
if (sheet === state.sheet) return state;
|
||||
return { ...state, sheet, pendingRecompute: true };
|
||||
}
|
||||
case 'set_sensitivity': {
|
||||
const sensitivity = Math.max(0, Math.min(100, Math.round(action.value)));
|
||||
if (sensitivity === state.sensitivity) return state;
|
||||
return { ...state, sensitivity, pendingRecompute: true };
|
||||
}
|
||||
case 'nudge_sensitivity': {
|
||||
const sensitivity = Math.max(
|
||||
0,
|
||||
Math.min(100, state.sensitivity + Math.round(action.delta))
|
||||
);
|
||||
if (sensitivity === state.sensitivity) return state;
|
||||
return { ...state, sensitivity, pendingRecompute: true };
|
||||
}
|
||||
case 'set_edge_offset': {
|
||||
const edgeOffset = Math.round(action.value);
|
||||
if (edgeOffset === state.edgeOffset) return state;
|
||||
return { ...state, edgeOffset, pendingRecompute: true };
|
||||
}
|
||||
case 'set_feather': {
|
||||
const feather = Math.max(0, Math.round(action.value));
|
||||
if (feather === state.feather) return state;
|
||||
return { ...state, feather, pendingRecompute: true };
|
||||
}
|
||||
case 'recompute': {
|
||||
return {
|
||||
...state,
|
||||
stats: action.stats,
|
||||
lastRecomputeAt: action.at,
|
||||
pendingRecompute: false,
|
||||
};
|
||||
}
|
||||
case 'approve': {
|
||||
if (state.stats.empty) return state; // blocked: no slab pixels
|
||||
return { ...state, approved: true, pendingRecompute: false };
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the UI should fire a recompute now: a change is pending and at
|
||||
* least RECOMPUTE_DELAY_MS have elapsed since the last recompute.
|
||||
* @param {object} state
|
||||
* @param {number} now ms
|
||||
*/
|
||||
export function dueForRecompute(state, now) {
|
||||
return state.pendingRecompute && now - state.lastRecomputeAt >= RECOMPUTE_DELAY_MS;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// M1 deterministic measurement math — pure functions, no DOM.
|
||||
//
|
||||
// Ground truth: docs/CAPTURE.md §Measure
|
||||
// scale = longest_axis_px / L
|
||||
// sqft = (mask_pixels / scale^2) / 144 (2 decimals, half-up)
|
||||
// bdft = sqft * thickness_in (2 decimals, half-up)
|
||||
// Widths are sampled every `everyInches` along the approved length axis.
|
||||
|
||||
/** px-per-inch scale: longest axis in pixels divided by its length in inches. */
|
||||
export function scale(longestAxisPx, lengthIn) {
|
||||
return longestAxisPx / lengthIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Round half-up to `decimals` places (NOT banker's rounding, not toFixed's
|
||||
* binary-float quirks). 30.625 -> 30.63, 1.005 -> 1.01.
|
||||
*/
|
||||
export function roundHalfUp(x, decimals) {
|
||||
const f = Math.pow(10, decimals);
|
||||
return Math.round((x + Number.EPSILON) * f) / f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Square feet from mask pixel area and the px-per-inch scale.
|
||||
* (mask_px / scale^2) gives square inches; /144 gives square feet.
|
||||
* @returns number rounded half-up to 2 decimals.
|
||||
*/
|
||||
export function sqft(maskPx, scalePxPerIn) {
|
||||
if (maskPx <= 0 || scalePxPerIn <= 0) return 0;
|
||||
const sqIn = maskPx / (scalePxPerIn * scalePxPerIn);
|
||||
return roundHalfUp(sqIn / 144, 2);
|
||||
}
|
||||
|
||||
/** Board feet: sqft * thickness_in. 1 sqft at 1in thick = 1 bdft. */
|
||||
export function bdft(sqftVal, thicknessIn) {
|
||||
return roundHalfUp(sqftVal * thicknessIn, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sampled perpendicular widths, in inches, every `everyInches` along the axis
|
||||
* from 0 up to (but not past) axisLengthPx.
|
||||
* @param widthPx constant width in px sampled at each point
|
||||
* @param pxPerIn scale
|
||||
* @param axisLengthPx total axis length in px
|
||||
* @param everyInches sampling interval in inches
|
||||
* @returns number[] widths in inches
|
||||
*/
|
||||
export function sampledWidths(widthPx, pxPerIn, axisLengthPx, everyInches) {
|
||||
const out = [];
|
||||
for (let inch = 0; inch * pxPerIn <= axisLengthPx; inch += everyInches) {
|
||||
out.push(widthPx / pxPerIn);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* min/max/avg (inches) over a list of sampled widths given in px.
|
||||
* @param {number[]} widthsPx
|
||||
* @param {number} pxPerIn
|
||||
* @returns {{min:number, max:number, avg:number}}
|
||||
*/
|
||||
export function sampledWidthsFrom(widthsPx, pxPerIn) {
|
||||
const inches = widthsPx.map((w) => w / pxPerIn);
|
||||
const min = Math.min(...inches);
|
||||
const max = Math.max(...inches);
|
||||
const avg = inches.reduce((a, b) => a + b, 0) / inches.length;
|
||||
return { min, max, avg };
|
||||
}
|
||||
|
||||
/** Snap an inch value to the nearest 1/8 inch. */
|
||||
export function snapToEighth(inches) {
|
||||
return Math.round(inches * 8) / 8;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// M1-5 Measure — mask width sampling along the approved length axis.
|
||||
//
|
||||
// Ground truth: docs/CAPTURE.md §Measure
|
||||
// - scale = longest_axis_px / L
|
||||
// - Widths: min, max, avg, sampled every 6 in along the approved length
|
||||
// axis, perpendicular to it
|
||||
//
|
||||
// sampleWidthsPx is the real scanline sampler the UI runs on the approved
|
||||
// mask: at each sample point t = 0, 6, 12, ... in (px) along the axis it
|
||||
// scans the perpendicular direction across the whole photo and measures the
|
||||
// contiguous run of kept pixels that covers the sample point.
|
||||
|
||||
/**
|
||||
* Sample perpendicular mask widths along a length axis.
|
||||
*
|
||||
* @param {Uint8Array} mask binary mask (1 = kept), row-major
|
||||
* @param {number} width photo width in px
|
||||
* @param {number} height photo height in px
|
||||
* @param {{origin:{x:number,y:number}, dir:{x:number,y:number}, lengthPx:number}} axis
|
||||
* origin = a point on the axis (e.g. the rectangle corner the long side
|
||||
* starts at), dir = unit vector along the slab length, lengthPx = its
|
||||
* pixel length.
|
||||
* @param {number} pxPerIn scale
|
||||
* @param {number} [everyIn=6] sampling interval in inches
|
||||
* @returns {number[]} widths in px, one per sample (t = 0, everyIn, ... in px
|
||||
* at or under lengthPx)
|
||||
*/
|
||||
export function sampleWidthsPx(mask, width, height, axis, pxPerIn, everyIn = 6) {
|
||||
const stepPx = everyIn * pxPerIn;
|
||||
const ux = axis.dir.x;
|
||||
const uy = axis.dir.y;
|
||||
// Perpendicular unit vector (left normal): (-uy, ux).
|
||||
const perpX = -uy;
|
||||
const perpY = ux;
|
||||
const out = [];
|
||||
|
||||
for (let t = 0; t <= axis.lengthPx; t += stepPx) {
|
||||
// Sample point.
|
||||
const sx = axis.origin.x + ux * t;
|
||||
const sy = axis.origin.y + uy * t;
|
||||
// Find the contiguous kept run along the perpendicular line that
|
||||
// covers (sx, sy), walking out from the sample point in both
|
||||
// perpendicular directions.
|
||||
const rx = Math.round(sx);
|
||||
const ry = Math.round(sy);
|
||||
if (rx < 0 || ry < 0 || rx >= width || ry >= height || !mask[ry * width + rx]) {
|
||||
out.push(0);
|
||||
continue;
|
||||
}
|
||||
let a = 0; // extent in the negative perpendicular direction
|
||||
let b = 0; // extent in the positive perpendicular direction
|
||||
for (;;) {
|
||||
const nx = rx - Math.round(perpX * (a + 1));
|
||||
const ny = ry - Math.round(perpY * (a + 1));
|
||||
if (nx < 0 || ny < 0 || nx >= width || ny >= height) break;
|
||||
if (!mask[ny * width + nx]) break;
|
||||
a += 1;
|
||||
}
|
||||
for (;;) {
|
||||
const nx = rx + Math.round(perpX * (b + 1));
|
||||
const ny = ry + Math.round(perpY * (b + 1));
|
||||
if (nx < 0 || ny < 0 || nx >= width || ny >= height) break;
|
||||
if (!mask[ny * width + nx]) break;
|
||||
b += 1;
|
||||
}
|
||||
out.push(a + b + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { browser } from '$app/environment';
|
||||
import { checkBrowserGate } from '$lib/core/browserGate.js';
|
||||
|
||||
// UAT-3: hard-stop browser gate (CAPTURE.md §Browsers).
|
||||
// Client-side only: the gate must be invisible to crawlers/bots
|
||||
// and to any non-JS context, and the app must not render in an
|
||||
// unsupported browser.
|
||||
const gate = browser ? checkBrowserGate(navigator.userAgent) : null;
|
||||
let unsupported = $state(gate ? !gate.supported : false);
|
||||
let browserName = $state(gate ? gate.browser.name : 'this browser');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>SlabUploader — Milestone 1</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if unsupported}
|
||||
<!-- Hard stop: the app does NOT render in an unsupported browser. -->
|
||||
<div class="gate">
|
||||
<h1>Unsupported browser</h1>
|
||||
<p>SlabUploader does not work in <strong>{browserName}</strong>.</p>
|
||||
<p class="sub">Use one of the supported clients:</p>
|
||||
<ul>
|
||||
<li>Phone — iOS Safari, Android Chrome</li>
|
||||
<li>PC — Chrome, Edge, Firefox</li>
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<main>
|
||||
<h1>SlabUploader</h1>
|
||||
<p>Milestone 1 — Capture (browser-only)</p>
|
||||
<slot />
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.gate {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 32rem;
|
||||
margin: 1rem auto;
|
||||
padding: 2rem;
|
||||
border: 2px solid #b00020;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gate h1 {
|
||||
color: #b00020;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.gate .sub {
|
||||
margin: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.gate ul {
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.gate li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
<script>
|
||||
/**
|
||||
* M1-5: Full capture flow — Add photo → Mask → Length axis → Measure.
|
||||
* Drives the pure core modules (mask.js, maskState.js, axis.js, measure.js,
|
||||
* math.js, inventory.js) with canvas overlays.
|
||||
*/
|
||||
import { keySheet, maskStats } from '$lib/core/mask.js';
|
||||
import {
|
||||
createMaskState,
|
||||
reduceMaskState,
|
||||
computeMask,
|
||||
RECOMPUTE_DELAY_MS
|
||||
} from '$lib/core/maskState.js';
|
||||
import {
|
||||
minAreaRect,
|
||||
axisFromRect,
|
||||
createAxisState,
|
||||
reduceAxisState,
|
||||
rotateAxis
|
||||
} from '$lib/core/axis.js';
|
||||
import { sampleWidthsPx } from '$lib/core/measure.js';
|
||||
import { scale as calcScale, sqft, bdft, sampledWidthsFrom } from '$lib/core/math.js';
|
||||
import { prepareInventoryMetadata } from '$lib/core/inventory.js';
|
||||
|
||||
// --- state ----------------------------------------------------------
|
||||
let state = $state('add_photo'); // add_photo | mask | axis | measure
|
||||
let photo = $state(null); // ImageBitmap
|
||||
let photoData = $state(null); // Uint8ClampedArray RGBA
|
||||
let photoW = $state(0);
|
||||
let photoH = $state(0);
|
||||
let maskState = $state(null);
|
||||
let maskResult = $state(null); // {mask, weights, stats}
|
||||
let axisState = $state(null);
|
||||
let rect = $state(null);
|
||||
let heicNotice = $state(false);
|
||||
let busy = $state(false);
|
||||
let debounceTimer = $state(null);
|
||||
|
||||
// measure
|
||||
let lengthIn = $state(48);
|
||||
let thicknessIn = $state(1);
|
||||
let result = $state(null);
|
||||
let pngUrl = $state(null);
|
||||
|
||||
// canvas refs
|
||||
let overlayCanvas = $state(null);
|
||||
let photoCanvas = $state(null);
|
||||
|
||||
// --- photo input ----------------------------------------------------
|
||||
async function onFileChange(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
heicNotice = false;
|
||||
|
||||
const isHeic =
|
||||
file.type === 'image/heic' || file.type === 'image/heif' ||
|
||||
file.name.toLowerCase().match(/\.(heic|heif)$/);
|
||||
|
||||
let bitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
} catch {
|
||||
// Browser cannot decode (HEIC on Chrome/FF)
|
||||
if (isHeic) {
|
||||
heicNotice = true;
|
||||
} else {
|
||||
heicNotice = true; // undecodable file of any type
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
photo = bitmap;
|
||||
photoW = bitmap.width;
|
||||
photoH = bitmap.height;
|
||||
|
||||
// Draw to canvas to get RGBA data
|
||||
const c = document.createElement('canvas');
|
||||
c.width = photoW;
|
||||
c.height = photoH;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
photoData = ctx.getImageData(0, 0, photoW, photoH).data;
|
||||
|
||||
// Initialize mask state
|
||||
maskState = createMaskState({ width: photoW, height: photoH });
|
||||
maskResult = null;
|
||||
|
||||
state = 'mask';
|
||||
// Trigger initial recompute
|
||||
setTimeout(() => doRecompute(), 100);
|
||||
}
|
||||
|
||||
function dismissHeic() {
|
||||
heicNotice = false;
|
||||
}
|
||||
|
||||
// --- mask recompute --------------------------------------------------
|
||||
function doRecompute() {
|
||||
if (!photoData || !maskState) return;
|
||||
busy = true;
|
||||
// Run in a microtask to let UI paint
|
||||
setTimeout(() => {
|
||||
const { mask, weights, stats } = computeMask(photoData, {
|
||||
width: photoW,
|
||||
height: photoH,
|
||||
sheet: maskState.sheet,
|
||||
sensitivity: maskState.sensitivity,
|
||||
edgeOffset: maskState.edgeOffset,
|
||||
feather: maskState.feather
|
||||
});
|
||||
maskResult = { mask, weights, stats };
|
||||
maskState = reduceMaskState(maskState, { type: 'recompute', stats, at: Date.now() });
|
||||
busy = false;
|
||||
drawOverlay();
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function scheduleRecompute() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => doRecompute(), RECOMPUTE_DELAY_MS);
|
||||
}
|
||||
|
||||
function onSheetChange(sheet) {
|
||||
maskState = reduceMaskState(maskState, { type: 'set_sheet', sheet });
|
||||
// Sheet change may recompute immediately
|
||||
doRecompute();
|
||||
}
|
||||
|
||||
function onSensitivityChange(val) {
|
||||
maskState = reduceMaskState(maskState, { type: 'set_sensitivity', value: Number(val) });
|
||||
scheduleRecompute();
|
||||
}
|
||||
|
||||
function nudgeSensitivity(delta) {
|
||||
maskState = reduceMaskState(maskState, { type: 'nudge_sensitivity', delta });
|
||||
scheduleRecompute();
|
||||
}
|
||||
|
||||
function approveMask() {
|
||||
if (!maskState || maskState.stats.empty) return;
|
||||
maskState = reduceMaskState(maskState, { type: 'approve' });
|
||||
// Compute bounding rect + axis
|
||||
rect = minAreaRect(maskResult.mask, photoW, photoH);
|
||||
if (rect) {
|
||||
axisState = createAxisState(rect);
|
||||
}
|
||||
state = 'axis';
|
||||
}
|
||||
|
||||
// --- axis ------------------------------------------------------------
|
||||
function rotateAxisBy(deg) {
|
||||
if (!axisState) return;
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
axisState = reduceAxisState(axisState, { type: 'rotate', angle: rad });
|
||||
}
|
||||
|
||||
function onRotateSlider(deg) {
|
||||
if (!axisState) return;
|
||||
const currentDeg = axisState.rect.angle * 180 / Math.PI;
|
||||
const deltaDeg = deg - currentDeg;
|
||||
if (deltaDeg === 0) return;
|
||||
const rad = (deltaDeg * Math.PI) / 180;
|
||||
axisState = reduceAxisState(axisState, { type: 'rotate', angle: rad });
|
||||
}
|
||||
|
||||
function approveAxis() {
|
||||
if (!axisState) return;
|
||||
axisState = reduceAxisState(axisState, { type: 'approve' });
|
||||
state = 'measure';
|
||||
}
|
||||
|
||||
// --- measure ---------------------------------------------------------
|
||||
function calculate() {
|
||||
if (!rect || !axisState || !maskResult) return;
|
||||
const axisLenPx = rect.width >= rect.height ? rect.width : rect.height;
|
||||
const pxPerIn = calcScale(axisLenPx, lengthIn);
|
||||
const axis = axisState.axis;
|
||||
const origin = { x: rect.center.x - axis.x * axisLenPx / 2, y: rect.center.y - axis.y * axisLenPx / 2 };
|
||||
const axisObj = { origin, dir: axis, lengthPx: axisLenPx };
|
||||
const widthsPx = sampleWidthsPx(maskResult.mask, photoW, photoH, axisObj, pxPerIn);
|
||||
const { min, max, avg } = sampledWidthsFrom(widthsPx, pxPerIn);
|
||||
const sqftVal = sqft(maskResult.stats.kept, pxPerIn);
|
||||
const bdftVal = bdft(sqftVal, thicknessIn);
|
||||
|
||||
const meta = prepareInventoryMetadata(
|
||||
maskResult.stats.kept, pxPerIn, lengthIn, thicknessIn, widthsPx
|
||||
);
|
||||
result = { ...meta, sqft: sqftVal, bdft: bdftVal, min, max, avg };
|
||||
|
||||
// Build inventory PNG: slab centered on transparent bg, 3:4 portrait, 80% fill
|
||||
generatePng();
|
||||
}
|
||||
|
||||
function generatePng() {
|
||||
const c = document.createElement('canvas');
|
||||
// 3:4 portrait, shorter side >= 1600 if source allows
|
||||
const srcMin = Math.min(photoW, photoH);
|
||||
const shorter = Math.max(1600, Math.min(srcMin, 1600));
|
||||
const longer = Math.round((shorter * 4) / 3);
|
||||
c.width = shorter;
|
||||
c.height = longer;
|
||||
const ctx = c.getContext('2d');
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
|
||||
// Scale slab to 80% fill
|
||||
const slabW = photoW;
|
||||
const slabH = photoH;
|
||||
const scaleW = (c.width * 0.8) / slabW;
|
||||
const scaleH = (c.height * 0.8) / slabH;
|
||||
const s = Math.min(scaleW, scaleH);
|
||||
const dw = slabW * s;
|
||||
const dh = slabH * s;
|
||||
const dx = (c.width - dw) / 2;
|
||||
const dy = (c.height - dh) / 2;
|
||||
|
||||
// Draw photo, then apply mask as alpha
|
||||
ctx.drawImage(photo, dx, dy, dw, dh);
|
||||
const imgData = ctx.getImageData(dx, dy, dw, dh);
|
||||
// Scale mask to dw x dh
|
||||
const maskData = maskResult.mask;
|
||||
for (let y = 0; y < dh; y++) {
|
||||
for (let x = 0; x < dw; x++) {
|
||||
const sx = Math.floor((x / dw) * photoW);
|
||||
const sy = Math.floor((y / dh) * photoH);
|
||||
const mIdx = sy * photoW + sx;
|
||||
const a = maskData[mIdx] ? 255 : 0;
|
||||
const i = (y * dw + x) * 4;
|
||||
imgData.data[i + 3] = a;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, dx, dy);
|
||||
|
||||
c.toBlob((blob) => {
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(blob);
|
||||
}, 'image/png');
|
||||
}
|
||||
|
||||
// --- overlay drawing -------------------------------------------------
|
||||
function drawOverlay() {
|
||||
if (!overlayCanvas || !photo) return;
|
||||
const canvas = overlayCanvas;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Draw photo
|
||||
ctx.drawImage(photo, 0, 0, w, h);
|
||||
|
||||
if ((state === 'mask' || state === 'axis' || state === 'measure') && maskResult) {
|
||||
// Checkerboard on removed pixels
|
||||
const weights = maskResult.weights;
|
||||
const mask = maskResult.mask;
|
||||
const sx = w / photoW;
|
||||
const sy = h / photoH;
|
||||
// Draw checkerboard pattern for removed areas
|
||||
const checker = 8;
|
||||
for (let y = 0; y < h; y += checker) {
|
||||
for (let x = 0; x < w; x += checker) {
|
||||
const px = Math.floor((x / w) * photoW);
|
||||
const py = Math.floor((y / h) * photoH);
|
||||
const idx = py * photoW + px;
|
||||
if (!mask[idx]) {
|
||||
const shade = ((Math.floor(x / checker) + Math.floor(y / checker)) % 2 === 0)
|
||||
? 'rgba(200,200,200,0.6)'
|
||||
: 'rgba(220,220,220,0.6)';
|
||||
ctx.fillStyle = shade;
|
||||
ctx.fillRect(x, y, checker, checker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((state === 'axis' || state === 'measure') && axisState) {
|
||||
// Draw bounding rectangle — uses the ROTATED rect from axisState.
|
||||
// The rect's long side IS the length axis (no separate line).
|
||||
const r = axisState.rect;
|
||||
const cx = (r.center.x / photoW) * w;
|
||||
const cy = (r.center.y / photoH) * h;
|
||||
const rw = (r.width / photoW) * w;
|
||||
const rh = (r.height / photoH) * h;
|
||||
ctx.save();
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(r.angle);
|
||||
ctx.strokeStyle = '#00ff88';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(-rw / 2, -rh / 2, rw, rh);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// --- back navigation -------------------------------------------------
|
||||
function goBack() {
|
||||
if (state === 'measure') state = 'axis';
|
||||
else if (state === 'axis') state = 'mask';
|
||||
else if (state === 'mask') state = 'add_photo';
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (photo) photo.close();
|
||||
photo = null;
|
||||
photoData = null;
|
||||
maskState = null;
|
||||
maskResult = null;
|
||||
axisState = null;
|
||||
rect = null;
|
||||
result = null;
|
||||
if (pngUrl) { URL.revokeObjectURL(pngUrl); pngUrl = null; }
|
||||
state = 'add_photo';
|
||||
}
|
||||
|
||||
// Resize overlay canvas when photo changes
|
||||
$effect(() => {
|
||||
if (photoW && photoH && overlayCanvas) {
|
||||
overlayCanvas.width = photoW;
|
||||
overlayCanvas.height = photoH;
|
||||
drawOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
// Redraw when state or mask changes
|
||||
$effect(() => {
|
||||
drawOverlay();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="capture">
|
||||
<h1>SlabUploader</h1>
|
||||
|
||||
{#if heicNotice}
|
||||
<div class="notice" role="alert">
|
||||
<p>Cannot decode this photo. Please retake as JPEG.</p>
|
||||
<button onclick={dismissHeic}>OK</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state === 'add_photo'}
|
||||
<section class="step add-photo">
|
||||
<h2>Add Photo</h2>
|
||||
<p class="setup-rules">
|
||||
Photograph the slab on a green or black sheet.<br>
|
||||
Sheet fills the frame. Slab fully on sheet.<br>
|
||||
Top-down, lens parallel to the face.<br>
|
||||
Length aligned with the long side of the picture.
|
||||
</p>
|
||||
<input type="file" accept="image/jpeg,image/png" onchange={onFileChange} />
|
||||
</section>
|
||||
{:else}
|
||||
<div class="preview-wrap">
|
||||
<canvas bind:this={overlayCanvas} class="preview"></canvas>
|
||||
{#if busy}<div class="busy">Processing…</div>{/if}
|
||||
</div>
|
||||
|
||||
{#if state === 'mask' && maskState}
|
||||
<section class="step mask-step">
|
||||
<h2>Mask</h2>
|
||||
<div class="controls">
|
||||
<div class="control-row">
|
||||
<label>Sheet</label>
|
||||
<div class="seg">
|
||||
<button class:active={maskState.sheet === 'auto'} onclick={() => onSheetChange('auto')}>Auto</button>
|
||||
<button class:active={maskState.sheet === 'green'} onclick={() => onSheetChange('green')}>Green</button>
|
||||
<button class:active={maskState.sheet === 'black'} onclick={() => onSheetChange('black')}>Black</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label>Sensitivity</label>
|
||||
<button class="nudge" onclick={() => nudgeSensitivity(-5)}>−</button>
|
||||
<input type="range" min="0" max="100" step="1" value={maskState.sensitivity} oninput={onSensitivityChange} />
|
||||
<span class="val">{maskState.sensitivity}</span>
|
||||
<button class="nudge" onclick={() => nudgeSensitivity(5)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="back" onclick={goBack}>Back</button>
|
||||
<button class="approve" disabled={maskState.stats.empty} onclick={approveMask}>
|
||||
Approve Mask
|
||||
</button>
|
||||
</div>
|
||||
{#if maskState.stats.empty}
|
||||
<p class="warn">No slab detected — adjust sensitivity or retake.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if state === 'axis' && axisState}
|
||||
<section class="step axis-step">
|
||||
<h2>Length Axis</h2>
|
||||
<p>Rotate the axis to follow the slab length, then approve.</p>
|
||||
<div class="controls">
|
||||
<div class="control-row">
|
||||
<label>Rotate</label>
|
||||
<button class="nudge" onclick={() => rotateAxisBy(-5)}>−</button>
|
||||
<input type="range" min="-180" max="180" step="1"
|
||||
value={Math.round(axisState.rect.angle * 180 / Math.PI)}
|
||||
oninput={(e) => onRotateSlider(Number(e.target.value))}
|
||||
aria-label="Rotate axis" />
|
||||
<span class="val">{Math.round(axisState.rect.angle * 180 / Math.PI)}°</span>
|
||||
<button class="nudge" onclick={() => rotateAxisBy(5)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="back" onclick={goBack}>Back</button>
|
||||
<button class="approve" onclick={approveAxis}>Approve Axis</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if state === 'measure'}
|
||||
<section class="step measure-step">
|
||||
<h2>Measure</h2>
|
||||
<div class="controls">
|
||||
<div class="control-row">
|
||||
<label>Length (in)</label>
|
||||
<input type="number" min="1" step="0.125" value={lengthIn} oninput={(e) => lengthIn = parseFloat(e.target.value) || 0} />
|
||||
</div>
|
||||
<div class="control-row">
|
||||
<label>Thickness (in)</label>
|
||||
<input type="number" min="0.125" step="0.125" value={thicknessIn} oninput={(e) => thicknessIn = parseFloat(e.target.value) || 0} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="back" onclick={goBack}>Back</button>
|
||||
<button class="approve" onclick={calculate}>Calculate</button>
|
||||
</div>
|
||||
|
||||
{#if result}
|
||||
<div class="results">
|
||||
<h3>Results</h3>
|
||||
<dl>
|
||||
<dt>Width (min)</dt><dd>{result.min?.toFixed(2)} in</dd>
|
||||
<dt>Width (max)</dt><dd>{result.max?.toFixed(2)} in</dd>
|
||||
<dt>Width (avg)</dt><dd>{result.avg?.toFixed(2)} in</dd>
|
||||
<dt>Length</dt><dd>{result.lengthIn} in</dd>
|
||||
<dt>Thickness</dt><dd>{result.thicknessIn} in</dd>
|
||||
<dt>Scale</dt><dd>{result.scale?.toFixed(2)} px/in</dd>
|
||||
<dt>Sq Ft</dt><dd>{result.sqft} ft²</dd>
|
||||
<dt>Board Feet</dt><dd>{result.bdft} bdft</dd>
|
||||
</dl>
|
||||
{#if pngUrl}
|
||||
<h3>Inventory PNG</h3>
|
||||
<img src={pngUrl} alt="Inventory PNG" class="png-preview" />
|
||||
{/if}
|
||||
<button class="reset" onclick={reset}>Start New Slab</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.capture {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
h1 { text-align: center; font-size: 1.4rem; margin-bottom: 1rem; }
|
||||
h2 { font-size: 1.1rem; margin-bottom: 0.5rem; }
|
||||
|
||||
.preview-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview {
|
||||
max-width: 100%;
|
||||
max-height: 50vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
.busy {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.step { margin-top: 1rem; }
|
||||
.setup-rules { font-size: 0.9rem; color: #555; margin-bottom: 1rem; line-height: 1.5; }
|
||||
|
||||
.controls { display: flex; flex-direction: column; gap: 0.75rem; margin: 0.5rem 0; }
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.control-row label { min-width: 100px; font-weight: 600; font-size: 0.9rem; }
|
||||
.control-row input[type="range"] { flex: 1; min-width: 120px; }
|
||||
.control-row input[type="number"] { width: 80px; padding: 6px; }
|
||||
.val { min-width: 40px; text-align: center; font-size: 0.9rem; }
|
||||
|
||||
.seg { display: flex; gap: 0; }
|
||||
.seg button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ccc;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.seg button:first-child { border-radius: 6px 0 0 6px; }
|
||||
.seg button:last-child { border-radius: 0 6px 6px 0; }
|
||||
.seg button.active { background: #333; color: #fff; border-color: #333; }
|
||||
|
||||
.nudge {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #ccc;
|
||||
background: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nudge:active { background: #eee; }
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.actions button {
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.back { background: #eee; }
|
||||
.approve { background: #22c55e; color: #fff; }
|
||||
.approve:disabled { background: #aaa; cursor: not-allowed; }
|
||||
|
||||
.warn { color: #dc2626; font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
.notice {
|
||||
background: #fef3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.notice button {
|
||||
margin-top: 0.5rem;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.results { margin-top: 1.5rem; }
|
||||
.results h3 { font-size: 1rem; margin-top: 1rem; }
|
||||
.results dl {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.25rem 1rem;
|
||||
}
|
||||
.results dt { font-weight: 600; font-size: 0.9rem; }
|
||||
.results dd { font-size: 0.9rem; color: #333; }
|
||||
|
||||
.png-preview {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
margin-top: 0.5rem;
|
||||
background: repeating-conic-gradient(#f0f0f0 0% 25%, #fff 0% 50%) 0 0 / 20px 20px;
|
||||
}
|
||||
.reset {
|
||||
margin-top: 1rem;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Minimal health route so the UAT container has a non-SPA probe target.
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export function GET() {
|
||||
return json({ ok: true, milestone: 1, app: 'slab-uploader' });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// M1-4: Playwright tests for the Length axis state.
|
||||
// RED first — the live UI lands in M1-5. These tests describe the
|
||||
// contract the UI must honour once the Mask state approves.
|
||||
//
|
||||
// Spec ground truth (docs/CAPTURE.md §Length axis):
|
||||
// - min-area bounding rectangle of the mask
|
||||
// - length axis on the long side of that rectangle
|
||||
// - user rotates the axis if it does not follow the slab length
|
||||
// - approve the axis
|
||||
|
||||
const SLAB_FIXTURE = 'tests/fixtures/slab-green.jpg';
|
||||
|
||||
test.describe('Length axis state (M1-5 live UI)', () => {
|
||||
test('advances to Length axis after approving the mask', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
// Wait for the initial mask recompute.
|
||||
await page.waitForTimeout(2200);
|
||||
// Approve the mask.
|
||||
await page.getByRole('button', { name: /approve/i }).tap();
|
||||
// The Length axis state is now visible.
|
||||
await expect(page.getByText(/length axis/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows the bounding rectangle and the proposed length axis', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
await page.waitForTimeout(2200);
|
||||
await page.getByRole('button', { name: /approve/i }).tap();
|
||||
// The bounding rectangle is drawn on the photo.
|
||||
await expect(
|
||||
page.locator('[data-testid="bounding-rect"]'),
|
||||
).toBeVisible();
|
||||
// The proposed length axis is drawn along the long side.
|
||||
await expect(
|
||||
page.locator('[data-testid="length-axis"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('rotate control adjusts the axis', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
await page.waitForTimeout(2200);
|
||||
await page.getByRole('button', { name: /approve/i }).tap();
|
||||
// The rotate control is present.
|
||||
const rotate = page.getByRole('slider', { name: /rotate axis/i });
|
||||
await expect(rotate).toBeVisible();
|
||||
// Rotating changes the axis angle.
|
||||
const before = await page
|
||||
.locator('[data-testid="length-axis"]')
|
||||
.getAttribute('data-angle');
|
||||
await rotate.fill('90');
|
||||
const after = await page
|
||||
.locator('[data-testid="length-axis"]')
|
||||
.getAttribute('data-angle');
|
||||
expect(before).not.toBe(after);
|
||||
});
|
||||
|
||||
test('approve the axis advances to Measure', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
await page.waitForTimeout(2200);
|
||||
// Approve the mask -> Length axis.
|
||||
await page.getByRole('button', { name: /approve/i }).tap();
|
||||
// Approve the axis -> Measure.
|
||||
const approveAxis = page.getByRole('button', {
|
||||
name: /approve axis/i,
|
||||
});
|
||||
await expect(approveAxis).toBeVisible();
|
||||
await approveAxis.tap();
|
||||
await expect(page.getByText(/measure/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
|
||||
// UAT-3: Browser gate (hard stop) — e2e.
|
||||
// Spec (CAPTURE.md §Browsers):
|
||||
// Phone: iOS Safari, Android Chrome.
|
||||
// PC: Chrome, Edge, Firefox.
|
||||
// Other clients get a hard stop.
|
||||
//
|
||||
// Acceptance: opening in an unsupported browser (e.g. Opera, Samsung
|
||||
// Internet) shows a hard-stop message, NOT the app.
|
||||
//
|
||||
// The app itself must not render (no <slot> content, no "Milestone 1
|
||||
// — Capture" text) in an unsupported browser; the hard-stop page lists
|
||||
// the spec browsers instead.
|
||||
|
||||
const DESKTOP_CHROME_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
||||
const DESKTOP_OPERA_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0';
|
||||
const DESKTOP_EDGE_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0';
|
||||
const DESKTOP_FIREFOX_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0';
|
||||
const IOS_SAFARI_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/605.1.15';
|
||||
const ANDROID_CHROME_UA =
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6331.115 Mobile Safari/537.36';
|
||||
const SAMSUNG_INTERNET_UA =
|
||||
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36';
|
||||
|
||||
async function openApp(page, ua) {
|
||||
await page.setExtraHTTPHeaders({ 'User-Agent': ua });
|
||||
await page.goto('/');
|
||||
// The gate runs on client hydration; wait a beat for the DOM to
|
||||
// settle on whichever branch rendered.
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// The app renders when the gate passes: the layout's <main> is present
|
||||
// and the capture UI (Add Photo heading) is inside it.
|
||||
async function expectApp(page) {
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
await expect(page.locator('main')).toContainText('Add Photo');
|
||||
// No hard-stop page.
|
||||
await expect(page.locator('.gate')).toHaveCount(0);
|
||||
}
|
||||
|
||||
// The hard stop renders when the gate fails: the .gate box is present,
|
||||
// the app's <main> is NOT, and the spec browsers are listed.
|
||||
async function expectHardStop(page) {
|
||||
await expect(page.locator('.gate h1')).toHaveText('Unsupported browser');
|
||||
await expect(page.locator('main')).toHaveCount(0);
|
||||
const gate = page.locator('.gate');
|
||||
await expect(gate).toContainText('iOS Safari');
|
||||
await expect(gate).toContainText('Android Chrome');
|
||||
await expect(gate).toContainText('Chrome');
|
||||
await expect(gate).toContainText('Edge');
|
||||
await expect(gate).toContainText('Firefox');
|
||||
}
|
||||
|
||||
test('UAT-3: app renders in desktop Chrome (supported)', async ({ page }) => {
|
||||
await openApp(page, DESKTOP_CHROME_UA);
|
||||
await expectApp(page);
|
||||
});
|
||||
|
||||
test('UAT-3: app renders in desktop Edge (supported)', async ({ page }) => {
|
||||
await openApp(page, DESKTOP_EDGE_UA);
|
||||
await expectApp(page);
|
||||
});
|
||||
|
||||
test('UAT-3: app renders in desktop Firefox (supported)', async ({ page }) => {
|
||||
await openApp(page, DESKTOP_FIREFOX_UA);
|
||||
await expectApp(page);
|
||||
});
|
||||
|
||||
test('UAT-3: app renders in iOS Safari (supported)', async ({ page, context }) => {
|
||||
await context.setExtraHTTPHeaders({ 'User-Agent': IOS_SAFARI_UA });
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(300);
|
||||
await expectApp(page);
|
||||
});
|
||||
|
||||
test('UAT-3: app renders in Android Chrome (supported)', async ({ page, context }) => {
|
||||
await context.setExtraHTTPHeaders({ 'User-Agent': ANDROID_CHROME_UA });
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(300);
|
||||
await expectApp(page);
|
||||
});
|
||||
|
||||
test('UAT-3: hard stop in desktop Opera (unsupported)', async ({ page }) => {
|
||||
await openApp(page, DESKTOP_OPERA_UA);
|
||||
await expect(page.locator('.gate h1')).toHaveText('Unsupported browser');
|
||||
// The app must NOT render.
|
||||
await expect(page.locator('main')).toHaveCount(0);
|
||||
// The hard-stop page lists the spec browsers.
|
||||
await expect(page.locator('.gate')).toContainText('iOS Safari');
|
||||
await expect(page.locator('.gate')).toContainText('Android Chrome');
|
||||
await expect(page.locator('.gate')).toContainText('Chrome');
|
||||
await expect(page.locator('.gate')).toContainText('Edge');
|
||||
await expect(page.locator('.gate')).toContainText('Firefox');
|
||||
});
|
||||
|
||||
test('UAT-3: hard stop in Samsung Internet (unsupported)', async ({ page, context }) => {
|
||||
await context.setExtraHTTPHeaders({ 'User-Agent': SAMSUNG_INTERNET_UA });
|
||||
await page.goto('/');
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page.locator('.gate h1')).toHaveText('Unsupported browser');
|
||||
await expect(page.locator('main')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// M1-3: Playwright tests for the Mask state (RED first — the live Mask UI
|
||||
// lands in M1-5; the pure state machine + geometry are unit-tested in
|
||||
// tests/unit/maskState.test.js).
|
||||
//
|
||||
// Spec ground truth (docs/CAPTURE.md):
|
||||
// - "Approve is blocked when the mask has no slab pixels. User retakes."
|
||||
// - "If the file is HEIC and the browser cannot decode it, show a one-tap
|
||||
// notice to retake the photo as JPEG."
|
||||
// - 2 knobs: sheet Auto/Green/Black, sensitivity 0–100 (slider + minus/plus).
|
||||
// Edge offset and feather are NOT user-exposed in M1 (core params only).
|
||||
// - Sensitivity slider updates immediately; overlay recomputes 2.0 s after
|
||||
// the last change.
|
||||
|
||||
const SLAB_FIXTURE = 'tests/fixtures/slab-green.jpg';
|
||||
const SHEET_ONLY_FIXTURE = 'tests/fixtures/sheet-only-green.jpg';
|
||||
const HEIC_FIXTURE = 'tests/fixtures/slab.heic';
|
||||
|
||||
test.describe('Mask state (M1-5 live UI)', () => {
|
||||
test('HEIC input that the browser cannot decode shows a one-tap retake notice', async ({ page }) => {
|
||||
// 1. Land on Add photo.
|
||||
await page.goto('/');
|
||||
// 2. Provide an HEIC file the browser cannot decode.
|
||||
const input = page.locator('input[type="file"]');
|
||||
await input.setInputFiles(HEIC_FIXTURE);
|
||||
// 3. The app must surface a one-tap 'retake as JPEG' notice — never a
|
||||
// silent crash, never a server-side decode.
|
||||
const notice = page.getByRole('alert', { name: /retake/i });
|
||||
await expect(notice).toBeVisible();
|
||||
await expect(notice).toHaveText(/retake as JPEG/i);
|
||||
// 4. Tapping it returns to Add photo.
|
||||
await notice.tap();
|
||||
await expect(page.getByRole('button', { name: /add photo/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Approve is blocked while the mask has no slab pixels', async ({ page }) => {
|
||||
// 1. Load a photo of the sheet only (no slab).
|
||||
await page.goto('/');
|
||||
const input = page.locator('input[type="file"]');
|
||||
await input.setInputFiles(SHEET_ONLY_FIXTURE);
|
||||
// 2. The Mask state opens with the overlay visible.
|
||||
await expect(page.getByText(/mask/i)).toBeVisible();
|
||||
// 3. Approve must be disabled — the mask has no slab pixels.
|
||||
const approve = page.getByRole('button', { name: /approve/i });
|
||||
await expect(approve).toBeDisabled();
|
||||
// 4. Retake and load a photo with a real slab -> approve enables.
|
||||
await page.getByRole('button', { name: /retake/i }).tap();
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
await expect(approve).toBeEnabled();
|
||||
});
|
||||
|
||||
test('keys the green sheet and shows the 2 spec knobs', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
// Sheet control with Auto/Green/Black.
|
||||
const sheet = page.getByRole('radiogroup', { name: /sheet/i });
|
||||
await expect(sheet.getByRole('radio', { name: /auto/i })).toBeChecked();
|
||||
await expect(sheet.getByRole('radio', { name: /green/i })).toBeVisible();
|
||||
await expect(sheet.getByRole('radio', { name: /black/i })).toBeVisible();
|
||||
// Sensitivity slider 0–100 with minus/plus.
|
||||
const slider = page.getByRole('slider', { name: /sensitivity/i });
|
||||
await expect(slider).toBeVisible();
|
||||
await expect(slider).toHaveAttribute('min', '0');
|
||||
await expect(slider).toHaveAttribute('max', '100');
|
||||
await expect(page.getByRole('button', { name: /decrease sensitivity/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /increase sensitivity/i })).toBeVisible();
|
||||
// Edge offset and feather are NOT in the spec — they must not be
|
||||
// user-exposed in the Mask UI.
|
||||
await expect(page.getByRole('slider', { name: /edge offset/i })).not.toBeVisible();
|
||||
await expect(page.getByRole('slider', { name: /feather/i })).not.toBeVisible();
|
||||
// Overlay visible (photo with checkerboard on removed px).
|
||||
await expect(page.locator('[data-testid="mask-overlay"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('sensitivity slider updates the control immediately', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
const slider = page.getByRole('slider', { name: /sensitivity/i });
|
||||
await slider.fill('80');
|
||||
// The control value is reflected immediately, not after the 2 s debounce.
|
||||
await expect(slider).toHaveValue('80');
|
||||
});
|
||||
|
||||
test('approve advances to Length axis after a non-empty mask', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
// Let the initial recompute land so stats are non-empty.
|
||||
await page.waitForTimeout(2200);
|
||||
await page.getByRole('button', { name: /approve/i }).tap();
|
||||
await expect(page.getByText(/length axis/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const SLAB_FIXTURE = 'tests/fixtures/slab-green.jpg';
|
||||
|
||||
// E2E smoke: the app serves and the health route responds.
|
||||
test('M1-1 harness smoke: app serves + health ok', async ({ page, request }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/SlabUploader/);
|
||||
const res = await request.get('/api/health');
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
// Regression: the preview canvas must fit the full photo within the
|
||||
// viewport without cropping. Spec: "Preview fits the device width.
|
||||
// Height is capped so Mask and Measure controls stay on screen."
|
||||
test('preview canvas fits full photo without cropping', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('input[type="file"]').setInputFiles(SLAB_FIXTURE);
|
||||
await page.waitForTimeout(2500); // mask recompute
|
||||
|
||||
const canvas = page.locator('canvas.preview');
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
// Get the canvas's rendered (CSS) size and its intrinsic (pixel) size.
|
||||
const box = await canvas.boundingBox();
|
||||
const intrinsic = await canvas.evaluate((c) => ({ w: c.width, h: c.height }));
|
||||
|
||||
// The rendered size must fit within the viewport.
|
||||
const vp = page.viewportSize();
|
||||
expect(box.width).toBeLessThanOrEqual(vp.width);
|
||||
expect(box.height).toBeLessThanOrEqual(vp.height * 0.55); // 50vh cap + tolerance
|
||||
|
||||
// Aspect ratio must be preserved (no distortion).
|
||||
const renderedRatio = box.width / box.height;
|
||||
const intrinsicRatio = intrinsic.w / intrinsic.h;
|
||||
expect(renderedRatio).toBeCloseTo(intrinsicRatio, 1);
|
||||
|
||||
// The canvas must not be clipped by its container.
|
||||
const wrap = page.locator('.preview-wrap');
|
||||
const wrapBox = await wrap.boundingBox();
|
||||
expect(box.width).toBeLessThanOrEqual(wrapBox.width + 1);
|
||||
expect(box.height).toBeLessThanOrEqual(wrapBox.height + 1);
|
||||
});
|
||||
|
After Width: | Height: | Size: 904 KiB |
|
After Width: | Height: | Size: 1020 KiB |
|
After Width: | Height: | Size: 988 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 996 KiB |
|
After Width: | Height: | Size: 744 KiB |
|
After Width: | Height: | Size: 464 B |
|
After Width: | Height: | Size: 473 B |
@@ -0,0 +1,377 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
minAreaRect,
|
||||
axisFromRect,
|
||||
rotateAxis,
|
||||
axisAngle,
|
||||
axisUnit,
|
||||
createAxisState,
|
||||
reduceAxisState,
|
||||
} from '../../src/lib/core/axis.js';
|
||||
|
||||
// M1-4 Length axis — unit tests (RED first, per strict TDD).
|
||||
//
|
||||
// Ground truth: docs/CAPTURE.md §Length axis
|
||||
// - min-area bounding rectangle of the mask
|
||||
// - length axis on the long side of that rectangle
|
||||
// - user rotates the axis if it does not follow the slab length
|
||||
// - approve the axis
|
||||
//
|
||||
// All functions are pure (no DOM) so the state is unit-testable in Node.
|
||||
|
||||
// --- helpers --------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a binary mask (Uint8Array) of w x h with a filled axis-aligned
|
||||
* rectangle of size rw x rh at (rx, ry).
|
||||
*/
|
||||
function rectMask(w, h, rx, ry, rw, rh) {
|
||||
const mask = new Uint8Array(w * h);
|
||||
for (let y = ry; y < ry + rh; y++) {
|
||||
for (let x = rx; x < rx + rw; x++) {
|
||||
mask[y * w + x] = 1;
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a binary mask of a rotated rectangle.
|
||||
* Center at (cx, cy), half-lengths (hl, hw), rotation in radians.
|
||||
*/
|
||||
function rotatedRectMask(w, h, cx, cy, hl, hw, rot) {
|
||||
const mask = new Uint8Array(w * h);
|
||||
const cos = Math.cos(rot);
|
||||
const sin = Math.sin(rot);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
// Transform to rect-local coords
|
||||
const lx = cos * dx + sin * dy;
|
||||
const ly = -sin * dx + cos * dy;
|
||||
if (Math.abs(lx) <= hl && Math.abs(ly) <= hw) {
|
||||
mask[y * w + x] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
// --- minAreaRect ------------------------------------------------------------------
|
||||
|
||||
describe('minAreaRect', () => {
|
||||
it('returns a tight axis-aligned rectangle for an axis-aligned mask', () => {
|
||||
// 8x4 rectangle at (2,2) on a 12x8 canvas.
|
||||
// Pixels at x=2..9 (8 cols), y=2..5 (4 rows).
|
||||
// Center-to-center span: width 7, height 3. Center (5.5, 3.5).
|
||||
const mask = rectMask(12, 8, 2, 2, 8, 4);
|
||||
const rect = minAreaRect(mask, 12, 8);
|
||||
// The min-area rect of an axis-aligned pixel block is tight:
|
||||
// width = (cols-1), height = (rows-1) in center-to-center units.
|
||||
expect(rect.width).toBeCloseTo(7, 2);
|
||||
expect(rect.height).toBeCloseTo(3, 2);
|
||||
expect(rect.angle).toBeCloseTo(0, 3);
|
||||
expect(rect.center.x).toBeCloseTo(5.5, 2); // (2+9)/2
|
||||
expect(rect.center.y).toBeCloseTo(3.5, 2); // (2+5)/2
|
||||
});
|
||||
|
||||
it('handles a single pixel', () => {
|
||||
const mask = new Uint8Array(10 * 10);
|
||||
mask[5 * 10 + 5] = 1; // pixel at (5,5)
|
||||
const rect = minAreaRect(mask, 10, 10);
|
||||
expect(rect.area).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null for an empty mask', () => {
|
||||
const mask = new Uint8Array(10 * 10);
|
||||
const rect = minAreaRect(mask, 10, 10);
|
||||
expect(rect).toBeNull();
|
||||
});
|
||||
|
||||
it('detects a rotated rectangle', () => {
|
||||
// 45-degree rotated rectangle: 20 units long, 4 units wide, centered
|
||||
const w = 40,
|
||||
h = 40,
|
||||
cx = 20,
|
||||
cy = 20,
|
||||
hl = 10,
|
||||
hw = 2;
|
||||
const mask = rotatedRectMask(w, h, cx, cy, hl, hw, Math.PI / 4);
|
||||
const rect = minAreaRect(mask, w, h);
|
||||
// Pixel rasterization of a 20x4 rect at 45 degrees: the convex hull
|
||||
// of the kept pixels approximates the true rect within ~1px per side.
|
||||
// The angle should be close to 45 degrees.
|
||||
expect(rect.angle).toBeCloseTo(Math.PI / 4, 1);
|
||||
// Long side ~20 (within 2px for rasterization)
|
||||
const longSide = Math.max(rect.width, rect.height);
|
||||
expect(longSide).toBeGreaterThanOrEqual(18);
|
||||
expect(longSide).toBeLessThanOrEqual(22);
|
||||
});
|
||||
|
||||
it('handles a horizontal bar (wider than tall)', () => {
|
||||
// 16x2 bar on a 20x10 canvas
|
||||
const mask = rectMask(20, 10, 2, 4, 16, 2);
|
||||
const rect = minAreaRect(mask, 20, 10);
|
||||
// 16 cols x 2 rows: width ~15-16, height ~1-2 (pixel rasterization)
|
||||
expect(rect.width).toBeGreaterThanOrEqual(14);
|
||||
expect(rect.width).toBeLessThanOrEqual(17);
|
||||
expect(rect.height).toBeGreaterThanOrEqual(1);
|
||||
expect(rect.height).toBeLessThanOrEqual(3);
|
||||
expect(rect.angle).toBeCloseTo(0, 1);
|
||||
});
|
||||
|
||||
it('handles a vertical bar (taller than wide)', () => {
|
||||
// 2x16 bar on a 10x20 canvas
|
||||
const mask = rectMask(10, 20, 4, 2, 2, 16);
|
||||
const rect = minAreaRect(mask, 10, 20);
|
||||
// Long side ~16, short side ~2
|
||||
const longSide = Math.max(rect.width, rect.height);
|
||||
const shortSide = Math.min(rect.width, rect.height);
|
||||
expect(longSide).toBeGreaterThanOrEqual(14);
|
||||
expect(longSide).toBeLessThanOrEqual(17);
|
||||
expect(shortSide).toBeGreaterThanOrEqual(1);
|
||||
expect(shortSide).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
// --- axisFromRect ------------------------------------------------------------------
|
||||
|
||||
describe('axisFromRect', () => {
|
||||
it('returns a unit vector along the long side of an axis-aligned rect', () => {
|
||||
// 8x4 rect: long side is horizontal (width=8 > height=4)
|
||||
const rect = {
|
||||
center: { x: 6, y: 4 },
|
||||
width: 8,
|
||||
height: 4,
|
||||
angle: 0,
|
||||
area: 32,
|
||||
};
|
||||
const axis = axisFromRect(rect);
|
||||
// Long side is width (8), so axis is along +x
|
||||
expect(axis.x).toBeCloseTo(1, 6);
|
||||
expect(axis.y).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('returns a unit vector along the long side when height > width', () => {
|
||||
// 4x8 rect: long side is vertical (height=8 > width=4)
|
||||
const rect = {
|
||||
center: { x: 6, y: 4 },
|
||||
width: 4,
|
||||
height: 8,
|
||||
angle: 0,
|
||||
area: 32,
|
||||
};
|
||||
const axis = axisFromRect(rect);
|
||||
// Long side is height, so axis is along +y
|
||||
expect(axis.x).toBeCloseTo(0, 6);
|
||||
expect(axis.y).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('rotates the axis with the rect angle', () => {
|
||||
// 8x4 rect rotated 90 degrees: long side becomes vertical
|
||||
const rect = {
|
||||
center: { x: 6, y: 4 },
|
||||
width: 8,
|
||||
height: 4,
|
||||
angle: Math.PI / 2,
|
||||
area: 32,
|
||||
};
|
||||
const axis = axisFromRect(rect);
|
||||
// The long side (width=8) is now along +y after 90-degree rotation
|
||||
expect(axis.x).toBeCloseTo(0, 6);
|
||||
expect(axis.y).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('returns the axis at 45 degrees for a rotated rect', () => {
|
||||
const rect = {
|
||||
center: { x: 20, y: 20 },
|
||||
width: 20,
|
||||
height: 4,
|
||||
angle: Math.PI / 4,
|
||||
area: 80,
|
||||
};
|
||||
const axis = axisFromRect(rect);
|
||||
// Long side at 45 degrees
|
||||
expect(axis.x).toBeCloseTo(Math.SQRT1_2, 3);
|
||||
expect(axis.y).toBeCloseTo(Math.SQRT1_2, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rotateAxis --------------------------------------------------------------------
|
||||
|
||||
describe('rotateAxis', () => {
|
||||
it('rotates by 0 degrees is a no-op', () => {
|
||||
const axis = { x: 1, y: 0 };
|
||||
const rot = rotateAxis(axis, 0);
|
||||
expect(rot.x).toBeCloseTo(1, 6);
|
||||
expect(rot.y).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('rotates by 90 degrees', () => {
|
||||
const axis = { x: 1, y: 0 };
|
||||
const rot = rotateAxis(axis, Math.PI / 2);
|
||||
expect(rot.x).toBeCloseTo(0, 6);
|
||||
expect(rot.y).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('rotates by -90 degrees', () => {
|
||||
const axis = { x: 1, y: 0 };
|
||||
const rot = rotateAxis(axis, -Math.PI / 2);
|
||||
expect(rot.x).toBeCloseTo(0, 6);
|
||||
expect(rot.y).toBeCloseTo(-1, 6);
|
||||
});
|
||||
|
||||
it('rotates a 45-degree axis by 45 degrees -> 90 degrees (vertical)', () => {
|
||||
const axis = { x: Math.SQRT1_2, y: Math.SQRT1_2 };
|
||||
const rot = rotateAxis(axis, Math.PI / 4);
|
||||
expect(rot.x).toBeCloseTo(0, 3);
|
||||
expect(rot.y).toBeCloseTo(1, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// --- axisAngle -------------------------------------------------------------------
|
||||
|
||||
describe('axisAngle', () => {
|
||||
it('returns 0 for +x axis', () => {
|
||||
expect(axisAngle({ x: 1, y: 0 })).toBeCloseTo(0, 6);
|
||||
});
|
||||
it('returns PI/2 for +y axis', () => {
|
||||
expect(axisAngle({ x: 0, y: 1 })).toBeCloseTo(Math.PI / 2, 6);
|
||||
});
|
||||
it('returns -PI/2 for -y axis', () => {
|
||||
expect(axisAngle({ x: 0, y: -1 })).toBeCloseTo(-Math.PI / 2, 6);
|
||||
});
|
||||
it('returns PI for -x axis', () => {
|
||||
expect(axisAngle({ x: -1, y: 0 })).toBeCloseTo(Math.PI, 6);
|
||||
});
|
||||
it('returns 45 degrees for diagonal', () => {
|
||||
expect(axisAngle({ x: Math.SQRT1_2, y: Math.SQRT1_2 })).toBeCloseTo(
|
||||
Math.PI / 4,
|
||||
3,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- axisUnit --------------------------------------------------------------------
|
||||
|
||||
describe('axisUnit', () => {
|
||||
it('creates a unit vector from an angle', () => {
|
||||
expect(axisUnit(0).x).toBeCloseTo(1, 6);
|
||||
expect(axisUnit(0).y).toBeCloseTo(0, 6);
|
||||
expect(axisUnit(Math.PI / 2).x).toBeCloseTo(0, 6);
|
||||
expect(axisUnit(Math.PI / 2).y).toBeCloseTo(1, 6);
|
||||
expect(axisUnit(-Math.PI / 2).x).toBeCloseTo(0, 6);
|
||||
expect(axisUnit(-Math.PI / 2).y).toBeCloseTo(-1, 6);
|
||||
expect(axisUnit(Math.PI).x).toBeCloseTo(-1, 6);
|
||||
expect(axisUnit(Math.PI).y).toBeCloseTo(0, 6);
|
||||
});
|
||||
});
|
||||
|
||||
// --- createAxisState -----------------------------------------------------------------
|
||||
|
||||
describe('createAxisState', () => {
|
||||
it('starts with the proposed axis from the rect, not approved', () => {
|
||||
const rect = {
|
||||
center: { x: 6, y: 4 },
|
||||
width: 8,
|
||||
height: 4,
|
||||
angle: 0,
|
||||
area: 32,
|
||||
};
|
||||
const state = createAxisState(rect);
|
||||
expect(state.rect).toBe(rect);
|
||||
expect(state.axis.x).toBeCloseTo(1, 6); // long side is horizontal
|
||||
expect(state.axis.y).toBeCloseTo(0, 6);
|
||||
expect(state.approved).toBe(false);
|
||||
});
|
||||
|
||||
it('proposed axis follows the rect angle', () => {
|
||||
const rect = {
|
||||
center: { x: 20, y: 20 },
|
||||
width: 20,
|
||||
height: 4,
|
||||
angle: Math.PI / 4,
|
||||
area: 80,
|
||||
};
|
||||
const state = createAxisState(rect);
|
||||
expect(state.axis.x).toBeCloseTo(Math.SQRT1_2, 3);
|
||||
expect(state.axis.y).toBeCloseTo(Math.SQRT1_2, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// --- reduceAxisState -----------------------------------------------------------------
|
||||
|
||||
describe('reduceAxisState', () => {
|
||||
const rect = {
|
||||
center: { x: 6, y: 4 },
|
||||
width: 8,
|
||||
height: 4,
|
||||
angle: 0,
|
||||
area: 32,
|
||||
};
|
||||
|
||||
it('rotate updates the axis immediately', () => {
|
||||
let s = createAxisState(rect);
|
||||
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
|
||||
// +x rotated by 90 degrees -> +y
|
||||
expect(s.axis.x).toBeCloseTo(0, 6);
|
||||
expect(s.axis.y).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('rotate changes the rect angle (not just a separate axis vector)', () => {
|
||||
let s = createAxisState(rect);
|
||||
const originalAngle = s.rect.angle;
|
||||
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 4 });
|
||||
// The rect itself must have rotated
|
||||
expect(s.rect.angle).toBeCloseTo(originalAngle + Math.PI / 4, 6);
|
||||
// And the axis is re-derived from the rotated rect
|
||||
expect(s.axis).toEqual(axisFromRect(s.rect));
|
||||
});
|
||||
|
||||
it('rotate by 90 deg: rect angle changes, axis is perpendicular to original', () => {
|
||||
let s = createAxisState(rect);
|
||||
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
|
||||
expect(s.rect.angle).toBeCloseTo(Math.PI / 2, 6);
|
||||
// Original axis was +x (1,0). After 90 deg rect rotation, axis is +y (0,1)
|
||||
expect(s.axis.x).toBeCloseTo(0, 6);
|
||||
expect(s.axis.y).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('rotate composes (rotates from current axis)', () => {
|
||||
let s = createAxisState(rect);
|
||||
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
|
||||
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 2 });
|
||||
// Two 90-degree rotations -> -x
|
||||
expect(s.axis.x).toBeCloseTo(-1, 6);
|
||||
expect(s.axis.y).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('rotate by 0 is a no-op', () => {
|
||||
let s = createAxisState(rect);
|
||||
const before = s;
|
||||
s = reduceAxisState(s, { type: 'rotate', angle: 0 });
|
||||
expect(s).toBe(before);
|
||||
});
|
||||
|
||||
it('approve sets approved=true', () => {
|
||||
let s = createAxisState(rect);
|
||||
s = reduceAxisState(s, { type: 'approve' });
|
||||
expect(s.approved).toBe(true);
|
||||
});
|
||||
|
||||
it('retract approval on rotate after approval', () => {
|
||||
let s = createAxisState(rect);
|
||||
s = reduceAxisState(s, { type: 'approve' });
|
||||
expect(s.approved).toBe(true);
|
||||
// Rotate after approval: axis changes, approval is retracted
|
||||
// (user is adjusting the axis, so it is no longer approved)
|
||||
s = reduceAxisState(s, { type: 'rotate', angle: Math.PI / 4 });
|
||||
expect(s.approved).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown actions return the same state', () => {
|
||||
const s = createAxisState(rect);
|
||||
expect(reduceAxisState(s, { type: 'bogus' })).toBe(s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* UAT-3: Browser gate (hard stop) — unit tests for the pure logic in
|
||||
* src/lib/core/browserGate.js.
|
||||
*
|
||||
* CAPTURE.md §Browsers:
|
||||
* Phone: iOS Safari, Android Chrome.
|
||||
* PC: Chrome, Edge, Firefox.
|
||||
* Other clients get a hard stop.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectBrowser, isAllowed, checkBrowserGate } from '$lib/core/browserGate.js';
|
||||
|
||||
// --- representative user agents ------------------------------------
|
||||
const UAs = {
|
||||
iosSafari:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/605.1.15',
|
||||
ipadSafari:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15',
|
||||
iosChrome:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6336.69 Mobile/15E148 Safari/605.1.15',
|
||||
androidChrome:
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6331.115 Mobile Safari/537.36',
|
||||
desktopChrome:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
desktopEdge:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0',
|
||||
desktopFirefox:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0',
|
||||
desktopOpera:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 OPR/110.0.0.0',
|
||||
androidOpera:
|
||||
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 OPR/110.0.0.0',
|
||||
androidSamsung:
|
||||
'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/124.0.0.0 Mobile Safari/537.36',
|
||||
iosSamsung:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.0.0 Mobile/15E148 SamsungBrowser/124.0.0.0',
|
||||
androidBrave:
|
||||
'Mozilla/5.0 (Linux; Android 14; SM-A546E) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.0.0.5336 Mobile Safari/537.36',
|
||||
unknown:
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) CustomBrowser/9.9.0 Safari/537.36',
|
||||
blank: '',
|
||||
};
|
||||
|
||||
describe('detectBrowser', () => {
|
||||
it('identifies iOS Safari on iPhone', () => {
|
||||
const b = detectBrowser(UAs.iosSafari);
|
||||
expect(b.name).toBe('iOS Safari');
|
||||
expect(b.platform).toBe('phone');
|
||||
});
|
||||
|
||||
it('identifies iPad Safari (iPadOS 13+ reports as Macintosh)', () => {
|
||||
const b = detectBrowser(UAs.ipadSafari);
|
||||
expect(b.name).toBe('Safari');
|
||||
expect(b.platform).toBe('pc');
|
||||
});
|
||||
|
||||
it('identifies Android Chrome', () => {
|
||||
const b = detectBrowser(UAs.androidChrome);
|
||||
expect(b.name).toBe('Android Chrome');
|
||||
expect(b.platform).toBe('phone');
|
||||
});
|
||||
|
||||
it('identifies desktop Chrome', () => {
|
||||
const b = detectBrowser(UAs.desktopChrome);
|
||||
expect(b.name).toBe('Chrome');
|
||||
expect(b.platform).toBe('pc');
|
||||
});
|
||||
|
||||
it('identifies desktop Edge (not Chrome)', () => {
|
||||
const b = detectBrowser(UAs.desktopEdge);
|
||||
expect(b.name).toBe('Microsoft Edge');
|
||||
expect(b.platform).toBe('pc');
|
||||
});
|
||||
|
||||
it('identifies desktop Firefox', () => {
|
||||
const b = detectBrowser(UAs.desktopFirefox);
|
||||
expect(b.name).toBe('Firefox');
|
||||
expect(b.platform).toBe('pc');
|
||||
});
|
||||
|
||||
it('identifies desktop Opera as its own browser (not Chrome)', () => {
|
||||
const b = detectBrowser(UAs.desktopOpera);
|
||||
expect(b.name).toBe('Opera');
|
||||
expect(b.platform).toBe('pc');
|
||||
});
|
||||
|
||||
it('identifies Android Opera (OPR beats Chrome)', () => {
|
||||
const b = detectBrowser(UAs.androidOpera);
|
||||
expect(b.name).toBe('Opera');
|
||||
expect(b.platform).toBe('phone');
|
||||
});
|
||||
|
||||
it('identifies Samsung Internet (SamsungBrowser beats Chrome)', () => {
|
||||
const b = detectBrowser(UAs.androidSamsung);
|
||||
expect(b.name).toBe('Samsung Internet');
|
||||
expect(b.platform).toBe('phone');
|
||||
});
|
||||
|
||||
it('identifies iOS Samsung Internet as unsupported', () => {
|
||||
const b = detectBrowser(UAs.iosSamsung);
|
||||
expect(b.name).toBe('Samsung Internet');
|
||||
expect(b.platform).toBe('phone');
|
||||
});
|
||||
|
||||
it('treats an unrecognized UA as Unknown', () => {
|
||||
const b = detectBrowser(UAs.unknown);
|
||||
expect(b.name).toBe('Unknown');
|
||||
expect(b.platform).toBe('pc');
|
||||
});
|
||||
|
||||
it('handles blank / undefined user agents without throwing', () => {
|
||||
expect(detectBrowser(UAs.blank).name).toBe('Unknown');
|
||||
expect(detectBrowser(undefined).name).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowed', () => {
|
||||
it('allows iOS Safari on phone', () => {
|
||||
expect(isAllowed({ name: 'iOS Safari', platform: 'phone' })).toBe(true);
|
||||
});
|
||||
|
||||
it('allows Android Chrome on phone', () => {
|
||||
expect(isAllowed({ name: 'Android Chrome', platform: 'phone' })).toBe(true);
|
||||
});
|
||||
|
||||
it('allows Chrome on PC', () => {
|
||||
expect(isAllowed({ name: 'Chrome', platform: 'pc' })).toBe(true);
|
||||
});
|
||||
|
||||
it('allows Edge on PC', () => {
|
||||
expect(isAllowed({ name: 'Microsoft Edge', platform: 'pc' })).toBe(true);
|
||||
});
|
||||
|
||||
it('allows Firefox on PC', () => {
|
||||
expect(isAllowed({ name: 'Firefox', platform: 'pc' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects Opera', () => {
|
||||
expect(isAllowed({ name: 'Opera', platform: 'pc' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Android Opera', () => {
|
||||
expect(isAllowed({ name: 'Opera', platform: 'phone' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Samsung Internet', () => {
|
||||
expect(isAllowed({ name: 'Samsung Internet', platform: 'phone' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Samsung Internet on iOS', () => {
|
||||
expect(isAllowed({ name: 'Samsung Internet', platform: 'phone' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Brave', () => {
|
||||
expect(isAllowed({ name: 'Brave', platform: 'phone' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Unknown', () => {
|
||||
expect(isAllowed({ name: 'Unknown', platform: 'pc' })).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects phone browsers on PC (impossible but rejected)', () => {
|
||||
expect(isAllowed({ name: 'iOS Safari', platform: 'pc' })).toBe(false);
|
||||
expect(isAllowed({ name: 'Android Chrome', platform: 'pc' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkBrowserGate', () => {
|
||||
it('supports exactly the five spec browsers + Safari (iPad)', () => {
|
||||
for (const ua of [
|
||||
UAs.iosSafari,
|
||||
UAs.ipadSafari,
|
||||
UAs.androidChrome,
|
||||
UAs.desktopChrome,
|
||||
UAs.desktopEdge,
|
||||
UAs.desktopFirefox,
|
||||
]) {
|
||||
expect(checkBrowserGate(ua).supported).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('hard-stops Opera, Samsung Internet, unknown, blank', () => {
|
||||
for (const ua of [
|
||||
UAs.desktopOpera,
|
||||
UAs.androidOpera,
|
||||
UAs.androidSamsung,
|
||||
UAs.iosSamsung,
|
||||
UAs.androidBrave,
|
||||
UAs.unknown,
|
||||
UAs.blank,
|
||||
]) {
|
||||
expect(checkBrowserGate(ua).supported).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the detected browser for the UI', () => {
|
||||
const r = checkBrowserGate(UAs.desktopOpera);
|
||||
expect(r.browser.name).toBe('Opera');
|
||||
expect(r.browser.platform).toBe('pc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as m from '../../src/lib/core/math.js';
|
||||
import { prepareInventoryMetadata } from '../../src/lib/core/inventory.js';
|
||||
|
||||
describe('prepareInventoryMetadata', () => {
|
||||
it('calculates correct metadata for a 21in x 12in slab at 1.75in thick', () => {
|
||||
const Lpx = 3360, L = 21, scale = 160, thickness = 1.75;
|
||||
const widthPx = 1920; // 12in
|
||||
const maskPx = Lpx * widthPx; // 6,451,200
|
||||
|
||||
const result = prepareInventoryMetadata(
|
||||
maskPx,
|
||||
scale,
|
||||
L,
|
||||
thickness,
|
||||
[1600, 1920, 2080, 1600]
|
||||
);
|
||||
|
||||
// sqft = (6,451,200 / 160^2) / 144 = 1.75
|
||||
expect(result.sqft).toBe(1.75);
|
||||
// bdft = 1.75 * 1.75 = 3.0625 -> 3.06
|
||||
expect(result.bdft).toBe(3.06);
|
||||
expect(result.lengthIn).toBe(21);
|
||||
expect(result.thicknessIn).toBe(1.75);
|
||||
expect(result.minWidth).toBe(10);
|
||||
expect(result.maxWidth).toBe(13);
|
||||
expect(result.avgWidth).toBe(11.25);
|
||||
});
|
||||
|
||||
it('handles zero area', () => {
|
||||
const result = prepareInventoryMetadata(0, 160, 21, 1.75, []);
|
||||
expect(result.sqft).toBe(0);
|
||||
expect(result.bdft).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
keySheet,
|
||||
maskStats,
|
||||
} from '../../src/lib/core/mask.js';
|
||||
|
||||
// M1-3 mask key geometry — unit tests (RED first, per strict TDD).
|
||||
//
|
||||
// Ground truth: docs/CAPTURE.md §Mask
|
||||
// - 4 knobs: sheet Auto/Green/Black, sensitivity 0–100, edge offset, feather
|
||||
// - Control value updates IMMEDIATELY; overlay recomputes 2.0 s after the
|
||||
// last sensitivity change (debounce).
|
||||
// - Approve blocked when maskStats is empty.
|
||||
//
|
||||
// All functions here are pure (no DOM) so the state is unit-testable in Node;
|
||||
// the live UI (M1-5) drives this machine.
|
||||
//
|
||||
// --- synthetic photo helpers (no DOM) -----------------------------------------\n//
|
||||
function solid(w, h, r, g, b) {
|
||||
const data = new Uint8ClampedArray(w * h * 4);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
data[i * 4 + 0] = r;
|
||||
data[i * 4 + 1] = g;
|
||||
data[i * 4 + 2] = b;
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function withRect(data, width, x0, y0, w, h, r, g, b) {
|
||||
for (let y = y0; y < y0 + h; y++) {
|
||||
for (let x = x0; x < x0 + w; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
data[i + 0] = r;
|
||||
data[i + 1] = g;
|
||||
data[i + 2] = b;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// 12 x 8 green sheet with an 8 x 4 brown slab at (2,2).
|
||||
const GREEN_PHOTO = () => withRect(solid(12, 8, 0, 200, 0), 12, 2, 2, 8, 4, 140, 90, 40);
|
||||
const DIMS = { width: 12, height: 8 };
|
||||
|
||||
// --- keySheet ---------------------------------------------------------------------
|
||||
//
|
||||
describe('keySheet', () => {
|
||||
it('keeps the slab in a green sheet photo', () => {
|
||||
const mask = keySheet(GREEN_PHOTO(), {
|
||||
sheet: 'green',
|
||||
sensitivity: 50,
|
||||
});
|
||||
// 8x4 = 32 pixels
|
||||
expect(maskStats(mask).kept).toBe(32);
|
||||
});
|
||||
|
||||
it('keeps the slab in a black sheet photo', () => {
|
||||
const blackPhoto = withRect(solid(12, 8, 10, 10, 10), 12, 2, 2, 8, 4, 220, 210, 190);
|
||||
const mask = keySheet(blackPhoto, {
|
||||
sheet: 'black',
|
||||
sensitivity: 50,
|
||||
});
|
||||
expect(maskStats(mask).kept).toBe(32);
|
||||
});
|
||||
|
||||
it('auto-selects the better key', () => {
|
||||
// A photo that works well for both should return the green one
|
||||
const mask = keySheet(GREEN_PHOTO(), {
|
||||
sheet: 'auto',
|
||||
sensitivity: 50,
|
||||
});
|
||||
expect(maskStats(mask).kept).toBe(32);
|
||||
});
|
||||
|
||||
it('handles sensitivity 0 (tightest)', () => {
|
||||
const mask = keySheet(GREEN_PHOTO(), {
|
||||
sheet: 'green',
|
||||
sensitivity: 0,
|
||||
});
|
||||
// At s=0, threshold is 150.
|
||||
// brown wood (140,90,40) dist = sqrt(140^2 + (90-255)^2 + 40^2) = sqrt(19600 + 27225 + 1600) = ~218.
|
||||
// 218 > 150, so kept.
|
||||
expect(maskStats(mask).kept).toBe(32);
|
||||
});
|
||||
|
||||
it('handles sensitivity 100 (loosest)', () => {
|
||||
const mask = keySheet(GREEN_PHOTO(), {
|
||||
sheet: 'green',
|
||||
sensitivity: 100,
|
||||
});
|
||||
// At s=100, threshold is 50.
|
||||
// Sheet green (0,200,0) dist = 55.
|
||||
// 55 > 50, so sheet is kept too!
|
||||
// 12x8 = 96 pixels
|
||||
expect(maskStats(mask).kept).toBe(96);
|
||||
});
|
||||
|
||||
it('green key passes non-green pixels through (black key handles them)', () => {
|
||||
// Real slab photos (sample_01..06) have a dark blue-gray background
|
||||
// (e.g. rgb 26,27,29) with a bright warm slab (e.g. rgb 250,200,140).
|
||||
// The green key is designed to key out a GREEN sheet only. On a dark
|
||||
// (non-green) surface it passes pixels through (keeps them) so that
|
||||
// the black key can remove them. The green key alone does NOT remove
|
||||
// a dark background — that is the black key's job. Auto selects the
|
||||
// black key in this case (see the auto test below).
|
||||
const darkBg = solid(12, 8, 26, 27, 29);
|
||||
withRect(darkBg, 12, 2, 2, 8, 4, 250, 200, 140);
|
||||
const mask = keySheet(darkBg, { sheet: 'green', sensitivity: 50 });
|
||||
const stats = maskStats(mask);
|
||||
// Green key on dark bg: all pixels are non-greenish, so all are kept.
|
||||
// This is BY DESIGN — the green key is a no-op on non-green surfaces,
|
||||
// and auto will select the black key instead.
|
||||
expect(stats.kept).toBe(96);
|
||||
});
|
||||
|
||||
it('auto selects black key over green key on a dark-background photo', () => {
|
||||
// Real photo: dark bg + bright slab. Green key keeps ~everything (bad).
|
||||
// Black key keeps only the slab (good). Auto must pick black.
|
||||
const darkBg = solid(12, 8, 26, 27, 29);
|
||||
withRect(darkBg, 12, 2, 2, 8, 4, 250, 200, 140);
|
||||
const mask = keySheet(darkBg, { sheet: 'auto', sensitivity: 50 });
|
||||
const stats = maskStats(mask);
|
||||
// Should keep only the slab (32 px), not the whole frame (96 px).
|
||||
expect(stats.kept).toBe(32);
|
||||
});
|
||||
|
||||
it('auto selects green key over black key on a green-sheet photo', () => {
|
||||
// Green sheet + brown slab. Green key keeps slab only. Black key may
|
||||
// keep slab + some sheet. Auto must pick green (fewer kept = better).
|
||||
const photo = GREEN_PHOTO();
|
||||
const mask = keySheet(photo, { sheet: 'auto', sensitivity: 50 });
|
||||
const stats = maskStats(mask);
|
||||
expect(stats.kept).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
// --- maskStats -------------------------------------------------------------------
|
||||
//
|
||||
describe('maskStats', () => {
|
||||
it('correctly reports kept count and empty status', () => {
|
||||
const mask = new Uint8Array([1, 0, 1, 0]);
|
||||
const stats = maskStats(mask);
|
||||
expect(stats.kept).toBe(2);
|
||||
expect(stats.total).toBe(4);
|
||||
expect(stats.empty).toBe(false);
|
||||
});
|
||||
|
||||
it('reports empty=true when no pixels kept', () => {
|
||||
const mask = new Uint8Array([0, 0, 0, 0]);
|
||||
const stats = maskStats(mask);
|
||||
expect(stats.kept).toBe(0);
|
||||
expect(stats.empty).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
offsetMask,
|
||||
featherMask,
|
||||
computeMask,
|
||||
createMaskState,
|
||||
reduceMaskState,
|
||||
dueForRecompute,
|
||||
RECOMPUTE_DELAY_MS,
|
||||
} from '../../src/lib/core/maskState.js';
|
||||
|
||||
// M1-3 Mask state — RED first (strict TDD).
|
||||
//
|
||||
// Ground truth: docs/CAPTURE.md §Mask
|
||||
// - 4 knobs: sheet Auto/Green/Black, sensitivity 0–100, edge offset, feather
|
||||
// - Control value updates IMMEDIATELY; overlay recomputes 2.0 s after the
|
||||
// last sensitivity change (debounce).
|
||||
// - Approve blocked when maskStats is empty.
|
||||
//
|
||||
// All functions here are pure (no DOM) so the state is unit-testable in Node;
|
||||
// the live UI (M1-5) drives this machine.
|
||||
//
|
||||
// --- synthetic photo helpers (no DOM) -----------------------------------------\
|
||||
//
|
||||
function solid(w, h, r, g, b) {
|
||||
const data = new Uint8ClampedArray(w * h * 4);
|
||||
for (let i = 0; i < w * h; i++) {
|
||||
data[i * 4 + 0] = r;
|
||||
data[i * 4 + 1] = g;
|
||||
data[i * 4 + 2] = b;
|
||||
data[i * 4 + 3] = 255;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function withRect(data, width, x0, y0, w, h, r, g, b) {
|
||||
for (let y = y0; y < y0 + h; y++) {
|
||||
for (let x = x0; x < x0 + w; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
data[i + 0] = r;
|
||||
data[i + 1] = g;
|
||||
data[i + 2] = b;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// 12 x 8 green sheet with an 8 x 4 brown slab at (2,2).
|
||||
const GREEN_PHOTO = () => withRect(solid(12, 8, 0, 200, 0), 12, 2, 2, 8, 4, 140, 90, 40);
|
||||
const DIMS = { width: 12, height: 8 };
|
||||
|
||||
// --- offsetMask ------------------------------------------------------------------
|
||||
//
|
||||
describe('offsetMask (edge offset)', () => {
|
||||
it('offset 0 is a no-op', () => {
|
||||
const mask = new Uint8Array([1, 1, 1, 0, 0, 0]);
|
||||
expect(offsetMask(mask, 3, 2, 0)).toEqual(new Uint8Array([1, 1, 1, 0, 0, 0]));
|
||||
});
|
||||
|
||||
it('positive offset erodes the mask boundary', () => {
|
||||
// 1 kept ring of 1s on a 5x1 row: erode 1 -> the single kept pixel
|
||||
// loses its kept neighbours' protection and is removed.
|
||||
const mask = new Uint8Array([0, 0, 1, 0, 0]);
|
||||
const eroded = offsetMask(mask, 5, 1, 1);
|
||||
expect(eroded).toEqual(new Uint8Array([0, 0, 0, 0, 0]));
|
||||
});
|
||||
|
||||
it('positive offset erodes a filled block by 1 px ring', () => {
|
||||
// 4x4 all-kept block -> 2x2 after one erosion.
|
||||
const mask = new Uint8Array(16).fill(1);
|
||||
const eroded = offsetMask(mask, 4, 4, 1);
|
||||
// corners + outer ring gone, inner 2x2 kept.
|
||||
expect(eroded[0]).toBe(0);
|
||||
expect(eroded[3]).toBe(0);
|
||||
expect(eroded[4]).toBe(0);
|
||||
expect(eroded[7]).toBe(0);
|
||||
expect(eroded[5]).toBe(1);
|
||||
expect(eroded[6]).toBe(1);
|
||||
expect(eroded[9]).toBe(1);
|
||||
expect(eroded[10]).toBe(1);
|
||||
});
|
||||
|
||||
it('negative offset dilates the mask', () => {
|
||||
// single kept pixel grows to a plus shape after one dilation.
|
||||
const mask = new Uint8Array(9);
|
||||
mask[4] = 1;
|
||||
const dilated = offsetMask(mask, 3, 3, -1);
|
||||
expect(dilated[1]).toBe(1); // up
|
||||
expect(dilated[3]).toBe(1); // left
|
||||
expect(dilated[4]).toBe(1); // center
|
||||
expect(dilated[5]).toBe(1); // right
|
||||
expect(dilated[7]).toBe(1); // down
|
||||
expect(dilated[0]).toBe(0); // corners untouched by 4-neighbour dilate
|
||||
});
|
||||
|
||||
it('multiple iterations compound', () => {
|
||||
// 6x6 block eroded twice -> 2x2 core.
|
||||
const mask = new Uint8Array(36).fill(1);
|
||||
const twice = offsetMask(mask, 6, 6, 2);
|
||||
let kept = 0;
|
||||
for (let i = 0; i < 36; i++) kept += twice[i];
|
||||
expect(kept).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// --- featherMask ------------------------------------------------------------------
|
||||
//
|
||||
describe('featherMask (feather)', () => {
|
||||
it('radius 0 returns identity weights (1 kept, 0 removed)', () => {
|
||||
const mask = new Uint8Array([1, 0, 1]);
|
||||
expect(featherMask(mask, 3, 1, 0)).toEqual(new Float32Array([1, 0, 1]));
|
||||
});
|
||||
|
||||
it('interior pixels away from the edge stay fully weighted', () => {
|
||||
// 5x1 row, all kept, feather 1: interior pixel (2,0) has min distance
|
||||
// 2 to any 0 (out-of-bounds is not 0, but row ends are boundary —
|
||||
// boundary pixels have no removed neighbour, so weight stays 1).
|
||||
const mask = new Uint8Array([1, 1, 1, 1, 1]);
|
||||
const w = featherMask(mask, 5, 1, 1);
|
||||
expect(w[2]).toBe(1);
|
||||
});
|
||||
|
||||
it('attenuates kept pixels adjacent to removed pixels', () => {
|
||||
// 3x1 row: [0,1,0], feather 1: the kept middle pixel is 1 px from a
|
||||
// removed pixel -> weight (r - dist + 1)/(r + 1) = (1-1+1)/(1+1) = 0.5.
|
||||
const mask = new Uint8Array([0, 1, 0]);
|
||||
const w = featherMask(mask, 3, 1, 1);
|
||||
expect(w[1]).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('attenuation scales with distance (farther = heavier)', () => {
|
||||
// 5x1: [0,1,1,1,0], feather 2:
|
||||
// x=1 dist 1 -> (2-1+1)/3 = 2/3
|
||||
// x=2 dist 2 -> (2-2+1)/3 = 1/3
|
||||
// x=3 dist 1 -> 2/3
|
||||
const mask = new Uint8Array([0, 1, 1, 1, 0]);
|
||||
const w = featherMask(mask, 5, 1, 2);
|
||||
expect(w[1]).toBeCloseTo(2 / 3);
|
||||
expect(w[2]).toBeCloseTo(1 / 3);
|
||||
expect(w[3]).toBeCloseTo(2 / 3);
|
||||
});
|
||||
});
|
||||
|
||||
// --- computeMask --------------------------------------------------------------------
|
||||
//
|
||||
describe('computeMask (pipeline)', () => {
|
||||
it('keys green and black sheets end-to-end', () => {
|
||||
const green = computeMask(GREEN_PHOTO(), {
|
||||
...DIMS,
|
||||
sheet: 'green',
|
||||
sensitivity: 50,
|
||||
});
|
||||
expect(green.stats.kept).toBe(32);
|
||||
expect(green.stats.empty).toBe(false);
|
||||
|
||||
const blackPhoto = withRect(solid(12, 8, 10, 10, 10), 12, 2, 2, 8, 4, 220, 210, 190);
|
||||
const black = computeMask(blackPhoto, {
|
||||
...DIMS,
|
||||
sheet: 'black',
|
||||
sensitivity: 50,
|
||||
});
|
||||
expect(black.stats.kept).toBe(32);
|
||||
});
|
||||
|
||||
it('edge offset erodes the kept count', () => {
|
||||
const base = computeMask(GREEN_PHOTO(), {
|
||||
...DIMS,
|
||||
sheet: 'green',
|
||||
sensitivity: 50,
|
||||
});
|
||||
const eroded = computeMask(GREEN_PHOTO(), {
|
||||
...DIMS,
|
||||
sheet: 'green',
|
||||
sensitivity: 50,
|
||||
edgeOffset: 1,
|
||||
});
|
||||
expect(eroded.stats.kept).toBeLessThan(base.stats.kept);
|
||||
});
|
||||
|
||||
it('feather does not change the approve stats', () => {
|
||||
const base = computeMask(GREEN_PHOTO(), {
|
||||
...DIMS,
|
||||
sheet: 'green',
|
||||
sensitivity: 50,
|
||||
});
|
||||
const feath = computeMask(GREEN_PHOTO(), {
|
||||
...DIMS,
|
||||
sheet: 'green',
|
||||
sensitivity: 50,
|
||||
feather: 2,
|
||||
});
|
||||
expect(feath.stats).toEqual(base.stats);
|
||||
});
|
||||
|
||||
it('reports empty stats for a sheet-only photo (approve must block)', () => {
|
||||
const res = computeMask(solid(12, 8, 0, 200, 0), {
|
||||
...DIMS,
|
||||
sheet: 'green',
|
||||
sensitivity: 50,
|
||||
});
|
||||
expect(res.stats.empty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- state machine --------------------------------------------------------------------
|
||||
//
|
||||
describe('mask state machine', () => {
|
||||
const fresh = () => createMaskState(DIMS);
|
||||
|
||||
it('starts with sheet auto, sensitivity 50, knobs 0, not approved, empty stats', () => {
|
||||
const s = fresh();
|
||||
expect(s.sheet).toBe('auto');
|
||||
expect(s.sensitivity).toBe(50);
|
||||
expect(s.edgeOffset).toBe(0);
|
||||
expect(s.feather).toBe(0);
|
||||
expect(s.approved).toBe(false);
|
||||
expect(s.stats.empty).toBe(true);
|
||||
});
|
||||
|
||||
it('set_sheet updates immediately and flags a pending recompute', () => {
|
||||
let s = fresh();
|
||||
s = reduceMaskState(s, { type: 'set_sheet', sheet: 'green' });
|
||||
expect(s.sheet).toBe('green');
|
||||
expect(s.pendingRecompute).toBe(true);
|
||||
});
|
||||
|
||||
it('sheet change to the same value is a no-op', () => {
|
||||
const s = fresh();
|
||||
expect(reduceMaskState(s, { type: 'set_sheet', sheet: 'auto' })).toBe(s);
|
||||
});
|
||||
|
||||
it('set_sensitivity updates immediately, clamps 0–100, flags recompute', () => {
|
||||
let s = fresh();
|
||||
s = reduceMaskState(s, { type: 'set_sensitivity', value: 80 });
|
||||
expect(s.sensitivity).toBe(80);
|
||||
expect(s.pendingRecompute).toBe(true);
|
||||
s = reduceMaskState(s, { type: 'set_sensitivity', value: 250 });
|
||||
expect(s.sensitivity).toBe(100);
|
||||
s = reduceMaskState(s, { type: 'set_sensitivity', value: -10 });
|
||||
expect(s.sensitivity).toBe(0);
|
||||
});
|
||||
|
||||
it('nudge_sensitivity steps by delta and clamps', () => {
|
||||
let s = fresh();
|
||||
s = reduceMaskState(s, { type: 'nudge_sensitivity', delta: -5 });
|
||||
expect(s.sensitivity).toBe(45);
|
||||
s = reduceMaskState(s, { type: 'nudge_sensitivity', delta: 5 });
|
||||
expect(s.sensitivity).toBe(50);
|
||||
s = reduceMaskState(s, { type: 'nudge_sensitivity', delta: -999 });
|
||||
expect(s.sensitivity).toBe(0);
|
||||
});
|
||||
|
||||
it('edge offset and feather update immediately and flag recompute', () => {
|
||||
let s = fresh();
|
||||
s = reduceMaskState(s, { type: 'set_edge_offset', value: 2 });
|
||||
expect(s.edgeOffset).toBe(2);
|
||||
expect(s.pendingRecompute).toBe(true);
|
||||
s = reduceMaskState(s, { type: 'set_feather', value: 3 });
|
||||
expect(s.feather).toBe(3);
|
||||
expect(s.pendingRecompute).toBe(true);
|
||||
});
|
||||
|
||||
it('recompute stores stats, clears pending, records time', () => {
|
||||
let s = fresh();
|
||||
s = reduceMaskState(s, { type: 'set_sensitivity', value: 60 });
|
||||
s = reduceMaskState(s, {
|
||||
type: 'recompute',
|
||||
stats: { kept: 32, total: 96, empty: false },
|
||||
at: 1234,
|
||||
});
|
||||
expect(s.stats).toEqual({ kept: 32, total: 96, empty: false });
|
||||
expect(s.pendingRecompute).toBe(false);
|
||||
expect(s.lastRecomputeAt).toBe(1234);
|
||||
});
|
||||
|
||||
it('approve is blocked while the mask is empty', () => {
|
||||
let s = fresh(); // stats empty by default
|
||||
const before = s;
|
||||
s = reduceMaskState(s, { type: 'approve' });
|
||||
expect(s.approved).toBe(false);
|
||||
expect(s).toBe(before); // no change at all
|
||||
});
|
||||
|
||||
it('approve succeeds once the mask has slab pixels', () => {
|
||||
let s = fresh();
|
||||
s = reduceMaskState(s, {
|
||||
type: 'recompute',
|
||||
stats: { kept: 32, total: 96, empty: false },
|
||||
at: 1,
|
||||
});
|
||||
s = reduceMaskState(s, { type: 'approve' });
|
||||
expect(s.approved).toBe(true);
|
||||
});
|
||||
|
||||
it('debounce: recompute is due only after 2.0 s of idle', () => {
|
||||
let s = fresh();
|
||||
s = reduceMaskState(s, { type: 'set_sensitivity', value: 51 });
|
||||
expect(dueForRecompute(s, s.lastRecomputeAt)).toBe(false);
|
||||
expect(dueForRecompute(s, s.lastRecomputeAt + 1999)).toBe(false);
|
||||
expect(RECOMPUTE_DELAY_MS).toBe(2000);
|
||||
expect(dueForRecompute(s, s.lastRecomputeAt + 2000)).toBe(true);
|
||||
});
|
||||
|
||||
it('debounce resets on every sensitivity change (timer restarts)', () => {
|
||||
let s = fresh();
|
||||
s = reduceMaskState(s, { type: 'set_sensitivity', value: 51 });
|
||||
// User keeps nudging: at t=1999 the earlier change would be due, but
|
||||
// a new change at t=1999 resets the window.
|
||||
s = reduceMaskState(s, { type: 'recompute', stats: s.stats, at: 0 });
|
||||
s = reduceMaskState(s, { type: 'set_sensitivity', value: 52 });
|
||||
// 1.9 s after the latest change -> not due; 2.0 s -> due.
|
||||
expect(dueForRecompute(s, 1999)).toBe(false);
|
||||
expect(dueForRecompute(s, 2000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as m from '../../src/lib/core/math.js';
|
||||
|
||||
// M1 deterministic core — unit tests (RED first, per TDD).
|
||||
// Ground truth for the math identities is the spec (docs/CAPTURE.md):
|
||||
// scale = longest_axis_px / L
|
||||
// sqft = (mask_px / scale^2) / 144
|
||||
// bdft = sqft * thickness_in
|
||||
// Widths are sampled every `everyInches` along the approved axis.
|
||||
|
||||
describe('scale', () => {
|
||||
it('scale = longest_axis_px / L', () => {
|
||||
expect(m.scale(3360, 84)).toBe(40);
|
||||
});
|
||||
it('scale for fixture-b geometry (21 in)', () => {
|
||||
// 21 in represented as 3360 px -> 160 px/in
|
||||
expect(m.scale(3360, 21)).toBeCloseTo(160, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sqft', () => {
|
||||
it('sqft = (mask_px / scale^2) / 144, 2 decimals', () => {
|
||||
// scale=40 -> scale^2=1600; 1,000,000/1600=625; /144=4.3403 -> 4.34
|
||||
expect(m.sqft(1_000_000, 40)).toBe(4.34);
|
||||
});
|
||||
it('zero-area mask -> 0', () => {
|
||||
expect(m.sqft(0, 40)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bdft', () => {
|
||||
it('bdft = sqft * thickness_in, 2 decimals (identity 1 sqft @ 1in = 1 bdft)', () => {
|
||||
expect(m.bdft(4.34, 1)).toBeCloseTo(4.34, 6);
|
||||
expect(m.bdft(4.34, 2.5)).toBe(10.85);
|
||||
});
|
||||
});
|
||||
|
||||
describe('e2e fixture math (cand_b: 21in x 1.75in)', () => {
|
||||
// If the slab were a clean rectangle exactly 21in long, at 160px/in that is
|
||||
// 3360 px long. Pick a width of 12in -> 1920px -> area 6,451,200 px.
|
||||
const Lpx = 3360, L = 21, scale = 160, thickness = 1.75;
|
||||
const widthPx = 1920; // 12in
|
||||
const maskPx = Lpx * widthPx; // 6,451,200
|
||||
it('sqft of a 21in x 12in slab = 1.75', () => {
|
||||
expect(m.sqft(maskPx, scale)).toBe(1.75);
|
||||
});
|
||||
it('bdft of that slab at 1.75in = 3.06', () => {
|
||||
const sqft = m.sqft(maskPx, scale);
|
||||
expect(m.bdft(sqft, thickness)).toBe(3.06); // 1.75*1.75=3.0625 -> 3.06 (half-up)
|
||||
});
|
||||
});
|
||||
|
||||
describe('width sampling', () => {
|
||||
it('samples perpendicular widths at 0,6,12,... in along the axis', () => {
|
||||
// pxPerIn=160. axisLengthPx = 3360 (21in). widthsAt samples every 6in.
|
||||
// Provide a constant width of 1920px (12in) at every sample point.
|
||||
const pxPerIn = 160;
|
||||
const axisLengthPx = 3360;
|
||||
const constantWidthPx = 1920;
|
||||
const out = m.sampledWidths(constantWidthPx, pxPerIn, axisLengthPx, 6);
|
||||
// 21in / 6 = 3.5 -> sample points at 0,6,12,18 (and not 24>21). 4 samples.
|
||||
expect(out.length).toBe(4);
|
||||
for (const w of out) expect(w).toBe(12); // 1920px/160 = 12in
|
||||
});
|
||||
it('min/max/avg of sampled widths', () => {
|
||||
const pxPerIn = 160;
|
||||
const widths = m.sampledWidthsFrom(
|
||||
// widths at px positions 0,960,1920,2880 (=0,6,12,18 in)
|
||||
[1600, 1920, 2080, 1600],
|
||||
pxPerIn
|
||||
);
|
||||
expect(widths.min).toBeCloseTo(10, 6);
|
||||
expect(widths.max).toBeCloseTo(13, 6);
|
||||
expect(widths.avg).toBeCloseTo(11.25, 6); // (10+12+13+10)/4 = 11.25
|
||||
});
|
||||
});
|
||||
|
||||
describe('1/8 inch stepping + rounding', () => {
|
||||
it('snaps a length to the nearest 1/8 inch', () => {
|
||||
expect(m.snapToEighth(10.1)).toBeCloseTo(10.125, 6); // 10 1/8
|
||||
expect(m.snapToEighth(10.2)).toBeCloseTo(10.25, 6); // 10 2/8=1/4
|
||||
expect(m.snapToEighth(10.0)).toBe(10);
|
||||
});
|
||||
it('rounds half-up to 2 decimals', () => {
|
||||
expect(m.roundHalfUp(30.625, 2)).toBe(30.63);
|
||||
expect(m.roundHalfUp(4.345, 2)).toBe(4.35);
|
||||
expect(m.roundHalfUp(1.005, 2)).toBe(1.01);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sampleWidthsPx } from '../../src/lib/core/measure.js';
|
||||
|
||||
// M1-5 Measure — pure geometry tests.
|
||||
//
|
||||
// Ground truth: docs/CAPTURE.md §Measure
|
||||
// - Widths: min, max, avg, sampled every 6 in along the approved length
|
||||
// axis, perpendicular to it
|
||||
// - scale = longest_axis_px / L
|
||||
//
|
||||
// sampleWidthsPx runs the real scanline mask sampling that the UI uses on
|
||||
// the approved mask, so these tests pin the exact pixel widths the e2e
|
||||
// fixture math is computed from.
|
||||
|
||||
/**
|
||||
* Build a binary mask directly (no keySheet dependency): 1 inside the slab
|
||||
* rectangle, 0 elsewhere. This keeps the test self-contained and focused on
|
||||
* sampleWidthsPx geometry, which is what the test suite is for.
|
||||
*/
|
||||
function buildMask(width, height, slabW, slabH, ox, oy) {
|
||||
const mask = new Uint8Array(width * height);
|
||||
for (let y = 0; y < height; y++)
|
||||
for (let x = 0; x < width; x++)
|
||||
mask[y * width + x] =
|
||||
x >= ox && x < ox + slabW && y >= oy && y < oy + slabH ? 1 : 0;
|
||||
return { mask, width, height };
|
||||
}
|
||||
|
||||
describe('sampleWidthsPx (M1-5 Measure geometry)', () => {
|
||||
it('samples perpendicular widths every 6in along a horizontal axis', () => {
|
||||
// 16 px/in. Photo 400x300. Slab 288x96 px (18in x 6in) at (0,0).
|
||||
// Axis along x at slab center y=48, from x=0, length 288 px.
|
||||
// stepPx = 6*16 = 96. Samples at t=0,96,192,288.
|
||||
// t=288 lands on the slab boundary (x=288, slab is x<288) -> width 0.
|
||||
const { mask, width, height } = buildMask(400, 300, 288, 96, 0, 0);
|
||||
const axis = { origin: { x: 0, y: 48 }, dir: { x: 1, y: 0 }, lengthPx: 288 };
|
||||
const widths = sampleWidthsPx(mask, width, height, axis, 16);
|
||||
expect(widths.length).toBe(4);
|
||||
expect(widths[0]).toBeCloseTo(96, 0);
|
||||
expect(widths[1]).toBeCloseTo(96, 0);
|
||||
expect(widths[2]).toBeCloseTo(96, 0);
|
||||
expect(widths[3]).toBe(0);
|
||||
});
|
||||
|
||||
it('returns widths perpendicular to a vertical axis', () => {
|
||||
// Slab 96x288 px (6in x 18in) at (0,0). Axis along y at x=48, length 288.
|
||||
const { mask, width, height } = buildMask(400, 300, 96, 288, 0, 0);
|
||||
const axis = { origin: { x: 48, y: 0 }, dir: { x: 0, y: 1 }, lengthPx: 288 };
|
||||
const widths = sampleWidthsPx(mask, width, height, axis, 16);
|
||||
expect(widths.length).toBe(4);
|
||||
expect(widths[0]).toBeCloseTo(96, 0);
|
||||
expect(widths[1]).toBeCloseTo(96, 0);
|
||||
expect(widths[2]).toBeCloseTo(96, 0);
|
||||
expect(widths[3]).toBe(0);
|
||||
});
|
||||
|
||||
it('samples only up to the axis length, not beyond', () => {
|
||||
// Slab 80 px (5in) wide: 2nd sample at 96 px > lengthPx=80.
|
||||
const { mask, width, height } = buildMask(200, 150, 80, 80, 0, 0);
|
||||
const axis = { origin: { x: 0, y: 0 }, dir: { x: 1, y: 0 }, lengthPx: 80 };
|
||||
const widths = sampleWidthsPx(mask, width, height, axis, 16);
|
||||
expect(widths.length).toBe(1);
|
||||
});
|
||||
|
||||
it('reports the real mask extent, not the photo extent', () => {
|
||||
// Slab 80 px tall at y=35: the perpendicular sample must be 80,
|
||||
// never the 150 px photo height.
|
||||
const { mask, width, height } = buildMask(200, 150, 80, 80, 0, 35);
|
||||
const axis = { origin: { x: 0, y: 35 }, dir: { x: 1, y: 0 }, lengthPx: 80 };
|
||||
const widths = sampleWidthsPx(mask, width, height, axis, 16);
|
||||
expect(widths.length).toBe(1);
|
||||
expect(widths[0]).toBeCloseTo(80, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Smoke test for the M1-1 harness: proves the JS unit runner works end-to-end.
|
||||
// This is deliberately trivial — later cards (M1-2+) replace it with the
|
||||
// deterministic-core suite. It must pass to certify the harness is GREEN.
|
||||
describe('M1 harness smoke', () => {
|
||||
it('adds numbers (harness is live)', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": false,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/unit/**/*.test.js'],
|
||||
exclude: ['tests/e2e/**', 'node_modules/**', 'build/**']
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "SlabUploader",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
|
After Width: | Height: | Size: 988 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"fixture": "dji_mimo_20260109_152422_20260109152421_1771310617476_photo.JPG",
|
||||
"label": "cand_a",
|
||||
"source": "\\\\truenas.local\\Business\\Samples",
|
||||
"orientation": "irregular",
|
||||
"sheet": "black",
|
||||
"image_px": [4000, 2256],
|
||||
"length_in": 28.0,
|
||||
"thickness_in": 2.25,
|
||||
"notes": "Organic/irregular slab shape; stress-test for min-area bounding rectangle + length-axis. Length is the longest axis of the slab."
|
||||
}
|
||||
|
After Width: | Height: | Size: 604 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"fixture": "dji_mimo_20260109_155754_20260109155755_1771310617107_photo.JPG",
|
||||
"label": "cand_c",
|
||||
"source": "\\\\truenas.local\\Business\\Samples",
|
||||
"orientation": "horizontal",
|
||||
"sheet": "black",
|
||||
"image_px": [4000, 2256],
|
||||
"length_in": 21.0,
|
||||
"thickness_in": 1.75,
|
||||
"notes": "Clean elongated slab, length runs horizontally. Second clean-case fixture."
|
||||
}
|
||||
|
After Width: | Height: | Size: 592 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"fixture": "dji_mimo_20260109_155818_20260109155817_1771310617082_photo.JPG",
|
||||
"label": "cand_b",
|
||||
"source": "\\\\truenas.local\\Business\\Samples",
|
||||
"orientation": "horizontal",
|
||||
"sheet": "black",
|
||||
"image_px": [4000, 2256],
|
||||
"length_in": 21.0,
|
||||
"thickness_in": 1.75,
|
||||
"notes": "Clean elongated slab, length runs horizontally. Primary clean-case fixture for expected sqft/bdft/widths."
|
||||
}
|
||||