完成项目的修改实现

This commit is contained in:
张梦南 2026-08-18 16:06:28 +08:00
parent 3fd0cf1add
commit a5d24ee58f
22 changed files with 6344 additions and 2 deletions

42
CSS/base.css Normal file
View File

@ -0,0 +1,42 @@
 * { box-sizing: border-box; }
html, body { min-height: 100%; }
body {
margin: 0;
color: var(--text);
background: var(--bg-base);
font-family: "Segoe UI", "Microsoft YaHei UI", system-ui, sans-serif;
font-size: 15px;
overflow: hidden;
}
body.mask-expanded { overflow: hidden; }
button, input, select { font: inherit; color: inherit; }
button:focus-visible, input:focus-visible, select:focus-visible, [tabindex]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
input[type="range"] {
width: 100%;
height: 4px;
border-radius: 2px;
background: var(--bg-hover);
accent-color: var(--accent);
cursor: pointer;
}
input[type="text"], select {
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { border-radius: 3px; background: var(--border-hover); }
::-webkit-scrollbar-thumb:hover { background: var(--text-3); }

218
CSS/components.css Normal file
View File

@ -0,0 +1,218 @@
 /* === SECTIONS === */
.section {
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.section:last-child { border-bottom: none; }
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.section h2 {
margin: 0;
color: var(--text);
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.section-note {
color: var(--text-3);
font-size: 13px;
}
/* === DROPZONE === */
.dropzone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 70px;
padding: 12px;
border: 1px dashed var(--border-hover);
border-radius: var(--radius-sm);
background: var(--bg-input);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dropzone:hover, .dropzone.is-dragging {
border-color: var(--accent);
background: var(--bg-hover);
}
.dropzone input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.dropzone > span {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.drop-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--accent-lo);
color: #fff;
font-size: 16px;
}
.drop-title { color: var(--text); font-size: 14px; font-weight: 600; }
.drop-hint { color: var(--text-3); font-size: 13px; }
/* === FAN VARIANTS === */
.fan-variants {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.fan-variant {
display: block;
padding: 7px 4px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
cursor: pointer;
text-align: center;
transition: border-color 0.15s, background 0.15s;
}
.fan-variant:hover { border-color: var(--border-hover); background: var(--bg-hover); }
.fan-variant.active {
border-color: var(--accent);
background: rgba(0, 180, 216, 0.08);
box-shadow: 0 0 8px var(--accent-glow);
}
.fan-variant strong, .fan-variant small { display: block; }
.fan-variant strong { color: var(--text); font-family: "STKaiti", "KaiTi", serif; font-size: 15px; }
.fan-variant small { color: var(--text-3); font-size: 12px; }
.fan-variant.active strong { color: var(--accent); }
/* === BLADE COUNT === */
.blade-count-control { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); }
.blade-count-control[hidden] { display: none; }
.blade-count-head { display: flex; justify-content: space-between; color: var(--text-2); font-size: 13px; }
.blade-count-head strong { color: var(--text); font-size: 13px; }
.blade-count-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
margin-top: 5px;
}
.blade-count-option {
padding: 5px 3px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
cursor: pointer;
text-align: center;
transition: border-color 0.15s, background 0.15s;
}
.blade-count-option:hover { border-color: var(--border-hover); background: var(--bg-hover); }
.blade-count-option.active {
border-color: var(--accent);
background: rgba(0, 180, 216, 0.1);
box-shadow: 0 0 6px var(--accent-glow);
}
.blade-count-option strong, .blade-count-option small { display: block; }
.blade-count-option strong { color: var(--text); font-size: 14px; }
.blade-count-option small { color: var(--text-3); font-size: 11px; }
.blade-count-option.active strong { color: var(--accent); }
.blade-count-help { margin: 5px 0 0; color: var(--text-3); font-size: 12px; line-height: 1.4; }
/* === BLADE PANEL === */
.blade-panel {
margin-bottom: 8px;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.blade-panel[hidden] { display: none; }
.blade-panel-head { display: flex; justify-content: space-between; color: var(--text-2); font-size: 13px; }
.blade-panel-head strong { color: var(--text); font-size: 13px; }
.blade-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 3px;
margin-top: 5px;
}
.blade-button {
height: 28px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
color: var(--text-2);
font-family: "Cascadia Code", monospace;
font-size: 13px;
cursor: pointer;
position: relative;
transition: border-color 0.15s, background 0.15s;
}
.blade-button:hover { border-color: var(--border-hover); background: var(--bg-hover); }
.blade-button.assigned::after {
content: '';
position: absolute;
top: 3px;
right: 3px;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent);
}
.blade-button.active {
border-color: var(--accent-lo);
background: var(--accent-lo);
color: #fff;
}
.blade-button.active::after { background: #fff; }
.blade-help { margin: 5px 0 0; color: var(--text-3); font-size: 12px; }
/* === FILE CHIP === */
.file-chip {
display: none;
margin-top: 6px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-input);
color: var(--accent);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-chip.show { display: block; }

419
CSS/controls.css Normal file
View File

@ -0,0 +1,419 @@
 /* === SEGMENTED === */
.segmented {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 3px;
padding: 2px;
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.segmented button {
border: 0;
border-radius: 3px;
padding: 6px 4px;
background: transparent;
color: var(--text-2);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.segmented button.active {
background: var(--accent-lo);
color: #fff;
}
.image-mode { margin-bottom: 6px; }
/* === IMAGE QUICK ACTIONS === */
.image-quick-actions {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 3px;
margin-bottom: 8px;
}
.image-quick-actions button {
min-height: 28px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
color: var(--text-2);
font-size: 13px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.image-quick-actions button:hover { border-color: var(--accent); color: var(--accent); }
/* === EDITOR BOX === */
.editor-box {
margin-top: 8px;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.editor-tools {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 3px;
}
.editor-shape-tools { margin-top: 4px; }
.editor-tool-divider-label {
display: flex;
align-items: center;
gap: 6px;
margin: 6px 0 3px;
color: var(--text-3);
font-size: 12px;
}
.editor-tool-divider-label::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.editor-tool {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 30px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
color: var(--text-2);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.editor-tool:hover { border-color: var(--border-hover); color: var(--text); }
.editor-tool.active {
border-color: var(--accent);
background: rgba(0, 180, 216, 0.12);
color: var(--accent);
}
.editor-tool-symbol { font-family: "Cascadia Code", monospace; font-size: 15px; }
/* === SELECTION STRIP === */
.selection-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-top: 6px;
padding: 6px;
border: 1px solid rgba(232, 112, 74, 0.3);
border-radius: 4px;
background: rgba(232, 112, 74, 0.08);
color: var(--warn);
font-size: 12px;
}
.selection-strip[hidden] { display: none !important; }
.selection-strip button {
min-height: 24px;
padding: 0 8px;
border: 1px solid rgba(232, 112, 74, 0.3);
border-radius: 3px;
background: transparent;
color: var(--warn);
font-size: 12px;
cursor: pointer;
}
.selection-strip button:hover { background: rgba(232, 112, 74, 0.15); }
/* === EDITOR CONTEXT === */
.editor-context {
margin-top: 6px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
}
.editor-context[hidden], .editor-color-row[hidden] { display: none !important; }
.editor-context .control:first-child { margin-top: 0; }
/* === SHAPE RATIO === */
.shape-ratio-control {
padding: 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
}
.shape-ratio-control[hidden] { display: none !important; }
.shape-ratio-head { display: flex; justify-content: space-between; color: var(--text-3); font-size: 12px; }
.shape-ratio-head strong { color: var(--accent); font-size: 12px; }
.shape-ratio-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3px;
margin-top: 4px;
}
.shape-ratio-toggle button {
min-height: 28px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--bg-input);
color: var(--text-2);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.shape-ratio-toggle button.active {
border-color: var(--accent);
background: rgba(0, 180, 216, 0.12);
color: var(--accent);
}
/* === TEXT INPUT & FONTS === */
.text-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px;
gap: 4px;
}
.text-input-row input, .text-input-row select {
height: 28px;
padding: 0 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-input);
color: var(--text);
font-size: 13px;
}
.text-input-row input:focus, .text-input-row select:focus { border-color: var(--accent); outline: none; }
.font-chip-label {
display: flex;
justify-content: space-between;
margin-top: 6px;
color: var(--text-3);
font-size: 12px;
}
.font-chip-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 3px;
margin-top: 4px;
}
.font-chip {
min-height: 38px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
color: var(--text);
font-size: 16px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.font-chip::after {
content: attr(data-font-kind);
display: block;
margin-top: 1px;
color: var(--text-3);
font-size: 10px;
}
.font-chip:hover { border-color: var(--border-hover); }
.font-chip.active { border-color: var(--accent); background: rgba(0, 180, 216, 0.1); color: var(--accent); }
/* === COLOR ROW === */
.editor-color-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
color: var(--text-3);
font-size: 12px;
}
.editor-color-row input[type="color"] {
width: 44px;
height: 26px;
border: 1px solid var(--border);
border-radius: 3px;
background: transparent;
cursor: pointer;
}
.editor-options {
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
gap: 4px;
margin-top: 6px;
}
.editor-options input[type="text"] {
height: 28px;
padding: 0 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-input);
color: var(--text);
font-size: 13px;
}
.editor-options input[type="color"] {
width: 44px;
height: 28px;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
cursor: pointer;
}
/* === EDITOR ACTIONS === */
.editor-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-top: 6px;
}
.editor-hint {
margin: 5px 0 0;
color: var(--text-3);
font-size: 12px;
line-height: 1.4;
}
/* === CONTROL (slider rows) === */
.control {
display: grid;
grid-template-columns: 60px 1fr 40px;
gap: 6px;
align-items: center;
margin: 5px 0;
}
.control label {
color: var(--text-2);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.value {
color: var(--accent);
font-family: "Cascadia Code", monospace;
font-size: 12px;
text-align: right;
}
/* === ROW TWO & MINI BUTTON === */
.row-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-top: 6px;
}
.mini-button, select {
width: 100%;
min-height: 28px;
padding: 0 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
color: var(--text);
font-size: 13px;
}
.mini-button {
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.mini-button:hover { border-color: var(--accent); color: var(--accent); }
/* === PRECISION GUIDE === */
.precision-guide {
display: flex;
justify-content: space-between;
gap: 6px;
margin-top: 6px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-input);
color: var(--text-3);
font-size: 12px;
}
.precision-guide strong { color: var(--accent); font-family: "Cascadia Code", monospace; font-size: 12px; }
/* === STRUCTURE FIELD === */
.structure-field {
display: grid;
grid-template-columns: minmax(0, 1fr) 120px;
gap: 8px;
align-items: center;
margin-top: 6px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
}
.structure-copy { min-width: 0; }
.structure-copy strong, .structure-copy small { display: block; }
.structure-copy strong { color: var(--text); font-size: 13px; font-weight: 600; }
.structure-copy small { color: var(--text-3); font-size: 12px; }
.structure-field select { min-height: 28px; padding: 0 4px; font-size: 12px; }
/* === TOGGLE ROW & SWITCH === */
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: 6px;
color: var(--text-2);
font-size: 13px;
line-height: 1.3;
}
.switch {
position: relative;
flex: 0 0 auto;
width: 34px;
height: 18px;
}
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.switch span {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--bg-hover);
transition: background 0.15s;
}
.switch span::after {
content: '';
position: absolute;
top: 2px; left: 2px;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--text-2);
transition: transform 0.15s, background 0.15s;
}
.switch input:checked + span { background: var(--accent-lo); }
.switch input:checked + span::after { transform: translateX(16px); background: var(--accent-hi); }

241
CSS/editor.css Normal file
View File

@ -0,0 +1,241 @@
 /* === MASK CANVAS === */
