Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8749b07611 | |||
| 2d5ff95441 | |||
| 2bc7eecd7c | |||
| f00c4a7460 | |||
| 0d06e6f7b7 | |||
| a83c9239ff | |||
| 88b9c47200 | |||
| eca306e869 | |||
| 509e2e82a3 | |||
| 25b1650a8a | |||
| b124450d0c | |||
| 507865e30b | |||
| 0284a2211a | |||
| 7e2a1845cf | |||
| dd011207a0 | |||
| 93666320ef | |||
| ba8e5f72f6 | |||
| 92d1a7ec5e |
@@ -1,10 +0,0 @@
|
||||
.git
|
||||
backend
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/.svelte-kit
|
||||
**/.DS_Store
|
||||
*.md
|
||||
!deploy/README.md
|
||||
.audit
|
||||
docs/archive
|
||||
@@ -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,19 +1,7 @@
|
||||
# Host
|
||||
|
||||
## Overnight Milestone 1 Capture (poteto Build)
|
||||
|
||||
- Host: `ubudual5060` / Tailscale `100.64.0.9`
|
||||
- Container: `slabuploader-m1-overnight` (image `slabuploader-m1:overnight`)
|
||||
- Port: **10881** → 80
|
||||
- URL: `http://100.64.0.9:10881`
|
||||
- Also available on the Grok Bot box at `http://100.64.0.3:18080` (not LAN-reachable for Ty).
|
||||
|
||||
## Ty local Node/SvelteKit (do not overwrite)
|
||||
|
||||
- Host: `ubudual5060` / `100.64.0.9`
|
||||
- Container: `slabuploader-m1`
|
||||
- Port: **10880** → 80
|
||||
- URL: `http://100.64.0.9:10880`
|
||||
|
||||
Future overnight Capture deploys use a **separate** container name and an open **108XX** port other than 10880.
|
||||
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).
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA: fall back to index.html for any path
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|svg|woff2?)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Milestone 1 — Capture
|
||||
|
||||
Runs in the browser on phone and PC as ordinary client code.
|
||||
No inference URL. No model. No backend. Mask, rectangle, measurements, crop, and PNG encode are deterministic functions of the photo and user inputs.
|
||||
No inference URL. No model. No backend. Mask, rectangle, measurements, and PNG encode are deterministic functions of the photo and user inputs.
|
||||
|
||||
Milestone 1 uses one reference photo. Further angles are later milestones.
|
||||
Milestone 1 ends when the user sees the inventory PNG and measurements. Milestone 2 is Review. Milestone 3 is Create. This file does not create a WooCommerce product.
|
||||
@@ -33,9 +33,9 @@ Otherwise the app opens Add photo.
|
||||
3. User sets sheet Auto / Green / Black and sensitivity 0–100 (slider or minus / plus). App updates the control immediately. App recomputes the overlay 2.0 seconds after the last sensitivity change. User adjusts until only the slab remains, then approves. App blocks approve if the mask has no slab pixels; user retakes. The approved mask is the footprint for every later step.
|
||||
4. App draws the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
|
||||
5. User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
||||
6. App sets `scale = longest_axis_px / L` (mask/wood span along the approved direction ÷ user `L`; rectangle is direction only).
|
||||
7. App computes widths, sqft, and bdft from the mask. App crops the masked slab into a 3:4 portrait frame: slab centered, 80% fill at the widest and tallest points, background transparent. App writes that frame as an inventory PNG (`image/png`, 8-bit RGBA).
|
||||
8. User sees the transparent PNG and the numbers. Milestone 1 stops here.
|
||||
6. App sets `scale = longest_axis_px / L`.
|
||||
7. App computes widths, sqft, and bdft from the mask, and writes the inventory PNG.
|
||||
8. User sees the PNG and the numbers. Milestone 1 stops here.
|
||||
|
||||
Calculate does not create a WooCommerce product.
|
||||
|
||||
@@ -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.
|
||||
@@ -53,13 +59,13 @@ One photo component is reused. Each state advances on approve. Back returns to t
|
||||
2. Add photo — camera or picker. One photo. Setup rules visible.
|
||||
3. Mask — background removal on that photo.
|
||||
4. Length axis — bounding rectangle and long-side axis on the same photo.
|
||||
5. Measure — length, thickness, Calculate, crop, inventory PNG, widths, sqft, bdft. Milestone 1 stops here.
|
||||
5. Measure — length, thickness, Calculate, inventory PNG, widths, sqft, bdft. Milestone 1 stops here.
|
||||
|
||||
## Display
|
||||
|
||||
Preview fits the device width. Height is capped so Mask and Measure controls stay on screen. Phone and PC use the same states.
|
||||
|
||||
The preview is display only. Mask, rectangle, crop, `scale`, sqft, and PNG encode use the source photo pixels, not the CSS size of the preview.
|
||||
The preview is display only. Mask, rectangle, `scale`, sqft, and PNG encode use the source photo pixels, not the CSS size of the preview.
|
||||
|
||||
## Runtime
|
||||
|
||||
@@ -83,34 +89,22 @@ Mask work runs in the worker. The last good overlay stays visible while a pass r
|
||||
|
||||
Approve is blocked when the mask has no slab pixels. User retakes.
|
||||
|
||||
User approves the mask when only the slab remains. That mask is the footprint for Length axis, crop, measurements, and the inventory PNG.
|
||||
User approves the mask when only the slab remains. That mask is the footprint for Length axis, measurements, and the inventory PNG.
|
||||
|
||||
## Length axis
|
||||
|
||||
On the same photo, keep the approved mask in view and draw the minimum-area bounding rectangle of the mask with the length axis on the long side of that rectangle. User rotates the rectangle (slider with minus and plus) until that direction follows the slab length, then approves the axis.
|
||||
On the same photo, draw the minimum-area bounding rectangle of the mask and the length axis on the long side of that rectangle. User rotates the axis if it does not follow the slab length, then approves the axis.
|
||||
|
||||
## Measure
|
||||
|
||||
User enters length `L` in inches (1/8 in steps) and thickness (1/8 in steps), then presses Calculate.
|
||||
|
||||
- `scale = longest_axis_px / L` where `longest_axis_px` is the mask/wood span along the approved length direction (slab pixels end-to-end on that axis), not the rectangle edge length. The rectangle sets direction only; user-entered `L` is wood end→end along that direction.
|
||||
- `scale = longest_axis_px / L`
|
||||
- Widths: min, max, avg, sampled every 6 in along the approved length axis, perpendicular to it
|
||||
- `sqft = (mask_pixels / scale²) / 144` (2 decimals)
|
||||
- `bdft = sqft × thickness_in` (2 decimals)
|
||||
- Crop: 3:4 portrait, slab centered, 80% fill at the widest and tallest points, transparent background
|
||||
- File: inventory PNG, `image/png`, 8-bit RGBA, alpha kept
|
||||
|
||||
User sees the transparent PNG and those numbers. Stop. Calculate does not create a WooCommerce product.
|
||||
|
||||
## Crop
|
||||
|
||||
On Calculate, the app builds the inventory image from the approved mask:
|
||||
|
||||
1. Keep slab pixels from the source photo. Removed pixels are transparent.
|
||||
2. Place the slab in a 3:4 portrait frame, centered.
|
||||
3. Scale so the widest and tallest points of the slab fill 80% of the frame. Extra margin where the slab is narrower.
|
||||
4. After that crop, shorter side ≥ 1600px when the source can supply it. Otherwise warn and require a new photo. No upscale from an undersized source.
|
||||
5. Encode as PNG with alpha.
|
||||
User sees the inventory PNG and those numbers. Stop. Calculate does not create a WooCommerce product.
|
||||
|
||||
## Device draft
|
||||
|
||||
@@ -138,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
|
||||
@@ -1,13 +1,18 @@
|
||||
# Build the static app, then serve only the built artifacts.
|
||||
# Build context is the repo root: `docker build -f frontend/Dockerfile .`
|
||||
FROM node:20-slim AS build
|
||||
# SlabUploader M1 — frontend only (no FastAPI/Woo). Multi-stage: build then serve.
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY deploy/nginx-m1.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
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);
|
||||
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>SlabUploader — Capture</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; background: #111; }
|
||||
#app { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,23 +1,29 @@
|
||||
{
|
||||
"name": "slabuploader-frontend",
|
||||
"name": "slab-uploader-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --import tsx --test src/**/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.3.0",
|
||||
"svelte": "^4.2.20"
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test:unit": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"svelte-preprocess": "^5.1.4",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^5.4.19"
|
||||
"@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'] }
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { supported } from './browser';
|
||||
import { createCaptureSession, type CaptureSession } from './capture/session';
|
||||
import type { Capture } from './capture/capture.types';
|
||||
import Resume from './capture/Resume.svelte';
|
||||
import AddPhoto from './capture/AddPhoto.svelte';
|
||||
import MaskPanel from './capture/MaskPanel.svelte';
|
||||
import AxisPanel from './capture/AxisPanel.svelte';
|
||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||
|
||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||
const session: CaptureSession = createCaptureSession();
|
||||
|
||||
let cap: Capture = { k: 'addPhoto' };
|
||||
let busy = false;
|
||||
let pngUrl: string | null = null;
|
||||
|
||||
const unsubCap = session.capture.subscribe((c) => {
|
||||
cap = c;
|
||||
pngUrl = c.k === 'measure' ? session.getInventoryUrl() : null;
|
||||
});
|
||||
const unsubBusy = session.busy.subscribe((b) => {
|
||||
busy = b;
|
||||
});
|
||||
|
||||
const pick = (f: File) => void session.addPhoto(f);
|
||||
|
||||
onMount(() => {
|
||||
void session.resumeProbe();
|
||||
});
|
||||
onDestroy(() => {
|
||||
unsubCap();
|
||||
unsubBusy();
|
||||
session.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if gate}
|
||||
<main class="hardstop">
|
||||
<h1>Unsupported browser</h1>
|
||||
<p>
|
||||
SlabUploader needs a modern browser with canvas, workers, and IndexedDB support:
|
||||
iOS Safari, Android Chrome, or desktop Chrome, Edge, or Firefox.
|
||||
</p>
|
||||
<p class="detail">Missing: {gate}</p>
|
||||
</main>
|
||||
{:else}
|
||||
<main class="app">
|
||||
{#if cap.k === 'resume'}
|
||||
<Resume onContinue={() => session.continueDraft()} onFresh={() => void session.startFresh()} />
|
||||
{:else if cap.k === 'addPhoto'}
|
||||
<AddPhoto onPicked={pick} />
|
||||
{:else if cap.k === 'mask'}
|
||||
<MaskPanel
|
||||
state={cap}
|
||||
{busy}
|
||||
setSensitivity={session.setSensitivity}
|
||||
setSheet={session.setSheet}
|
||||
approveMask={session.approveMask}
|
||||
retakes={session.retakes}
|
||||
back={session.back}
|
||||
/>
|
||||
{:else if cap.k === 'axis'}
|
||||
<AxisPanel
|
||||
state={cap}
|
||||
setAxisAngle={session.setAxisAngle}
|
||||
approveAxis={session.approveAxis}
|
||||
back={session.back}
|
||||
/>
|
||||
{:else}
|
||||
<MeasurePanel
|
||||
state={cap}
|
||||
setLength={session.setLength}
|
||||
setThickness={session.setThickness}
|
||||
calculate={session.calculate}
|
||||
back={session.back}
|
||||
{pngUrl}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
background: #121212;
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
}
|
||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; }
|
||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||
.hardstop h1 { font-size: 1.6rem; }
|
||||
.detail { color: #888; font-size: 0.85rem; }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -1,26 +0,0 @@
|
||||
// Supported clients: iOS Safari, Android Chrome, PC Chrome/Edge/Firefox.
|
||||
// Gated on required API presence; anything missing → hard stop.
|
||||
export function supported(): string | null {
|
||||
if (typeof window === 'undefined') return 'window unavailable';
|
||||
if (typeof createImageBitmap !== 'function') return 'createImageBitmap';
|
||||
if (typeof Worker === 'undefined') return 'Worker';
|
||||
if (typeof indexedDB === 'undefined') return 'IndexedDB';
|
||||
const hasOffscreen =
|
||||
typeof OffscreenCanvas !== 'undefined' &&
|
||||
typeof OffscreenCanvas.prototype.convertToBlob === 'function';
|
||||
const hasCanvasBlob =
|
||||
typeof HTMLCanvasElement !== 'undefined' &&
|
||||
typeof HTMLCanvasElement.prototype.toBlob === 'function';
|
||||
if (!hasOffscreen && !hasCanvasBlob) return 'canvas PNG encode';
|
||||
const ua = navigator.userAgent;
|
||||
const isIOS = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const isAndroid = /Android/i.test(ua);
|
||||
const isSafari = /Safari/i.test(ua) && !/Chrome|CriOS|Edg|Firefox|FxiOS/i.test(ua);
|
||||
const isChrome = /Chrome|CriOS/i.test(ua) && !/Edg/i.test(ua);
|
||||
const isEdge = /Edg/i.test(ua);
|
||||
const isFirefox = /Firefox|FxiOS/i.test(ua);
|
||||
if (isIOS && isSafari) return null;
|
||||
if (isAndroid && isChrome) return null;
|
||||
if (!isIOS && !isAndroid && (isChrome || isEdge || isFirefox)) return null;
|
||||
return 'unsupported browser';
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let onPicked: (f: File) => void;
|
||||
|
||||
const pick = (e: Event) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) onPicked(f);
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Add photo</h1>
|
||||
<ul class="setup">
|
||||
<li>Sheet is green or black, fills the frame around the slab.</li>
|
||||
<li>Slab fully on the sheet, nothing on top of it.</li>
|
||||
<li>Top-down, lens parallel to the face.</li>
|
||||
<li>Slab length along the long side of the picture.</li>
|
||||
</ul>
|
||||
<div class="pick">
|
||||
<label class="pick-btn">Take photo
|
||||
<input type="file" accept="image/*" capture="environment" on:change={pick} />
|
||||
</label>
|
||||
<label class="pick-btn">Choose from gallery
|
||||
<input type="file" accept="image/*" on:change={pick} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 24px 16px; color: #eee; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
.setup { color: #bbb; line-height: 1.6; font-size: 0.95rem; }
|
||||
.pick { margin-top: 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.pick-btn {
|
||||
background: #00e5ff; color: #002; border: 0; border-radius: 8px;
|
||||
padding: 12px 20px; font-size: 1rem; font-weight: 600;
|
||||
text-align: center; cursor: pointer;
|
||||
}
|
||||
.pick-btn input[type='file'] { display: none; }
|
||||
</style>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'axis' }>;
|
||||
export let setAxisAngle: (deg: number) => void;
|
||||
export let approveAxis: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
$: maskW = state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 ? state.approvedMask.bitset.length / maskW : 0;
|
||||
$: angleDeg = Math.round(state.axis.bbox.angleDeg);
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Length axis</h1>
|
||||
<p class="hint">Rotate the rectangle until it follows the slab length. The rectangle sets direction only; you enter length L on the next step.</p>
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
{maskW}
|
||||
{maskH}
|
||||
/>
|
||||
<div class="angle">
|
||||
<button type="button" class="thumb" aria-label="Rotate axis counter-clockwise" on:click={() => setAxisAngle(angleDeg - 1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="179" step="1"
|
||||
value={angleDeg}
|
||||
on:input={(e) => setAxisAngle(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Rotate axis clockwise" on:click={() => setAxisAngle(angleDeg + 1)}>+</button>
|
||||
<span class="val">{angleDeg}°</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="primary" on:click={approveAxis}>Approve axis</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 8px; }
|
||||
.hint { color: #aaa; font-size: 0.9rem; margin: 0 0 12px; }
|
||||
.angle { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
||||
.thumb {
|
||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 4ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.primary {
|
||||
flex: 1; padding: 14px; font-size: 1rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost { flex: 0.7; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
</style>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Capture, Sheet } from './capture.types';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'mask' }>;
|
||||
export let busy: boolean;
|
||||
export let setSensitivity: (n: number) => void;
|
||||
export let setSheet: (s: Sheet) => void;
|
||||
export let approveMask: () => void;
|
||||
export let retakes: () => void;
|
||||
export let back: () => void;
|
||||
|
||||
const sheets: Sheet[] = ['auto', 'green', 'black'];
|
||||
$: empty = state.overlay !== null && state.overlay.slabPx === 0;
|
||||
$: maskW = state.overlay && state.source.widthPx > 0 ? state.source.widthPx : 0;
|
||||
$: maskH = maskW > 0 && state.overlay ? state.overlay.bitset.length / maskW : 0;
|
||||
$: canApprove = state.overlay !== null && state.overlay.slabPx > 0;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Mask</h1>
|
||||
<div class="stage-wrap">
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
maskBitset={state.overlay?.bitset ?? null}
|
||||
{maskW}
|
||||
{maskH}
|
||||
/>
|
||||
{#if busy}<div class="busy">Working…</div>{/if}
|
||||
{#if state.failed}<div class="warn">Mask pass failed. Adjust and retry, or retake the photo.</div>{/if}
|
||||
{#if empty}<div class="warn">No slab pixels in the mask. Adjust sensitivity or retake.</div>{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="seg" role="group" aria-label="Sheet">
|
||||
{#each sheets as s (s)}
|
||||
<button type="button" class:active={state.control.sheet === s} on:click={() => setSheet(s)}>
|
||||
{s[0].toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="sens">
|
||||
<button type="button" class="thumb" aria-label="Decrease sensitivity" on:click={() => setSensitivity(state.control.sensitivity - 1)}>−</button>
|
||||
<input
|
||||
type="range" min="0" max="100" step="1"
|
||||
value={state.control.sensitivity}
|
||||
on:input={(e) => setSensitivity(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<button type="button" class="thumb" aria-label="Increase sensitivity" on:click={() => setSensitivity(state.control.sensitivity + 1)}>+</button>
|
||||
<span class="val">{state.control.sensitivity}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button type="button" class="ghost" on:click={retakes}>Retake</button>
|
||||
<button type="button" class="primary" disabled={!canApprove} on:click={approveMask}>
|
||||
Approve mask
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.stage-wrap { position: relative; }
|
||||
.busy {
|
||||
position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.7);
|
||||
color: #fff; padding: 4px 10px; border-radius: 6px; font-size: 0.85rem;
|
||||
}
|
||||
.warn { color: #ffb4a2; margin-top: 8px; font-size: 0.9rem; }
|
||||
.controls { margin-top: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.seg { display: flex; gap: 8px; }
|
||||
.seg button {
|
||||
flex: 1; padding: 12px; font-size: 1rem; border-radius: 8px;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #ccc;
|
||||
}
|
||||
.seg button.active { background: #00e5ff; color: #002; font-weight: 700; border-color: #00e5ff; }
|
||||
.sens { display: flex; align-items: center; gap: 10px; }
|
||||
.thumb {
|
||||
width: 48px; height: 48px; border-radius: 50%; font-size: 1.6rem;
|
||||
border: 1px solid #444; background: #1d1d1d; color: #eee; flex: none;
|
||||
}
|
||||
input[type='range'] { flex: 1; }
|
||||
.val { min-width: 2.2ch; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.row { display: flex; gap: 12px; margin-top: 20px; }
|
||||
.primary {
|
||||
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
</style>
|
||||
@@ -1,146 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Capture } from './capture.types';
|
||||
import { inToEighths, eighthsToIn } from './capture.types';
|
||||
import { fmtEighths } from './session';
|
||||
import PhotoStage from './PhotoStage.svelte';
|
||||
|
||||
export let state: Extract<Capture, { k: 'measure' }>;
|
||||
export let setLength: (e: number) => void;
|
||||
export let setThickness: (e: number) => void;
|
||||
export let calculate: () => Promise<void>;
|
||||
export let back: () => void;
|
||||
export let pngUrl: string | null;
|
||||
|
||||
let calcBusy = false;
|
||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||
|
||||
const positiveInches = (raw: string): number | null => {
|
||||
const inches = Number.parseFloat(raw);
|
||||
return Number.isFinite(inches) && inches > 0 ? inches : null;
|
||||
};
|
||||
|
||||
const commitLength = () => {
|
||||
const inches = positiveInches(String(lengthIn));
|
||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
||||
setLength(eighths);
|
||||
lengthIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
||||
};
|
||||
const commitThickness = () => {
|
||||
const inches = positiveInches(String(thicknessIn));
|
||||
const eighths = inches === null ? 0 : inToEighths(inches);
|
||||
setThickness(eighths);
|
||||
thicknessIn = eighths === 0 ? '' : String(eighthsToIn(eighths));
|
||||
};
|
||||
|
||||
$: canCalc = positiveInches(String(lengthIn)) !== null && positiveInches(String(thicknessIn)) !== null;
|
||||
|
||||
const doCalc = async () => {
|
||||
calcBusy = true;
|
||||
try {
|
||||
commitLength();
|
||||
commitThickness();
|
||||
await calculate();
|
||||
} finally { calcBusy = false; }
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Measure</h1>
|
||||
<PhotoStage
|
||||
blob={state.source.blob}
|
||||
bbox={state.axis.bbox}
|
||||
maskBitset={state.approvedMask.bitset}
|
||||
maskW={state.source.widthPx > 0 ? state.source.widthPx : 0}
|
||||
maskH={state.source.widthPx > 0 ? state.approvedMask.bitset.length / state.source.widthPx : 0}
|
||||
/>
|
||||
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<span class="label">Length L</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Length L"
|
||||
inputmode="decimal"
|
||||
step="0.125"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
bind:value={lengthIn}
|
||||
on:blur={commitLength}
|
||||
on:change={commitLength}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Thickness</span>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="Thickness"
|
||||
inputmode="decimal"
|
||||
step="0.125"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
bind:value={thicknessIn}
|
||||
on:blur={commitThickness}
|
||||
on:change={commitThickness}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="ghost" on:click={back}>Back</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
disabled={calcBusy || !canCalc}
|
||||
on:click={doCalc}
|
||||
>
|
||||
{calcBusy ? 'Calculating…' : 'Calculate'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if state.results}
|
||||
<div class="results">
|
||||
<div class="nums">
|
||||
<div><span>Widths</span><span>{fmtEighths(state.results.widths.min)} – {fmtEighths(state.results.widths.max)} (avg {fmtEighths(state.results.widths.avg)})</span></div>
|
||||
<div><span>Sqft</span><span>{state.results.sqft.toFixed(2)}</span></div>
|
||||
<div><span>Bdft</span><span>{state.results.bdft.toFixed(2)}</span></div>
|
||||
<div><span>Scale</span><span>{state.results.scale.toFixed(2)} px/in</span></div>
|
||||
</div>
|
||||
{#if state.results.sourceBelowFloor}
|
||||
<div class="warn">Source is below the 1600px floor for the inventory PNG. Retake a larger photo.</div>
|
||||
{/if}
|
||||
{#if pngUrl && !state.results.sourceBelowFloor}
|
||||
<div class="png-wrap">
|
||||
<img src={pngUrl} alt="Inventory PNG" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 16px; color: #eee; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 12px; }
|
||||
.fields { display: flex; gap: 16px; margin-top: 16px; }
|
||||
.field { flex: 1; }
|
||||
.label { display: block; font-size: 0.9rem; color: #aaa; margin-bottom: 8px; }
|
||||
.field input {
|
||||
box-sizing: border-box; width: 100%; min-height: 52px; padding: 12px; font-size: 1rem;
|
||||
border-radius: 10px; border: 1px solid #444; background: #1d1d1d; color: #eee;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.row { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.primary {
|
||||
flex: 2; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.primary:disabled { background: #333; color: #777; }
|
||||
.ghost { flex: 1; padding: 14px; border-radius: 10px; border: 1px solid #444; background: transparent; color: #ccc; }
|
||||
.results { margin-top: 20px; }
|
||||
.nums { display: flex; flex-direction: column; gap: 8px; }
|
||||
.nums div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding-bottom: 6px; }
|
||||
.nums span:first-child { color: #aaa; }
|
||||
.warn { color: #ffb4a2; margin-top: 12px; font-size: 0.9rem; }
|
||||
.png-wrap { margin-top: 16px; text-align: center; background-image: linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%); background-size: 16px 16px; background-position: 0 0,0 8px,8px -8px,-8px 0; background-color: #eee; border-radius: 8px; padding: 8px; }
|
||||
.png-wrap img { max-width: 100%; border-radius: 4px; }
|
||||
</style>
|
||||
@@ -1,138 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import type { BBox } from './capture.types';
|
||||
import { fitDisplay } from './fitDisplay';
|
||||
|
||||
// Display-only: renders the photo fitted to device width (height capped) plus
|
||||
// one overlay layer. All math lives on source pixels, not this canvas.
|
||||
export let blob: Blob;
|
||||
export let maxH = 420;
|
||||
export let bbox: BBox | null = null;
|
||||
export let maskBitset: Uint8Array | null = null;
|
||||
export let maskW = 0;
|
||||
export let maskH = 0;
|
||||
|
||||
let url: string | null = null;
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
let stage: HTMLDivElement | undefined;
|
||||
let imgW = 0, imgH = 0; // source px, learned once per blob
|
||||
let parentW = 0;
|
||||
|
||||
const setBlobUrl = (b: Blob) => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
url = URL.createObjectURL(b);
|
||||
};
|
||||
|
||||
$: if (blob) {
|
||||
setBlobUrl(blob);
|
||||
imgW = 0;
|
||||
}
|
||||
|
||||
const applyFit = () => {
|
||||
if (!canvas || !imgW) return;
|
||||
if (parentW <= 0 && stage) parentW = stage.clientWidth;
|
||||
const fit = fitDisplay(parentW, imgW, imgH, maxH);
|
||||
if (!fit) return;
|
||||
canvas.style.width = `${fit.fitW}px`;
|
||||
canvas.style.height = `${fit.fitH}px`;
|
||||
};
|
||||
|
||||
const redraw = () => {
|
||||
if (!url || !canvas) return;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!canvas) return;
|
||||
imgW = img.width;
|
||||
imgH = img.height;
|
||||
canvas.width = imgW;
|
||||
canvas.height = imgH;
|
||||
applyFit();
|
||||
ctx.drawImage(img, 0, 0);
|
||||
if (maskBitset && maskW > 0 && maskH > 0) {
|
||||
const off = document.createElement('canvas');
|
||||
off.width = maskW;
|
||||
off.height = maskH;
|
||||
const octx = off.getContext('2d')!;
|
||||
const d = octx.createImageData(maskW, maskH);
|
||||
const tile = 8;
|
||||
for (let p = 0; p < maskW * maskH; p++) {
|
||||
if (!maskBitset[p]) {
|
||||
const x = p % maskW;
|
||||
const y = (p / maskW) | 0;
|
||||
const light = ((x / tile) | 0) % 2 === ((y / tile) | 0) % 2;
|
||||
d.data[p * 4] = light ? 220 : 180;
|
||||
d.data[p * 4 + 1] = light ? 220 : 180;
|
||||
d.data[p * 4 + 2] = light ? 220 : 180;
|
||||
d.data[p * 4 + 3] = 220;
|
||||
}
|
||||
}
|
||||
octx.putImageData(d, 0, 0);
|
||||
ctx.drawImage(off, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
if (bbox && bbox.corners.length === 4) {
|
||||
const c = bbox.corners;
|
||||
ctx.strokeStyle = '#00e5ff';
|
||||
ctx.lineWidth = Math.max(2, canvas.width / 400);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c[0][0], c[0][1]);
|
||||
for (let i = 1; i < 4; i++) ctx.lineTo(c[i][0], c[i][1]);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
// The axis reads corners[3] → corners[0], the long side.
|
||||
const a = c[3];
|
||||
const b = c[0];
|
||||
ctx.strokeStyle = '#ff3d71';
|
||||
ctx.lineWidth = Math.max(3, canvas.width / 300);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a[0], a[1]);
|
||||
ctx.lineTo(b[0], b[1]);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#ff3d71';
|
||||
ctx.beginPath();
|
||||
const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
|
||||
const r = Math.max(10, canvas.width / 60);
|
||||
ctx.moveTo(b[0], b[1]);
|
||||
ctx.lineTo(b[0] - r * Math.cos(ang - 0.4), b[1] - r * Math.sin(ang - 0.4));
|
||||
ctx.lineTo(b[0] - r * Math.cos(ang + 0.4), b[1] - r * Math.sin(ang + 0.4));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
$: if (url || bbox || maskBitset) void tick().then(redraw);
|
||||
|
||||
onMount(() => {
|
||||
if (stage) parentW = stage.clientWidth;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const e = entries[0];
|
||||
if (!e) return;
|
||||
const w = e.contentRect.width;
|
||||
if (w === parentW) return;
|
||||
parentW = w;
|
||||
applyFit();
|
||||
});
|
||||
if (stage) ro.observe(stage);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="stage" bind:this={stage}>
|
||||
{#if url}
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
{:else}
|
||||
<div class="loading">Decoding photo…</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stage { width: 100%; display: flex; justify-content: center; }
|
||||
canvas { display: block; border-radius: 8px; background: #222; }
|
||||
.loading { padding: 40px; color: #999; }
|
||||
</style>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let onContinue: () => void;
|
||||
export let onFresh: () => void;
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
<h1>Resume?</h1>
|
||||
<p>You have an in-progress slab on this device.</p>
|
||||
<div class="row">
|
||||
<button class="primary" type="button" on:click={onContinue}>Continue</button>
|
||||
<button class="ghost" type="button" on:click={onFresh}>Start fresh</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.panel { padding: 24px 16px; text-align: center; color: #eee; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
p { color: #aaa; }
|
||||
.row { display: flex; gap: 12px; justify-content: center; margin-top: 24px; }
|
||||
.primary {
|
||||
flex: 1; max-width: 200px; padding: 14px; font-size: 1.05rem; border-radius: 10px; border: 0;
|
||||
background: #00e5ff; color: #002; font-weight: 700;
|
||||
}
|
||||
.ghost {
|
||||
flex: 1; max-width: 200px; padding: 14px; border-radius: 10px; border: 1px solid #444;
|
||||
background: transparent; color: #ccc;
|
||||
}
|
||||
</style>
|
||||
@@ -1,132 +0,0 @@
|
||||
// Domain model for the M1 capture flow.
|
||||
// The Capture tagged union is the state machine: each branch carries exactly the
|
||||
// data its state can hold, so an illegal state is a type error.
|
||||
|
||||
// Integer eighths of an inch; divide by 8 only at display.
|
||||
export type Eighths = number;
|
||||
|
||||
export const inToEighths = (inches: number): Eighths => Math.round(inches * 8);
|
||||
export const eighthsToIn = (e: Eighths): number => e / 8;
|
||||
|
||||
// Source: all geometry, measurement, and PNG work uses source pixels, never the
|
||||
// CSS size of the preview.
|
||||
export interface Source {
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
export type Sheet = 'auto' | 'green' | 'black';
|
||||
export interface MaskParams {
|
||||
sheet: Sheet;
|
||||
sensitivity: number; // 0–100, step 1
|
||||
}
|
||||
|
||||
// Axis-aligned box, source px.
|
||||
export interface ABox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// Minimum-area bounding rectangle of the mask, source px.
|
||||
export interface BBox {
|
||||
corners: [number, number][]; // 4 corners; corners[3] → corners[0] is the long side
|
||||
longSidePx: number; // geometric extent only; scale uses mask span along the axis
|
||||
angleDeg: number; // long-side angle, degrees, [0, 180)
|
||||
}
|
||||
|
||||
// The approved mask is the footprint for axis, measurement, and the inventory PNG.
|
||||
export interface Mask {
|
||||
params: MaskParams;
|
||||
slabPx: number; // 0 ⇒ approve blocked, user retakes
|
||||
bitset: Uint8Array; // source-grid mask, 1 byte/px
|
||||
bbox: BBox;
|
||||
slabBounds: ABox; // axis-aligned extent of slab px, for the 80%-fill PNG crop
|
||||
}
|
||||
|
||||
// Direction only: an oriented rectangle over the slab. The arrow reads
|
||||
// corners[3] → corners[0] (the long side).
|
||||
export interface Axis {
|
||||
bbox: BBox;
|
||||
}
|
||||
|
||||
export interface Widths {
|
||||
min: Eighths;
|
||||
max: Eighths;
|
||||
avg: Eighths;
|
||||
every6In: Eighths[];
|
||||
}
|
||||
|
||||
export interface Measures {
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
scale: number; // px per inch
|
||||
widths: Widths;
|
||||
sqft: number; // 2 dp
|
||||
bdft: number; // 2 dp
|
||||
inventoryPng: Blob;
|
||||
sourceBelowFloor: boolean;
|
||||
}
|
||||
|
||||
// The machine. 'resume' exists only while a draft exists and has not been chosen.
|
||||
export type Capture =
|
||||
| { k: 'resume'; draft: Draft }
|
||||
| { k: 'addPhoto' }
|
||||
| {
|
||||
k: 'mask';
|
||||
source: Source;
|
||||
control: MaskParams; // immediate control value (what the UI shows)
|
||||
overlay: Mask | null; // last settled mask; null until first pass
|
||||
failed: boolean;
|
||||
}
|
||||
| {
|
||||
k: 'axis';
|
||||
source: Source;
|
||||
approvedMask: Mask; // frozen footprint
|
||||
axis: Axis; // live axis.bbox being nudged
|
||||
}
|
||||
| {
|
||||
k: 'measure';
|
||||
source: Source;
|
||||
approvedMask: Mask;
|
||||
axis: Axis; // frozen
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
results: Measures | null;
|
||||
};
|
||||
|
||||
// Device draft (IndexedDB): one in-progress slab. Small by design — the bitset
|
||||
// and PNG are re-derived from stored data, never stored second-hand.
|
||||
export interface Draft {
|
||||
version: 1;
|
||||
stage: 'mask' | 'axis' | 'measure'; // last approved state
|
||||
source: Source;
|
||||
maskParams: MaskParams; // → re-derive mask on resume
|
||||
mask: Omit<Mask, 'bitset' | 'params'>; // slabPx, bbox, slabBounds
|
||||
axisBbox?: BBox | null; // present when stage >= axis; absent on legacy drafts
|
||||
lengthEighths: Eighths;
|
||||
thicknessEighths: Eighths;
|
||||
results: Omit<Measures, 'inventoryPng'> | null; // present when results computed
|
||||
inventoryPng: Blob | null; // stored so Continue can show Measure results
|
||||
}
|
||||
|
||||
// Worker protocol. Job ids increase; the worker drops anything older than the
|
||||
// current id, so a stale result can never blank a newer one.
|
||||
export type WorkerIn =
|
||||
| { id: number; kind: 'init'; blob: Blob }
|
||||
| { id: number; kind: 'mask'; sheet: Sheet; sensitivity: number }
|
||||
| { id: number; kind: 'invalidate' };
|
||||
|
||||
export type WorkerOut =
|
||||
| { id: number; kind: 'decoded'; w: number; h: number }
|
||||
| {
|
||||
id: number;
|
||||
kind: 'ready';
|
||||
slabPx: number;
|
||||
bitset: Uint8Array;
|
||||
bbox: BBox;
|
||||
slabBounds: ABox;
|
||||
}
|
||||
| { id: number; kind: 'failed'; reason: string };
|
||||
@@ -1,26 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fitDisplay } from './fitDisplay.ts';
|
||||
|
||||
test('parentW=0 → null (no size until laid out)', () => {
|
||||
assert.equal(fitDisplay(0, 1000, 750, 420), null);
|
||||
assert.equal(fitDisplay(-5, 1000, 750, 420), null);
|
||||
});
|
||||
|
||||
test('280 wide, 4:3 landscape → contain, under the height cap', () => {
|
||||
const fit = fitDisplay(280, 1600, 1200, 420);
|
||||
assert.ok(fit);
|
||||
assert.equal(fit.fitW, 280);
|
||||
assert.ok(Math.abs(fit.fitH - 210) < 1e-9);
|
||||
assert.ok(fit.fitH <= 420);
|
||||
});
|
||||
|
||||
test('portrait under maxH never stretches: height capped, width shrinks', () => {
|
||||
// 1000 wide source, 3000 tall. Full-width fit would be 900 tall > 420.
|
||||
const fit = fitDisplay(400, 1000, 3000, 420);
|
||||
assert.ok(fit);
|
||||
assert.equal(fit.fitH, 420);
|
||||
assert.ok(Math.abs(fit.fitW - 140) < 1e-9);
|
||||
// aspect preserved: fitW/fitH === imgW/imgH
|
||||
assert.ok(Math.abs(fit.fitW / fit.fitH - 1000 / 3000) < 1e-9);
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
// Contain-fit of an image into a parent width with a height cap. Pure and
|
||||
// display-only; all measurement math stays on source pixels.
|
||||
// Null when the parent is not laid out yet (clientWidth 0) — the caller must
|
||||
// not finalize CSS size until it is, to avoid a wrong-aspect flash.
|
||||
export function fitDisplay(
|
||||
parentW: number, imgW: number, imgH: number, maxH: number,
|
||||
): { fitW: number; fitH: number } | null {
|
||||
if (parentW <= 0 || imgW <= 0 || imgH <= 0) return null;
|
||||
const fitW = parentW;
|
||||
let fitH = (imgH / imgW) * fitW;
|
||||
if (fitH > maxH) {
|
||||
// Cap height and shrink width to preserve aspect; never stretch.
|
||||
fitH = maxH;
|
||||
return { fitW: (fitH * imgW) / imgH, fitH };
|
||||
}
|
||||
return { fitW, fitH };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
import { inToEighths } from './capture.types';
|
||||
|
||||
const panelSrc = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'MeasurePanel.svelte'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
test('MeasurePanel exposes typed number inputs for Length L and Thickness', () => {
|
||||
const inputs = panelSrc.match(/<input\b[^>]*>/g) ?? [];
|
||||
assert.ok(inputs.length >= 2, 'expected at least two <input> fields');
|
||||
assert.ok(
|
||||
inputs.some((el) => /type="number"/.test(el) && /aria-label="Length L"/.test(el)),
|
||||
'Length L must be a type=number input',
|
||||
);
|
||||
assert.ok(
|
||||
inputs.some((el) => /type="number"/.test(el) && /aria-label="Thickness"/.test(el)),
|
||||
'Thickness must be a type=number input',
|
||||
);
|
||||
assert.equal(
|
||||
panelSrc.includes('class="stepper"'),
|
||||
false,
|
||||
'stepper-only UI is the UAT fail; primary control must be typed input',
|
||||
);
|
||||
assert.equal(
|
||||
/<span class="v">\{fmtEighths\(state\.lengthEighths\)\}<\/span>/.test(panelSrc),
|
||||
false,
|
||||
'Length must not be a read-only fmtEighths span',
|
||||
);
|
||||
});
|
||||
|
||||
test('typed inches snap to nearest 1/8 via inToEighths', () => {
|
||||
assert.equal(inToEighths(12), 96);
|
||||
assert.equal(inToEighths(12.1), 97);
|
||||
assert.equal(inToEighths(0.06), 0);
|
||||
assert.equal(inToEighths(0.07), 1);
|
||||
assert.equal(inToEighths(1.999), 16);
|
||||
});
|
||||
@@ -1,363 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type {
|
||||
Capture, Draft, Eighths, Mask, MaskParams, Measures, Sheet, Source, WorkerIn, WorkerOut,
|
||||
} from './capture.types';
|
||||
import { eighthsToIn } from './capture.types';
|
||||
import { loadDraft, saveDraft, clearDraft } from '../draft/idb';
|
||||
import { measure } from '../measure/measure';
|
||||
import { orientedRect } from '../geometry/rect';
|
||||
import { encodeInventoryPng } from '../png/inventory';
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const BUSY_MS = 300;
|
||||
|
||||
export interface CaptureSession {
|
||||
capture: import('svelte/store').Readable<Capture>;
|
||||
busy: import('svelte/store').Readable<boolean>;
|
||||
resumeProbe(): Promise<void>;
|
||||
continueDraft(): void;
|
||||
startFresh(): void;
|
||||
addPhoto(file: File): Promise<void>;
|
||||
retakes(): void;
|
||||
setSensitivity(n: number): void;
|
||||
setSheet(s: Sheet): void;
|
||||
approveMask(): void;
|
||||
setAxisAngle(deg: number): void;
|
||||
approveAxis(): void;
|
||||
setLength(e: Eighths): void;
|
||||
setThickness(e: Eighths): void;
|
||||
calculate(): Promise<void>;
|
||||
back(): void;
|
||||
getInventoryUrl(): string | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
// The one writer. Owns the worker port, the single debounce timer, and the draft.
|
||||
// Every transition to an approved branch persists; no explicit "save".
|
||||
export function createCaptureSession(): CaptureSession {
|
||||
const capture = writable<Capture>({ k: 'addPhoto' });
|
||||
const busy = writable(false);
|
||||
let state: Capture = { k: 'addPhoto' };
|
||||
let worker: Worker | null = null;
|
||||
let jobId = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let passStarted = 0;
|
||||
let busyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pngUrl: string | null = null;
|
||||
let resumed = false;
|
||||
|
||||
const set = (next: Capture) => {
|
||||
state = next;
|
||||
capture.set(next);
|
||||
};
|
||||
|
||||
const teardownWorker = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (worker) {
|
||||
worker.postMessage({ id: ++jobId, kind: 'invalidate' } satisfies WorkerIn);
|
||||
worker.terminate();
|
||||
worker = null;
|
||||
}
|
||||
if (pngUrl) {
|
||||
URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = null;
|
||||
}
|
||||
};
|
||||
|
||||
const makeWorker = (blob: Blob) => {
|
||||
if (worker) worker.terminate();
|
||||
worker = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.onmessage = (e: MessageEvent<WorkerOut>) => onWorkerOut(e.data);
|
||||
worker.postMessage({ id: ++jobId, kind: 'init', blob } satisfies WorkerIn);
|
||||
};
|
||||
|
||||
const onWorkerOut = (msg: WorkerOut) => {
|
||||
if (msg.id < jobId - 1) return; // stale
|
||||
if (msg.kind === 'decoded') {
|
||||
if (state.k === 'mask') {
|
||||
set({ ...state, source: { ...state.source, widthPx: msg.w, heightPx: msg.h } });
|
||||
}
|
||||
schedulePass();
|
||||
return;
|
||||
}
|
||||
busy.set(false);
|
||||
if (busyTimer) {
|
||||
clearTimeout(busyTimer);
|
||||
busyTimer = null;
|
||||
}
|
||||
if (state.k !== 'mask') return;
|
||||
if (msg.kind === 'failed') {
|
||||
set({ ...state, failed: true });
|
||||
return;
|
||||
}
|
||||
const mask: Mask = {
|
||||
params: { sheet: state.control.sheet, sensitivity: state.control.sensitivity },
|
||||
slabPx: msg.slabPx,
|
||||
bitset: msg.bitset,
|
||||
bbox: msg.bbox,
|
||||
slabBounds: msg.slabBounds,
|
||||
};
|
||||
set({ ...state, overlay: mask, failed: false });
|
||||
};
|
||||
|
||||
const runPass = () => {
|
||||
if (state.k !== 'mask' || !worker) return;
|
||||
passStarted = Date.now();
|
||||
busyTimer = setTimeout(() => busy.set(true), BUSY_MS);
|
||||
worker.postMessage({
|
||||
id: ++jobId,
|
||||
kind: 'mask',
|
||||
sheet: state.control.sheet,
|
||||
sensitivity: state.control.sensitivity,
|
||||
} satisfies WorkerIn);
|
||||
};
|
||||
|
||||
// The 2.0s debounce: re-armed on every control change; a sheet change
|
||||
// recomputes immediately (spec allows), so it clears the pending pass.
|
||||
const schedulePass = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(runPass, DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const draftFromState = (): Draft | null => {
|
||||
const strip = (m: Mask) => ({ slabPx: m.slabPx, bbox: m.bbox, slabBounds: m.slabBounds });
|
||||
if (state.k === 'axis') {
|
||||
return {
|
||||
version: 1, stage: 'axis', source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null, inventoryPng: null,
|
||||
};
|
||||
}
|
||||
if (state.k === 'measure') {
|
||||
const { inventoryPng: _drop, ...rest } = state.results ?? ({} as Measures);
|
||||
return {
|
||||
version: 1, stage: 'measure', source: state.source, maskParams: state.approvedMask.params,
|
||||
mask: strip(state.approvedMask), axisBbox: state.axis.bbox,
|
||||
lengthEighths: state.lengthEighths, thicknessEighths: state.thicknessEighths,
|
||||
results: state.results ? rest : null,
|
||||
inventoryPng: state.results?.inventoryPng ?? null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const persist = async () => {
|
||||
const d = draftFromState();
|
||||
if (d) await saveDraft(d as Draft);
|
||||
};
|
||||
|
||||
// Re-derive the mask bitset from the stored photo + params so Measure can run.
|
||||
const rederiveMask = (source: Source, params: MaskParams, stub: Omit<Mask, 'bitset' | 'params'>): Promise<Mask> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const w = new Worker(new URL('../worker/mask.worker.ts', import.meta.url), { type: 'module' });
|
||||
let id = 0;
|
||||
const fail = (reason: string) => {
|
||||
w.terminate();
|
||||
reject(new Error(reason));
|
||||
};
|
||||
w.onmessage = (e: MessageEvent<WorkerOut>) => {
|
||||
const msg = e.data;
|
||||
if (msg.kind === 'decoded') {
|
||||
w.postMessage({ id: ++id, kind: 'mask', sheet: params.sheet, sensitivity: params.sensitivity } satisfies WorkerIn);
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'failed') {
|
||||
fail(msg.reason);
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'ready') {
|
||||
w.terminate();
|
||||
resolve({
|
||||
params,
|
||||
slabPx: msg.slabPx || stub.slabPx,
|
||||
bitset: msg.bitset,
|
||||
bbox: msg.slabPx > 0 ? msg.bbox : stub.bbox,
|
||||
slabBounds: msg.slabPx > 0 ? msg.slabBounds : stub.slabBounds,
|
||||
});
|
||||
}
|
||||
};
|
||||
w.postMessage({ id: ++id, kind: 'init', blob: source.blob } satisfies WorkerIn);
|
||||
});
|
||||
|
||||
// Legacy drafts (pre-axisBbox, with heading) carry no usable axis state;
|
||||
// fall back to the mask's own bbox.
|
||||
const draftAxisBbox = (d: Draft) => d.axisBbox ?? d.mask.bbox;
|
||||
|
||||
const restoreFromDraft = async (d: Draft) => {
|
||||
const source: Source = { widthPx: d.source.widthPx, heightPx: d.source.heightPx, blob: d.source.blob };
|
||||
if (d.stage === 'mask') {
|
||||
teardownWorker();
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: false });
|
||||
makeWorker(source.blob);
|
||||
return;
|
||||
}
|
||||
teardownWorker();
|
||||
try {
|
||||
const mask = await rederiveMask(source, d.maskParams, d.mask);
|
||||
if (d.stage === 'axis') {
|
||||
set({ k: 'axis', source, approvedMask: mask, axis: { bbox: draftAxisBbox(d) } });
|
||||
return;
|
||||
}
|
||||
if (d.inventoryPng) {
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(d.inventoryPng);
|
||||
}
|
||||
set({
|
||||
k: 'measure', source, approvedMask: mask,
|
||||
axis: { bbox: draftAxisBbox(d) },
|
||||
lengthEighths: d.lengthEighths, thicknessEighths: d.thicknessEighths,
|
||||
results: d.results
|
||||
? { ...d.results, inventoryPng: d.inventoryPng ?? new Blob() }
|
||||
: null,
|
||||
});
|
||||
} catch {
|
||||
set({ k: 'mask', source, control: { ...d.maskParams }, overlay: null, failed: true });
|
||||
makeWorker(source.blob);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
capture,
|
||||
busy,
|
||||
|
||||
async resumeProbe() {
|
||||
if (resumed) return;
|
||||
resumed = true;
|
||||
const d = await loadDraft();
|
||||
if (d) set({ k: 'resume', draft: d });
|
||||
},
|
||||
|
||||
continueDraft() {
|
||||
if (state.k !== 'resume') return;
|
||||
void restoreFromDraft(state.draft);
|
||||
},
|
||||
|
||||
async startFresh() {
|
||||
if (state.k !== 'resume') return;
|
||||
teardownWorker();
|
||||
await clearDraft();
|
||||
set({ k: 'addPhoto' });
|
||||
},
|
||||
|
||||
async addPhoto(file: File) {
|
||||
teardownWorker();
|
||||
set({ k: 'mask', source: { widthPx: 0, heightPx: 0, blob: file }, control: { sheet: 'auto', sensitivity: 50 }, overlay: null, failed: false });
|
||||
makeWorker(file);
|
||||
},
|
||||
|
||||
retakes() {
|
||||
teardownWorker();
|
||||
set({ k: 'addPhoto' });
|
||||
},
|
||||
|
||||
setSensitivity(n: number) {
|
||||
if (state.k !== 'mask') return;
|
||||
const s = Math.max(0, Math.min(100, Math.round(n)));
|
||||
set({ ...state, control: { ...state.control, sensitivity: s } });
|
||||
schedulePass();
|
||||
},
|
||||
|
||||
setSheet(s: Sheet) {
|
||||
if (state.k !== 'mask') return;
|
||||
set({ ...state, control: { ...state.control, sheet: s } });
|
||||
runPass();
|
||||
},
|
||||
|
||||
approveMask() {
|
||||
if (state.k !== 'mask' || !state.overlay || state.overlay.slabPx === 0) return;
|
||||
teardownWorker();
|
||||
const overlay = state.overlay;
|
||||
set({ k: 'axis', source: state.source, approvedMask: overlay, axis: { bbox: { corners: overlay.bbox.corners.map((c) => [...c] as [number, number]), longSidePx: overlay.bbox.longSidePx, angleDeg: overlay.bbox.angleDeg } } });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setAxisAngle(deg: number) {
|
||||
if (state.k !== 'axis') return;
|
||||
const a = ((Math.round(deg) % 180) + 180) % 180;
|
||||
set({ ...state, axis: { bbox: orientedRect(state.axis.bbox, a) } });
|
||||
void persist();
|
||||
},
|
||||
|
||||
approveAxis() {
|
||||
if (state.k !== 'axis') return;
|
||||
set({
|
||||
k: 'measure', source: state.source, approvedMask: state.approvedMask,
|
||||
axis: { bbox: state.axis.bbox },
|
||||
lengthEighths: 0, thicknessEighths: 0, results: null,
|
||||
});
|
||||
void persist();
|
||||
},
|
||||
|
||||
setLength(e: Eighths) {
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, lengthEighths: Math.max(0, Math.round(e)) });
|
||||
void persist();
|
||||
},
|
||||
|
||||
setThickness(e: Eighths) {
|
||||
if (state.k !== 'measure') return;
|
||||
set({ ...state, thicknessEighths: Math.max(0, Math.round(e)) });
|
||||
void persist();
|
||||
},
|
||||
|
||||
async calculate() {
|
||||
if (state.k !== 'measure') return;
|
||||
const { source, approvedMask, axis, lengthEighths, thicknessEighths } = state;
|
||||
if (lengthEighths === 0 || thicknessEighths === 0) return;
|
||||
const bitmap = await createImageBitmap(source.blob);
|
||||
const base = measure(
|
||||
approvedMask.bitset, source.widthPx, source.heightPx,
|
||||
axis.bbox, lengthEighths, thicknessEighths, approvedMask.slabPx,
|
||||
);
|
||||
let pngBlob: Blob;
|
||||
let belowFloor = false;
|
||||
try {
|
||||
const r = await encodeInventoryPng({ bitmap, mask: approvedMask, source });
|
||||
pngBlob = r.blob;
|
||||
belowFloor = r.sourceBelowFloor;
|
||||
} catch {
|
||||
pngBlob = new Blob();
|
||||
}
|
||||
bitmap.close();
|
||||
if (pngUrl) URL.revokeObjectURL(pngUrl);
|
||||
pngUrl = URL.createObjectURL(pngBlob);
|
||||
const results: Measures = { ...base, inventoryPng: pngBlob, sourceBelowFloor: belowFloor };
|
||||
set({ ...state, results });
|
||||
void persist();
|
||||
},
|
||||
|
||||
back() {
|
||||
if (state.k === 'mask') {
|
||||
teardownWorker();
|
||||
set({ k: 'addPhoto' });
|
||||
} else if (state.k === 'axis') {
|
||||
// back to mask keeps the last approved mask as the live overlay
|
||||
const m = state.approvedMask;
|
||||
set({ k: 'mask', source: state.source, control: m.params, overlay: m, failed: false });
|
||||
} else if (state.k === 'measure') {
|
||||
set({ k: 'axis', source: state.source, approvedMask: state.approvedMask, axis: { bbox: state.axis.bbox } });
|
||||
}
|
||||
},
|
||||
|
||||
getInventoryUrl() {
|
||||
return pngUrl;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
teardownWorker();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Display helper: format eighths as inches with fractions.
|
||||
export function fmtEighths(e: Eighths): string {
|
||||
const whole = Math.floor(eighthsToIn(e));
|
||||
const rem = e - whole * 8;
|
||||
if (rem === 0) return `${whole}"`;
|
||||
const frac: Record<number, string> = { 1: '1/8', 2: '1/4', 3: '3/8', 4: '1/2', 5: '5/8', 6: '3/4', 7: '7/8' };
|
||||
const part = frac[rem] ?? `${rem}/8`;
|
||||
return whole > 0 ? `${whole} ${part}"` : `${part}"`;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { get, set, del } from 'idb-keyval';
|
||||
import type { Draft } from '../capture/capture.types';
|
||||
|
||||
const KEY = 'slabuploader-draft';
|
||||
|
||||
export async function loadDraft(): Promise<Draft | null> {
|
||||
try {
|
||||
const d = await get<Draft>(KEY);
|
||||
return d && d.version === 1 ? d : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveDraft(d: Draft): Promise<void> {
|
||||
await set(KEY, d);
|
||||
}
|
||||
|
||||
export async function clearDraft(): Promise<void> {
|
||||
try {
|
||||
await del(KEY);
|
||||
} catch {
|
||||
// idb unavailable: draft persistence is best-effort, app state is not
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { minAreaRect, orientedRect, maskSpanAlong } from './rect.ts';
|
||||
import { measure } from '../measure/measure.ts';
|
||||
|
||||
// 100×20 horizontal slab
|
||||
const W = 100, H = 20;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let x = 0; x < W; x++) for (let y = 0; y < H; y++) bitset[y * W + x] = 1;
|
||||
|
||||
test('maskSpanAlong of a 100×20 slab: X ≈ 99–100, Y ≈ 19–20', () => {
|
||||
const sx = maskSpanAlong(bitset, W, H, 1, 0);
|
||||
const sy = maskSpanAlong(bitset, W, H, 0, 1);
|
||||
assert.ok(sx >= 99 && sx <= 100, `spanX ${sx}`);
|
||||
assert.ok(sy >= 19 && sy <= 20, `spanY ${sy}`);
|
||||
});
|
||||
|
||||
test('orientedRect changes angleDeg and preserves longSidePx', () => {
|
||||
const prev = minAreaRect(bitset, W, H);
|
||||
assert.ok(prev, 'minAreaRect returned a box');
|
||||
const rotated = orientedRect(prev, 45);
|
||||
assert.ok(Math.abs(rotated.angleDeg - 45) < 1e-9);
|
||||
assert.equal(rotated.longSidePx, prev.longSidePx);
|
||||
// The long side (angleDeg 0 → +x) must be preserved by the rotation.
|
||||
const longOf = (c) => Math.hypot(c[3][0] - c[0][0], c[3][1] - c[0][1]);
|
||||
assert.ok(Math.abs(longOf(rotated.corners) - longOf(prev.corners)) < 1e-9);
|
||||
assert.ok(Math.abs(longOf(prev.corners) - prev.longSidePx) < 1e-9);
|
||||
// angle normalization to [0, 180)
|
||||
assert.ok(orientedRect(prev, 190).angleDeg >= 0 && orientedRect(prev, 190).angleDeg < 180);
|
||||
});
|
||||
|
||||
test('measure scale uses mask span along the axis, not longSidePx', () => {
|
||||
// Thin slab (20 long, 100 wide): minAreaRect's long side (angleDeg 90) is
|
||||
// the 100-px dimension along +y. Rotate the axis to angleDeg 0 (+x), which
|
||||
// crosses only the 20-px slab width — span 19, not longSidePx 99.
|
||||
const TW = 20, TH = 100;
|
||||
const thin = new Uint8Array(TW * TH).fill(1);
|
||||
const thinBox = minAreaRect(thin, TW, TH);
|
||||
assert.ok(thinBox && thinBox.longSidePx >= 99, `longSide ${thinBox?.longSidePx}`);
|
||||
const rotated = orientedRect(thinBox, 0);
|
||||
const lengthEighths = 24; // 3 in
|
||||
const m = measure(thin, TW, TH, rotated, lengthEighths, 8, TW * TH);
|
||||
// Scale = span along the axis / length. Span ≈ 19 → scale ≈ 6.3 px/in,
|
||||
// far from the old longSidePx/3 ≈ 33.
|
||||
const ex = rotated.corners[0][0] - rotated.corners[3][0];
|
||||
const ey = rotated.corners[0][1] - rotated.corners[3][1];
|
||||
const elen = Math.hypot(ex, ey);
|
||||
const span = maskSpanAlong(thin, TW, TH, ex / elen, ey / elen);
|
||||
assert.ok(Math.abs(m.scale - span / (lengthEighths / 8)) < 1e-9);
|
||||
assert.ok(m.scale < 10, `scale ${m.scale} should be span-based, not ${thinBox.longSidePx / 3}`);
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { ABox, BBox } from '../capture/capture.types';
|
||||
|
||||
export function slabBounds(bitset: Uint8Array, w: number, h: number): ABox {
|
||||
let minX = w, minY = h, maxX = -1, maxY = -1;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (bitset[row + x]) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return { x: 0, y: 0, w: 0, h: 0 };
|
||||
return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
// Convex hull (Andrew's monotone chain). Points are [x, y].
|
||||
export function convexHull(pts: [number, number][]): [number, number][] {
|
||||
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
if (p.length < 3) return p;
|
||||
const cross = (o: [number, number], a: [number, number], b: [number, number]) =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
const lower: [number, number][] = [];
|
||||
for (const pt of p) {
|
||||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
|
||||
lower.push(pt);
|
||||
}
|
||||
const upper: [number, number][] = [];
|
||||
for (let i = p.length - 1; i >= 0; i--) {
|
||||
const pt = p[i];
|
||||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
|
||||
upper.push(pt);
|
||||
}
|
||||
lower.pop();
|
||||
upper.pop();
|
||||
return lower.concat(upper);
|
||||
}
|
||||
|
||||
// Rotating calipers: true minimum-area bounding rectangle of the mask.
|
||||
export function minAreaRect(bitset: Uint8Array, w: number, h: number): BBox | null {
|
||||
const pts: [number, number][] = [];
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) if (bitset[row + x]) pts.push([x, y]);
|
||||
}
|
||||
if (pts.length < 2) return null;
|
||||
const hull = convexHull(pts);
|
||||
|
||||
let best = { area: Infinity, cx: 0, cy: 0, ux: 1, uy: 0, hw: 0, hh: 0 };
|
||||
const n = hull.length;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
const ex = hull[j][0] - hull[i][0];
|
||||
const ey = hull[j][1] - hull[i][1];
|
||||
const len = Math.hypot(ex, ey);
|
||||
if (len === 0) continue;
|
||||
const ux = ex / len, uy = ey / len;
|
||||
const vx = -uy, vy = ux;
|
||||
let minU = Infinity, maxU = -Infinity, minV = Infinity, maxV = -Infinity;
|
||||
for (let k = 0; k < n; k++) {
|
||||
const dx = hull[k][0] - hull[i][0];
|
||||
const dy = hull[k][1] - hull[i][1];
|
||||
const u = dx * ux + dy * uy;
|
||||
const v = dx * vx + dy * vy;
|
||||
if (u < minU) minU = u;
|
||||
if (u > maxU) maxU = u;
|
||||
if (v < minV) minV = v;
|
||||
if (v > maxV) maxV = v;
|
||||
}
|
||||
const rw = maxU - minU;
|
||||
const rh = maxV - minV;
|
||||
const area = rw * rh;
|
||||
if (area < best.area) {
|
||||
best = { area, cx: hull[i][0] + (minU + maxU) / 2 * ux + (minV + maxV) / 2 * vx, cy: hull[i][1] + (minU + maxU) / 2 * uy + (minV + maxV) / 2 * vy, ux, uy, hw: rw / 2, hh: rh / 2 };
|
||||
}
|
||||
}
|
||||
|
||||
// long-side direction: the edge with the larger extent
|
||||
const lu = best.hw >= best.hh ? best.ux : -best.uy;
|
||||
const lv = best.hw >= best.hh ? best.uy : best.ux;
|
||||
const su = best.hw >= best.hh ? -best.uy : best.ux;
|
||||
const sv = best.hw >= best.hh ? -best.ux : -best.uy;
|
||||
const halfL = best.hw >= best.hh ? best.hw : best.hh;
|
||||
const halfS = best.hw >= best.hh ? best.hh : best.hw;
|
||||
const corners: [number, number][] = [];
|
||||
for (const [a, b] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
|
||||
corners.push([best.cx + a * halfL * lu + b * halfS * su, best.cy + a * halfL * lv + b * halfS * sv]);
|
||||
}
|
||||
const angleDeg = ((Math.atan2(lv, lu) * 180) / Math.PI + 180) % 180;
|
||||
return { corners, longSidePx: halfL * 2, angleDeg };
|
||||
}
|
||||
|
||||
// Rotate the long side to a new angle, keeping center and half-extents.
|
||||
// Corners are rebuilt in minAreaRect order so corners[3] → corners[0] stays the
|
||||
// long side (the axis arrow direction).
|
||||
export function orientedRect(prev: BBox, angleDeg: number): BBox {
|
||||
const a = ((angleDeg % 180) + 180) % 180;
|
||||
const halfL = prev.longSidePx / 2;
|
||||
const halfS = Math.hypot(prev.corners[0][0] - prev.corners[1][0], prev.corners[0][1] - prev.corners[1][1]) / 2;
|
||||
// corners[i] is a corner point: average of all four = center
|
||||
const cx = (prev.corners[0][0] + prev.corners[1][0] + prev.corners[2][0] + prev.corners[3][0]) / 4;
|
||||
const cy = (prev.corners[0][1] + prev.corners[1][1] + prev.corners[2][1] + prev.corners[3][1]) / 4;
|
||||
const r = (a * Math.PI) / 180;
|
||||
const lu = Math.cos(r), lv = Math.sin(r);
|
||||
const su = -lv, sv = lu;
|
||||
const corners: [number, number][] = [];
|
||||
for (const [aSign, bSign] of [[1, 1], [1, -1], [-1, -1], [-1, 1]] as const) {
|
||||
corners.push([cx + aSign * halfL * lu + bSign * halfS * su, cy + aSign * halfL * lv + bSign * halfS * sv]);
|
||||
}
|
||||
return { corners, longSidePx: prev.longSidePx, angleDeg: a };
|
||||
}
|
||||
|
||||
// Project every slab pixel onto the unit vector (ux, uy); return max − min
|
||||
// extent in px. 0 when the mask is empty.
|
||||
export function maskSpanAlong(bitset: Uint8Array, w: number, h: number, ux: number, uy: number): number {
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
if (p < min) min = p;
|
||||
if (p > max) max = p;
|
||||
}
|
||||
}
|
||||
return max < min ? 0 : max - min;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import App from './App.svelte';
|
||||
|
||||
const root = document.querySelector('#app');
|
||||
if (root) new App({ target: root });
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { BBox, Eighths, Measures, Widths } from '../capture/capture.types';
|
||||
import { maskSpanAlong } from '../geometry/rect';
|
||||
|
||||
// Display / direction: corners[3] → corners[0] is the long side.
|
||||
export function axisEndpoints(bbox: BBox): { start: [number, number]; end: [number, number] } {
|
||||
return { start: bbox.corners[3], end: bbox.corners[0] };
|
||||
}
|
||||
|
||||
// Wood end→end along the approved direction: project the mask onto the axis
|
||||
// unit vector and return the segment on the bbox center line covering that span.
|
||||
function maskAxisSegment(
|
||||
bitset: Uint8Array, w: number, h: number, bbox: BBox,
|
||||
): { start: [number, number]; end: [number, number]; span: number; ux: number; uy: number } {
|
||||
const { start: a, end: b } = axisEndpoints(bbox);
|
||||
const ex = b[0] - a[0];
|
||||
const ey = b[1] - a[1];
|
||||
const elen = Math.hypot(ex, ey) || 1;
|
||||
const ux = ex / elen, uy = ey / elen;
|
||||
const cx = (bbox.corners[0][0] + bbox.corners[1][0] + bbox.corners[2][0] + bbox.corners[3][0]) / 4;
|
||||
const cy = (bbox.corners[0][1] + bbox.corners[1][1] + bbox.corners[2][1] + bbox.corners[3][1]) / 4;
|
||||
const cProj = cx * ux + cy * uy;
|
||||
let minP = Infinity, maxP = -Infinity;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!bitset[row + x]) continue;
|
||||
const p = x * ux + y * uy;
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
}
|
||||
}
|
||||
if (maxP < minP) return { start: a, end: b, span: 0, ux, uy };
|
||||
return {
|
||||
start: [cx + ux * (minP - cProj), cy + uy * (minP - cProj)],
|
||||
end: [cx + ux * (maxP - cProj), cy + uy * (maxP - cProj)],
|
||||
span: maxP - minP,
|
||||
ux,
|
||||
uy,
|
||||
};
|
||||
}
|
||||
|
||||
// Sampled every 6 in along the wood axis; each sample is a line probe
|
||||
// perpendicular to the axis, width = max run of slab px across the probe.
|
||||
function sampleWidths(
|
||||
bitset: Uint8Array, w: number, h: number,
|
||||
start: [number, number], ux: number, uy: number,
|
||||
scale: number, lengthIn: number,
|
||||
): Eighths[] {
|
||||
const px = -uy, py = ux;
|
||||
const stepPx = 6 * scale;
|
||||
const count = Math.max(1, Math.floor(lengthIn / 6) + 1);
|
||||
const out: Eighths[] = [];
|
||||
const half = Math.max(2, Math.round((w + h) / 4));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = i * stepPx;
|
||||
const ox = start[0] + ux * t;
|
||||
const oy = start[1] + uy * t;
|
||||
let minP = Infinity, maxP = -Infinity;
|
||||
for (let p = -half; p <= half; p++) {
|
||||
const x = Math.round(ox + px * p);
|
||||
const y = Math.round(oy + py * p);
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) continue;
|
||||
if (bitset[y * w + x]) {
|
||||
if (p < minP) minP = p;
|
||||
if (p > maxP) maxP = p;
|
||||
}
|
||||
}
|
||||
if (maxP < minP) continue;
|
||||
out.push(Math.max(0, Math.round(((maxP - minP + 1) / scale) * 8)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function measure(
|
||||
bitset: Uint8Array, w: number, h: number,
|
||||
bbox: BBox,
|
||||
lengthEighths: Eighths, thicknessEighths: Eighths,
|
||||
slabPx: number,
|
||||
): Omit<Measures, 'inventoryPng'> & { sourceBelowFloor: false } {
|
||||
const lengthIn = lengthEighths / 8;
|
||||
const seg = maskAxisSegment(bitset, w, h, bbox);
|
||||
const scale = (seg.span || maskSpanAlong(bitset, w, h, seg.ux, seg.uy)) / lengthIn;
|
||||
const every6In = sampleWidths(bitset, w, h, seg.start, seg.ux, seg.uy, scale, lengthIn);
|
||||
let widths: Widths;
|
||||
if (every6In.length > 0) {
|
||||
const min = Math.min(...every6In);
|
||||
const max = Math.max(...every6In);
|
||||
const avg = Math.round(every6In.reduce((a, b) => a + b, 0) / every6In.length);
|
||||
widths = { min, max, avg, every6In };
|
||||
} else {
|
||||
widths = { min: 0, max: 0, avg: 0, every6In: [] };
|
||||
}
|
||||
const sqft = Math.round((slabPx / (scale * scale) / 144) * 100) / 100;
|
||||
const bdft = Math.round(sqft * (thicknessEighths / 8) * 100) / 100;
|
||||
return {
|
||||
lengthEighths,
|
||||
thicknessEighths,
|
||||
scale,
|
||||
widths,
|
||||
sqft,
|
||||
bdft,
|
||||
sourceBelowFloor: false,
|
||||
};
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { composeInventoryFrame } from './inventory.ts';
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
const makePhoto = (w: number, h: number, paint: (x: number, y: number) => [number, number, number, number] = () => [255, 255, 255, 255]) => {
|
||||
const rgba = new Uint8Array(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const [r, g, b, a] = paint(x, y);
|
||||
const i = (y * w + x) * 4;
|
||||
rgba[i] = r; rgba[i + 1] = g; rgba[i + 2] = b; rgba[i + 3] = a;
|
||||
}
|
||||
}
|
||||
return { width: w, height: h, rgba };
|
||||
};
|
||||
|
||||
const maskFor = (bitset: Uint8Array, slabBounds: Mask['slabBounds']): Pick<Mask, 'bitset' | 'slabBounds'> =>
|
||||
({ bitset, slabBounds });
|
||||
|
||||
test('alpha contract: removed pixels transparent, slab pixels keep RGB at alpha 255', () => {
|
||||
const W = 8, H = 8;
|
||||
const bitset = new Uint8Array(W * H);
|
||||
for (let y = 1; y < 5; y++) for (let x = 1; x < 5; x++) bitset[y * W + x] = 1;
|
||||
bitset[2 * W + 2] = 0;
|
||||
bitset[3 * W + 3] = 0;
|
||||
const SLAB = [200, 40, 20, 255] as const;
|
||||
const SHEET = [0, 255, 0, 255] as const;
|
||||
const src = makePhoto(W, H, (x, y) => (bitset[y * W + x] === 1 ? [...SLAB] : [...SHEET]));
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: W, heightPx: H };
|
||||
const out = composeInventoryFrame(src, maskFor(bitset, { x: 1, y: 1, w: 4, h: 4 }), source);
|
||||
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width < 1600, 'undersized source stays small');
|
||||
|
||||
let opaque = 0;
|
||||
let transparent = 0;
|
||||
for (let i = 0; i < out.rgba.length; i += 4) {
|
||||
const a = out.rgba[i + 3];
|
||||
if (a === 0) {
|
||||
transparent++;
|
||||
assert.equal(out.rgba[i], 0);
|
||||
assert.equal(out.rgba[i + 1], 0);
|
||||
assert.equal(out.rgba[i + 2], 0);
|
||||
continue;
|
||||
}
|
||||
opaque++;
|
||||
assert.equal(a, 255);
|
||||
assert.deepEqual(
|
||||
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]],
|
||||
[SLAB[0], SLAB[1], SLAB[2]],
|
||||
'opaque pixel must be slab RGB, never sheet green',
|
||||
);
|
||||
}
|
||||
assert.ok(opaque > 0, 'slab pixels are opaque');
|
||||
assert.ok(transparent > 0, 'frame has transparent pixels');
|
||||
});
|
||||
|
||||
test('3:4 frame at the floor: 2000×1800 slab → 1600×2133', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2000, heightPx: 1800 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 2000, h: 1800 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.width, 1600);
|
||||
assert.equal(out.height, 2133);
|
||||
assert.equal(out.sourceBelowFloor, false);
|
||||
});
|
||||
|
||||
test('80% fill: limiting dimension maps to 0.8 × frame side', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 2200, heightPx: 2000 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 100, y: 100, w: 2000, h: 1800 }),
|
||||
source,
|
||||
);
|
||||
let minx = out.width, maxx = -1, miny = out.height, maxy = -1;
|
||||
for (let y = 0; y < out.height; y++) {
|
||||
for (let x = 0; x < out.width; x++) {
|
||||
if (out.rgba[(y * out.width + x) * 4 + 3] !== 0) {
|
||||
if (x < minx) minx = x;
|
||||
if (x > maxx) maxx = x;
|
||||
if (y < miny) miny = y;
|
||||
if (y > maxy) maxy = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
const xs = [minx, maxx], ys = [miny, maxy];
|
||||
const wExtent = Math.max(...xs) - Math.min(...xs) + 1;
|
||||
const hExtent = Math.max(...ys) - Math.min(...ys) + 1;
|
||||
assert.ok(Math.abs(wExtent - 1280) <= 2, `width extent ${wExtent} ≈ 1280 (0.8×1600)`);
|
||||
assert.ok(Math.abs(hExtent - 1152) <= 2, `height extent ${hExtent} ≈ 1152 (0.8×1800×0.64)`);
|
||||
});
|
||||
|
||||
test('belowFloor: small slab → sourceBelowFloor, outW < 1600', () => {
|
||||
const source: Pick<Source, 'widthPx' | 'heightPx'> = { widthPx: 900, heightPx: 700 };
|
||||
const bitset = new Uint8Array(source.widthPx * source.heightPx).fill(1);
|
||||
const out = composeInventoryFrame(
|
||||
makePhoto(source.widthPx, source.heightPx),
|
||||
maskFor(bitset, { x: 0, y: 0, w: 900, h: 700 }),
|
||||
source,
|
||||
);
|
||||
assert.equal(out.sourceBelowFloor, true);
|
||||
assert.ok(out.width < 1600, `outW ${out.width}`);
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { Mask, Source } from '../capture/capture.types';
|
||||
|
||||
const FLOOR = 1600; // shorter side, px
|
||||
const FILL = 0.8; // 80% fill at the widest and tallest points of the slab
|
||||
const ASPECT_W = 3;
|
||||
const ASPECT_H = 4;
|
||||
|
||||
type MaskPick = Pick<Mask, 'bitset' | 'slabBounds'>;
|
||||
type SourcePick = Pick<Source, 'widthPx' | 'heightPx'>;
|
||||
|
||||
export interface InventoryInput {
|
||||
bitmap: ImageBitmap;
|
||||
mask: MaskPick;
|
||||
source: SourcePick;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8Array;
|
||||
sourceBelowFloor: boolean;
|
||||
}
|
||||
|
||||
export function composeInventoryFrame(
|
||||
src: { width: number; height: number; rgba: Uint8Array | Uint8ClampedArray },
|
||||
mask: MaskPick,
|
||||
source: SourcePick,
|
||||
): Frame {
|
||||
const b = mask.slabBounds;
|
||||
const x0 = Math.max(0, Math.round(b.x));
|
||||
const y0 = Math.max(0, Math.round(b.y));
|
||||
const x1 = Math.min(source.widthPx, x0 + Math.max(1, Math.round(b.w)));
|
||||
const y1 = Math.min(source.heightPx, y0 + Math.max(1, Math.round(b.h)));
|
||||
const sw = x1 - x0;
|
||||
const sh = y1 - y0;
|
||||
const belowFloor = Math.min(sw, sh) < FLOOR;
|
||||
const outW = belowFloor ? Math.max(1, Math.min(sw, FLOOR)) : FLOOR;
|
||||
const outH = Math.round((outW * ASPECT_H) / ASPECT_W);
|
||||
const scale = Math.min((outW * FILL) / Math.max(sw, 1), (outH * FILL) / Math.max(sh, 1));
|
||||
const dw = sw * scale;
|
||||
const dh = sh * scale;
|
||||
const ox = (outW - dw) / 2;
|
||||
const oy = (outH - dh) / 2;
|
||||
const rgba = new Uint8Array(outW * outH * 4);
|
||||
const xEnd = ox + dw;
|
||||
const yEnd = oy + dh;
|
||||
for (let y = 0; y < outH; y++) {
|
||||
for (let x = 0; x < outW; x++) {
|
||||
if (x + 0.5 < ox || x + 0.5 >= xEnd || y + 0.5 < oy || y + 0.5 >= yEnd) continue;
|
||||
const sx = Math.floor(x0 + (x + 0.5 - ox) / scale);
|
||||
const sy = Math.floor(y0 + (y + 0.5 - oy) / scale);
|
||||
if (sx < 0 || sy < 0 || sx >= source.widthPx || sy >= source.heightPx) continue;
|
||||
if (mask.bitset[sy * source.widthPx + sx] !== 1) continue;
|
||||
const si = (sy * source.widthPx + sx) * 4;
|
||||
const di = (y * outW + x) * 4;
|
||||
rgba[di] = src.rgba[si];
|
||||
rgba[di + 1] = src.rgba[si + 1];
|
||||
rgba[di + 2] = src.rgba[si + 2];
|
||||
rgba[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return { width: outW, height: outH, rgba, sourceBelowFloor: belowFloor };
|
||||
}
|
||||
|
||||
export async function encodeInventoryPng(
|
||||
input: InventoryInput,
|
||||
): Promise<{ blob: Blob; sourceBelowFloor: boolean }> {
|
||||
const { bitmap, mask, source } = input;
|
||||
const srcCanvas = new OffscreenCanvas(source.widthPx, source.heightPx);
|
||||
const srcCtx = srcCanvas.getContext('2d')!;
|
||||
srcCtx.drawImage(bitmap, 0, 0);
|
||||
const img = srcCtx.getImageData(0, 0, source.widthPx, source.heightPx);
|
||||
const frame = composeInventoryFrame(
|
||||
{ width: source.widthPx, height: source.heightPx, rgba: img.data },
|
||||
mask,
|
||||
source,
|
||||
);
|
||||
const out = new OffscreenCanvas(frame.width, frame.height);
|
||||
const ctx = out.getContext('2d')!;
|
||||
ctx.putImageData(new ImageData(new Uint8ClampedArray(frame.rgba), frame.width, frame.height), 0, 0);
|
||||
const blob = await out.convertToBlob({ type: 'image/png' });
|
||||
return { blob, sourceBelowFloor: frame.sourceBelowFloor };
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { minAreaRect, slabBounds } from '../geometry/rect';
|
||||
import type { MaskParams, WorkerIn, WorkerOut } from '../capture/capture.types';
|
||||
|
||||
let bitmap: ImageBitmap | null = null;
|
||||
let data: Uint8ClampedArray | null = null;
|
||||
let w = 0, h = 0;
|
||||
let current = 0; // highest job id issued by main; older replies are dropped
|
||||
|
||||
// Keep slab pixels; remove the sheet. Higher sensitivity widens the sheet match.
|
||||
const greenKeep = (r: number, g: number, b: number, s: number): boolean => {
|
||||
const t = s / 100;
|
||||
const isSheet = g - Math.max(r, b) > 10 + 35 * t;
|
||||
return !isSheet;
|
||||
};
|
||||
const blackKeep = (r: number, g: number, b: number, s: number): boolean => {
|
||||
const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
const isSheet = lum < 40 + 140 * (s / 100);
|
||||
return !isSheet;
|
||||
};
|
||||
const autoPick = (samples: { r: number; g: number; b: number }[]): 'green' | 'black' => {
|
||||
let gr = 0, gl = 0;
|
||||
for (const p of samples) {
|
||||
if (p.g - Math.max(p.r, p.b) > 20) gr++;
|
||||
if (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b < 70) gl++;
|
||||
}
|
||||
return gl >= gr ? 'black' : 'green';
|
||||
};
|
||||
|
||||
function runMask(params: MaskParams, id: number): WorkerOut {
|
||||
if (!data) return { id, kind: 'failed', reason: 'no source decoded' };
|
||||
let sheet = params.sheet;
|
||||
if (sheet === 'auto') {
|
||||
const samples: { r: number; g: number; b: number }[] = [];
|
||||
for (let y = 0; y < h; y += 16)
|
||||
for (let x = 0; x < w; x += 16) {
|
||||
const i = (y * w + x) * 4;
|
||||
samples.push({ r: data[i], g: data[i + 1], b: data[i + 2] });
|
||||
}
|
||||
sheet = autoPick(samples);
|
||||
}
|
||||
const keep = sheet === 'green' ? greenKeep : blackKeep;
|
||||
const s = Math.max(0, Math.min(100, params.sensitivity));
|
||||
const bitset = new Uint8Array(w * h);
|
||||
let slabPx = 0;
|
||||
for (let i = 0, p = 0; i < data.length; i += 4, p++) {
|
||||
if (keep(data[i], data[i + 1], data[i + 2], s)) {
|
||||
bitset[p] = 1;
|
||||
slabPx++;
|
||||
}
|
||||
}
|
||||
if (slabPx === 0) {
|
||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
const bbox = minAreaRect(bitset, w, h);
|
||||
if (!bbox) {
|
||||
return { id, kind: 'ready', slabPx: 0, bitset, bbox: { corners: [], longSidePx: 0, angleDeg: 0 }, slabBounds: { x: 0, y: 0, w: 0, h: 0 } };
|
||||
}
|
||||
return { id, kind: 'ready', slabPx, bitset, bbox, slabBounds: slabBounds(bitset, w, h) };
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerIn>) => {
|
||||
const msg = e.data;
|
||||
if (msg.id < current) return; // superseded by a newer job
|
||||
current = msg.id;
|
||||
if (msg.kind === 'init') {
|
||||
if (bitmap) {
|
||||
bitmap.close();
|
||||
bitmap = null;
|
||||
}
|
||||
data = null;
|
||||
w = h = 0;
|
||||
createImageBitmap(msg.blob)
|
||||
.then((bmp) => {
|
||||
w = bmp.width;
|
||||
h = bmp.height;
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(bmp, 0, 0);
|
||||
data = ctx.getImageData(0, 0, w, h).data;
|
||||
bitmap = bmp;
|
||||
self.postMessage({ id: msg.id, kind: 'decoded', w, h } as WorkerOut);
|
||||
})
|
||||
.catch((err) => self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut));
|
||||
return;
|
||||
}
|
||||
if (msg.kind === 'invalidate') {
|
||||
if (bitmap) {
|
||||
bitmap.close();
|
||||
bitmap = null;
|
||||
}
|
||||
data = null;
|
||||
w = h = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const out = runMask({ sheet: msg.sheet, sensitivity: msg.sensitivity }, msg.id);
|
||||
if (out.kind === 'ready') {
|
||||
self.postMessage(out, [out.bitset.buffer]);
|
||||
} else {
|
||||
self.postMessage(out);
|
||||
}
|
||||
} catch (err) {
|
||||
self.postMessage({ id: msg.id, kind: 'failed', reason: String(err) } satisfies WorkerOut);
|
||||
}
|
||||
};
|
||||
|
||||
export {};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"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/**']
|
||||
}
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import sveltePreprocess from 'svelte-preprocess';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ preprocess: sveltePreprocess() })],
|
||||
worker: { format: 'es' },
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||