@huaqiu/component-gen-app 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ports.ts ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — the whole contract between the portable app
3
+ * and its host (DSH adapter or standalone server).
4
+ *
5
+ * The app has ZERO DSH imports. It only knows this ports interface. Two
6
+ * adapters implement it with the same HTTP client against different origins:
7
+ *
8
+ * - DSH adapter → `createDshPorts()` in `dsh-tool-symbol-footprint`, fetch
9
+ * to `/api/v1/huaqiu/component-gen/*` (plugin-owned webServer route).
10
+ * - Standalone → `createHttpPorts()` in `api/component-gen-client.ts`,
11
+ * fetch to `http://localhost:<port>/api/v1/huaqiu/component-gen/*`.
12
+ */
13
+
14
+ // ── Domain types (shared with `@huaqiu/component-gen-server`) ────────────────
15
+
16
+ export type ComponentGenPage = 'symbol' | 'footprint'
17
+
18
+ export type JobKind = 'symbol' | 'extract-footprint' | 'generate-footprint'
19
+
20
+ export type JobStatus =
21
+ | 'queued'
22
+ | 'running'
23
+ | 'needs_confirmation'
24
+ | 'completed'
25
+ | 'failed'
26
+ | 'cancelled'
27
+
28
+ export interface JobInput {
29
+ /** data URL of the uploaded image (server stores a thumbnail into history). */
30
+ imageDataUrl?: string
31
+ instruction?: string
32
+ packageType?: string
33
+ dimensions?: Record<string, number>
34
+ fileName?: string
35
+ /** which dimensions the human edited (footprint confirmations). */
36
+ edited?: Record<string, boolean>
37
+ }
38
+
39
+ export interface JobState {
40
+ id: string
41
+ kind: JobKind
42
+ status: JobStatus
43
+ progress?: string
44
+ /** structured result of the generation function (status/kind/fileUrl/...). */
45
+ result?: Record<string, unknown>
46
+ /** extracted dimensions for the `needs_confirmation` phase. */
47
+ dimensions?: Record<string, unknown>
48
+ pkgType?: string | null
49
+ fileName?: string | null
50
+ error?: string
51
+ createdAt: string
52
+ updatedAt: string
53
+ }
54
+
55
+ export type JobEvent =
56
+ | { type: 'progress'; message: string; at: string }
57
+ | { type: 'needs_confirmation'; dimensions: Record<string, unknown>; pkgType?: string | null; fileName?: string | null; at: string }
58
+ | { type: 'completed'; job: JobState; at: string }
59
+ | { type: 'failed'; error: string; result?: Record<string, unknown>; at: string }
60
+ | { type: 'cancelled'; at: string }
61
+
62
+ export interface StartJobRequest {
63
+ kind: JobKind
64
+ input: JobInput
65
+ }
66
+
67
+ export interface HistoryQuery {
68
+ limit?: number
69
+ cursor?: string | null
70
+ }
71
+
72
+ export interface HistoryPage {
73
+ entries: HistoryEntry[]
74
+ nextCursor?: string | null
75
+ }
76
+
77
+ export interface HistoryEntry {
78
+ id: string
79
+ kind: 'symbol' | 'footprint'
80
+ createdAt: string
81
+ status: 'generated' | 'failed' | 'cancelled'
82
+ input: {
83
+ imageId?: string
84
+ instruction?: string
85
+ packageType?: string
86
+ dimensions?: Record<string, number>
87
+ }
88
+ /** which dimensions the human edited (footprint confirmations). */
89
+ edited?: Record<string, boolean>
90
+ result?: {
91
+ artifactId: string
92
+ filename: string
93
+ fileUrl?: string
94
+ size?: number
95
+ }
96
+ error?: string
97
+ }
98
+
99
+ export interface HistoryPatch {
100
+ status?: HistoryEntry['status']
101
+ result?: HistoryEntry['result']
102
+ error?: string
103
+ edited?: Record<string, boolean>
104
+ }
105
+
106
+ /** Request to reopen a generated history entry in the active generation page. */
107
+ export interface ReopenRequest {
108
+ /** monotonically increasing so re-clicking the same entry re-applies. */
109
+ n: number
110
+ entry: HistoryEntry
111
+ }
112
+
113
+ export interface ComponentGenConfig {
114
+ hostMode: boolean
115
+ capabilities: {
116
+ symbol: boolean
117
+ footprint: boolean
118
+ }
119
+ limits: {
120
+ /** max accepted input image bytes. */
121
+ imageBytes: number
122
+ }
123
+ }
124
+
125
+ /** Auth capability consumed through the public `@huaqiu/dsh-auth` surface. */
126
+ export interface ComponentGenAuthPort {
127
+ isAuthenticated(): Promise<boolean>
128
+ getUserInfo(): Promise<{ nickname?: string } | null>
129
+ /** Trigger the existing dsh-auth login flow (host-owned). */
130
+ login(): Promise<void>
131
+ onAuthStateChanged(listener: (authenticated: boolean) => void): () => void
132
+ }
133
+
134
+ /** The whole contract the app needs from its host. */
135
+ export interface ComponentGenPorts {
136
+ config(): Promise<ComponentGenConfig>
137
+ startJob(req: StartJobRequest, signal?: AbortSignal): Promise<JobState>
138
+ jobEvents(jobId: string, onEvent: (e: JobEvent) => void): () => void
139
+ abortJob(jobId: string): Promise<void>
140
+ history(query: HistoryQuery): Promise<HistoryPage>
141
+ historyEntry(id: string): Promise<HistoryEntry | null>
142
+ patchHistory(id: string, patch: HistoryPatch): Promise<HistoryEntry>
143
+ deleteHistory(id: string): Promise<void>
144
+ /** raw artifact text for preview (from `@huaqiu/dsh-artifacts` routes). */
145
+ artifactContent(artifactId: string): Promise<string>
146
+ /** data URL of a stored input thumbnail. */
147
+ inputImage(imageId: string): Promise<string>
148
+ auth: ComponentGenAuthPort
149
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — scoped stylesheet.
3
+ *
4
+ * Injected as a `<style>` tag (never a CSS import) so both build surfaces work:
5
+ * the standalone vite bundle AND the DSH client-module bundle (tsdown has no
6
+ * CSS pipeline). Reuses the `hq-genhit__*` class vocabulary (shared with
7
+ * `dsh-tool-symbol-footprint`) plus an `cga-*` app-shell layer. Uses the same
8
+ * DSW design tokens so the app follows the host palette in DSH.
9
+ */
10
+ export const APP_STYLE_ID = 'hq-cga-styles'
11
+ export const APP_PLUGIN_ID = '@huaqiu/component-gen-app'
12
+
13
+ const CSS = `
14
+ /* ── shared with dsh-tool-symbol-footprint (editor + preview) ─────────────── */
15
+ .hq-genhit__stage { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; box-sizing: border-box; background: var(--dsw-alias-markdown-code-block, #0a1929); overflow: hidden; }
16
+ .hq-genhit__stage--symbol, .hq-genhit__stage--footprint { height: 300px; }
17
+ .hq-genhit__canvas { display: block; width: 100%; height: 100%; background: var(--dsw-alias-markdown-code-block, #0a1929); }
18
+ .hq-genhit__stage-msg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 10px 14px; box-sizing: border-box; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); text-align: center; }
19
+ .hq-genhit__editor { padding: 0 12px 4px; }
20
+ .hq-genhit__geom { display: block; width: 100%; height: auto; touch-action: none; }
21
+ .hq-genhit__body { fill: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); stroke: var(--dsw-alias-label-primary, currentColor); stroke-width: 1.5; }
22
+ .hq-genhit__pad { fill: var(--dsw-alias-accent-primary, currentColor); opacity: 0.85; }
23
+ .hq-genhit__ball { fill: var(--dsw-alias-accent-primary, currentColor); opacity: 0.85; }
24
+ .hq-genhit__epad { fill: var(--dsw-alias-accent-primary, currentColor); opacity: 0.25; stroke: var(--dsw-alias-accent-primary, currentColor); stroke-width: 1; stroke-dasharray: 2 2; }
25
+ .hq-genhit__pkg { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; padding: 0 0 8px; }
26
+ .hq-genhit__badge--pkg { border-color: var(--dsw-alias-accent-primary, currentColor); color: var(--dsw-alias-accent-primary, currentColor); }
27
+ .hq-genhit__pkg-meta { font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
28
+ .hq-genhit__pkg-meta--sep { opacity: 0.85; }
29
+ .hq-genhit__dimline { stroke: var(--dsw-alias-label-secondary, currentColor); stroke-width: 1; }
30
+ .hq-genhit__dimlabel { fill: var(--dsw-alias-label-secondary, currentColor); font: 11px/1 system-ui, sans-serif; }
31
+ .hq-genhit__dimlabel--clickable { cursor: pointer; }
32
+ .hq-genhit__dimlabel--clickable:hover { fill: var(--dsw-alias-accent-primary, currentColor); text-decoration: underline; }
33
+ .hq-genhit__tol { fill: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, currentColor)); font: 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; }
34
+ .hq-genhit__arrow { fill: var(--dsw-alias-label-secondary, currentColor); }
35
+ .hq-genhit__handle { fill: var(--dsw-alias-accent-primary, currentColor); stroke: var(--dsw-alias-bg-layer-1, #fff); stroke-width: 1.5; cursor: ew-resize; }
36
+ .hq-genhit__handle--h { cursor: ns-resize; }
37
+ .hq-genhit__handle--wh { cursor: nwse-resize; }
38
+ .hq-genhit__drag-hint { padding: 0 2px 6px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, currentColor)); }
39
+ .hq-genhit__fields { display: flex; flex-wrap: wrap; gap: 8px 12px; padding: 0 0 10px; }
40
+ .hq-genhit__field { display: inline-flex; align-items: center; gap: 6px; }
41
+ .hq-genhit__field-label { font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
42
+ .hq-genhit__field-label--edited { color: var(--dsw-alias-accent-primary, currentColor); }
43
+ .hq-genhit__field-tag { font: 10px/1.2 system-ui, sans-serif; padding: 1px 4px; border-radius: 4px; }
44
+ .hq-genhit__field-tag--ai { color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, currentColor)); border: 1px solid var(--dsw-alias-border-l1, currentColor); }
45
+ .hq-genhit__field-tag--edited { color: var(--dsw-alias-accent-primary, currentColor); border: 1px solid var(--dsw-alias-accent-primary, currentColor); }
46
+ .hq-genhit__field-input { width: 72px; padding: 3px 6px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-l1, currentColor); background: var(--dsw-alias-bg-layer-1, transparent); color: var(--dsw-alias-label-primary, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace); }
47
+ .hq-genhit__field--invalid .hq-genhit__field-input { border-color: var(--dsw-alias-state-error-primary, currentColor); }
48
+ .hq-genhit__field-unit { font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); }
49
+ .hq-genhit__adv { padding: 0 0 8px; }
50
+ .hq-genhit__adv-toggle { display: inline-flex; align-items: center; gap: 4px; border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; padding: 3px 8px; background: var(--dsw-alias-bg-layer-1, transparent); color: var(--dsw-alias-label-secondary, currentColor); font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); cursor: pointer; }
51
+ .hq-genhit__fields--adv { padding-top: 8px; }
52
+ .hq-genhit__validation { display: flex; align-items: baseline; gap: 6px; padding: 6px 10px; margin: 0 0 10px; border-radius: 6px; font: var(--dsw-font-xxs-12, 12px/1.4 system-ui, sans-serif); color: var(--dsw-alias-label-secondary, currentColor); background: var(--dsw-alias-interactive-bg-hover, transparent); }
53
+ .hq-genhit__validation--warn { color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-error-primary, currentColor)); background: var(--dsw-alias-state-warning-bg, transparent); }
54
+ .hq-genhit__validation-detail { opacity: 0.85; }
55
+
56
+ /* ── app shell (cga-*) — tokens/geometry follow dsh's Modal + settings dialog
57
+ (mask-1/mask-blur, layer-2 + elevation, r24 dialog, 28x28 r8 close, 13px/500
58
+ labels, r8 inputs on bg-layer-3 with border-l4, capsule sm buttons) ─────── */
59
+ .cga-app { display: flex; flex-direction: column; gap: 12px; width: 100%; box-sizing: border-box; font: var(--dsw-font-s-14, 14px/1.5 system-ui, sans-serif); color: var(--dsw-alias-label-primary, currentColor); }
60
+ .cga-app__head { display: flex; align-items: center; gap: 8px; }
61
+ .cga-app__head-title { font-size: 16px; line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary, currentColor); }
62
+ .cga-app__head-close { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; margin-left: auto; padding: 0; border: none; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary, currentColor); cursor: pointer; }
63
+ .cga-app__head-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); }
64
+ .cga-panel { border: 0.5px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2)); border-radius: 8px; background: var(--dsw-alias-bg-layer-1, transparent); overflow: hidden; }
65
+ .cga-panel__body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
66
+ .cga-upload { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 18px 12px; border: 1px dashed var(--dsw-alias-border-l3, currentColor); border-radius: 8px; cursor: pointer; }
67
+ .cga-upload--dragging { border-color: var(--dsw-alias-brand-primary, var(--dsw-alias-accent-primary, currentColor)); background: var(--dsw-alias-interactive-bg-hover, transparent); }
68
+ .cga-upload__thumb { max-width: 160px; max-height: 120px; border-radius: 6px; object-fit: contain; }
69
+ .cga-upload__text { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, currentColor)); text-align: center; }
70
+ .cga-upload__browse { margin-top: 2px; height: 28px; padding: 0 10px; border: 0.5px solid var(--dsw-alias-border-l3, currentColor); border-radius: 14px; background: transparent; color: var(--dsw-alias-label-primary, currentColor); font: inherit; font-size: 12px; line-height: 18px; cursor: pointer; }
71
+ .cga-upload__browse:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); }
72
+ .cga-field { display: flex; flex-direction: column; gap: 6px; }
73
+ .cga-field__label { font-size: 13px; font-weight: 500; line-height: 1.5; color: var(--dsw-alias-label-primary, currentColor); }
74
+ .cga-field__input, .cga-field__select { height: 34px; padding: 0 12px; border: 0.5px solid var(--dsw-alias-border-l4, currentColor); border-radius: 8px; background: var(--dsw-alias-bg-layer-3, transparent); color: var(--dsw-alias-label-primary, currentColor); font: inherit; font-size: 13px; line-height: 1.5; }
75
+ .cga-field__input:focus-visible, .cga-field__select:focus-visible { outline: none; border-color: var(--dsw-alias-brand-primary, var(--dsw-alias-accent-primary, currentColor)); }
76
+ .cga-field__input::placeholder { color: var(--dsw-alias-label-dimmed, var(--dsw-alias-label-tertiary, currentColor)); }
77
+ .cga-actions { display: flex; flex-wrap: wrap; gap: 8px; }
78
+ .cga-btn { display: inline-flex; align-items: center; justify-content: center; gap: 4px; height: 28px; padding: 0 10px; border: none; border-radius: 14px; background: transparent; color: var(--dsw-alias-label-primary, currentColor); font: inherit; font-size: 12px; line-height: 18px; cursor: pointer; }
79
+ .cga-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.08)); }
80
+ .cga-btn:disabled { opacity: 0.4; cursor: default; }
81
+ .cga-btn--primary { background: var(--dsw-alias-button-primary-fill, var(--dsw-alias-accent-primary, currentColor)); border-color: transparent; color: var(--dsw-alias-label-primary-foreground, #fff); }
82
+ .cga-btn--primary:hover { background: var(--dsw-alias-button-primary-hover, var(--dsw-alias-accent-primary, currentColor)); }
83
+ .cga-btn--primary:disabled { opacity: 0.6; }
84
+ .cga-btn--outline { border: 0.5px solid var(--dsw-alias-border-l3, currentColor); }
85
+ .cga-app__head-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
86
+ .cga-banner { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; font-size: 12px; line-height: 1.5; }
87
+ .cga-banner--info { color: var(--dsw-alias-label-secondary, currentColor); background: var(--dsw-alias-interactive-bg-hover, transparent); }
88
+ .cga-banner--warn { color: var(--dsw-alias-state-warning-primary, currentColor); background: var(--dsw-alias-state-warning-bg, transparent); }
89
+ .cga-banner--error { color: var(--dsw-alias-state-error-primary, currentColor); background: var(--dsw-alias-state-error-bg, transparent); }
90
+ .cga-progress { display: flex; align-items: center; gap: 8px; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary, currentColor); }
91
+ .cga-spinner { width: 14px; height: 14px; border-radius: 50%; border: 2px solid var(--dsw-alias-border-l3, currentColor); border-top-color: var(--dsw-alias-brand-primary, var(--dsw-alias-accent-primary, currentColor)); animation: cga-spin 0.8s linear infinite; flex: none; }
92
+ @keyframes cga-spin { to { transform: rotate(360deg); } }
93
+ .cga-history { display: flex; flex-direction: column; gap: 6px; }
94
+ .cga-history__item { display: flex; align-items: center; gap: 8px; padding: 10px 12px; border: 0.5px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2)); border-radius: 8px; }
95
+ .cga-history__meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
96
+ .cga-history__title { font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-primary, currentColor); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
97
+ .cga-history__sub { font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary, currentColor)); }
98
+ .cga-history__act { border: 0; background: none; color: var(--dsw-alias-brand-primary, var(--dsw-alias-accent-primary, currentColor)); font: inherit; font-size: 12px; line-height: 1.5; cursor: pointer; padding: 2px 4px; }
99
+ .cga-history__act:hover { color: var(--dsw-alias-label-primary, currentColor); }
100
+ .cga-auth { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-interactive-bg-hover, transparent); font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary, currentColor); }
101
+
102
+ /* History modal — same chrome as dsh's Modal/settings dialog. */
103
+ .cga-history-dialog { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 24px; box-sizing: border-box; }
104
+ .cga-history-dialog__mask { position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1); backdrop-filter: var(--dsw-mask-blur); }
105
+ .cga-history-dialog__panel { position: relative; z-index: 1; display: flex; flex-direction: column; width: min(480px, 100%); max-height: min(560px, calc(100vh - 48px)); overflow: hidden; border-radius: 24px; background: var(--dsw-alias-bg-layer-2); box-shadow: var(--dsw-elevation-prominent); }
106
+ .cga-history-dialog__head { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 20px 14px 8px 24px; }
107
+ .cga-history-dialog__title { font-size: 16px; line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary, currentColor); }
108
+ .cga-history-dialog__body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 24px 24px; }
109
+ `
110
+
111
+ export function injectAppStyles(): void {
112
+ if (typeof document === 'undefined') return
113
+ if (document.getElementById(APP_STYLE_ID)) return
114
+ const style = document.createElement('style')
115
+ style.id = APP_STYLE_ID
116
+ style.setAttribute('data-plugin', APP_PLUGIN_ID)
117
+ style.textContent = CSS
118
+ document.head.appendChild(style)
119
+ }
120
+
121
+ export function removeAppStyles(): void {
122
+ if (typeof document === 'undefined') return
123
+ document.getElementById(APP_STYLE_ID)?.remove()
124
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Pure geometry/dimension model for the interactive footprint dimension
3
+ * editor (rich-hit). Two-way-bound: the SVG package silhouette and the numeric
4
+ * inputs are two views of the same `values` map. Drag a handle → values
5
+ * change; type in an input → geometry re-renders. Only "confirm"/"cancel" go
6
+ * back to the model.
7
+ */
8
+
9
+ /** Per-key mm bounds used to keep edited values sane. */
10
+ const DIM_BOUNDS: Record<string, { min: number; max: number }> = {
11
+ W: { min: 0.1, max: 500 },
12
+ L: { min: 0.1, max: 500 },
13
+ width: { min: 0.1, max: 500 },
14
+ height: { min: 0.1, max: 500 },
15
+ bodyWidth: { min: 0.1, max: 500 },
16
+ bodyLength: { min: 0.1, max: 500 },
17
+ pitch: { min: 0.05, max: 100 },
18
+ }
19
+
20
+ export function dimensionBounds(key: string): { min: number; max: number } {
21
+ return DIM_BOUNDS[key] ?? { min: 0.01, max: 1000 }
22
+ }
23
+
24
+ export function clampDimension(value: number, min: number, max: number): number {
25
+ const n = Number(value)
26
+ if (!Number.isFinite(n)) return min
27
+ if (n < min) return min
28
+ if (n > max) return max
29
+ return n
30
+ }
31
+
32
+ /** Parse a dimension string (allows a trailing unit like "6.2mm"). Null when invalid. */
33
+ export function parseDimension(text: string): number | null {
34
+ const m = text.trim().match(/^([+-]?(\d+(\.\d+)?|\.\d+))([^0-9]*)$/)
35
+ if (!m) return null
36
+ const n = Number(m[1])
37
+ return Number.isFinite(n) ? n : null
38
+ }
39
+
40
+ /** Format a number for display: max 2 decimals, no trailing zeros. */
41
+ export function formatDimension(value: number | null | undefined): string {
42
+ if (typeof value !== 'number' || !Number.isFinite(value)) return ''
43
+ return String(Math.round(value * 100) / 100)
44
+ }
45
+
46
+ /** A dimension map with all scalar values coerced to numbers. */
47
+ export type DimensionValues = Record<string, number>
48
+
49
+ /** Geometry key picks among a dimension map. */
50
+ export interface DimensionGeometry {
51
+ widthKey: string | null
52
+ heightKey: string | null
53
+ otherKeys: string[]
54
+ numericKeys: string[]
55
+ }
56
+
57
+ /**
58
+ * Pick the geometry keys among a dimension map: a width-like key and a
59
+ * height-like key (rendered as the draggable board outline) and the rest.
60
+ */
61
+ export function pickGeometry(dimensions: DimensionValues): DimensionGeometry {
62
+ let widthKey: string | null = null
63
+ let heightKey: string | null = null
64
+ const numericKeys: string[] = []
65
+ const widthCandidates = ['W', 'width', 'Width', 'D', 'd_max', 'd_min', 'bodyWidth', 'body_width', 'bodyLength', 'body_length', 'boardWidth', 'E', 'e_max', 'e_min']
66
+ const heightCandidates = ['H', 'L', 'height', 'Height', 'Length', 'E', 'e_max', 'e_min', 'bodyLength', 'body_length', 'bodyWidth', 'body_width', 'boardHeight', 'D', 'd_max', 'd_min']
67
+
68
+ for (const k of Object.keys(dimensions)) {
69
+ const v = dimensions[k]
70
+ const n = typeof v === 'number' ? v : (typeof v === 'string' ? parseDimension(v) : null)
71
+ if (n == null) continue
72
+ numericKeys.push(k)
73
+ }
74
+ for (const c of widthCandidates) {
75
+ if (!widthKey && dimensions[c] != null) widthKey = c
76
+ }
77
+ for (const c of heightCandidates) {
78
+ if (c !== widthKey && dimensions[c] != null) { heightKey = c; break }
79
+ }
80
+ const otherKeys = numericKeys.filter((k) => k !== widthKey && k !== heightKey)
81
+ return { widthKey, heightKey, otherKeys, numericKeys }
82
+ }
83
+
84
+ /** Normalize a dimension map into numeric values + geometry key picks. */
85
+ export function normalizeDimensions(dimensions: Record<string, unknown> | null): DimensionGeometry & { values: DimensionValues } {
86
+ const values: DimensionValues = {}
87
+ if (dimensions && typeof dimensions === 'object' && !Array.isArray(dimensions)) {
88
+ for (const k of Object.keys(dimensions)) {
89
+ const v = dimensions[k]
90
+ const n = typeof v === 'number' ? v : (typeof v === 'string' ? parseDimension(v) : null)
91
+ if (n != null) values[k] = n
92
+ }
93
+ }
94
+ const geom = pickGeometry(values)
95
+ return { values, ...geom }
96
+ }
97
+
98
+ /**
99
+ * Fit a W×H rectangle into a viewBox, preserving aspect ratio and leaving
100
+ * padding for the dimension lines. Returns {x,y,w,h} in viewBox units.
101
+ */
102
+ export function rectFromValues(
103
+ values: DimensionValues,
104
+ widthKey: string | null,
105
+ heightKey: string | null,
106
+ viewW: number,
107
+ viewH: number,
108
+ pad: number,
109
+ ): { x: number; y: number; w: number; h: number } {
110
+ let W = widthKey && values[widthKey] != null ? values[widthKey] : 1
111
+ let H = heightKey && values[heightKey] != null ? values[heightKey] : 1
112
+ if (W <= 0) W = 1
113
+ if (H <= 0) H = 1
114
+ const availW = Math.max(1, viewW - pad * 2)
115
+ const availH = Math.max(1, viewH - pad * 2)
116
+ const scale = Math.min(availW / W, availH / H)
117
+ const w = W * scale
118
+ const h = H * scale
119
+ return { x: (viewW - w) / 2, y: (viewH - h) / 2, w, h }
120
+ }
121
+
122
+ /** Structured message sent back to the agent when the user confirms. */
123
+ export function dimensionConfirmMessage(
124
+ pkgType: string | null,
125
+ fileName: string | null,
126
+ values: DimensionValues,
127
+ edited: Record<string, boolean>,
128
+ ): string {
129
+ const label = pkgType || fileName || 'the component'
130
+ const parts = Object.keys(values).map((k) => `${k}=${formatDimension(values[k])}`)
131
+ let msg = `The user confirmed the footprint dimensions for ${label}: ${parts.join(', ')}. ` +
132
+ 'Call generate_footprint_from_dimensions with exactly these dimensions now.'
133
+ const editedKeys = Object.keys(edited).filter((k) => edited[k])
134
+ if (editedKeys.length > 0) {
135
+ msg += ` The user manually changed: ${editedKeys.join(', ')}.`
136
+ }
137
+ return msg
138
+ }
139
+
140
+ /** Structured message sent back to the agent when the user declines. */
141
+ export function dimensionDeclineMessage(pkgType: string | null, fileName: string | null): string {
142
+ const label = pkgType || fileName || 'the component'
143
+ return `The user declined to generate a footprint from the extracted dimensions for ${label}. ` +
144
+ 'Do not generate a footprint without new instructions.'
145
+ }
146
+
147
+ /** First positive numeric value across alias keys, else the fallback. */
148
+ export function numVal(values: DimensionValues, keys: string[], fallback: number): number {
149
+ for (const key of keys) {
150
+ const v = values[key]
151
+ const n = typeof v === 'number' ? v : (typeof v === 'string' ? parseDimension(v) : null)
152
+ if (n != null && Number.isFinite(n) && n > 0) return n
153
+ }
154
+ return fallback
155
+ }
156
+
157
+ /** Pin count from the dimension map (aliases: pin_count, pins, pinCount, n_max…). */
158
+ export function pinCountOf(values: DimensionValues, fallback: number): number {
159
+ const n = numVal(values, ['pin_count', 'pins', 'pinCount', 'n_max', 'n'], -1)
160
+ return n >= 0 ? Math.max(2, Math.round(n)) : fallback
161
+ }
162
+
163
+ /** BGA ball-grid rows/columns (aliases: rows, columns, cols). */
164
+ export function bgaGrid(values: DimensionValues, fallback: number): { rows: number; cols: number } {
165
+ let rows = Math.round(numVal(values, ['rows', 'row'], fallback))
166
+ let cols = Math.round(numVal(values, ['columns', 'col', 'cols'], fallback))
167
+ if (!(rows > 0)) rows = fallback
168
+ if (!(cols > 0)) cols = fallback
169
+ return { rows, cols }
170
+ }
171
+
172
+ /** Human-readable package-family label for the editor's info row. */
173
+ export function pkgFamilyLabel(pkgType: string | null): string {
174
+ return String(pkgType || '').toUpperCase()
175
+ }
176
+
177
+ const PITCH_KEYS = new Set(['pitch', 'pitch_d', 'pitch_e', 'lead_pitch', 'pitch_x', 'e'])
178
+ const PIN_KEYS = new Set(['pin_count', 'pins', 'pinCount', 'total_pins'])
179
+
180
+ /**
181
+ * Split the numeric keys into the small "essential" set (body W/H, pitch,
182
+ * pin count) and the rest, which fold into a collapsible "Advanced" section.
183
+ */
184
+ export function classifyDimensions(
185
+ numericKeys: string[],
186
+ widthKey: string | null,
187
+ heightKey: string | null,
188
+ ): { essential: string[]; advanced: string[] } {
189
+ const essential: string[] = []
190
+ const advanced: string[] = []
191
+ for (const k of numericKeys) {
192
+ if (k === widthKey || k === heightKey || PITCH_KEYS.has(k) || PIN_KEYS.has(k)) {
193
+ if (!essential.includes(k)) essential.push(k)
194
+ } else if (!advanced.includes(k)) {
195
+ advanced.push(k)
196
+ }
197
+ }
198
+ return { essential, advanced }
199
+ }
200
+
201
+ /**
202
+ * Find the min/max tolerance partner for a body dimension key, e.g.
203
+ * `d_max` → `d_min`. Returns { min, max } or null when no partner exists.
204
+ */
205
+ export function toleranceOf(values: DimensionValues, key: string | null): { min: number; max: number } | null {
206
+ if (!key || !values || typeof values !== 'object') return null
207
+ let lo: number | null = null
208
+ let hi: number | null = null
209
+ if (/_(max|min)$/.test(key)) {
210
+ const base = key.replace(/_(max|min)$/, '')
211
+ const loKey = `${base}_min`
212
+ const hiKey = `${base}_max`
213
+ const loV = values[loKey]
214
+ const hiV = values[hiKey]
215
+ if (loV != null) lo = loV
216
+ if (hiV != null) hi = hiV
217
+ } else {
218
+ const loV = values[`${key}_min`]
219
+ const hiV = values[`${key}_max`]
220
+ if (loV != null) lo = loV
221
+ if (hiV != null) hi = hiV
222
+ }
223
+ if (lo == null && hi == null) return null
224
+ return { min: lo != null ? lo : (hi as number), max: hi != null ? hi : (lo as number) }
225
+ }
226
+
227
+ /** Structural validation of the dimension set. */
228
+ export function validateDimensions(values: DimensionValues): Array<{ key: string; code: 'out_of_range' | 'min_gt_max' | 'invalid' }> {
229
+ const issues: Array<{ key: string; code: 'out_of_range' | 'min_gt_max' | 'invalid' }> = []
230
+ for (const key of Object.keys(values)) {
231
+ const v = values[key]
232
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
233
+ issues.push({ key, code: 'invalid' })
234
+ continue
235
+ }
236
+ const b = dimensionBounds(key)
237
+ if (v < b.min || v > b.max) issues.push({ key, code: 'out_of_range' })
238
+ }
239
+ const pairs: Array<[string, string]> = [['d_min', 'd_max'], ['e_min', 'e_max'], ['a_min', 'a_max'], ['b_min', 'b_max'], ['l_min', 'l_max']]
240
+ for (const [lo, hi] of pairs) {
241
+ const loV = values[lo]
242
+ const hiV = values[hi]
243
+ if (loV != null && hiV != null && loV > hiV) {
244
+ issues.push({ key: hi, code: 'min_gt_max' })
245
+ }
246
+ }
247
+ return issues
248
+ }
249
+
250
+ /** Compact summary used in the package info row: body W×H, pitch and pins. */
251
+ export function summaryOf(
252
+ values: DimensionValues,
253
+ widthKey: string | null,
254
+ heightKey: string | null,
255
+ t: (key: string, params?: Record<string, unknown>) => string,
256
+ ): string[] {
257
+ const parts: string[] = []
258
+ const bodyW = widthKey && values[widthKey] != null ? formatDimension(values[widthKey]) : null
259
+ const bodyH = heightKey && values[heightKey] != null ? formatDimension(values[heightKey]) : null
260
+ if (bodyW != null && bodyH != null) {
261
+ parts.push(`${t('card.editor.body')} ${bodyW} \u00d7 ${bodyH} ${t('card.editor.unit')}`)
262
+ }
263
+ const pitch = numVal(values, ['pitch', 'pitch_d', 'pitch_e', 'lead_pitch', 'pitch_x', 'e'], -1)
264
+ if (pitch >= 0) parts.push(`${t('card.editor.pitch')} ${formatDimension(pitch)} ${t('card.editor.unit')}`)
265
+ return parts
266
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `@huaqiu/component-gen-app` — ECAD preview + download helpers.
3
+ *
4
+ * The preview pipeline is bundled (offline-friendly): `@huaqiu/ecad-renderer`
5
+ * (subpath entries, self-contained ESM) + `@huaqiu/kicad-sexpr-parser` are
6
+ * bundled by tsdown.
7
+ *
8
+ * Unlike the plugin HIT card, artifact TEXT is not fetched from a hardcoded
9
+ * route here — the host supplies it through `ComponentGenPorts.artifactContent`
10
+ * (HTTP or standalone), so this module stays transport-agnostic.
11
+ */
12
+ import type { schematicProto } from '@huaqiu/kicad-sexpr-parser'
13
+ import type { boardProto } from '@huaqiu/kicad-sexpr-parser'
14
+ import { BoardParser, SchematicParser } from '@huaqiu/kicad-sexpr-parser'
15
+ import { renderSymbol } from '@huaqiu/ecad-renderer/symbol'
16
+ import { renderFootprint } from '@huaqiu/ecad-renderer/footprint'
17
+
18
+ function wrapFootprintInBoard(src: string): string {
19
+ return `(kicad_pcb (version 20240108) (generator "huaqiu-dsh") ${src})`
20
+ }
21
+
22
+ function parseSymbol(source: string): schematicProto.I_LibSymbol {
23
+ const sp = new SchematicParser()
24
+ if (typeof sp.parseLibSymbols !== 'function') {
25
+ throw new Error('parseLibSymbols is not a method on SchematicParser')
26
+ }
27
+ const symbols = sp.parseLibSymbols(source)
28
+ if (!symbols || symbols.length === 0) throw new Error('no symbols found in .kicad_sym source')
29
+ return symbols[0]!
30
+ }
31
+
32
+ function parseFootprint(source: string): boardProto.I_Footprint {
33
+ const bp = new BoardParser()
34
+ if (typeof bp.parse !== 'function') throw new Error('parse is not a method on BoardParser')
35
+ const toParse = /^\s*\(\s*kicad_pcb\b/.test(source) ? source : wrapFootprintInBoard(source)
36
+ const board = bp.parse(toParse)
37
+ if (!board || !Array.isArray(board.footprints) || board.footprints.length === 0) {
38
+ throw new Error('no footprints found in wrapped kicad_pcb source')
39
+ }
40
+ return board.footprints[0]!
41
+ }
42
+
43
+ /**
44
+ * Render a single generated artifact (symbol/footprint) onto a canvas. Returns
45
+ * the renderer dispose handle so the caller can release the viewer.
46
+ */
47
+ export async function renderArtifactToCanvas(
48
+ kind: string,
49
+ content: string,
50
+ canvas: HTMLCanvasElement,
51
+ ): Promise<() => void> {
52
+ let dispose: (() => void) | undefined
53
+ if (kind === 'symbol') {
54
+ const sym = parseSymbol(content)
55
+ const r = await renderSymbol(sym, { canvas, interactive: true })
56
+ dispose = () => r.dispose()
57
+ } else if (kind === 'footprint') {
58
+ const fp = parseFootprint(content)
59
+ const r = await renderFootprint(fp, { canvas, interactive: true })
60
+ dispose = () => r.dispose()
61
+ } else {
62
+ throw new Error(`unsupported preview kind: ${kind}`)
63
+ }
64
+ return () => { try { dispose?.() } catch { /* ignore */ } }
65
+ }
66
+
67
+ /** Size a canvas to its CSS box (device-pixel-ratio aware). */
68
+ export function sizeCanvasFor(canvas: HTMLCanvasElement): void {
69
+ const dpr = window.devicePixelRatio || 1
70
+ const cssW = canvas.clientWidth || 720
71
+ const cssH = canvas.clientHeight || 320
72
+ canvas.width = Math.max(100, Math.floor(cssW * dpr))
73
+ canvas.height = Math.max(100, Math.floor(cssH * dpr))
74
+ }
75
+
76
+ /** Trigger a browser download of a text artifact. */
77
+ export function triggerDownload(filename: string, text: string, mime = 'text/plain;charset=utf-8'): void {
78
+ try {
79
+ const blob = new Blob([text], { type: mime })
80
+ const url = URL.createObjectURL(blob)
81
+ const a = document.createElement('a')
82
+ a.href = url
83
+ a.download = filename
84
+ document.body.appendChild(a)
85
+ a.click()
86
+ a.remove()
87
+ setTimeout(() => { try { URL.revokeObjectURL(url) } catch { /* ignore */ } }, 2000)
88
+ } catch (err) {
89
+ console.warn('[hq-cga] download failed', err)
90
+ }
91
+ }