.mask-wrap {
position: relative;
aspect-ratio: 390 / 232;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border);
background: #f9fafb;
cursor: grab;
touch-action: none;
margin-top: 6px;
}
.mask-backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(8px);
}
.mask-backdrop[hidden] { display: none; }
.mask-wrap.is-expanded {
position: fixed;
top: 50%;
left: 50%;
z-index: 41;
width: min(1000px, calc(100vw - 36px));
transform: translate(-50%, -50%);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.mask-wrap.is-adjusting { cursor: grabbing; }
.mask-wrap.is-editing { cursor: crosshair; }
.mask-wrap.has-brush-cursor #maskCanvas, .mask-wrap.has-text-preview #maskCanvas { cursor: none; }
#maskCanvas { width: 100%; height: 100%; display: block; touch-action: none; }
.brush-cursor {
position: absolute;
width: 16px;
height: 16px;
border: 2px solid var(--cursor-color, #c8ccd4);
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
z-index: 5;
}
.brush-cursor::before {
content: '';
position: absolute;
top: 50%; left: 50%;
width: 3px; height: 3px;
border-radius: 50%;
background: var(--cursor-color, #c8ccd4);
transform: translate(-50%, -50%);
}
.brush-cursor::after {
content: attr(data-label);
position: absolute;
top: 100%; left: 50%;
transform: translate(-50%, 4px);
padding: 1px 5px;
border-radius: 3px;
background: var(--bg-elevated);
color: var(--text);
font-size: 12px;
white-space: nowrap;
border: 1px solid var(--border);
}
.brush-cursor.is-eraser { border-style: dashed; border-color: var(--warn); }
.brush-cursor.is-eraser::before { background: var(--warn); }
.brush-cursor.is-eraser::after { border-color: var(--warn); color: var(--warn); }
.brush-cursor[hidden] { display: none; }
.text-placement-preview {
position: absolute;
z-index: 5;
pointer-events: none;
transform: rotate(var(--text-preview-angle, 0deg));
opacity: 0.7;
}
.text-placement-preview[hidden] { display: none; }
.text-preview-glyph { color: var(--text-preview-color, #c8ccd4); line-height: 1; }
.text-placement-preview.is-outline .text-preview-glyph {
color: transparent;
-webkit-text-stroke: 1.5px var(--text-preview-color, #c8ccd4);
}
.text-preview-anchor {
position: absolute;
top: 100%; left: 50%;
width: 10px; height: 10px;
border: 1.5px solid var(--warn);
border-radius: 50%;
background: var(--bg-base);
transform: translate(-50%, 5px);
}
.text-preview-anchor::before { content: ''; position: absolute; top: 50%; left: -9px; width: 15px; height: 1px; background: var(--warn); transform: translateY(-50%); }
.text-preview-anchor::after { content: ''; position: absolute; top: -9px; left: 50%; width: 1px; height: 15px; background: var(--warn); transform: translateX(-50%); }
.mask-expand {
position: absolute;
top: 6px; right: 6px;
z-index: 3;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: rgba(255, 255, 255, 0.85);
color: var(--text-2);
font-size: 13px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.mask-expand:hover { background: var(--bg-hover); color: var(--accent); }
.mask-wrap.is-expanded .mask-expand { padding: 6px 10px; font-size: 14px; }
.mask-wrap.is-expanded .adjust-hint { left: 12px; bottom: 12px; }
.adjust-hint {
position: absolute;
bottom: 6px; left: 6px;
z-index: 2;
padding: 3px 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.85);
color: var(--text-3);
font-size: 12px;
}
.demo-label {
position: absolute;
bottom: 6px; right: 6px;
z-index: 2;
padding: 2px 8px;
border-radius: 999px;
background: rgba(0, 180, 216, 0.15);
color: var(--accent);
font-size: 12px;
border: 1px solid rgba(0, 180, 216, 0.3);
}
/* === FLOATING TOOLBAR === */
.mask-floating-toolbar {
position: fixed;
bottom: 12px;
left: 50%;
z-index: 42;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 5px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transform: translateX(-50%);
}
.mask-floating-toolbar[hidden], .float-tool-context[hidden], .float-control[hidden] { display: none !important; }
.float-tool-group, .float-tool-context, .float-control { display: flex; align-items: center; gap: 4px; }
.float-tool-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
min-width: 32px;
min-height: 30px;
padding: 4px 7px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text-2);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.float-tool-button:hover { border-color: var(--border); background: var(--bg-hover); color: var(--text); }
.float-tool-button.active { border-color: var(--accent); background: rgba(0, 180, 216, 0.12); color: var(--accent); }
.float-tool-icon { font-family: "Cascadia Code", monospace; font-size: 15px; }
.float-tool-divider { width: 1px; height: 22px; background: var(--border); }
.float-transform-readout {
min-width: 64px;
color: var(--text-3);
font-family: "Cascadia Code", monospace;
font-size: 12px;
text-align: center;
}
.float-tool-context {
min-height: 30px;
padding: 2px 4px;
border-radius: 4px;
background: var(--bg-input);
}
.float-control > span { color: var(--text-3); font-size: 12px; white-space: nowrap; }
.float-control input[type="range"] { width: 72px; accent-color: var(--accent); }
.float-control output { min-width: 22px; color: var(--accent); font-family: "Cascadia Code", monospace; font-size: 12px; text-align: center; }
.float-text-input {
width: 100px;
height: 26px;
padding: 0 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-input);
color: var(--text);
font-size: 13px;
}
.float-text-input:focus { border-color: var(--accent); outline: none; }
.float-select {
max-width: 84px;
height: 26px;
padding: 0 4px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-input);
color: var(--text);
font-size: 12px;
}
.float-color-input {
width: 26px;
height: 26px;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
cursor: pointer;
}

89
CSS/export.css Normal file
View File

@ -0,0 +1,89 @@
 /* === EXPORT === */
.export-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-top: 8px;
}
.export-button {
min-height: 40px;
border: 0;
border-radius: var(--radius-sm);
background: var(--accent-lo);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
}
.export-button:hover { background: var(--accent); box-shadow: 0 0 12px var(--accent-glow); }
.export-button:active { background: var(--accent-lo); }
.export-button:disabled { opacity: 0.4; cursor: wait; }
.export-button.secondary {
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
}
.export-button.secondary:hover { border-color: var(--accent); color: var(--accent); }
/* === PIVOT DOWNLOAD === */
.pivot-download {
display: grid;
grid-template-columns: 30px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
margin-top: 8px;
padding: 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-input);
}
.pivot-mark {
width: 30px; height: 30px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%;
background: var(--bg-elevated);
color: var(--accent);
font-family: "STKaiti", "KaiTi", serif;
font-size: 15px;
font-weight: 700;
}
.pivot-copy { min-width: 0; }
.pivot-copy strong, .pivot-copy small { display: block; }
.pivot-copy strong { color: var(--text); font-size: 13px; }
.pivot-copy small { color: var(--text-3); font-size: 11px; line-height: 1.3; }
.pivot-link {
display: inline-flex;
align-items: center;
gap: 3px;
min-height: 28px;
padding: 0 8px;
border: 1px solid var(--accent-lo);
border-radius: 4px;
background: var(--accent-lo);
color: #fff;
font-size: 12px;
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.pivot-link:hover { background: var(--accent); }
.pivot-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* === STATUS === */
.status {
min-height: 28px;
margin-top: 8px;
padding: 6px 8px;
border-left: 2px solid var(--accent);
border-radius: 0 4px 4px 0;
background: var(--bg-input);
color: var(--text-2);
font-size: 13px;
}

143
CSS/layout.css Normal file
View File

@ -0,0 +1,143 @@
 /* === LAYOUT === */
@font-face {
font-family: "Pacifico";
src: url("../pacifico/Pacifico.ttf") format("truetype");
font-weight: 400;
font-style: normal;
font-display: swap;
}
.site-frame {
width: 100%;
height: 100vh;
display: grid;
grid-template-rows: 44px minmax(0, 1fr);
overflow: hidden;
}
.site-topbar {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 16px;
padding: 0 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
z-index: 5;
}
.site-brand-home {
display: flex;
align-items: center;
height: 44px;
text-decoration: none;
color: var(--text);
}
.site-brand-text {
font-family: "Pacifico", cursive;
font-size: 22px;
font-weight: 400;
letter-spacing: 0;
color: var(--accent);
text-shadow: 0 0 12px var(--accent-glow);
}
.site-page-title {
margin: 0;
font-size: 15px;
font-weight: 400;
color: var(--text-2);
font-family: "Cascadia Code", "Consolas", monospace;
}
.site-top-actions {
display: flex;
align-items: center;
gap: 8px;
}
.site-status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-elevated);
color: var(--text-2);
font-size: 14px;
}
.site-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 2px rgba(76, 175, 106, 0.2);
}
.shell {
display: grid;
grid-template-columns: 300px minmax(0, 1fr) 300px;
height: 100%;
overflow: hidden;
background: var(--border);
gap: 1px;
}
.panel-left, .panel-right {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
background: var(--bg-panel);
}
.panel-left { border-right: 1px solid var(--border); }
.panel-right { border-left: 1px solid var(--border); }
/* === BRAND HEADER === */
.brand {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.eyebrow {
margin: 0;
color: var(--accent);
font-family: "Cascadia Code", "Consolas", monospace;
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.brand-title {
margin: 2px 0 0;
color: var(--text);
font-family: "Segoe UI", system-ui, sans-serif;
font-size: 18px;
font-weight: 600;
}
.subtitle { display: none; }
.seal {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--warn);
border-radius: 4px;
color: var(--warn);
font-family: "STKaiti", "KaiTi", serif;
font-size: 16px;
font-weight: 700;
}

58
CSS/responsive.css Normal file
View File

@ -0,0 +1,58 @@
 /* === RESPONSIVE === */
@media (max-width: 1200px) {
.shell {
grid-template-columns: 280px minmax(0, 1fr) 280px;
}
}
@media (max-width: 900px) {
.shell {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(300px, 1fr) auto;
overflow-y: auto;
}
.panel-left, .panel-right {
height: auto;
max-height: none;
border: none;
border-bottom: 1px solid var(--border);
}
.stage { min-height: 50vh; order: 0; }
.panel-left { order: 1; }
.panel-right { order: 2; }
.mask-wrap { width: 100%; }
.mask-wrap.is-expanded { width: min(1000px, calc(100vw - 24px)); }
}
@media (max-width: 640px) {
.site-topbar { grid-template-columns: 1fr auto; gap: 8px; padding: 0 8px; }
.site-brand-text { font-size: 15px; }
.site-page-title { display: none; }
.site-status-pill { display: none; }
.mask-floating-toolbar {
bottom: 4px;
width: calc(100vw - 8px);
gap: 2px;
padding: 3px;
}
.float-tool-button { min-width: 28px; min-height: 28px; padding: 3px 5px; }
.float-tool-divider { display: none; }
.float-transform-readout { min-width: 52px; }
.float-tool-context { flex-wrap: wrap; justify-content: center; }
.pivot-download { grid-template-columns: 28px minmax(0, 1fr); }
.pivot-mark { width: 28px; height: 28px; }
.pivot-link { grid-column: 2; justify-self: start; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
transition: none !important;
animation-duration: 0.01ms !important;
}
}

131
CSS/stage.css Normal file
View File

@ -0,0 +1,131 @@
 /* === 3D STAGE === */
.stage {
position: relative;
overflow: hidden;
background:
radial-gradient(ellipse at 30% 20%, rgba(0, 120, 212, 0.04) 0%, transparent 60%),
radial-gradient(ellipse at 70% 80%, rgba(0, 120, 212, 0.03) 0%, transparent 60%),
#f9fafb;
}
.stage::before {
content: '';
position: absolute;
inset: 0;
opacity: 0.5;
background-image:
linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 0, 0, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
#glCanvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: grab;
z-index: 1;
touch-action: none;
}
#glCanvas:active { cursor: grabbing; }
.stage-top, .stage-bottom {
position: absolute;
left: 12px; right: 12px;
z-index: 3;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
pointer-events: none;
}
.stage-top { top: 10px; }
.stage-bottom { bottom: 10px; }
.model-tag {
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
color: var(--accent);
font-size: 13px;
}
.metric {
display: flex;
align-items: baseline;
gap: 3px;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
color: var(--text-3);
font-size: 12px;
}
.metric strong { color: var(--text); font-family: "Cascadia Code", monospace; font-size: 14px; }
.view-help {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
color: var(--text-3);
font-size: 12px;
}
.view-actions {
display: flex;
gap: 4px;
pointer-events: auto;
}
.view-actions button {
min-width: 30px;
height: 28px;
border: 1px solid var(--border);
border-radius: 4px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(8px);
color: var(--text-2);
font-size: 14px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.view-actions button:hover { border-color: var(--accent); color: var(--accent); }
/* === BUSY === */
.busy {
position: absolute;
inset: 0;
z-index: 8;
display: none;
place-items: center;
background: rgba(243, 244, 246, 0.7);
backdrop-filter: blur(4px);
color: var(--accent);
font-size: 14px;
}
.busy.show { display: grid; }
.busy::before {
content: '';
display: block;
width: 32px;
height: 32px;
margin-bottom: 8px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

22
CSS/variables.css Normal file
View File

@ -0,0 +1,22 @@
 :root {
--bg-base: #f3f4f6;
--bg-panel: #ffffff;
--bg-elevated: #f9fafb;
--bg-input: #f3f4f6;
--bg-hover: #eceef1;
--border: #e2e5ea;
--border-hover: #c8cdd6;
--text: #1a1d23;
--text-2: #5a5f6a;
--text-3: #8b8f99;
--accent: #0078d4;
--accent-hi: #2b88d8;
--accent-lo: #005a9e;
--accent-glow: rgba(0, 120, 212, 0.12);
--warn: #d13438;
--warn-glow: rgba(209, 52, 56, 0.12);
--ok: #107c10;
--radius: 8px;
--radius-sm: 5px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

1170
JavaScript/canvas_editor.js Normal file

File diff suppressed because it is too large Load Diff

443
JavaScript/config.js Normal file

File diff suppressed because one or more lines are too long

611
JavaScript/export.js Normal file
View File

@ -0,0 +1,611 @@
 function buildRelief() {
var detail = DETAIL[ui.detail.value];
var minT = Number(ui.minThickness.value);
var maxT = Number(ui.maxThickness.value);
var margin = currentReliefMargin();
var gamma = Number(ui.reliefGamma.value);
var nr = detail.radial;
var nv = detail.across;
var weldedBlades = ui.includeFrame.checked && state.frameWelds ? state.frameWelds.filter(Boolean).length : 0;
var baseTrianglesPerBlade = 4 * nr * nv + rootCapTriangleCount();
var triangleCapacity = baseTrianglesPerBlade * FAN.angles.length + weldedBlades * 5;
var mesh = createMeshWriter(triangleCapacity);
for (let bladeIndex = 0; bladeIndex < FAN.angles.length; bladeIndex++) {
var angle = FAN.angles[bladeIndex];
var frameHole = ui.includeFrame.checked && state.frameWelds ? state.frameWelds[bladeIndex] : null;
var weldJ = Math.floor((nv - 1) / 2);
var reliefHole = null;
var top = Array.from({
length: nr
}, () => new Array(nv));
var bottom = Array.from({
length: nr
}, () => new Array(nv));
var makeReliefPair = (radius, localY, normalizedCross=null) => {
var world = rotatePoint(-radius, localY, angle);
var cross = normalizedCross === null ? clamp(localY / reliefHalfAt(radius, margin), -1, 1) : normalizedCross;
var lum = state.imageMode === 'blade' ? sampleBladeLuminance(bladeIndex, radius, cross) : sampleLuminance(world[0], world[1]);
var darkness = Math.pow(1 - lum, gamma);
var thickness = minT + darkness * (maxT - minT);
return {
top: [world[0], world[1], FAN.baseZ + thickness],
bottom: [world[0], world[1], FAN.baseZ]
};
}
;
for (let i = 0; i < nr; i++) {
var r = reliefRadiusAt(i, nr);
var half = reliefHalfAt(r, margin);
for (let j = 0; j < nv; j++) {
var cross = -1 + 2 * j / (nv - 1);
var localY = cross * half;
var pair = makeReliefPair(r, localY, cross);
top[i][j] = pair.top;
bottom[i][j] = pair.bottom;
}
}
for (let i = 0; i < nr - 1; i++) {
for (let j = 0; j < nv - 1; j++) {
var a = top[i][j]
, b = top[i + 1][j]
, c = top[i + 1][j + 1]
, d = top[i][j + 1];
pushTri(mesh, a, c, b);
pushTri(mesh, a, d, c);
var ba = bottom[i][j]
, bb = bottom[i + 1][j]
, bc = bottom[i + 1][j + 1]
, bd = bottom[i][j + 1];
pushTri(mesh, ba, bb, bc);
if (frameHole && i === 0 && j === weldJ) {
reliefHole = [ba, bc, bd];
} else {
pushTri(mesh, ba, bc, bd);
}
}
}
for (let i = 0; i < nr - 1; i++) {
var ta = top[i][0]
, tb = top[i + 1][0]
, ba = bottom[i][0]
, bb = bottom[i + 1][0];
pushTri(mesh, ta, tb, bb);
pushTri(mesh, ta, bb, ba);
var j = nv - 1;
var tc = top[i][j]
, td = top[i + 1][j]
, bc = bottom[i][j]
, bd = bottom[i + 1][j];
pushTri(mesh, tc, bd, td);
pushTri(mesh, tc, bc, bd);
}
var outerI = nr - 1;
var outerTip = makeReliefPair(outerReliefTipRadius(), 0, 0);
for (let j = 0; j < nv - 1; j++) {
var ta = top[outerI][j]
, tb = top[outerI][j + 1];
var ba = bottom[outerI][j]
, bb = bottom[outerI][j + 1];
pushTriFacing(mesh, ta, outerTip.top, tb, true);
pushTriFacing(mesh, ba, bb, outerTip.bottom, false);
}
var outerLower = {
top: top[outerI][0],
bottom: bottom[outerI][0]
};
var outerUpper = {
top: top[outerI][nv - 1],
bottom: bottom[outerI][nv - 1]
};
for (const [current,next] of [[outerLower, outerTip], [outerTip, outerUpper]]) {
pushTri(mesh, current.top, next.bottom, current.bottom);
pushTri(mesh, current.top, next.top, next.bottom);
}
var tip = makeReliefPair(FAN.rootTipRadius, 0, 0);
var upperRoot = FAN.rootInnerUpper.map( ([radius,half]) => makeReliefPair(radius, Math.max(.25, half - margin)));
var lowerRoot = FAN.rootInnerUpper.map( ([radius,half]) => makeReliefPair(radius, -Math.max(.25, half - margin)));
var joinBottom = {
top: top[0][0],
bottom: bottom[0][0]
};
var joinTop = {
top: top[0][nv - 1],
bottom: bottom[0][nv - 1]
};
for (let j = 0; j < nv - 1; j++) {
pushTriFacing(mesh, tip.top, top[0][j], top[0][j + 1], true);
pushTriFacing(mesh, tip.bottom, bottom[0][j], bottom[0][j + 1], false);
}
for (const polygon of [[tip, ...upperRoot, joinTop], [tip, ...lowerRoot, joinBottom]]) {
for (const triangle of triangulatePairPolygon(polygon)) {
pushTriFacing(mesh, triangle[0].top, triangle[1].top, triangle[2].top, true);
pushTriFacing(mesh, triangle[0].bottom, triangle[1].bottom, triangle[2].bottom, false);
}
}
var rootSide = [joinBottom, ...lowerRoot.slice().reverse(), tip, ...upperRoot, joinTop];
for (let index = 0; index < rootSide.length - 1; index++) {
var current = rootSide[index]
, next = rootSide[index + 1];
pushTri(mesh, current.top, current.bottom, next.bottom);
pushTri(mesh, current.top, next.bottom, next.top);
}
if (frameHole && reliefHole) {
var matchedRelief = matchTriangleLoop(frameHole, reliefHole);
for (let index = 0; index < 3; index++) {
var next = (index + 1) % 3;
var frameA = frameHole[index]
, frameB = frameHole[next];
var reliefA = matchedRelief[index]
, reliefB = matchedRelief[next];
pushTri(mesh, frameA, frameB, reliefB);
pushTri(mesh, frameA, reliefB, reliefA);
}
}
}
if (mesh.offset !== triangleCapacity * 9) {
throw new Error(`网格面数校验失败:预计 ${triangleCapacity.toLocaleString()},实际 ${(mesh.offset / 9).toLocaleString()}`);
}
state.reliefPositions = mesh.positions;
state.reliefNormals = mesh.normals;
updateMeshBuffers();
var reliefTriangles = state.reliefPositions.length / 9;
var frameTriangles = ui.includeFrame.checked && state.framePositions ? state.framePositions.length / 9 : 0;
var total = reliefTriangles + frameTriangles;
var weldStatus = ui.includeFrame.checked ? ' · 浮雕已焊接' : '';
ui.status.textContent = `${DETAIL[ui.detail.value].label}${weldStatus} · ${total.toLocaleString()} 个三角面 · 预计 STL ${(84 + total * 50) / 1048576 < 1 ? ((84 + total * 50) / 1024).toFixed(0) + ' KB' : ((84 + total * 50) / 1048576).toFixed(1) + ' MB'}`;
}
function closedReliefBladeSources() {
var detail = DETAIL[ui.detail.value];
var nr = detail.radial;
var nv = detail.across;
var baseTrianglesPerBlade = 4 * nr * nv + rootCapTriangleCount();
var sources = [];
var triangleCursor = 0;
for (let bladeIndex = 0; bladeIndex < FAN.angles.length; bladeIndex++) {
var wasWelded = Boolean(ui.includeFrame.checked && state.frameWelds && state.frameWelds[bladeIndex]);
var storedTriangleCount = baseTrianglesPerBlade + (wasWelded ? 5 : 0);
var sourceStart = triangleCursor * 9;
var sourceEnd = sourceStart + storedTriangleCount * 9;
if (!wasWelded) {
sources.push(state.reliefPositions.slice(sourceStart, sourceEnd));
} else {
var closed = new Float32Array(baseTrianglesPerBlade * 9);
var coreTriangleCount = baseTrianglesPerBlade - 1;
closed.set(state.reliefPositions.subarray(sourceStart, sourceStart + coreTriangleCount * 9));
var weldJ = Math.floor((nv - 1) / 2);
var r0 = FAN.rMin;
var r1 = reliefRadiusAt(1, nr);
var half0 = reliefHalfAt(r0);
var half1 = reliefHalfAt(r1);
var cross0 = -1 + 2 * weldJ / (nv - 1);
var cross1 = -1 + 2 * (weldJ + 1) / (nv - 1);
var angle = FAN.angles[bladeIndex];
var ba2 = rotatePoint(-r0, cross0 * half0, angle);
var bc2 = rotatePoint(-r1, cross1 * half1, angle);
var bd2 = rotatePoint(-r0, cross1 * half0, angle);
var offset = coreTriangleCount * 9;
closed.set([ba2[0], ba2[1], FAN.baseZ, bc2[0], bc2[1], FAN.baseZ, bd2[0], bd2[1], FAN.baseZ], offset);
sources.push(closed);
}
triangleCursor += storedTriangleCount;
}
if (triangleCursor * 9 !== state.reliefPositions.length) {
throw new Error('浮雕叶片拆分校验失败,请重新生成网格后再试');
}
return sources;
}
function writeSTL() {
if (!state.reliefPositions)
return;
var sources = ui.includeFrame.checked ? [state.framePositions, state.reliefPositions] : [state.reliefPositions];
var triangleCount = sources.reduce( (sum, source) => sum + source.length / 9, 0);
var buffer = new ArrayBuffer(84 + triangleCount * 50);
var bytes = new Uint8Array(buffer);
var header = new TextEncoder().encode(`DreamLife Translucent Relief - ${FAN.name} - millimeters`);
bytes.set(header.slice(0, 80), 0);
var view = new DataView(buffer);
view.setUint32(80, triangleCount, true);
var offset = 84;
for (const source of sources) {
for (let i = 0; i < source.length; i += 9) {
var a = [source[i], source[i + 1], source[i + 2]];
var b = [source[i + 3], source[i + 4], source[i + 5]];
var c = [source[i + 6], source[i + 7], source[i + 8]];
var n = triangleNormal(a, b, c);
view.setFloat32(offset, n[0], true);
view.setFloat32(offset + 4, n[1], true);
view.setFloat32(offset + 8, n[2], true);
var p = offset + 12;
for (const vertex of [a, b, c]) {
view.setFloat32(p, vertex[0], true);
view.setFloat32(p + 4, vertex[1], true);
view.setFloat32(p + 8, vertex[2], true);
p += 12;
}
view.setUint16(offset + 48, 0, true);
offset += 50;
}
}
var blob = new Blob([buffer],{
type: 'model/stl'
});
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
var assigned = state.bladeConfigs.filter( (config) => config.image).length;
var sourceName = state.imageMode === 'blade' ? `${assigned}片独立图片` : state.globalConfig.name;
link.download = `${sanitizeName(sourceName)}_${FAN.name}_${state.bladeCount}片_DreamLife_不含转轴.stl`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout( () => URL.revokeObjectURL(url), 2000);
ui.status.textContent = `已导出 ${link.download} · 不含转轴 · ${triangleCount.toLocaleString()} 个三角面`;
}
var CRC32_TABLE = ( () => {
var table = new Uint32Array(256);
for (let index = 0; index < 256; index++) {
var value = index;
for (let bit = 0; bit < 8; bit++)
value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
table[index] = value >>> 0;
}
return table;
}
)();
function crc32(chunks) {
var crc = 0xffffffff;
for (const chunk of chunks) {
for (let index = 0; index < chunk.length; index++) {
crc = CRC32_TABLE[(crc ^ chunk[index]) & 0xff] ^ (crc >>> 8);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function currentSourceName() {
var assigned = state.bladeConfigs.filter( (config) => config.image).length;
return state.imageMode === 'blade' ? `${assigned}片独立图片` : state.globalConfig.name;
}
function xmlEscape(value) {
return String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;'
})[character]);
}
function format3MFNumber(value) {
var normalized = value === 0 ? 0 : Math.fround(value);
return Number(normalized.toPrecision(9)).toString();
}
function index3MFMesh(sources) {
var triangleCount = sources.reduce( (sum, source) => sum + source.length / 9, 0);
var triangleIndices = new Uint32Array(triangleCount * 3);
var vertices = new Float32Array(triangleIndices.length * 3);
var tableSize = 1024;
while (tableSize < triangleCount * 2)
tableSize *= 2;
var slots = new Int32Array(tableSize);
var mask = tableSize - 1;
var floatBits = new Float32Array(3);
var uintBits = new Uint32Array(floatBits.buffer);
var boundsMin = [Infinity, Infinity, Infinity];
var boundsMax = [-Infinity, -Infinity, -Infinity];
var vertexCount = 0;
var occurrence = 0;
var findOrAddVertex = (rawX, rawY, rawZ) => {
var x = rawX === 0 ? 0 : rawX;
var y = rawY === 0 ? 0 : rawY;
var z = rawZ === 0 ? 0 : rawZ;
floatBits[0] = x;
floatBits[1] = y;
floatBits[2] = z;
var hash = Math.imul(uintBits[0] ^ (uintBits[0] >>> 16), 0x45d9f3b);
hash ^= Math.imul(uintBits[1] ^ (uintBits[1] >>> 16), 0x119de1f3);
hash ^= Math.imul(uintBits[2] ^ (uintBits[2] >>> 16), 0x344b1a4d);
var slot = hash & mask;
while (slots[slot]) {
var existing = slots[slot] - 1;
var base = existing * 3;
if (vertices[base] === x && vertices[base + 1] === y && vertices[base + 2] === z)
return existing;
slot = (slot + 1) & mask;
}
var base = vertexCount * 3;
vertices[base] = x;
vertices[base + 1] = y;
vertices[base + 2] = z;
slots[slot] = vertexCount + 1;
boundsMin[0] = Math.min(boundsMin[0], x);
boundsMax[0] = Math.max(boundsMax[0], x);
boundsMin[1] = Math.min(boundsMin[1], y);
boundsMax[1] = Math.max(boundsMax[1], y);
boundsMin[2] = Math.min(boundsMin[2], z);
boundsMax[2] = Math.max(boundsMax[2], z);
return vertexCount++;
}
;
for (const source of sources) {
for (let index = 0; index < source.length; index += 3) {
triangleIndices[occurrence++] = findOrAddVertex(source[index], source[index + 1], source[index + 2]);
}
}
return {
triangleCount,
triangleIndices,
vertexCount,
vertices: vertices.subarray(0, vertexCount * 3),
boundsMin,
boundsMax
};
}
function build3MFModelChunks(meshEntries, title, structure) {
var encoder = new TextEncoder();
var chunks = [];
var lines = [];
var characterCount = 0;
var flush = () => {
if (!lines.length)
return;
chunks.push(encoder.encode(lines.join('')));
lines = [];
characterCount = 0;
}
;
var append = (line) => {
lines.push(line);
characterCount += line.length;
if (characterCount >= 1024 * 1024)
flush();
}
;
var safeTitle = xmlEscape(title);
append('<?xml version="1.0" encoding="UTF-8"?>');
append('<model unit="millimeter" xml:lang="zh-CN" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">');
append(`<metadata name="Title">${safeTitle}</metadata>`);
append('<metadata name="Application">DreamLife-Translucent_Relief</metadata>');
append(`<metadata name="Description">${xmlEscape(FAN.name)}折扇骨架与透光浮雕可拆分打印模型,不含转轴</metadata>`);
append(`<metadata name="CreationDate">${new Date().toISOString().slice(0, 10)}</metadata>`);
append('<resources>');
var boundsMin = [Infinity, Infinity, Infinity];
for (let entryIndex = 0; entryIndex < meshEntries.length; entryIndex++) {
var entry = meshEntries[entryIndex];
var mesh = entry.mesh;
var objectId = entryIndex + 1;
for (let axis = 0; axis < 3; axis++)
boundsMin[axis] = Math.min(boundsMin[axis], mesh.boundsMin[axis]);
append(`<object id="${objectId}" type="model" name="${xmlEscape(entry.name)}" partnumber="${xmlEscape(entry.partNumber)}"><mesh><vertices>`);
for (let vertex = 0; vertex < mesh.vertexCount; vertex++) {
var offset = vertex * 3;
append(`<vertex x="${format3MFNumber(mesh.vertices[offset])}" y="${format3MFNumber(mesh.vertices[offset + 1])}" z="${format3MFNumber(mesh.vertices[offset + 2])}"/>`);
}
append('</vertices><triangles>');
for (let triangle = 0; triangle < mesh.triangleCount; triangle++) {
var offset = triangle * 3;
append(`<triangle v1="${mesh.triangleIndices[offset]}" v2="${mesh.triangleIndices[offset + 1]}" v3="${mesh.triangleIndices[offset + 2]}"/>`);
}
append('</triangles></mesh></object>');
}
var buildObjectIds;
if (structure === 'parts') {
var assemblyId = meshEntries.length + 1;
append(`<object id="${assemblyId}" type="model" name="${safeTitle} 总成" partnumber="FAN-ASSEMBLY"><components>`);
for (let entryIndex = 0; entryIndex < meshEntries.length; entryIndex++) {
append(`<component objectid="${entryIndex + 1}"/>`);
}
append('</components></object>');
buildObjectIds = [assemblyId];
} else if (structure === 'objects') {
buildObjectIds = meshEntries.map( (entry, index) => index + 1);
} else {
buildObjectIds = [1];
}
append('</resources>');
var tx = Math.max(0, -boundsMin[0]);
var ty = Math.max(0, -boundsMin[1]);
var tz = Math.max(0, -boundsMin[2]);
var transform = `1 0 0 0 1 0 0 0 1 ${format3MFNumber(tx)} ${format3MFNumber(ty)} ${format3MFNumber(tz)}`;
append('<build>');
for (const objectId of buildObjectIds) {
var partNumber = structure === 'objects' ? meshEntries[objectId - 1].partNumber : 'FAN-ASSEMBLY';
append(`<item objectid="${objectId}" partnumber="${xmlEscape(partNumber)}" transform="${transform}"/>`);
}
append('</build></model>');
flush();
return chunks;
}
function zipStored(entries) {
var encoder = new TextEncoder();
var now = new Date();
var dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1);
var dosDate = ((Math.max(1980, now.getFullYear()) - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
var parts = [];
var records = [];
var offset = 0;
for (const entry of entries) {
var name = encoder.encode(entry.name);
var size = entry.chunks.reduce( (sum, chunk) => sum + chunk.length, 0);
if (size > 0xffffffff)
throw new Error('3MF 内容超过普通 ZIP 的 4 GB 上限');
var checksum = crc32(entry.chunks);
var header = new Uint8Array(30 + name.length);
var view = new DataView(header.buffer);
view.setUint32(0, 0x04034b50, true);
view.setUint16(4, 20, true);
view.setUint16(6, 0x0800, true);
view.setUint16(8, 0, true);
view.setUint16(10, dosTime, true);
view.setUint16(12, dosDate, true);
view.setUint32(14, checksum, true);
view.setUint32(18, size, true);
view.setUint32(22, size, true);
view.setUint16(26, name.length, true);
header.set(name, 30);
parts.push(header, ...entry.chunks);
records.push({
name,
size,
checksum,
offset
});
offset += header.length + size;
}
var centralStart = offset;
for (const record of records) {
var header = new Uint8Array(46 + record.name.length);
var view = new DataView(header.buffer);
view.setUint32(0, 0x02014b50, true);
view.setUint16(4, 20, true);
view.setUint16(6, 20, true);
view.setUint16(8, 0x0800, true);
view.setUint16(10, 0, true);
view.setUint16(12, dosTime, true);
view.setUint16(14, dosDate, true);
view.setUint32(16, record.checksum, true);
view.setUint32(20, record.size, true);
view.setUint32(24, record.size, true);
view.setUint16(28, record.name.length, true);
view.setUint32(42, record.offset, true);
header.set(record.name, 46);
parts.push(header);
offset += header.length;
}
var end = new Uint8Array(22);
var endView = new DataView(end.buffer);
endView.setUint32(0, 0x06054b50, true);
endView.setUint16(8, records.length, true);
endView.setUint16(10, records.length, true);
endView.setUint32(12, offset - centralStart, true);
endView.setUint32(16, centralStart, true);
parts.push(end);
return new Blob(parts,{
type: 'model/3mf'
});
}
function downloadBlob(blob, filename) {
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout( () => URL.revokeObjectURL(url), 5000);
}
function nextPaint() {
return new Promise( (resolve) => requestAnimationFrame( () => window.setTimeout(resolve, 0)));
}
function prepare3MFRawEntries(structure, title) {
if (structure === 'merged') {
return [{
name: title,
partNumber: 'FAN-MERGED',
sources: ui.includeFrame.checked ? [state.framePositions, state.reliefPositions] : [state.reliefPositions]
}];
}
var entries = [];
if (ui.includeFrame.checked) {
entries.push({
name: `${FAN.name}${state.bladeCount}片骨架`,
partNumber: 'FAN-FRAME',
sources: [state.frameOriginalPositions || state.framePositions]
});
}
closedReliefBladeSources().forEach( (source, bladeIndex) => {
var serial = String(bladeIndex + 1).padStart(2, '0');
entries.push({
name: `浮雕扇叶 ${serial}`,
partNumber: `FAN-BLADE-${serial}`,
sources: [source]
});
}
);
return entries;
}
async function write3MF() {
if (!state.reliefPositions)
return;
showBusy(true, '正在整理 3MF 网格');
try {
await nextPaint();
var structure = ui.threeMFStructure.value;
var title = `${sanitizeName(currentSourceName())}_${FAN.name}_${state.bladeCount}片_DreamLife_不含转轴`;
var rawEntries = prepare3MFRawEntries(structure, title);
var meshEntries = [];
for (let entryIndex = 0; entryIndex < rawEntries.length; entryIndex++) {
var entry = rawEntries[entryIndex];
ui.busyText.textContent = structure === 'merged' ? '正在整理整扇网格' : `正在整理零件 ${entryIndex + 1} / ${rawEntries.length}`;
if (entryIndex % 3 === 0)
await nextPaint();
meshEntries.push({
name: entry.name,
partNumber: entry.partNumber,
mesh: index3MFMesh(entry.sources)
});
}
ui.busyText.textContent = '正在打包 3MF 文件';
await nextPaint();
var modelChunks = build3MFModelChunks(meshEntries, title, structure);
var encoder = new TextEncoder();
var blob = zipStored([{
name: '[Content_Types].xml',
chunks: [encoder.encode('<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/></Types>')]
}, {
name: '_rels/.rels',
chunks: [encoder.encode('<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/></Relationships>')]
}, {
name: '3D/3dmodel.model',
chunks: modelChunks
}]);
var suffix = structure === 'parts' ? '_总成可拆零件' : structure === 'objects' ? '_独立对象' : '';
var filename = `${title}${suffix}.model.3mf`;
downloadBlob(blob, filename);
var vertexCount = meshEntries.reduce( (sum, entry) => sum + entry.mesh.vertexCount, 0);
var triangleCount = meshEntries.reduce( (sum, entry) => sum + entry.mesh.triangleCount, 0);
var structureLabel = structure === 'parts' ? `1 个总成 · ${meshEntries.length} 个可拆零件` : structure === 'objects' ? `${meshEntries.length} 个独立对象` : '1 个完整对象';
ui.status.textContent = `已导出 ${filename} · 不含转轴 · ${structureLabel} · ${vertexCount.toLocaleString()} 个顶点 · ${triangleCount.toLocaleString()} 个三角面`;
} catch (error) {
console.error(error);
ui.status.textContent = `3MF 导出失败:${error.message}`;
} finally {
showBusy(false);
}
}

451
JavaScript/geometry.js Normal file
View File

@ -0,0 +1,451 @@
 function anglesForBladeCount(count) {
var safeCount = SUPPORTED_BLADE_COUNTS.includes(Number(count)) ? Number(count) : 11;
var half = (safeCount - 1) / 2;
return Array.from({
length: safeCount
}, (_, index) => (index - half) * BLADE_ANGLE_STEP);
}
function syncActiveBladeConfigs() {
var start = Math.floor((COMMON_ANGLES.length - state.bladeCount) / 2);
state.bladeConfigs = state.bladeConfigSlots.slice(start, start + state.bladeCount);
}
function outlineExtents(angles) {
var minX = Infinity
, maxX = -Infinity
, minY = Infinity
, maxY = -Infinity;
angles.forEach( (angle) => {
FAN.outline.forEach( ([x,y]) => {
var point = rotatePoint(x, y, angle);
minX = Math.min(minX, point[0]);
maxX = Math.max(maxX, point[0]);
minY = Math.min(minY, point[1]);
maxY = Math.max(maxY, point[1]);
}
);
}
);
return {
minX,
maxX,
minY,
maxY
};
}
function updateActiveBounds() {
var full = outlineExtents(anglesForBladeCount(11));
var active = outlineExtents(FAN.angles);
var lowerPadding = full.minY - FAN.yMin;
var upperPadding = FAN.yMax - full.maxY;
state.activeBounds = {
xMin: FAN.xMin,
xMax: FAN.xMax,
yMin: active.minY - lowerPadding,
yMax: active.maxY + upperPadding
};
}
function modelBounds() {
return state.activeBounds || {
xMin: FAN.xMin,
xMax: FAN.xMax,
yMin: FAN.yMin,
yMax: FAN.yMax
};
}
function widthAt(radius) {
if (FAN.headArc && radius >= FAN.headArc.startRadius) {
var offset = radius - FAN.headArc.centerRadius;
return Math.sqrt(Math.max(0, FAN.headArc.radius ** 2 - offset ** 2));
}
var p = FAN.profile;
for (let i = 0; i < p.length - 1; i++) {
if (radius >= p[i][0] && radius <= p[i + 1][0]) {
var t = (radius - p[i][0]) / (p[i + 1][0] - p[i][0]);
return p[i][1] + (p[i + 1][1] - p[i][1]) * t;
}
}
return radius < p[0][0] ? p[0][1] : p[p.length - 1][1];
}
function rotatePoint(x, y, angleDegrees) {
var a = deg(angleDegrees)
, c = Math.cos(a)
, s = Math.sin(a);
return [x * c - y * s, x * s + y * c];
}
function decodeBase64(base64) {
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
var chunk = 1 << 15;
for (let start = 0; start < binary.length; start += chunk) {
var end = Math.min(start + chunk, binary.length);
for (let i = start; i < end; i++)
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function parseBinarySTL(base64) {
var bytes = decodeBase64(base64);
var view = new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);
var count = view.getUint32(80, true);
if (84 + count * 50 > bytes.byteLength)
throw new Error('内置骨架 STL 数据不完整');
var positions = new Float32Array(count * 9);
var normals = new Float32Array(count * 9);
var offset = 84;
for (let t = 0; t < count; t++,
offset += 50) {
var nx = view.getFloat32(offset, true)
, ny = view.getFloat32(offset + 4, true)
, nz = view.getFloat32(offset + 8, true);
var out = t * 9;
for (let v = 0; v < 3; v++) {
var source = offset + 12 + v * 12;
positions[out + v * 3] = view.getFloat32(source, true);
positions[out + v * 3 + 1] = view.getFloat32(source + 4, true);
positions[out + v * 3 + 2] = view.getFloat32(source + 8, true);
normals[out + v * 3] = nx;
normals[out + v * 3 + 1] = ny;
normals[out + v * 3 + 2] = nz;
}
}
return {
positions,
normals,
count
};
}
function assembleFrameFromTemplates() {
if (!state.frameTemplates)
throw new Error('扇叶骨架模板尚未载入');
// 叶片编号按预览画面从右向左排列:第 1 片位于右端,最后一片位于左端。
// 首尾护骨是非对称件,必须与画面方位一致,否则导出后会装反。
var roles = FAN.angles.map( (angle, index) => index === 0 ? 'right' : (index === FAN.angles.length - 1 ? 'left' : 'inner'));
var totalLength = roles.reduce( (sum, role) => sum + state.frameTemplates[role].positions.length, 0);
var positions = new Float32Array(totalLength);
var normals = new Float32Array(totalLength);
var output = 0;
roles.forEach( (role, bladeIndex) => {
var template = state.frameTemplates[role];
var radians = deg(FAN.angles[bladeIndex]);
var cosine = Math.cos(radians)
, sine = Math.sin(radians);
for (let index = 0; index < template.positions.length; index += 3) {
var x = template.positions[index]
, y = template.positions[index + 1];
positions[output + index] = x * cosine - y * sine;
positions[output + index + 1] = x * sine + y * cosine;
positions[output + index + 2] = template.positions[index + 2];
var nx = template.normals[index]
, ny = template.normals[index + 1];
normals[output + index] = nx * cosine - ny * sine;
normals[output + index + 1] = nx * sine + ny * cosine;
normals[output + index + 2] = template.normals[index + 2];
}
output += template.positions.length;
}
);
return {
positions,
normals,
count: totalLength / 9
};
}
function rebuildFrameFromTemplates() {
var frame = assembleFrameFromTemplates();
state.frameOriginalPositions = frame.positions;
state.frameOriginalNormals = frame.normals;
state.framePositions = frame.positions;
state.frameNormals = frame.normals;
state.frameRemovedTriangles = 0;
state.frameWelds = null;
prepareFrameWelds();
}
function removeDetachedPivotParts(mesh) {
var triangleCount = mesh.count;
if (triangleCount < 2)
return {
...mesh,
removedTriangles: 0,
componentCount: 1
};
var parent = new Int32Array(triangleCount);
var rank = new Uint8Array(triangleCount);
for (let triangle = 0; triangle < triangleCount; triangle++)
parent[triangle] = triangle;
var find = (item) => {
var root = item;
while (parent[root] !== root)
root = parent[root];
while (parent[item] !== item) {
var next = parent[item];
parent[item] = root;
item = next;
}
return root;
}
;
var union = (a, b) => {
var rootA = find(a)
, rootB = find(b);
if (rootA === rootB)
return;
if (rank[rootA] < rank[rootB])
[rootA,rootB] = [rootB, rootA];
parent[rootB] = rootA;
if (rank[rootA] === rank[rootB])
rank[rootA]++;
}
;
var vertexIds = new Int32Array(triangleCount * 3);
var vertexTable = new Map();
var vertexCount = 0;
for (let triangle = 0; triangle < triangleCount; triangle++) {
var triangleOffset = triangle * 9;
for (let vertex = 0; vertex < 3; vertex++) {
var offset = triangleOffset + vertex * 3;
var key = `${Math.round(mesh.positions[offset] * 10000)},${Math.round(mesh.positions[offset + 1] * 10000)},${Math.round(mesh.positions[offset + 2] * 10000)}`;
var vertexId = vertexTable.get(key);
if (vertexId === undefined) {
vertexId = vertexCount++;
vertexTable.set(key, vertexId);
}
vertexIds[triangle * 3 + vertex] = vertexId;
}
}
var edgeOwners = new Map();
for (let triangle = 0; triangle < triangleCount; triangle++) {
var offset = triangle * 3;
var ids = [vertexIds[offset], vertexIds[offset + 1], vertexIds[offset + 2]];
for (const [aIndex,bIndex] of [[0, 1], [1, 2], [2, 0]]) {
var a = ids[aIndex]
, b = ids[bIndex];
var key = a < b ? `${a}:${b}` : `${b}:${a}`;
var owner = edgeOwners.get(key);
if (owner === undefined)
edgeOwners.set(key, triangle);
else
union(triangle, owner);
}
}
var componentSizes = new Map();
var largestRoot = 0;
var largestSize = 0;
for (let triangle = 0; triangle < triangleCount; triangle++) {
var root = find(triangle);
var size = (componentSizes.get(root) || 0) + 1;
componentSizes.set(root, size);
if (size > largestSize) {
largestSize = size;
largestRoot = root;
}
}
if (componentSizes.size === 1)
return {
...mesh,
removedTriangles: 0,
componentCount: 1
};
var positions = new Float32Array(largestSize * 9);
var normals = new Float32Array(largestSize * 9);
var outputOffset = 0;
for (let triangle = 0; triangle < triangleCount; triangle++) {
if (find(triangle) !== largestRoot)
continue;
var inputOffset = triangle * 9;
positions.set(mesh.positions.subarray(inputOffset, inputOffset + 9), outputOffset);
normals.set(mesh.normals.subarray(inputOffset, inputOffset + 9), outputOffset);
outputOffset += 9;
}
return {
positions,
normals,
count: largestSize,
removedTriangles: triangleCount - largestSize,
componentCount: componentSizes.size
};
}
function triangleNormal(a, b, c) {
var ux = b[0] - a[0]
, uy = b[1] - a[1]
, uz = b[2] - a[2];
var vx = c[0] - a[0]
, vy = c[1] - a[1]
, vz = c[2] - a[2];
var nx = uy * vz - uz * vy;
var ny = uz * vx - ux * vz;
var nz = ux * vy - uy * vx;
var len = Math.hypot(nx, ny, nz) || 1;
return [nx / len, ny / len, nz / len];
}
function createMeshWriter(triangleCapacity) {
return {
positions: new Float32Array(triangleCapacity * 9),
normals: new Float32Array(triangleCapacity * 9),
offset: 0
};
}
function pushTri(mesh, a, b, c) {
var n = triangleNormal(a, b, c);
var offset = mesh.offset;
mesh.positions[offset] = a[0];
mesh.positions[offset + 1] = a[1];
mesh.positions[offset + 2] = a[2];
mesh.positions[offset + 3] = b[0];
mesh.positions[offset + 4] = b[1];
mesh.positions[offset + 5] = b[2];
mesh.positions[offset + 6] = c[0];
mesh.positions[offset + 7] = c[1];
mesh.positions[offset + 8] = c[2];
for (let vertex = 0; vertex < 3; vertex++) {
mesh.normals[offset] = n[0];
mesh.normals[offset + 1] = n[1];
mesh.normals[offset + 2] = n[2];
offset += 3;
}
mesh.offset = offset;
}
function pushTriFacing(mesh, a, b, c, upward) {
var normal = triangleNormal(a, b, c);
var facesUp = normal[2] >= 0;
if (facesUp === upward)
pushTri(mesh, a, b, c);
else
pushTri(mesh, a, c, b);
}
function triangulatePairPolygon(pairs) {
var cross2D = (a, b, c) => {
var pa = a.bottom
, pb = b.bottom
, pc = c.bottom;
return (pb[0] - pa[0]) * (pc[1] - pa[1]) - (pb[1] - pa[1]) * (pc[0] - pa[0]);
}
;
var signedArea = pairs.reduce( (sum, pair, index) => {
var next = pairs[(index + 1) % pairs.length];
return sum + pair.bottom[0] * next.bottom[1] - next.bottom[0] * pair.bottom[1];
}
, 0);
var indices = pairs.map( (pair, index) => index);
if (signedArea < 0)
indices.reverse();
var triangles = [];
var pointInside = (point, a, b, c) => {
var c1 = cross2D(a, b, point);
var c2 = cross2D(b, c, point);
var c3 = cross2D(c, a, point);
return c1 >= -1e-8 && c2 >= -1e-8 && c3 >= -1e-8;
}
;
while (indices.length > 3) {
var clipped = false;
for (let position = 0; position < indices.length; position++) {
var previous = indices[(position + indices.length - 1) % indices.length];
var current = indices[position];
var next = indices[(position + 1) % indices.length];
if (cross2D(pairs[previous], pairs[current], pairs[next]) <= 1e-8)
continue;
var containsPoint = indices.some( (candidate) => candidate !== previous && candidate !== current && candidate !== next && pointInside(pairs[candidate], pairs[previous], pairs[current], pairs[next]));
if (containsPoint)
continue;
triangles.push([pairs[previous], pairs[current], pairs[next]]);
indices.splice(position, 1);
clipped = true;
break;
}
if (!clipped)
throw new Error('扇叶根部轮廓三角化失败');
}
triangles.push(indices.map( (index) => pairs[index]));
return triangles;
}
function prepareFrameWelds() {
if (!state.framePositions || state.frameWelds)
return;
var positions = state.framePositions;
var normals = state.frameNormals;
var used = new Set();
var welds = [];
for (let bladeIndex = 0; bladeIndex < FAN.angles.length; bladeIndex++) {
var target = rotatePoint(-(FAN.rootTipRadius + 1.3), 0, FAN.angles[bladeIndex]);
var bestTriangle = -1;
var bestScore = Infinity;
for (let triangle = 0; triangle < positions.length; triangle += 9) {
var triangleIndex = triangle / 9;
if (used.has(triangleIndex))
continue;
var z0 = positions[triangle + 2]
, z1 = positions[triangle + 5]
, z2 = positions[triangle + 8];
if (Math.abs(z0 - .1) > .0005 || Math.abs(z1 - .1) > .0005 || Math.abs(z2 - .1) > .0005)
continue;
var cx = (positions[triangle] + positions[triangle + 3] + positions[triangle + 6]) / 3;
var cy = (positions[triangle + 1] + positions[triangle + 4] + positions[triangle + 7]) / 3;
var score = (cx - target[0]) ** 2 + (cy - target[1]) ** 2;
if (score < bestScore) {
bestScore = score;
bestTriangle = triangleIndex;
}
}
if (bestTriangle < 0)
continue;
used.add(bestTriangle);
var offset = bestTriangle * 9;
welds[bladeIndex] = [[positions[offset], positions[offset + 1], positions[offset + 2]], [positions[offset + 3], positions[offset + 4], positions[offset + 5]], [positions[offset + 6], positions[offset + 7], positions[offset + 8]]];
}
var filteredPositions = [];
var filteredNormals = [];
for (let triangle = 0; triangle < positions.length; triangle += 9) {
if (used.has(triangle / 9))
continue;
for (let index = 0; index < 9; index++) {
filteredPositions.push(positions[triangle + index]);
filteredNormals.push(normals[triangle + index]);
}
}
state.framePositions = new Float32Array(filteredPositions);
state.frameNormals = new Float32Array(filteredNormals);
state.frameWelds = welds;
}
function matchTriangleLoop(frameLoop, reliefLoop) {
var permutations = [[0, 1, 2], [1, 2, 0], [2, 0, 1], [0, 2, 1], [2, 1, 0], [1, 0, 2]];
var best = permutations[0];
var bestScore = Infinity;
for (const permutation of permutations) {
var score = 0;
for (let index = 0; index < 3; index++) {
var frame = frameLoop[index];
var relief = reliefLoop[permutation[index]];
score += (frame[0] - relief[0]) ** 2 + (frame[1] - relief[1]) ** 2 + (frame[2] - relief[2]) ** 2;
}
if (score < bestScore) {
bestScore = score;
best = permutation;
}
}
return best.map( (index) => reliefLoop[index]);
}

180
JavaScript/image.js Normal file
View File

@ -0,0 +1,180 @@
 function makeDemoImage() {
var canvas = document.createElement('canvas');
canvas.width = 1000;
canvas.height = 720;
var ctx = canvas.getContext('2d');
var sky = ctx.createLinearGradient(0, 0, 0, canvas.height);
sky.addColorStop(0, '#f7f3e7');
sky.addColorStop(.62, '#cdded8');
sky.addColorStop(1, '#7fa5a3');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(33,70,74,.15)';
for (let i = 0; i < 6; i++) {
ctx.beginPath();
ctx.ellipse(160 + i * 175, 164 + (i % 2) * 42, 126, 28, -.08, 0, Math.PI * 2);
ctx.fill();
}
var ridges = [{
y: 420,
color: '#7b9995',
amp: 130,
step: 145
}, {
y: 510,
color: '#496f72',
amp: 170,
step: 125
}, {
y: 610,
color: '#193f48',
amp: 190,
step: 112
}];
ridges.forEach( (ridge, layer) => {
ctx.beginPath();
ctx.moveTo(0, canvas.height);
ctx.lineTo(0, ridge.y);
for (let x = 0; x <= canvas.width + ridge.step; x += ridge.step) {
var peak = ridge.y - ridge.amp * (.54 + .46 * Math.sin(x * .014 + layer));
ctx.quadraticCurveTo(x + ridge.step * .45, peak, x + ridge.step, ridge.y + Math.sin(x * .021) * 22);
}
ctx.lineTo(canvas.width, canvas.height);
ctx.closePath();
ctx.fillStyle = ridge.color;
ctx.globalAlpha = .44 + layer * .16;
ctx.fill();
}
);
ctx.globalAlpha = 1;
ctx.strokeStyle = '#163d45';
ctx.lineWidth = 7;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(742, 620);
ctx.bezierCurveTo(716, 476, 760, 340, 700, 192);
ctx.stroke();
ctx.lineWidth = 3;
for (let i = 0; i < 13; i++) {
var y = 230 + i * 25;
var side = i % 2 ? -1 : 1;
ctx.beginPath();
ctx.moveTo(716 + Math.sin(i) * 16, y);
ctx.quadraticCurveTo(790 + side * 18, y - 32, 842 + side * 44, y - 45);
ctx.stroke();
}
ctx.fillStyle = '#244f55';
for (let i = 0; i < 42; i++) {
var x = 690 + Math.sin(i * 2.1) * (55 + i % 7 * 9);
var y = 205 + (i * 17) % 310;
ctx.beginPath();
ctx.ellipse(x, y, 13, 5, (i % 5) * .42, 0, Math.PI * 2);
ctx.fill();
}
var img = new Image();
img.src = canvas.toDataURL('image/png');
return img;
}
function imageLuminance(image, u, v) {
if (!image)
return 1;
var px = clamp(u * (image.width - 1), 0, image.width - 1);
var py = clamp(v * (image.height - 1), 0, image.height - 1);
var x0 = Math.floor(px)
, y0 = Math.floor(py);
var x1 = Math.min(x0 + 1, image.width - 1)
, y1 = Math.min(y0 + 1, image.height - 1);
var tx = px - x0
, ty = py - y0;
var sample = (x, y) => {
var i = (y * image.width + x) * 4;
return (image.data[i] * .2126 + image.data[i + 1] * .7152 + image.data[i + 2] * .0722) / 255;
}
;
var top = sample(x0, y0) * (1 - tx) + sample(x1, y0) * tx;
var bottom = sample(x0, y1) * (1 - tx) + sample(x1, y1) * tx;
var lum = top * (1 - ty) + bottom * ty;
var contrast = Number(ui.contrast.value) / 100;
lum = clamp((lum - .5) * contrast + .5, 0, 1);
if (ui.invert.checked)
lum = 1 - lum;
return lum;
}
function sampleLuminance(x, y) {
var bounds = modelBounds();
var u = clamp((y - bounds.yMin) / (bounds.yMax - bounds.yMin), 0, 1);
var v = clamp((x - bounds.xMin) / (bounds.xMax - bounds.xMin), 0, 1);
return imageLuminance(state.sourcePixels, u, v);
}
function sampleBladeLuminance(bladeIndex, radius, cross) {
var pixels = state.bladeConfigs[bladeIndex].pixels;
if (!pixels)
return 1;
var u = clamp((radius - FAN.rMin) / (FAN.rMax - FAN.rMin), 0, 1);
var v = clamp((1 - cross) / 2, 0, 1);
return imageLuminance(pixels, u, v);
}
function readImageFile(file) {
return new Promise( (resolve, reject) => {
var reader = new FileReader();
reader.onload = () => {
var img = new Image();
img.onload = () => resolve({
file,
img
});
img.onerror = () => reject(new Error(`${file.name} 无法读取`));
img.src = reader.result;
}
;
reader.onerror = () => reject(new Error(`${file.name} 无法读取`));
reader.readAsDataURL(file);
}
);
}
async function loadFiles(fileList) {
var files = [...(fileList || [])].filter( (file) => file.type.startsWith('image/'));
if (!files.length) {
ui.status.textContent = '请选择 JPG、PNG 或 WEBP 图片。';
return;
}
try {
showBusy(true);
if (state.imageMode === 'whole') {
var loaded = await readImageFile(files[0]);
state.globalConfig.image = loaded.img;
state.globalConfig.name = loaded.file.name;
state.globalConfig.isDemo = false;
ui.status.textContent = '整扇图片已载入,正在生成浮雕。';
} else {
var available = FAN.angles.length - state.selectedBlade;
var loaded = await Promise.all(files.slice(0, available).map(readImageFile));
loaded.forEach( ({file, img}, offset) => {
var config = state.bladeConfigs[state.selectedBlade + offset];
config.image = img;
config.name = file.name;
config.isDemo = false;
}
);
ui.status.textContent = `已从第 ${state.selectedBlade + 1} 片开始载入 ${loaded.length} 张图片。`;
}
ui.fileInput.value = '';
applyConfigToControls();
scheduleRebuild({
redrawImage: true
});
} catch (error) {
console.error(error);
ui.status.textContent = `图片载入失败:${error.message}`;
showBusy(false);
}
}

58
JavaScript/initialize.js Normal file
View File

@ -0,0 +1,58 @@
 function initialize() {
FAN.angles = anglesForBladeCount(state.bladeCount);
syncActiveBladeConfigs();
updateActiveBounds();
renderBladeButtons();
updateOutput(ui.imageScale, '%');
updateOutput(ui.offsetX, '%');
updateOutput(ui.offsetY, '%');
updateOutput(ui.imageRotation, '°');
updateOutput(ui.brushSize, ' px');
updateOutput(ui.textSize, ' px');
updateOutput(ui.textArc, '%');
updateOutput(ui.polygonSides, ' 边');
updateOutput(ui.splineTension, '%');
updateOutput(ui.minThickness);
updateOutput(ui.maxThickness);
updateOutput(ui.contrast, '%');
updateOutput(ui.reliefMargin, ' mm');
updateOutput(ui.reliefGamma);
updateOutput(ui.blur, ' px');
updatePrecisionGuide();
update3MFStructureHint();
updateVariantUi();
setTextFont(state.textFont);
setTextStyle(state.textStyle);
setTextArc(state.textArc);
setEditorTool('move');
bindEvents();
try {
initGL();
} catch (error) {
console.error(error);
ui.status.textContent = error.message;
}
var demo = makeDemoImage();
demo.onload = () => {
state.globalConfig.image = demo;
state.globalConfig.isDemo = true;
applyConfigToControls();
drawSource();
window.setTimeout( () => {
try {
loadFrameVariant(state.fanVariant);
buildRelief();
} catch (error) {
console.error(error);
ui.status.textContent = `骨架载入失败:${error.message}`;
} finally {
showBusy(false);
}
}
, 30);
}
;
}
initialize();

927
JavaScript/ui.js Normal file
View File

@ -0,0 +1,927 @@
 function updateVariantUi() {
[...ui.fanVariants.querySelectorAll('[data-variant]')].forEach( (button) => {
var active = button.dataset.variant === state.fanVariant;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
}
);
[...ui.bladeCountOptions.querySelectorAll('[data-blade-count]')].forEach( (button) => {
var active = Number(button.dataset.bladeCount) === state.bladeCount;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
);
var spread = (state.bladeCount - 1) * BLADE_ANGLE_STEP;
var bounds = modelBounds();
ui.variantSeal.textContent = FAN.seal;
ui.modelTag.textContent = `实时预览 · ${FAN.name} ${FAN.angles.length}`;
ui.metricSize.textContent = `${Math.round(bounds.xMax - bounds.xMin)} × ${Math.round(bounds.yMax - bounds.yMin)}`;
ui.bladeCountSummary.textContent = `${state.bladeCount}片 · 展开${spread}°`;
ui.bladeOrderHint.textContent = `1 右侧 · ${state.bladeCount} 左侧`;
ui.frameToggleLabel.textContent = `包含「${FAN.name}${state.bladeCount}片骨架(不含转轴),并将浮雕焊接到扇叶`;
}
function loadFrameVariant(key) {
var variant = FAN_VARIANTS[key];
if (!variant)
throw new Error('未找到所选扇形');
FAN = variant;
FAN.angles = anglesForBladeCount(state.bladeCount);
state.fanVariant = key;
state.maskLookup = null;
state.frameWelds = null;
updateActiveBounds();
var templateAssets = FRAME_BLADE_STL_BASE64[key];
state.frameTemplates = {
left: parseBinarySTL(templateAssets.left),
inner: parseBinarySTL(templateAssets.inner),
right: parseBinarySTL(templateAssets.right)
};
rebuildFrameFromTemplates();
updateVariantUi();
}
function setFanVariant(key) {
if (key === state.fanVariant || !FAN_VARIANTS[key])
return;
hideEditorPointerPreviews();
showBusy(true, '正在切换扇形骨架');
window.setTimeout( () => {
try {
loadFrameVariant(key);
updatePrecisionGuide();
drawSource();
buildRelief();
ui.status.textContent = `已切换为${FAN.name}${FAN.shape}),图片和编辑内容已保留。`;
} catch (error) {
console.error(error);
ui.status.textContent = `扇形切换失败:${error.message}`;
} finally {
showBusy(false);
}
}
, 20);
}
function renderBladeButtons() {
ui.bladeGrid.replaceChildren();
FAN.angles.forEach( (angle, index) => {
var button = document.createElement('button');
button.type = 'button';
button.className = 'blade-button';
button.textContent = String(index + 1);
button.addEventListener('click', () => selectBlade(index));
ui.bladeGrid.appendChild(button);
}
);
updateBladeButtons();
}
function setBladeCount(nextCount) {
var count = Number(nextCount);
if (!SUPPORTED_BLADE_COUNTS.includes(count) || count === state.bladeCount)
return;
hideEditorPointerPreviews();
syncActiveConfig();
var previousAngle = FAN.angles[state.selectedBlade] ?? 0;
showBusy(true, `正在调整为 ${count} 片扇叶`);
window.setTimeout( () => {
try {
state.bladeCount = count;
FAN.angles = anglesForBladeCount(count);
syncActiveBladeConfigs();
state.selectedBlade = FAN.angles.reduce( (best, angle, index) => Math.abs(angle - previousAngle) < Math.abs(FAN.angles[best] - previousAngle) ? index : best, 0);
state.maskLookup = null;
updateActiveBounds();
if (state.frameTemplates)
rebuildFrameFromTemplates();
renderBladeButtons();
updateVariantUi();
updatePrecisionGuide();
applyConfigToControls();
drawSource();
buildRelief();
update3MFStructureHint();
ui.status.textContent = `已切换为 ${count} 片扇叶 · 展开 ${(count - 1) * BLADE_ANGLE_STEP}° · 图片和编辑内容已按中心位置保留。`;
} catch (error) {
console.error(error);
ui.status.textContent = `扇叶数量切换失败:${error.message}`;
} finally {
showBusy(false);
}
}
, 20);
}
function syncActiveConfig() {
var config = activeConfig();
config.scale = Number(ui.imageScale.value);
config.offsetX = Number(ui.offsetX.value);
config.offsetY = Number(ui.offsetY.value);
config.rotation = Number(ui.imageRotation.value);
}
function renderConfig(config, forceEditorLayer=false) {
var canvas = config.canvas;
var ctx = canvas.getContext('2d', {
willReadFrequently: true
});
ctx.save();
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (config.image && config.image.complete) {
var img = config.image;
var rotation = ((config.rotation % 360) + 360) % 360;
var radians = deg(rotation);
var iw = Math.abs(img.naturalWidth * Math.cos(radians)) + Math.abs(img.naturalHeight * Math.sin(radians));
var ih = Math.abs(img.naturalWidth * Math.sin(radians)) + Math.abs(img.naturalHeight * Math.cos(radians));
var baseScale = config.fit === 'cover' ? Math.max(canvas.width / iw, canvas.height / ih) : Math.min(canvas.width / iw, canvas.height / ih);
var scale = baseScale * config.scale / 100;
var ox = config.offsetX / 100 * canvas.width;
var oy = config.offsetY / 100 * canvas.height;
ctx.translate(canvas.width / 2 + ox, canvas.height / 2 + oy);
ctx.rotate(deg(rotation));
ctx.filter = `blur(${Number(ui.blur.value)}px)`;
ctx.drawImage(img, -img.naturalWidth * scale / 2, -img.naturalHeight * scale / 2, img.naturalWidth * scale, img.naturalHeight * scale);
}
ctx.restore();
if (config.editDirty || forceEditorLayer)
ctx.drawImage(config.editCanvas, 0, 0, canvas.width, canvas.height);
config.pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
}
function update3MFStructureHint() {
var structure = ui.threeMFStructure.value;
var partCount = FAN.angles.length + (ui.includeFrame.checked ? 1 : 0);
if (structure === 'parts') {
ui.threeMFHint.textContent = `1 个总成内含 ${partCount} 个命名零件,相对位置锁定`;
} else if (structure === 'objects') {
ui.threeMFHint.textContent = `${partCount} 个独立对象,可在切片软件中逐件选择`;
} else {
ui.threeMFHint.textContent = '焊接为 1 个完整网格,适合直接切片';
}
}
function updatePrecisionGuide() {
var detail = DETAIL[ui.detail.value];
var maxHalf = Math.max(...FAN.profile.map( (point) => point[1])) - currentReliefMargin();
var radialStep = (FAN.rMax - FAN.rMin) / (detail.radial - 1);
var acrossStep = 2 * maxHalf / (detail.across - 1);
ui.precisionHint.textContent = `采样间距约 ${radialStep.toFixed(2)} × ${acrossStep.toFixed(2)} mm`;
ui.precisionGrid.textContent = `${detail.radial} × ${detail.across} / 片`;
}
function showBusy(show, label='正在重建浮雕') {
ui.busy.classList.toggle('show', show);
ui.exportBtn.disabled = show;
ui.export3mfBtn.disabled = show;
if (show)
ui.busyText.textContent = label;
}
function scheduleRebuild({redrawImage=false}={}) {
window.clearTimeout(state.rebuildTimer);
if (redrawImage) {
try {
drawSource();
} catch (error) {
console.error(error);
}
} else {
showBusy(true);
}
state.rebuildTimer = window.setTimeout( () => {
try {
if (redrawImage)
showBusy(true);
buildRelief();
} catch (error) {
console.error(error);
ui.status.textContent = `生成失败:${error.message}`;
} finally {
showBusy(false);
}
}
, 90);
}
function updateOutput(input, suffix='') {
var output = document.querySelector(`output[for="${input.id}"]`);
if (output)
output.textContent = `${input.value}${suffix}`;
}
function setImageControl(input, value) {
var min = Number(input.min);
var max = Number(input.max);
input.value = String(clamp(value, min, max));
updateOutput(input, '%');
updateFloatingTransformStatus();
}
function normalizeRotation(value) {
var normalized = ((value + 180) % 360 + 360) % 360 - 180;
return normalized === -180 && value > 0 ? 180 : normalized;
}
function setRotationControl(value) {
var rotation = normalizeRotation(value);
ui.imageRotation.value = String(rotation);
activeConfig().rotation = rotation;
updateOutput(ui.imageRotation, '°');
updateFloatingTransformStatus();
}
function resetImageTransform() {
var config = activeConfig();
config.rotation = 0;
config.scale = 100;
config.offsetX = 0;
config.offsetY = 0;
setImageControl(ui.imageScale, 100);
setImageControl(ui.offsetX, 0);
setImageControl(ui.offsetY, 0);
setRotationControl(0);
scheduleRebuild({
redrawImage: true
});
}
function bindRange(input, suffix, redrawImage) {
input.addEventListener('input', () => {
updateOutput(input, suffix);
if (redrawImage)
syncActiveConfig();
scheduleRebuild({
redrawImage
});
}
);
}
function setFit(mode) {
activeConfig().fit = mode;
ui.coverBtn.classList.toggle('active', mode === 'cover');
ui.containBtn.classList.toggle('active', mode === 'contain');
scheduleRebuild({
redrawImage: true
});
}
function updateBladeButtons() {
[...ui.bladeGrid.children].forEach( (button, index) => {
button.classList.toggle('active', index === state.selectedBlade);
button.classList.toggle('assigned', Boolean(state.bladeConfigs[index].image));
var name = state.bladeConfigs[index].name || '尚未设置图片';
button.title = `叶片 ${index + 1}${name}`;
button.setAttribute('aria-pressed', String(index === state.selectedBlade));
}
);
}
function updateImageUi() {
if (state.imageMode === 'whole') {
var config = state.globalConfig;
ui.coverBtn.textContent = '铺满扇面';
ui.containBtn.textContent = '完整图片';
ui.dropTitle.textContent = '选择或拖入一张图片';
ui.dropHint.textContent = '页面全程离线,图片不会上传';
if (config.image && !config.isDemo) {
ui.fileChip.textContent = `${config.name} · ${config.image.naturalWidth} × ${config.image.naturalHeight}px`;
ui.fileChip.classList.add('show');
ui.demoLabel.style.display = 'none';
} else {
ui.fileChip.classList.remove('show');
ui.demoLabel.textContent = '内置示例';
ui.demoLabel.style.display = '';
}
} else {
var config = activeConfig();
var assigned = state.bladeConfigs.filter( (item) => item.image).length;
ui.coverBtn.textContent = '铺满叶片';
ui.containBtn.textContent = '完整图片';
ui.dropTitle.textContent = `为第 ${state.selectedBlade + 1} 片选择图片`;
ui.dropHint.textContent = '可多选图片,将按叶片顺序自动填入';
ui.fileChip.textContent = config.image ? `叶片 ${state.selectedBlade + 1} · ${config.name} · ${config.image.naturalWidth} × ${config.image.naturalHeight}px` : `叶片 ${state.selectedBlade + 1} 尚未设置 · 已完成 ${assigned} / ${FAN.angles.length}`;
ui.fileChip.classList.add('show');
ui.demoLabel.textContent = `叶片 ${state.selectedBlade + 1}`;
ui.demoLabel.style.display = '';
}
updateBladeButtons();
}
function applyConfigToControls() {
var config = activeConfig();
setImageControl(ui.imageScale, config.scale);
setImageControl(ui.offsetX, config.offsetX);
setImageControl(ui.offsetY, config.offsetY);
setRotationControl(config.rotation);
ui.coverBtn.classList.toggle('active', config.fit === 'cover');
ui.containBtn.classList.toggle('active', config.fit === 'contain');
updateImageUi();
}
function selectBlade(index) {
hideEditorPointerPreviews();
syncActiveConfig();
state.selectedBlade = clamp(index, 0, FAN.angles.length - 1);
applyConfigToControls();
drawSource();
updateSelectionUi();
ui.status.textContent = `正在编辑第 ${state.selectedBlade + 1} 片叶片,可单独缩放、移动和旋转。`;
}
function setImageMode(mode) {
if (mode === state.imageMode)
return;
hideEditorPointerPreviews();
syncActiveConfig();
state.imageMode = mode;
ui.wholeModeBtn.classList.toggle('active', mode === 'whole');
ui.bladeModeBtn.classList.toggle('active', mode === 'blade');
ui.bladePanel.hidden = mode === 'whole';
ui.fileInput.multiple = mode === 'blade';
applyConfigToControls();
updateSelectionUi();
scheduleRebuild({
redrawImage: true
});
}
function readImageFile(file) {
return new Promise( (resolve, reject) => {
var reader = new FileReader();
reader.onload = () => {
var img = new Image();
img.onload = () => resolve({
file,
img
});
img.onerror = () => reject(new Error(`${file.name} 无法读取`));
img.src = reader.result;
}
;
reader.onerror = () => reject(new Error(`${file.name} 无法读取`));
reader.readAsDataURL(file);
}
);
}
async function loadFiles(fileList) {
var files = [...(fileList || [])].filter( (file) => file.type.startsWith('image/'));
if (!files.length) {
ui.status.textContent = '请选择 JPG、PNG 或 WEBP 图片。';
return;
}
try {
showBusy(true);
if (state.imageMode === 'whole') {
var loaded = await readImageFile(files[0]);
state.globalConfig.image = loaded.img;
state.globalConfig.name = loaded.file.name;
state.globalConfig.isDemo = false;
ui.status.textContent = '整扇图片已载入,正在生成浮雕。';
} else {
var available = FAN.angles.length - state.selectedBlade;
var loaded = await Promise.all(files.slice(0, available).map(readImageFile));
loaded.forEach( ({file, img}, offset) => {
var config = state.bladeConfigs[state.selectedBlade + offset];
config.image = img;
config.name = file.name;
config.isDemo = false;
}
);
ui.status.textContent = `已从第 ${state.selectedBlade + 1} 片开始载入 ${loaded.length} 张图片。`;
}
ui.fileInput.value = '';
applyConfigToControls();
scheduleRebuild({
redrawImage: true
});
} catch (error) {
console.error(error);
ui.status.textContent = `图片载入失败:${error.message}`;
showBusy(false);
}
}
function resetAll() {
[state.globalConfig, ...state.bladeConfigSlots].forEach( (config) => {
config.fit = 'cover';
config.rotation = 0;
config.scale = 100;
config.offsetX = 0;
config.offsetY = 0;
}
);
var values = {
imageScale: 100,
offsetX: 0,
offsetY: 0,
minThickness: .60,
maxThickness: 2.20,
reliefMargin: 0,
contrast: 135,
reliefGamma: .95,
blur: .5,
detail: 'ultra'
};
Object.entries(values).forEach( ([key,value]) => {
ui[key].value = value;
}
);
ui.invert.checked = false;
ui.includeFrame.checked = true;
ui.threeMFStructure.value = 'merged';
applyConfigToControls();
updateOutput(ui.imageScale, '%');
updateOutput(ui.offsetX, '%');
updateOutput(ui.offsetY, '%');
updateOutput(ui.minThickness);
updateOutput(ui.maxThickness);
updateOutput(ui.contrast, '%');
updateOutput(ui.reliefMargin, ' mm');
updateOutput(ui.reliefGamma);
updateOutput(ui.blur, ' px');
updatePrecisionGuide();
update3MFStructureHint();
scheduleRebuild({
redrawImage: true
});
}
function bindEvents() {
ui.fanVariants.addEventListener('click', (event) => {
var button = event.target.closest('[data-variant]');
if (button)
setFanVariant(button.dataset.variant);
}
);
ui.bladeCountOptions.addEventListener('click', (event) => {
var button = event.target.closest('[data-blade-count]');
if (button)
setBladeCount(button.dataset.bladeCount);
}
);
ui.wholeModeBtn.addEventListener('click', () => setImageMode('whole'));
ui.bladeModeBtn.addEventListener('click', () => setImageMode('blade'));
ui.fileInput.addEventListener('change', () => loadFiles(ui.fileInput.files));
['dragenter', 'dragover'].forEach( (name) => ui.dropzone.addEventListener(name, (event) => {
event.preventDefault();
ui.dropzone.classList.add('is-dragging');
}
));
['dragleave', 'drop'].forEach( (name) => ui.dropzone.addEventListener(name, (event) => {
event.preventDefault();
ui.dropzone.classList.remove('is-dragging');
}
));
ui.dropzone.addEventListener('drop', (event) => loadFiles(event.dataTransfer.files));
ui.coverBtn.addEventListener('click', () => setFit('cover'));
ui.containBtn.addEventListener('click', () => setFit('contain'));
ui.rotateLeft.addEventListener('click', () => {
setRotationControl(Number(ui.imageRotation.value) - 90);
scheduleRebuild({
redrawImage: true
});
}
);
ui.rotateRight.addEventListener('click', () => {
setRotationControl(Number(ui.imageRotation.value) + 90);
scheduleRebuild({
redrawImage: true
});
}
);
ui.zoomOut.addEventListener('click', () => {
setImageControl(ui.imageScale, Number(ui.imageScale.value) - 10);
scheduleRebuild({
redrawImage: true
});
}
);
ui.zoomIn.addEventListener('click', () => {
setImageControl(ui.imageScale, Number(ui.imageScale.value) + 10);
scheduleRebuild({
redrawImage: true
});
}
);
ui.resetImage.addEventListener('click', resetImageTransform);
document.querySelectorAll('[data-editor-tool]').forEach( (button) => {
button.addEventListener('click', () => setEditorTool(button.dataset.editorTool));
}
);
document.querySelectorAll('[data-shape-ratio]').forEach( (button) => {
button.addEventListener('click', () => setShapeRatioMode(button.dataset.shapeRatio));
}
);
ui.undoEdit.addEventListener('click', undoEditorChange);
ui.clearEdit.addEventListener('click', clearEditorChanges);
ui.deleteSelected.addEventListener('click', deleteSelectedObject);
ui.brushSize.addEventListener('input', () => {
updateOutput(ui.brushSize, ' px');
ui.floatBrushSize.value = ui.brushSize.value;
ui.floatBrushSizeValue.textContent = ui.brushSize.value;
refreshBrushCursorSize();
refreshEditorPointerPreview();
}
);
ui.textSize.addEventListener('input', () => {
updateOutput(ui.textSize, ' px');
ui.floatTextSize.value = ui.textSize.value;
ui.floatTextSizeValue.textContent = ui.textSize.value;
applyTextControlsToSelected();
refreshEditorPointerPreview();
}
);
ui.textValue.addEventListener('input', () => {
ui.floatTextValue.value = ui.textValue.value;
applyTextControlsToSelected();
refreshEditorPointerPreview();
}
);
ui.textFontChips.querySelectorAll('[data-text-font]').forEach( (button) => {
button.addEventListener('click', () => setTextFont(button.dataset.textFont));
}
);
ui.textStyle.addEventListener('change', () => setTextStyle(ui.textStyle.value));
ui.textArc.addEventListener('input', () => setTextArc(ui.textArc.value));
ui.polygonSides.addEventListener('input', () => {
updateOutput(ui.polygonSides, ' 边');
ui.floatPolygonSides.value = ui.polygonSides.value;
ui.floatPolygonSidesValue.textContent = ui.polygonSides.value;
}
);
ui.splineTension.addEventListener('input', () => {
updateOutput(ui.splineTension, '%');
ui.floatSplineTension.value = ui.splineTension.value;
ui.floatSplineTensionValue.textContent = `${ui.splineTension.value}%`;
}
);
ui.editorColor.addEventListener('input', () => {
ui.floatEditorColor.value = ui.editorColor.value;
refreshBrushCursorSize();
applyTextControlsToSelected();
refreshEditorPointerPreview();
}
);
ui.floatBrushSize.addEventListener('input', () => {
ui.brushSize.value = ui.floatBrushSize.value;
ui.floatBrushSizeValue.textContent = ui.floatBrushSize.value;
updateOutput(ui.brushSize, ' px');
refreshBrushCursorSize();
refreshEditorPointerPreview();
}
);
ui.floatTextValue.addEventListener('input', () => {
ui.textValue.value = ui.floatTextValue.value;
applyTextControlsToSelected();
refreshEditorPointerPreview();
}
);
ui.floatTextFont.addEventListener('change', () => setTextFont(ui.floatTextFont.value));
ui.floatTextStyle.addEventListener('change', () => setTextStyle(ui.floatTextStyle.value));
ui.floatTextSize.addEventListener('input', () => {
ui.textSize.value = ui.floatTextSize.value;
ui.floatTextSizeValue.textContent = ui.floatTextSize.value;
updateOutput(ui.textSize, ' px');
applyTextControlsToSelected();
refreshEditorPointerPreview();
}
);
ui.floatTextArc.addEventListener('input', () => setTextArc(ui.floatTextArc.value));
ui.floatPolygonSides.addEventListener('input', () => {
ui.polygonSides.value = ui.floatPolygonSides.value;
ui.floatPolygonSidesValue.textContent = ui.floatPolygonSides.value;
updateOutput(ui.polygonSides, ' 边');
}
);
ui.floatSplineTension.addEventListener('input', () => {
ui.splineTension.value = ui.floatSplineTension.value;
ui.floatSplineTensionValue.textContent = `${ui.floatSplineTension.value}%`;
updateOutput(ui.splineTension, '%');
}
);
ui.floatShapeRatio.addEventListener('change', () => setShapeRatioMode(ui.floatShapeRatio.value));
ui.floatEditorColor.addEventListener('input', () => {
ui.editorColor.value = ui.floatEditorColor.value;
refreshBrushCursorSize();
applyTextControlsToSelected();
refreshEditorPointerPreview();
}
);
ui.floatRotateLeft.addEventListener('click', () => {
setRotationControl(Number(ui.imageRotation.value) - 90);
scheduleRebuild({
redrawImage: true
});
}
);
ui.floatRotateRight.addEventListener('click', () => {
setRotationControl(Number(ui.imageRotation.value) + 90);
scheduleRebuild({
redrawImage: true
});
}
);
ui.floatImageRotation.addEventListener('input', () => {
setRotationControl(Number(ui.floatImageRotation.value));
scheduleRebuild({
redrawImage: true
});
}
);
ui.floatZoomOut.addEventListener('click', () => {
setImageControl(ui.imageScale, Number(ui.imageScale.value) - 10);
scheduleRebuild({
redrawImage: true
});
}
);
ui.floatZoomIn.addEventListener('click', () => {
setImageControl(ui.imageScale, Number(ui.imageScale.value) + 10);
scheduleRebuild({
redrawImage: true
});
}
);
ui.floatResetImage.addEventListener('click', resetImageTransform);
ui.floatUndoEdit.addEventListener('click', undoEditorChange);
ui.floatDeleteSelected.addEventListener('click', deleteSelectedObject);
ui.floatClearEdit.addEventListener('click', clearEditorChanges);
ui.expandMask.addEventListener('click', () => setMaskExpanded(!state.maskExpanded));
ui.maskBackdrop.addEventListener('click', () => setMaskExpanded(false));
window.addEventListener('resize', fitExpandedMask);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && state.maskExpanded)
setMaskExpanded(false);
if ((event.key === 'Delete' || event.key === 'Backspace') && state.editorTool === 'select' && !/INPUT|TEXTAREA|SELECT/.test(event.target.tagName)) {
event.preventDefault();
deleteSelectedObject();
}
}
);
ui.maskCanvas.addEventListener('pointerdown', (event) => {
event.preventDefault();
updateEditorPointerPreview(event);
if (state.editorTool !== 'move') {
var point = state.editorTool === 'text' ? pointerOnTextEditCanvas(event) : pointerOnActiveEditCanvas(event);
if (!point) {
ui.status.textContent = state.imageMode === 'blade' ? `请在第 ${state.selectedBlade + 1} 片叶片的高亮范围内编辑。` : '请在扇面范围内编辑。';
return;
}
var config = activeConfig();
if (state.editorTool === 'select') {
clearEditorPreview(false);
var object = hitTestVectorObject(config, point);
config.selectedObjectId = object ? object.id : null;
if (object) {
pushEditHistory(config);
state.editorObjectDragging = true;
state.editorPointerId = event.pointerId;
state.editorObjectDragStart = point;
state.editorObjectOriginal = JSON.parse(JSON.stringify(object));
state.editorObjectMoved = false;
if (object.type === 'text')
syncTextControlsFromSelected(object);
ui.maskCanvas.setPointerCapture(event.pointerId);
}
setEditorTool('select');
return;
}
pushEditHistory(config);
if (state.editorTool === 'text') {
if (!placeEditorText(config, point))
config.editHistory.pop();
return;
}
state.editorDrawing = true;
state.editorPointerId = event.pointerId;
state.editorLastPoint = point;
if (VECTOR_SHAPE_TOOLS.includes(state.editorTool)) {
state.editorShapeStart = point;
state.editorShapePoints = [point];
var preview = createShapeObject(config, state.editorTool, point, point, state.editorShapePoints);
renderEditorPreview(config, preview);
} else {
paintEditorSegment(config, point, point, state.editorTool === 'eraser');
}
ui.maskCanvas.setPointerCapture(event.pointerId);
return;
}
state.imageDragging = true;
state.imagePointerX = event.clientX;
state.imagePointerY = event.clientY;
state.imageStartX = Number(ui.offsetX.value);
state.imageStartY = Number(ui.offsetY.value);
ui.maskWrap.classList.add('is-adjusting');
ui.maskCanvas.setPointerCapture(event.pointerId);
}
);
ui.maskCanvas.addEventListener('pointerenter', updateEditorPointerPreview);
ui.maskCanvas.addEventListener('pointermove', (event) => {
updateEditorPointerPreview(event);
if (state.editorObjectDragging && event.pointerId === state.editorPointerId) {
var point = pointerOnActiveEditCanvas(event);
var config = activeConfig();
var object = selectedVectorObject(config);
if (point && object && state.editorObjectOriginal) {
Object.keys(object).forEach( (key) => delete object[key]);
Object.assign(object, JSON.parse(JSON.stringify(state.editorObjectOriginal)));
var dx = point[0] - state.editorObjectDragStart[0];
var dy = point[1] - state.editorObjectDragStart[1];
translateVectorObject(object, dx, dy);
state.editorObjectMoved = Math.hypot(dx, dy) > .5;
refreshCommittedEditor(config, true);
}
return;
}
if (state.editorDrawing && event.pointerId === state.editorPointerId) {
var point = pointerOnActiveEditCanvas(event);
if (point) {
var config = activeConfig();
if (VECTOR_SHAPE_TOOLS.includes(state.editorTool)) {
if (state.editorTool === 'spline') {
var last = state.editorShapePoints[state.editorShapePoints.length - 1];
if (!last || Math.hypot(point[0] - last[0], point[1] - last[1]) > 2)
state.editorShapePoints.push(point);
}
var preview = createShapeObject(config, state.editorTool, state.editorShapeStart, point, state.editorShapePoints);
renderEditorPreview(config, preview);
} else
paintEditorSegment(config, state.editorLastPoint || point, point, state.editorTool === 'eraser');
state.editorLastPoint = point;
} else
state.editorLastPoint = null;
return;
}
if (!state.imageDragging)
return;
var rect = ui.maskCanvas.getBoundingClientRect();
var pointerDX = event.clientX - state.imagePointerX;
var pointerDY = event.clientY - state.imagePointerY;
var dx;
var dy;
if (state.imageMode === 'blade') {
// Convert the screen drag to the selected blade's own radial/across axes.
// Source X runs from root to tip; source Y runs from upper to lower edge.
var bounds = modelBounds();
var modelDX = pointerDY / rect.height * (bounds.xMax - bounds.xMin);
var modelDY = pointerDX / rect.width * (bounds.yMax - bounds.yMin);
var local = rotatePoint(modelDX, modelDY, -FAN.angles[state.selectedBlade]);
var maxHalf = Math.max(...FAN.profile.map( (point) => point[1]));
dx = -local[0] / (FAN.rMax - FAN.rMin) * 100;
dy = -local[1] / (2 * maxHalf) * 100;
} else {
dx = pointerDX / rect.width * 100;
dy = pointerDY / rect.height * 100;
}
setImageControl(ui.offsetX, Math.round(state.imageStartX + dx));
setImageControl(ui.offsetY, Math.round(state.imageStartY + dy));
scheduleRebuild({
redrawImage: true
});
}
);
var stopImageDrag = () => {
var config = activeConfig();
if (state.editorObjectDragging) {
if (!state.editorObjectMoved)
config.editHistory.pop();
state.editorObjectDragging = false;
state.editorPointerId = null;
state.editorObjectDragStart = null;
state.editorObjectOriginal = null;
if (state.editorObjectMoved)
scheduleRebuild();
state.editorObjectMoved = false;
refreshCommittedEditor(config, true);
}
if (state.editorDrawing && VECTOR_SHAPE_TOOLS.includes(state.editorTool)) {
var preview = state.editorPreview?.object;
var start = state.editorShapeStart;
var end = state.editorLastPoint || start;
var valid = preview && (state.editorTool === 'spline' ? (preview.points || []).length > 2 : Math.hypot(end[0] - start[0], end[1] - start[1]) > 3);
state.editorDrawing = false;
state.editorPointerId = null;
state.editorShapeStart = null;
state.editorShapePoints = null;
state.editorLastPoint = null;
if (valid)
commitVectorObject(config, JSON.parse(JSON.stringify(preview)));
else {
config.editHistory.pop();
clearEditorPreview(true);
}
}
state.imageDragging = false;
ui.maskWrap.classList.remove('is-adjusting');
finishEditorStroke();
}
;
ui.maskCanvas.addEventListener('pointerup', stopImageDrag);
ui.maskCanvas.addEventListener('pointercancel', stopImageDrag);
ui.maskCanvas.addEventListener('pointerleave', () => {
if (state.editorTool === 'text')
return;
if (!state.editorDrawing && !state.editorObjectDragging && !state.imageDragging)
hideEditorPointerPreviews();
}
);
ui.maskCanvas.addEventListener('wheel', (event) => {
if (state.editorTool !== 'move')
return;
event.preventDefault();
var factor = Math.exp(-event.deltaY * .0015);
setImageControl(ui.imageScale, Math.round(Number(ui.imageScale.value) * factor));
scheduleRebuild({
redrawImage: true
});
}
, {
passive: false
});
bindRange(ui.imageScale, '%', true);
bindRange(ui.offsetX, '%', true);
bindRange(ui.offsetY, '%', true);
bindRange(ui.imageRotation, '°', true);
bindRange(ui.blur, ' px', true);
bindRange(ui.contrast, '%', false);
bindRange(ui.minThickness, '', false);
bindRange(ui.maxThickness, '', false);
bindRange(ui.reliefGamma, '', false);
ui.reliefMargin.addEventListener('input', () => {
updateOutput(ui.reliefMargin, ' mm');
updatePrecisionGuide();
scheduleRebuild({
redrawImage: true
});
}
);
ui.invert.addEventListener('change', () => scheduleRebuild());
ui.detail.addEventListener('change', () => {
updatePrecisionGuide();
scheduleRebuild();
}
);
ui.threeMFStructure.addEventListener('change', update3MFStructureHint);
ui.includeFrame.addEventListener('change', () => {
update3MFStructureHint();
updateMeshBuffers();
scheduleRebuild();
}
);
ui.resetAll.addEventListener('click', resetAll);
ui.exportBtn.addEventListener('click', writeSTL);
ui.export3mfBtn.addEventListener('click', write3MF);
ui.glCanvas.addEventListener('pointerdown', (event) => {
state.dragging = true;
state.lastX = event.clientX;
state.lastY = event.clientY;
ui.glCanvas.setPointerCapture(event.pointerId);
}
);
ui.glCanvas.addEventListener('pointermove', (event) => {
if (!state.dragging)
return;
state.camera.yaw += (event.clientX - state.lastX) * .007;
state.camera.pitch = clamp(state.camera.pitch + (event.clientY - state.lastY) * .007, -1.45, 1.45);
state.lastX = event.clientX;
state.lastY = event.clientY;
}
);
ui.glCanvas.addEventListener('pointerup', () => {
state.dragging = false;
}
);
ui.glCanvas.addEventListener('wheel', (event) => {
event.preventDefault();
state.camera.zoom = clamp(state.camera.zoom * Math.exp(event.deltaY * .001), 260, 880);
}
, {
passive: false
});
ui.topView.addEventListener('click', () => {
state.camera.pitch = 0;
state.camera.yaw = -Math.PI / 2;
state.camera.zoom = 690;
}
);
ui.isoView.addEventListener('click', () => {
state.camera.pitch = .60;
state.camera.yaw = -Math.PI / 2 - .08;
state.camera.zoom = 620;
}
);
ui.fitView.addEventListener('click', () => {
state.camera.zoom = 620;
}
);
}

175
JavaScript/webgl.js Normal file
View File

@ -0,0 +1,175 @@
 var gl, program, bufferPosition, bufferNormal, bufferColor, vertexCount = 0;
function createShader(glContext, type, source) {
var shader = glContext.createShader(type);
glContext.shaderSource(shader, source);
glContext.compileShader(shader);
if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS))
throw new Error(glContext.getShaderInfoLog(shader));
return shader;
}
function initGL() {
gl = ui.glCanvas.getContext('webgl', {
antialias: true,
alpha: true
});
if (!gl)
throw new Error('当前浏览器不支持 WebGL 预览,但仍可导出 STL');
var vertex = createShader(gl, gl.VERTEX_SHADER, `
attribute vec3 aPosition;
attribute vec3 aNormal;
attribute vec3 aColor;
uniform mat4 uMVP;
uniform mat4 uModel;
varying vec3 vNormal;
varying vec3 vColor;
varying vec3 vPos;
void main() {
vec4 world = uModel * vec4(aPosition, 1.0);
gl_Position = uMVP * vec4(aPosition, 1.0);
vNormal = normalize(mat3(uModel) * aNormal);
vColor = aColor;
vPos = world.xyz;
}
`);
var fragment = createShader(gl, gl.FRAGMENT_SHADER, `
precision mediump float;
varying vec3 vNormal;
varying vec3 vColor;
varying vec3 vPos;
void main() {
vec3 n = normalize(vNormal);
vec3 lightA = normalize(vec3(-0.45, -0.35, 0.82));
vec3 lightB = normalize(vec3(0.75, 0.25, 0.44));
float diffuse = max(dot(n, lightA), 0.0) * 0.72 + max(dot(n, lightB), 0.0) * 0.18;
float rim = pow(1.0 - abs(n.z), 2.0) * 0.18;
vec3 color = vColor * (0.42 + diffuse) + rim * vec3(0.75, 0.92, 0.90);
gl_FragColor = vec4(color, 1.0);
}
`);
program = gl.createProgram();
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
throw new Error(gl.getProgramInfoLog(program));
bufferPosition = gl.createBuffer();
bufferNormal = gl.createBuffer();
bufferColor = gl.createBuffer();
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.CULL_FACE);
gl.cullFace(gl.BACK);
requestAnimationFrame(renderGL);
}
function updateMeshBuffers() {
if (!gl || !state.reliefPositions || !state.framePositions)
return;
var include = ui.includeFrame.checked;
var frameLength = include ? state.framePositions.length : 0;
var totalLength = frameLength + state.reliefPositions.length;
var positions = new Float32Array(totalLength);
var normals = new Float32Array(totalLength);
var colors = new Float32Array(totalLength);
var offset = 0;
if (include) {
positions.set(state.framePositions, 0);
normals.set(state.frameNormals, 0);
for (let i = 0; i < frameLength; i += 3) {
colors[i] = .14;
colors[i + 1] = .29;
colors[i + 2] = .31;
}
offset = frameLength;
}
positions.set(state.reliefPositions, offset);
normals.set(state.reliefNormals, offset);
var minT = Number(ui.minThickness.value);
var maxT = Number(ui.maxThickness.value);
var thicknessRange = Math.max(.01, maxT - minT);
for (let i = offset; i < totalLength; i += 3) {
var thickness = clamp(positions[i + 2] - FAN.baseZ, minT, maxT);
var darkness = (thickness - minT) / thicknessRange;
colors[i] = .91 + (.31 - .91) * darkness;
colors[i + 1] = .97 + (.56 - .97) * darkness;
colors[i + 2] = .91 + (.54 - .91) * darkness;
}
vertexCount = totalLength / 3;
gl.bindBuffer(gl.ARRAY_BUFFER, bufferPosition);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, bufferNormal);
gl.bufferData(gl.ARRAY_BUFFER, normals, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, bufferColor);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
}
function mat4Multiply(a, b) {
var out = new Float32Array(16);
for (let col = 0; col < 4; col++) {
for (let row = 0; row < 4; row++) {
out[col * 4 + row] = a[row] * b[col * 4] + a[4 + row] * b[col * 4 + 1] + a[8 + row] * b[col * 4 + 2] + a[12 + row] * b[col * 4 + 3];
}
}
return out;
}
function perspective(fov, aspect, near, far) {
var f = 1 / Math.tan(fov / 2);
var nf = 1 / (near - far);
return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0]);
}
function translation(x, y, z) {
return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1]);
}
function rotationX(a) {
var c = Math.cos(a)
, s = Math.sin(a);
return new Float32Array([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1]);
}
function rotationZ(a) {
var c = Math.cos(a)
, s = Math.sin(a);
return new Float32Array([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
}
function renderGL() {
requestAnimationFrame(renderGL);
if (!gl || !program || !vertexCount)
return;
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var width = Math.max(1, Math.floor(ui.glCanvas.clientWidth * dpr));
var height = Math.max(1, Math.floor(ui.glCanvas.clientHeight * dpr));
if (ui.glCanvas.width !== width || ui.glCanvas.height !== height) {
ui.glCanvas.width = width;
ui.glCanvas.height = height;
}
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.useProgram(program);
var center = translation(90, 0, -4.5);
var rotation = mat4Multiply(rotationX(state.camera.pitch), rotationZ(state.camera.yaw));
var model = mat4Multiply(rotation, center);
var view = translation(0, 0, -state.camera.zoom);
var proj = perspective(deg(36), width / height, 1, 1600);
var mvp = mat4Multiply(proj, mat4Multiply(view, model));
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uMVP'), false, mvp);
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uModel'), false, model);
var positionLoc = gl.getAttribLocation(program, 'aPosition');
gl.bindBuffer(gl.ARRAY_BUFFER, bufferPosition);
gl.enableVertexAttribArray(positionLoc);
gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, 0, 0);
var normalLoc = gl.getAttribLocation(program, 'aNormal');
gl.bindBuffer(gl.ARRAY_BUFFER, bufferNormal);
gl.enableVertexAttribArray(normalLoc);
gl.vertexAttribPointer(normalLoc, 3, gl.FLOAT, false, 0, 0);
var colorLoc = gl.getAttribLocation(program, 'aColor');
gl.bindBuffer(gl.ARRAY_BUFFER, bufferColor);
gl.enableVertexAttribArray(colorLoc);
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);
}

210
README.md
View File

@ -1,3 +1,209 @@
# Translucent_Relief # DreamLife-Translucent_Relief
透光浮雕扇。 一款纯浏览器端的图片转 3D 打印透光浮雕折扇模型工具。用户上传图片后,可在线编辑扇面、调整透光浮雕参数,最终导出 STL 或 3MF 文件用于 3D 打印。全部计算在本地完成,图片不上传任何服务器。
<div align="center">
<br>
[**中文**](README.md) [English](README_EN.md)
<br>
</div>
## 功能概览
- **图片转透光浮雕**:将照片亮度映射为扇叶厚度,打印后背光可呈现原图
- **三套扇形骨架**:大型甲(圆弧扇头)、大型乙(折线圆头,默认)、大型丙(花瓣尖头)
- **扇叶配图模式**:整扇共用一张图,或 11 片扇叶逐片独立配图
- **2D 扇面编辑器**画笔、橡皮、文字14 款中文字体、弧形排布、9 种矢量图形工具
- **实时 3D 预览**:自研 WebGL 渲染管线,按厚度映射颜色,支持拖动旋转和滚轮缩放
- **多格式导出**STL二进制和 3MF含 ZIP 压缩5 档网格精度3 种 3MF 拆分方式
## 项目结构
```
Translucent_Relief/
├── index.html HTML 主文件(三栏布局 + 外部引用)
├── README.md 本文档
├── CSS/
│ ├── variables.css 设计令牌(颜色、圆角、阴影)
│ ├── base.css 全局重置、body、滚动条、输入元素
│ ├── layout.css 顶栏、三栏网格布局、品牌头部(含 Pacifico @font-face
│ ├── components.css 扇形选择、扇叶面板、拖拽区、文件标签
│ ├── editor.css 2D 画布、画笔光标、浮动工具栏
│ ├── controls.css 滑块、开关、字体选择器、颜色选择器
│ ├── export.css 导出按钮、转轴下载、状态栏
│ ├── stage.css 3D 预览舞台、加载动画
│ └── responsive.css 媒体查询1200px / 900px / 640px
├── JavaScript/
│ ├── config.js 状态对象、常量、扇形定义、Base64 STL 骨架数据
│ ├── geometry.js STL 解析、网格焊接、三角化、点在多边形测试
│ ├── image.js 图片加载、亮度采样、示例图生成
│ ├── canvas_editor.js 2D 画布编辑器全部逻辑(画笔/文字/图形/选择/撤销)
│ ├── export.js STL/3MF 导出、ZIP 压缩、CRC32、网格构建
│ ├── webgl.js WebGL 初始化、着色器、渲染循环
│ ├── ui.js 事件绑定、UI 更新、交互控制
│ └── initialize.js 初始化函数及启动调用
└── pacifico/
└── Pacifico.ttf 品牌标题字体
```
### JS 加载顺序
文件之间存在全局作用域依赖,必须按以下顺序加载:
```
config.js → geometry.js → image.js → canvas_editor.js → export.js → webgl.js → ui.js → initialize.js
```
`config.js` 定义全局的 `state``ui``FAN``DETAIL` 等变量,后续文件引用这些变量。`initialize.js` 末尾调用 `initialize()` 启动应用。
## 三栏布局
| 区域 | 宽度 | 内容 |
|------|------|------|
| 左栏 - 工具与编辑 | 300px | 扇形选择、图片上传、2D 扇面编辑器、构图控制 |
| 中栏 - 3D 预览 | 自适应 | WebGL 实时预览画布、视图控制、模型信息 |
| 右栏 - 参数与导出 | 300px | 透光浮雕参数、打印输出设置、STL/3MF 导出 |
顶栏44px左侧显示 Pacifico 字体的 DreamLife 品牌名,右侧显示"浏览器本地处理"状态标识。
## 扇形骨架
三种扇形各有独立的轮廓几何和 STL 骨架模板(以 Base64 内嵌在 `config.js` 中):
| 名称 | 标识 | 扇头形状 | 包围尺寸mm |
|------|------|----------|---------------|
| 大型甲 | 甲 | 圆弧扇头 | 242 × 410 |
| 大型乙 | 乙 | 折线圆头 | 232 × 387 |
| 大型丙 | 丙 | 花瓣尖头 | 235 × 383 |
默认使用大型乙11 片扇叶,展开角度 140 度。扇叶间距固定 14 度。
## 编辑器工具
### 基础工具
| 工具 | 说明 |
|------|------|
| 移动 | 拖动图片位置,滚轮缩放(默认激活) |
| 选择 | 选中并移动文字或几何图形对象 |
| 画笔 | 自由手绘,笔粗 2-60px |
| 橡皮 | 擦除手绘笔迹和文字 |
| 文字 | 放置文字,支持弧形排布 |
### 矢量图形
直线、圆形、方形、三角形、多边形3-10 边)、星形、菱形、箭头、样条曲线(平滑度 0-100%)。
几何图形支持"正图形(等比例)"和"自由比例"两种模式。
### 文字字体
14 款中文艺术字体:黑体、雅宋、楷书、行楷(默认)、隶书、圆体、舒体、琥珀、新魏、彩云、姚体、中宋、仿宋、细黑。字体来自本机系统,缺失时自动回退。
支持实心填充和空心描边两种字形样式,字号 12-360px弧度 -100% 至 100%。
## 透光浮雕参数
| 参数 | 范围 | 默认值 | 说明 |
|------|------|--------|------|
| 最薄处 | 0.35-1.20mm | 0.60mm | 最薄处透光厚度 |
| 最厚处 | 1.10-3.20mm | 2.20mm | 最厚处暗部厚度 |
| 边缘留量 | 0.00-1.80mm | 0.00mm | 浮雕与骨架边缘的距离 |
| 图像对比 | 60-190% | 135% | 图像对比度调整 |
| 暗部层次 | 0.55-2.20 | 0.95 | Gamma 校正,影响暗部细节 |
| 柔化噪点 | 0-4px | 0.5px | 高斯模糊柔化噪点 |
| 反相 | 开/关 | 关 | 适合底片或线稿 |
## 导出
### 格式
| 格式 | 说明 |
|------|------|
| STL | 二进制格式80 字节文件头50 字节/三角形 |
| 3MF | 标准规范,含 ZIP stored 压缩、CRC32 校验、顶点去重 |
### 网格精度
| 精度 | 采样网格 | 说明 |
|------|----------|------|
| 快速预览 | 72 × 14 | 草稿级 |
| 标准打印 | 144 × 28 | 常规打印 |
| 高精度打印 | 240 × 48 | 高质量 |
| 极致精度 | 320 × 64 | 推荐 |
| 参考作品级 | 512 × 96 | 约 219 万面 |
### 3MF 拆分方式
- **整扇单一对象**:焊接为 1 个完整网格,适合直接切片(默认)
- **总成内可拆零件**1 个总成对象包含 N 个组件引用
- **独立对象**:每个网格作为独立的构建对象
转轴不包含在导出文件中,需单独从 MakerWorld 下载配套零件。
## 设计系统
### 配色
浅色系,以微软 Fluent 设计为参考:
| 用途 | 颜色 |
|------|------|
| 页面背景 | #f3f4f6 |
| 面板背景 | #ffffff |
| 主文字 | #1a1d23 |
| 次文字 | #5a5f6a |
| 强调蓝 | #0078d4 |
| 警告红 | #d13438 |
| 成功绿 | #107c10 |
### 字体
| 用途 | 字体 |
|------|------|
| 正文 | Segoe UI, Microsoft YaHei UI, system-ui |
| 品牌标题 | Pacifico |
| 等宽(数据/标签) | Cascadia Code, Consolas |
| 印章/扇形名 | STKaiti, KaiTi |
## 3D 渲染
使用 WebGL 1.0,无外部 3D 库依赖。自研顶点和片段着色器:
- 两方向光照(主光 72% + 补光 18%+ 边缘光
- 环境光基线 0.42
- 骨架网格渲染为深青色(#234A4F
- 浮雕网格按厚度映射颜色:薄处浅绿、厚处深绿
- 透视投影 36 度 FOV近裁 1 / 远裁 1600
- 深度测试 + 背面剔除
- 支持高 DPI 屏幕DPR 上限 2
## 图片隐私
所有图片处理在浏览器本地完成,图片数据不离开设备。页面可完全离线运行(首次加载后无需网络)。
## 技术栈
- HTML5 + CSS3Grid 布局、CSS 变量、backdrop-filter
- 原生 JavaScript无框架、无构建工具、无外部依赖
- WebGL 1.0(自写着色器)
- Canvas 2D API扇面编辑器
- File API / Blob API文件读取和导出
- 响应式设计1200px / 900px / 640px 断点)
## 使用方式
直接在浏览器中打开 `index.html` 即可使用。推荐使用 Chrome 或 Edge 浏览器以获得最佳 WebGL 性能。
操作流程:
1. 选择扇形(甲/乙/丙)
2. 选择图片模式(整扇一张图 / 单片独立图)
3. 上传图片或使用内置示例
4. 调整图片构图(缩放、移动、旋转)
5. 使用编辑器添加画笔、文字或图形(可选)
6. 调整透光浮雕参数(厚度、对比度、层次等)
7. 在 3D 预览中确认效果
8. 选择精度并导出 STL 或 3MF

209
README_EN.md Normal file
View File

@ -0,0 +1,209 @@
# DreamLife-Translucent_Relief
A fully browser-based image-to-3D-printing lithophane folding fan model tool. Users upload an image, edit the fan surface, adjust lithophane relief parameters, and export STL or 3MF files for 3D printing. All computation runs locally — no image data is ever uploaded to a server.
<div align="center">
<br>
[中文](README.md) [**English**](README_EN.md)
<br>
</div>
## Feature Overview
- **Image to Lithophane**: Maps photo luminance to blade thickness; the printed fan reveals the original image when backlit
- **Three Fan Skeletons**: Type A (rounded arc head), Type B (folded-line round head, default), Type C (petal pointed head)
- **Blade Image Modes**: One shared image for the whole fan, or 11 individual images per blade
- **2D Fan Surface Editor**: Brush, eraser, text (14 Chinese art fonts, curved layout), 9 vector shape tools
- **Real-time 3D Preview**: Custom WebGL rendering pipeline with thickness-based color mapping, drag-to-rotate and scroll-to-zoom
- **Multi-format Export**: STL (binary) and 3MF (with ZIP compression), 5 mesh precision levels, 3 assembly modes
## Project Structure
```
Translucent_Relief/
├── index.html Main HTML file (three-panel layout + external references)
├── README_EN.md This document
├── CSS/
│ ├── variables.css Design tokens (colors, radius, shadows)
│ ├── base.css Global reset, body, scrollbars, input elements
│ ├── layout.css Top bar, three-column grid, brand header (includes Pacifico @font-face)
│ ├── components.css Fan variant selector, blade panel, dropzone, file chip
│ ├── editor.css 2D canvas, brush cursor, floating toolbar
│ ├── controls.css Sliders, toggles, font picker, color picker
│ ├── export.css Export buttons, pivot download, status bar
│ ├── stage.css 3D preview stage, loading animation
│ └── responsive.css Media queries (1200px / 900px / 640px)
├── JavaScript/
│ ├── config.js State object, constants, fan definitions, Base64 STL skeleton data
│ ├── geometry.js STL parsing, mesh welding, triangulation, point-in-polygon test
│ ├── image.js Image loading, luminance sampling, demo image generation
│ ├── canvas_editor.js Full 2D canvas editor logic (brush/text/shapes/select/undo)
│ ├── export.js STL/3MF export, ZIP compression, CRC32, mesh building
│ ├── webgl.js WebGL initialization, shaders, render loop
│ ├── ui.js Event binding, UI updates, interaction controls
│ └── initialize.js Initialization function and startup call
└── pacifico/
└── Pacifico.ttf Brand title font
```
### JS Load Order
Files share a global scope dependency chain and must be loaded in the following order:
```
config.js → geometry.js → image.js → canvas_editor.js → export.js → webgl.js → ui.js → initialize.js
```
`config.js` defines global variables (`state`, `ui`, `FAN`, `DETAIL`) referenced by subsequent files. `initialize.js` calls `initialize()` at the end to start the application.
## Three-Panel Layout
| Region | Width | Content |
|--------|-------|---------|
| Left panel — Tools & Editor | 300px | Fan selection, image upload, 2D fan surface editor, composition controls |
| Center panel — 3D Preview | Adaptive | WebGL real-time preview canvas, view controls, model info |
| Right panel — Parameters & Export | 300px | Lithophane relief parameters, print output settings, STL/3MF export |
The top bar (44px) displays the DreamLife brand wordmark in Pacifico font on the left, and a "browser-local processing" status indicator on the right.
## Fan Skeletons
Three fan types, each with independent outline geometry and STL skeleton templates (embedded as Base64 in `config.js`):
| Name | Seal | Head Shape | Bounding Size (mm) |
|------|------|------------|-------------------|
| Type A (jia) | 甲 | Rounded arc head | 242 × 410 |
| Type B (yi) | 乙 | Folded-line round head | 232 × 387 |
| Type C (bing) | 丙 | Petal pointed head | 235 × 383 |
Default: Type B, 11 blades, 140-degree spread. Fixed 14-degree spacing between blades.
## Editor Tools
### Basic Tools
| Tool | Description |
|------|-------------|
| Move | Drag image position, scroll to zoom (default active) |
| Select | Select and move text or vector shape objects |
| Brush | Freehand painting, brush size 2-60px |
| Eraser | Erase brush strokes and text |
| Text | Place text on fan surface with curved layout support |
### Vector Shapes
Line, ellipse, rectangle, triangle, polygon (3-10 sides), star, diamond, arrow, spline curve (tension 0-100%).
Shape tools support both "regular (equal ratio)" and "free ratio" modes.
### Text Fonts
14 Chinese art fonts: Modern Hei, Elegant Song, Regular Kai, Running Kai (default), Clerical Li, Round, Shuti, Amber Hupo, Xinwei Stele, Caiyun Decorative, Yaoti Vintage, Zhongsong Classic, Fangsong Refined, Thin Hei. Fonts use the local system; missing fonts fall back automatically.
Supports solid fill and hollow outline styles. Font size 12-360px, arc curvature -100% to 100%.
## Lithophane Relief Parameters
| Parameter | Range | Default | Description |
|-----------|-------|---------|-------------|
| Min Thickness | 0.35-1.20mm | 0.60mm | Thinnest translucent layer |
| Max Thickness | 1.10-3.20mm | 2.20mm | Thickest opaque layer |
| Edge Margin | 0.00-1.80mm | 0.00mm | Distance between relief and skeleton edge |
| Contrast | 60-190% | 135% | Image contrast adjustment |
| Dark Gamma | 0.55-2.20 | 0.95 | Gamma correction for shadow detail |
| Blur | 0-4px | 0.5px | Gaussian blur for noise softening |
| Invert | On/Off | Off | For negatives or line art |
## Export
### Formats
| Format | Description |
|--------|-------------|
| STL | Binary format, 80-byte header, 50 bytes per triangle |
| 3MF | Standard spec with ZIP stored compression, CRC32 checksums, vertex deduplication |
### Mesh Precision
| Precision | Sample Grid | Description |
|-----------|-------------|-------------|
| Draft | 72 × 14 | Quick preview |
| Standard | 144 × 28 | Regular printing |
| Fine | 240 × 48 | High quality |
| Ultra | 320 × 64 | Recommended |
| Reference | 512 × 96 | ~2.19M triangles |
### 3MF Assembly Modes
- **Merged single object**: Welded into one complete mesh, ready for slicing (default)
- **Assembly with parts**: One assembly object containing N component references
- **Independent objects**: Each mesh as a separate build object
The pivot pin is not included in export files. It must be downloaded separately from MakerWorld.
## Design System
### Color Palette
Light scheme inspired by Microsoft Fluent design:
| Usage | Color |
|-------|-------|
| Page background | #f3f4f6 |
| Panel background | #ffffff |
| Primary text | #1a1d23 |
| Secondary text | #5a5f6a |
| Accent blue | #0078d4 |
| Warning red | #d13438 |
| Success green | #107c10 |
### Typography
| Usage | Font |
|-------|------|
| Body text | Segoe UI, Microsoft YaHei UI, system-ui |
| Brand title | Pacifico |
| Monospace (data/labels) | Cascadia Code, Consolas |
| Seal / fan names | STKaiti, KaiTi |
## 3D Rendering
Uses WebGL 1.0 with no external 3D library. Custom vertex and fragment shaders:
- Two-directional lighting (key light 72% + fill light 18%) + rim lighting
- Ambient light baseline 0.42
- Skeleton mesh rendered in dark teal (#234A4F)
- Relief mesh colored by thickness: light green at thin areas, dark green at thick areas
- Perspective projection 36-degree FOV, near plane 1 / far plane 1600
- Depth testing + back-face culling
- High-DPI support (DPR capped at 2)
## Image Privacy
All image processing happens locally in the browser. Image data never leaves the device. The page runs fully offline after initial load (no network connection required).
## Technology Stack
- HTML5 + CSS3 (Grid layout, CSS variables, backdrop-filter)
- Vanilla JavaScript (no frameworks, no build tools, no external dependencies)
- WebGL 1.0 (custom shaders)
- Canvas 2D API (fan surface editor)
- File API / Blob API (file reading and export)
- Responsive design (1200px / 900px / 640px breakpoints)
## Usage
Open `index.html` directly in a browser. Chrome or Edge is recommended for the best WebGL performance.
Workflow:
1. Select a fan type (A / B / C)
2. Choose image mode (whole-fan single image / per-blade individual images)
3. Upload an image or use the built-in demo
4. Adjust image composition (scale, position, rotation)
5. Use the editor to add brush strokes, text, or shapes (optional)
6. Adjust lithophane relief parameters (thickness, contrast, gamma, etc.)
7. Verify the result in the 3D preview
8. Select precision and export STL or 3MF

549
index.html Normal file
View File

@ -0,0 +1,549 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="description" content="DreamLife-Translucent_Relief 是一款在线图片转3D打印折扇模型工具支持整扇或单片扇叶配图、缩放旋转、画笔文字编辑、透光厚度与精度调整可导出STL和3MF模型适合制作照片浮雕扇、透光摆件和个性化礼物。">
<meta name="keywords" content="DreamLife-Translucent_Relief,浮雕扇生成器,照片转浮雕,图片转STL,图片转3MF,3D打印折扇,透光浮雕,照片浮雕,3D打印工具,在线建模工具,STL生成器,3MF生成器,扇子模型,个性化3D打印">
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1">
<meta name="generator" content="DreamLife-Translucent_Relief">
<meta name="application-name" content="DreamLife-Translucent_Relief">
<meta name="copyright" content="© 2026 DreamLife-Translucent_Relief">
<meta name="theme-color" content="#f3f4f6">
<meta property="og:locale" content="zh_CN">
<meta property="og:type" content="website">
<meta property="og:site_name" content="DreamLife">
<meta property="og:title" content="DreamLife-Translucent_Relief - 图片转3D打印STL/3MF折扇模型">
<meta property="og:description" content="上传图片在线生成11片透光浮雕折扇支持逐片独立配图、画笔文字编辑、厚度与精度调整并可导出STL和3MF模型用于3D打印。">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="DreamLife-Translucent_Relief - 图片转3D打印折扇模型">
<meta name="twitter:description" content="上传图片生成可3D打印的透光浮雕折扇在线编辑并导出STL或3MF。">
<title>DreamLife-Translucent_Relief - 图片转3D打印STL/3MF折扇模型</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "DreamLife-Translucent_Relief",
"applicationCategory": "DesignApplication",
"operatingSystem": "Web Browser",
"inLanguage": "zh-CN",
"description": "在线把照片或图片转换为可3D打印的透光浮雕折扇模型支持扇叶独立配图、画笔文字编辑以及STL和3MF导出。",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "CNY"
},
"featureList": [
"图片转透光浮雕折扇",
"11片扇叶与逐片独立配图",
"画笔、橡皮和文字编辑",
"透光厚度与模型精度调整",
"STL与3MF模型导出"
],
"publisher": {
"@type": "Organization",
"name": "DreamLife",
"url": "https://dreamlife.dev/"
}
}
</script>
<link rel="stylesheet" href="CSS/variables.css">
<link rel="stylesheet" href="CSS/base.css">
<link rel="stylesheet" href="CSS/layout.css">
<link rel="stylesheet" href="CSS/components.css">
<link rel="stylesheet" href="CSS/editor.css">
<link rel="stylesheet" href="CSS/controls.css">
<link rel="stylesheet" href="CSS/export.css">
<link rel="stylesheet" href="CSS/stage.css">
<link rel="stylesheet" href="CSS/responsive.css">
</head>
<body>
<div class="site-frame">
<header class="site-topbar">
<a class="site-brand-home" href="#" aria-label="DreamLife">
<span class="site-brand-text">DreamLife</span>
</a>
<div class="site-top-actions">
<span class="site-status-pill">
<span class="site-status-dot" aria-hidden="true"></span>
浏览器本地处理
</span>
</div>
</header>
<main class="shell">
<!-- LEFT PANEL: Tools & 2D Editor -->
<aside class="panel-left" aria-label="工具与编辑">
<header class="brand">
<div>
<p class="eyebrow">Image to Lithophane</p>
<p class="brand-title">Translucent Relief</p>
<p class="subtitle">选择甲、乙、丙三种扇形整扇共用一张图或为11片扇叶逐片独立配图。</p>
</div>
<div class="seal" id="variantSeal" aria-hidden="true"></div>
</header>
<section class="section">
<div class="section-head">
<h2>扇形</h2>
<span class="section-note">三套骨架</span>
</div>
<div class="fan-variants" id="fanVariants" role="group" aria-label="选择扇形">
<button class="fan-variant" type="button" data-variant="jia">
<strong>大型甲</strong>
<small>圆弧扇头</small>
</button>
<button class="fan-variant active" type="button" data-variant="yi">
<strong>大型乙</strong>
<small>折线圆头</small>
</button>
<button class="fan-variant" type="button" data-variant="bing">
<strong>大型丙</strong>
<small>花瓣尖头</small>
</button>
</div>
<div class="blade-count-control" hidden aria-hidden="true" data-release-state="reserved-for-next-update">
<div class="blade-count-head">
<strong>扇叶数量</strong>
<span id="bladeCountSummary">11片 · 展开140°</span>
</div>
<div class="blade-count-options" id="bladeCountOptions" role="radiogroup" aria-label="选择扇叶数量">
<button class="blade-count-option" type="button" data-blade-count="5" role="radio" aria-checked="false"><strong>5片</strong><small>56° · 迷你</small></button>
<button class="blade-count-option" type="button" data-blade-count="6" role="radio" aria-checked="false"><strong>6片</strong><small>70° · 紧凑</small></button>
<button class="blade-count-option" type="button" data-blade-count="7" role="radio" aria-checked="false"><strong>7片</strong><small>84° · 省料</small></button>
<button class="blade-count-option" type="button" data-blade-count="8" role="radio" aria-checked="false"><strong>8片</strong><small>98° · 轻巧</small></button>
<button class="blade-count-option" type="button" data-blade-count="9" role="radio" aria-checked="false"><strong>9片</strong><small>112° · 推荐</small></button>
<button class="blade-count-option" type="button" data-blade-count="10" role="radio" aria-checked="false"><strong>10片</strong><small>126° · 均衡</small></button>
<button class="blade-count-option active" type="button" data-blade-count="11" role="radio" aria-checked="true"><strong>11片</strong><small>140° · 完整</small></button>
<button class="blade-count-option" type="button" data-blade-count="12" role="radio" aria-checked="false"><strong>12片</strong><small>154° · 宽展</small></button>
<button class="blade-count-option" type="button" data-blade-count="13" role="radio" aria-checked="false"><strong>13片</strong><small>168° · 最大</small></button>
</div>
<p class="blade-count-help">固定14°叶片间距双数档会相对中轴错开7°左右仍保持对称最大168°避免超过半圆。</p>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>图片</h2>
<span class="section-note">JPG / PNG / WEBP</span>
</div>
<div class="segmented image-mode" role="group" aria-label="图片分配模式">
<button id="wholeModeBtn" class="active" type="button">整扇一张图</button>
<button id="bladeModeBtn" type="button">单片独立图</button>
</div>
<div class="blade-panel" id="bladePanel" hidden>
<div class="blade-panel-head">
<strong>选择要编辑的叶片</strong>
<span id="bladeOrderHint">1 右侧 · 11 左侧</span>
</div>
<div class="blade-grid" id="bladeGrid" aria-label="叶片选择"></div>
<p class="blade-help">可一次选择多张图片,将从当前叶片开始按顺序填入。</p>
</div>
<label class="dropzone" id="dropzone">
<input id="fileInput" type="file" accept="image/*">
<span>
<span class="drop-icon"></span>
<span class="drop-title" id="dropTitle">选择或拖入图片</span>
<span class="drop-hint" id="dropHint">页面全程离线,图片不会上传</span>
</span>
</label>
<div class="file-chip" id="fileChip"></div>
<div class="mask-backdrop" id="maskBackdrop" hidden></div>
<div class="mask-wrap" id="maskWrap">
<canvas id="maskCanvas" width="780" height="464" aria-label="横向扇面裁切预览"></canvas>
<span class="brush-cursor" id="brushCursor" aria-hidden="true" hidden></span>
<span class="text-placement-preview" id="textPlacementPreview" aria-hidden="true" hidden>
<span class="text-preview-glyph" id="textPreviewGlyph">鸡你太美</span>
<span class="text-preview-anchor"></span>
</span>
<button class="mask-expand" id="expandMask" type="button" aria-expanded="false" aria-controls="maskCanvas" title="放大扇面编辑区">⛶ 放大编辑</button>
<span class="adjust-hint" id="adjustHint">拖动图片 · 滚轮缩放</span>
<span class="demo-label" id="demoLabel">内置示例</span>
</div>
<div class="mask-floating-toolbar" id="maskFloatingToolbar" role="toolbar" aria-label="放大扇面编辑工具" hidden>
<div class="float-tool-group" role="group" aria-label="扇面工具">
<button class="float-tool-button active" type="button" data-editor-tool="move" title="拖动图片"><span class="float-tool-icon"></span>移图</button>
<button class="float-tool-button" type="button" data-editor-tool="select" title="选择并移动文字或几何"><span class="float-tool-icon"></span>选择</button>
<button class="float-tool-button" type="button" data-editor-tool="brush" title="在扇面上绘画"><span class="float-tool-icon"></span>画笔</button>
<button class="float-tool-button" type="button" data-editor-tool="eraser" title="擦除手绘和文字"><span class="float-tool-icon"></span>橡皮</button>
<button class="float-tool-button" type="button" data-editor-tool="text" title="在扇面放置文字"><span class="float-tool-icon"></span>文字</button>
<button class="float-tool-button" type="button" data-editor-tool="line" title="直线"> 直线</button>
<button class="float-tool-button" type="button" data-editor-tool="ellipse" title="圆形或椭圆">○ 圆形</button>
<button class="float-tool-button" type="button" data-editor-tool="rect" title="方形或矩形">□ 方形</button>
<button class="float-tool-button" type="button" data-editor-tool="triangle" title="三角形">△ 三角</button>
<button class="float-tool-button" type="button" data-editor-tool="polygon" title="规则多边形">⬡ 多边形</button>
<button class="float-tool-button" type="button" data-editor-tool="star" title="星形">☆ 星形</button>
<button class="float-tool-button" type="button" data-editor-tool="diamond" title="菱形">◇ 菱形</button>
<button class="float-tool-button" type="button" data-editor-tool="arrow" title="箭头">➜ 箭头</button>
<button class="float-tool-button" type="button" data-editor-tool="spline" title="自由样条曲线">〰 样条</button>
</div>
<span class="float-tool-divider" aria-hidden="true"></span>
<div class="float-tool-group" role="group" aria-label="图片缩放和旋转">
<button class="float-tool-button" id="floatZoomOut" type="button" title="缩小图片"> 缩小</button>
<button class="float-tool-button" id="floatZoomIn" type="button" title="放大图片"> 放大</button>
<button class="float-tool-button" id="floatRotateLeft" type="button" title="图片左转 90 度">↶ 左转</button>
<button class="float-tool-button" id="floatRotateRight" type="button" title="图片右转 90 度">↷ 右转</button>
<button class="float-tool-button" id="floatResetImage" type="button" title="图片恢复居中">复位</button>
<span class="float-transform-readout" id="floatTransformValue">100% · 0°</span>
<label class="float-control" title="任意角度旋转图片">
<span>角度</span>
<input id="floatImageRotation" type="range" min="-180" max="180" value="0" step="1">
<output id="floatImageRotationValue" for="floatImageRotation"></output>
</label>
</div>
<span class="float-tool-divider" aria-hidden="true"></span>
<div class="float-tool-context" id="floatToolContext" hidden>
<label class="float-control" id="floatBrushControl" hidden>
<span>笔粗</span>
<input id="floatBrushSize" type="range" min="2" max="60" value="16" step="1">
<output id="floatBrushSizeValue" for="floatBrushSize">16</output>
</label>
<label class="float-control" id="floatTextControl" hidden>
<span>文字</span>
<input class="float-text-input" id="floatTextValue" type="text" value="鸡你太美" maxlength="24">
</label>
<label class="float-control" id="floatTextSizeControl" hidden>
<span>字号</span>
<input id="floatTextSize" type="range" min="12" max="360" value="48" step="1">
<output id="floatTextSizeValue" for="floatTextSize">48</output>
</label>
<label class="float-control" id="floatTextArcControl" hidden>
<span>弧度</span>
<input id="floatTextArc" type="range" min="-100" max="100" value="35" step="1">
<output id="floatTextArcValue" for="floatTextArc">35%</output>
</label>
<label class="float-control" id="floatTextFontControl" hidden>
<span>字体</span>
<select class="float-select" id="floatTextFont" aria-label="艺术字体">
<option value="modern">现代黑体</option>
<option value="song">雅宋</option>
<option value="kai">楷书</option>
<option value="xingkai" selected>行楷</option>
<option value="lishu">隶书</option>
<option value="youyuan">圆体</option>
<option value="shuti">舒体</option>
<option value="hupo">琥珀</option>
<option value="xinwei">新魏碑意</option>
<option value="caiyun">彩云装饰</option>
<option value="yaoti">姚体古风</option>
<option value="zhongsong">中宋雅正</option>
<option value="fangsong">仿宋清雅</option>
<option value="xihei">细黑简雅</option>
</select>
</label>
<label class="float-control" id="floatTextStyleControl" hidden>
<span>字形</span>
<select class="float-select" id="floatTextStyle" aria-label="文字填充方式">
<option value="fill" selected>实心</option>
<option value="outline">空心描边</option>
</select>
</label>
<label class="float-control" id="floatPolygonSidesControl" hidden>
<span>边数</span>
<input id="floatPolygonSides" type="range" min="3" max="10" value="6" step="1">
<output id="floatPolygonSidesValue" for="floatPolygonSides">6</output>
</label>
<label class="float-control" id="floatSplineTensionControl" hidden>
<span>平滑</span>
<input id="floatSplineTension" type="range" min="0" max="100" value="55" step="1">
<output id="floatSplineTensionValue" for="floatSplineTension">55%</output>
</label>
<label class="float-control" id="floatShapeRatioControl" hidden>
<span>比例</span>
<select class="float-select" id="floatShapeRatio" aria-label="几何图形比例模式">
<option value="regular" selected>正图形 · 等比例</option>
<option value="free">自由比例</option>
</select>
</label>
<label class="float-control" id="floatColorControl" hidden title="画笔和文字颜色">
<span>颜色</span>
<input class="float-color-input" id="floatEditorColor" type="color" value="#172c31">
</label>
</div>
<div class="float-tool-group" role="group" aria-label="编辑记录">
<button class="float-tool-button" id="floatUndoEdit" type="button">撤销</button>
<button class="float-tool-button" id="floatDeleteSelected" type="button" disabled>删选中</button>
<button class="float-tool-button" id="floatClearEdit" type="button">清除</button>
</div>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>构图与编辑</h2>
</div>
<div class="segmented" role="group" aria-label="图片适配方式">
<button id="coverBtn" class="active" type="button">铺满扇面</button>
<button id="containBtn" type="button">完整图片</button>
</div>
<div class="image-quick-actions" aria-label="图片快捷调整">
<button id="zoomOut" type="button"> 缩小</button>
<button id="resetImage" type="button">居中复位</button>
<button id="zoomIn" type="button"> 放大</button>
</div>
<div class="control">
<label for="imageScale">缩放</label>
<input id="imageScale" type="range" min="70" max="220" value="100" step="1">
<output class="value" for="imageScale">100%</output>
</div>
<div class="control">
<label for="offsetX">水平</label>
<input id="offsetX" type="range" min="-50" max="50" value="0" step="1">
<output class="value" for="offsetX">0%</output>
</div>
<div class="control">
<label for="offsetY">垂直</label>
<input id="offsetY" type="range" min="-50" max="50" value="0" step="1">
<output class="value" for="offsetY">0%</output>
</div>
<div class="control">
<label for="imageRotation">旋转</label>
<input id="imageRotation" type="range" min="-180" max="180" value="0" step="1">
<output class="value" for="imageRotation"></output>
</div>
<div class="row-two">
<button class="mini-button" id="rotateLeft" type="button">↶ 左转 90°</button>
<button class="mini-button" id="rotateRight" type="button">↷ 右转 90°</button>
</div>
<div class="editor-box">
<div class="editor-tools" role="group" aria-label="扇面编辑工具">
<button class="editor-tool active" type="button" data-editor-tool="move"><span class="editor-tool-symbol"></span>移动</button>
<button class="editor-tool" type="button" data-editor-tool="select"><span class="editor-tool-symbol"></span>选择</button>
<button class="editor-tool" type="button" data-editor-tool="brush"><span class="editor-tool-symbol"></span>画笔</button>
<button class="editor-tool" type="button" data-editor-tool="eraser"><span class="editor-tool-symbol"></span>橡皮</button>
<button class="editor-tool" type="button" data-editor-tool="text"><span class="editor-tool-symbol"></span>文字</button>
</div>
<div class="editor-tool-divider-label">几何与曲线</div>
<div class="editor-tools editor-shape-tools" role="group" aria-label="几何与曲线工具">
<button class="editor-tool" type="button" data-editor-tool="line"><span class="editor-tool-symbol"></span>直线</button>
<button class="editor-tool" type="button" data-editor-tool="ellipse"><span class="editor-tool-symbol"></span>圆形</button>
<button class="editor-tool" type="button" data-editor-tool="rect"><span class="editor-tool-symbol"></span>方形</button>
<button class="editor-tool" type="button" data-editor-tool="triangle"><span class="editor-tool-symbol"></span>三角</button>
<button class="editor-tool" type="button" data-editor-tool="polygon"><span class="editor-tool-symbol"></span>多边形</button>
<button class="editor-tool" type="button" data-editor-tool="star"><span class="editor-tool-symbol"></span>星形</button>
<button class="editor-tool" type="button" data-editor-tool="diamond"><span class="editor-tool-symbol"></span>菱形</button>
<button class="editor-tool" type="button" data-editor-tool="arrow"><span class="editor-tool-symbol"></span>箭头</button>
<button class="editor-tool" type="button" data-editor-tool="spline"><span class="editor-tool-symbol"></span>样条</button>
</div>
<div class="editor-context" id="brushEditorOptions" hidden>
<div class="control">
<label for="brushSize">笔/线粗细</label>
<input id="brushSize" type="range" min="2" max="60" value="16" step="1">
<output class="value" for="brushSize">16 px</output>
</div>
</div>
<div class="editor-context" id="shapeEditorOptions" hidden>
<div class="shape-ratio-control" id="shapeRatioControl" hidden>
<div class="shape-ratio-head">
<span>图形比例</span>
<strong id="shapeRatioHint">正图形 · 等比例</strong>
</div>
<div class="shape-ratio-toggle" role="group" aria-label="几何图形比例模式">
<button class="active" type="button" data-shape-ratio="regular" aria-pressed="true">正图形 · 等比例</button>
<button type="button" data-shape-ratio="free" aria-pressed="false">自由比例</button>
</div>
</div>
<div class="control" id="polygonSidesControl" hidden>
<label for="polygonSides">边数</label>
<input id="polygonSides" type="range" min="3" max="10" value="6" step="1">
<output class="value" for="polygonSides">6 边</output>
</div>
<div class="control" id="splineTensionControl" hidden>
<label for="splineTension">平滑度</label>
<input id="splineTension" type="range" min="0" max="100" value="55" step="1">
<output class="value" for="splineTension">55%</output>
</div>
</div>
<div class="editor-context" id="textEditorOptions" hidden>
<div class="text-input-row">
<input id="textValue" type="text" value="鸡你太美" maxlength="24" aria-label="要添加的文字" placeholder="输入文字后移入扇面">
<select id="textStyle" aria-label="文字填充方式">
<option value="fill" selected>实心</option>
<option value="outline">空心描边</option>
</select>
</div>
<div class="font-chip-label">
<span>14 款艺术字体</span>
<small>本机字体 · 缺失时自动回退</small>
</div>
<div class="font-chip-grid" id="textFontChips" role="group" aria-label="选择艺术字体">
<button class="font-chip" type="button" data-text-font="modern" data-font-kind="醒目" style="font-family:'Microsoft YaHei UI','Microsoft YaHei',sans-serif">黑体</button>
<button class="font-chip" type="button" data-text-font="song" data-font-kind="典雅" style="font-family:'STSong','SimSun',serif">雅宋</button>
<button class="font-chip" type="button" data-text-font="kai" data-font-kind="端庄" style="font-family:'STKaiti','KaiTi',serif">楷书</button>
<button class="font-chip active" type="button" data-text-font="xingkai" data-font-kind="书法" style="font-family:'STXingkai','KaiTi',serif">行楷</button>
<button class="font-chip" type="button" data-text-font="lishu" data-font-kind="古朴" style="font-family:'LiSu','STLiti',serif">隶书</button>
<button class="font-chip" type="button" data-text-font="youyuan" data-font-kind="柔和" style="font-family:'YouYuan','Microsoft YaHei',sans-serif">圆体</button>
<button class="font-chip" type="button" data-text-font="shuti" data-font-kind="灵动" style="font-family:'FZShuTi','STKaiti','KaiTi',serif">舒体</button>
<button class="font-chip" type="button" data-text-font="hupo" data-font-kind="厚重" style="font-family:'STHupo','SimHei',sans-serif">琥珀</button>
<button class="font-chip" type="button" data-text-font="xinwei" data-font-kind="碑刻" style="font-family:'STXinwei','华文新魏','STLiti',serif">新魏</button>
<button class="font-chip" type="button" data-text-font="caiyun" data-font-kind="装饰" style="font-family:'STCaiyun','华文彩云','SimHei',sans-serif">彩云</button>
<button class="font-chip" type="button" data-text-font="yaoti" data-font-kind="古风" style="font-family:'FZYaoTi','方正姚体','STKaiti',serif">姚体</button>
<button class="font-chip" type="button" data-text-font="zhongsong" data-font-kind="雅正" style="font-family:'STZhongsong','华文中宋','SimSun',serif">中宋</button>
<button class="font-chip" type="button" data-text-font="fangsong" data-font-kind="清雅" style="font-family:'FangSong','仿宋','SimSun',serif">仿宋</button>
<button class="font-chip" type="button" data-text-font="xihei" data-font-kind="简雅" style="font-family:'STXihei','华文细黑','Microsoft YaHei',sans-serif">细黑</button>
</div>
<div class="control" id="textSizeControl">
<label for="textSize">字号</label>
<input id="textSize" type="range" min="12" max="360" value="48" step="1">
<output class="value" for="textSize">48 px</output>
</div>
<div class="control" id="textArcControl">
<label for="textArc">弧度</label>
<input id="textArc" type="range" min="-100" max="100" value="35" step="1">
<output class="value" for="textArc">35%</output>
</div>
</div>
<div class="editor-color-row" id="editorColorControl" hidden>
<span>画笔 / 文字颜色</span>
<input id="editorColor" type="color" value="#172c31" aria-label="画笔和文字颜色">
</div>
<div class="editor-actions">
<button class="mini-button" id="undoEdit" type="button">撤销上一步</button>
<button class="mini-button" id="clearEdit" type="button">清除全部编辑</button>
</div>
<div class="selection-strip" id="selectionInfo" hidden>
<span id="selectionLabel">已选中对象,可直接拖动位置</span>
<button id="deleteSelected" type="button">删除选中</button>
</div>
<p class="editor-hint" id="editorHint">移动模式:直接拖动图片,滚轮缩放;上方滑杆或按钮可旋转。</p>
</div>
</section>
</aside>
<!-- CENTER: 3D Preview -->
<section class="stage" aria-label="三维预览">
<canvas id="glCanvas"></canvas>
<div class="stage-top">
<div class="model-tag" id="modelTag">实时预览 · 大型乙 11 骨</div>
<div class="metric">
<span>包围尺寸</span>
<strong id="metricSize">232 × 387</strong>
<span>mm</span>
</div>
</div>
<div class="stage-bottom">
<div class="view-help">拖动旋转 · 滚轮缩放</div>
<div class="view-actions">
<button id="topView" type="button" title="顶视图"></button>
<button id="isoView" type="button" title="立体视图"></button>
<button id="fitView" type="button" title="适合窗口"></button>
</div>
</div>
<div class="busy" id="busy">
<span id="busyText">正在重建浮雕</span>
</div>
</section>
<!-- RIGHT PANEL: Parameters & Export -->
<aside class="panel-right" aria-label="参数与导出">
<section class="section">
<div class="section-head">
<h2>透光与浮雕</h2>
<span class="section-note">mm</span>
</div>
<div class="control">
<label for="minThickness" title="最薄处透光厚度">最薄处</label>
<input id="minThickness" type="range" min="0.35" max="1.20" value="0.60" step="0.05">
<output class="value" for="minThickness">0.60</output>
</div>
<div class="control">
<label for="maxThickness" title="最厚处暗部厚度">最厚处</label>
<input id="maxThickness" type="range" min="1.10" max="3.20" value="2.20" step="0.05">
<output class="value" for="maxThickness">2.20</output>
</div>
<div class="control">
<label for="reliefMargin" title="数值越小,浮雕越贴近骨架边缘">边缘留量</label>
<input id="reliefMargin" type="range" min="0.00" max="1.80" value="0.00" step="0.05">
<output class="value" for="reliefMargin">0 mm</output>
</div>
<div class="control">
<label for="contrast">图像对比</label>
<input id="contrast" type="range" min="60" max="190" value="135" step="5">
<output class="value" for="contrast">135%</output>
</div>
<div class="control">
<label for="reliefGamma">暗部层次</label>
<input id="reliefGamma" type="range" min="0.55" max="2.20" value="0.95" step="0.05">
<output class="value" for="reliefGamma">0.95</output>
</div>
<div class="control">
<label for="blur">柔化噪点</label>
<input id="blur" type="range" min="0" max="4" value="0.5" step="0.5">
<output class="value" for="blur">0.5 px</output>
</div>
<div class="toggle-row">
<span>反相(适合底片/线稿)</span>
<label class="switch">
<input id="invert" type="checkbox">
<span aria-hidden="true"></span>
</label>
</div>
</section>
<section class="section">
<div class="section-head">
<h2>打印输出</h2>
<span class="section-note">STL / 3MF</span>
</div>
<div class="row-two">
<select id="detail" aria-label="网格精度">
<option value="draft">快速预览</option>
<option value="standard">标准打印</option>
<option value="fine">高精度打印</option>
<option value="ultra" selected>极致精度(推荐)</option>
<option value="reference">参考作品级(约 219 万面)</option>
</select>
<button class="mini-button" id="resetAll" type="button">恢复推荐值</button>
</div>
<div class="precision-guide" id="precisionGuide">
<span id="precisionHint">采样间距约 0.42 × 0.65 mm</span>
<strong id="precisionGrid">320 × 64 / 片</strong>
</div>
<label class="structure-field" for="threeMFStructure">
<span class="structure-copy">
<strong>3MF 拆分方式</strong>
<small id="threeMFHint">焊接为 1 个完整网格,适合直接切片</small>
</span>
<select id="threeMFStructure">
<option value="merged" selected>整扇单一对象</option>
<option value="parts">总成内可拆零件</option>
<option value="objects">独立对象</option>
</select>
</label>
<div class="toggle-row">
<span id="frameToggleLabel">包含「大型乙」骨架(不含转轴),并将浮雕焊接到扇叶</span>
<label class="switch">
<input id="includeFrame" type="checkbox" checked>
<span aria-hidden="true"></span>
</label>
</div>
<div class="export-actions">
<button class="export-button" id="exportBtn" type="button">导出 STL</button>
<button class="export-button secondary" id="export3mfBtn" type="button">导出 3MF</button>
</div>
<div class="pivot-download" id="pivotDownload">
<span class="pivot-mark" aria-hidden="true"></span>
<span class="pivot-copy">
<strong>转轴不再随模型导出</strong>
<small>STL / 3MF 均不含转轴,请单独下载配套零件。</small>
</span>
<a class="pivot-link" href="https://makerworld.com.cn/zh/models/2755291-tou-guang-fu-diao-zhe-shan-sheng-cheng-qi-1zhang-tu-pian-zhuan-3d-da-yin-zhe-shan#profileId-3201681" target="_blank" rel="noopener noreferrer" aria-label="前往 MakerWorld 下载配套转轴(新窗口打开)">下载转轴 ↗</a>
</div>
<div class="status" id="status" role="status" aria-live="polite">正在准备内置骨架(不含转轴)…</div>
</section>
</aside>
</main>
</div>
<canvas id="sourceCanvas" width="980" height="580" hidden></canvas>
<script src="JavaScript/config.js"></script>
<script src="JavaScript/geometry.js"></script>
<script src="JavaScript/image.js"></script>
<script src="JavaScript/canvas_editor.js"></script>
<script src="JavaScript/export.js"></script>
<script src="JavaScript/webgl.js"></script>
<script src="JavaScript/ui.js"></script>
<script src="JavaScript/initialize.js"></script>
</body>
</html>

BIN
pacifico/Pacifico.ttf Normal file

Binary file not shown.

Binary file not shown.