feat(view): colour the ③ 3D rebar by diameter or by member, with a legend
Role colours stay the default (spec §6.7). A control on ③ switches to diameter (nominal size, H and S of one size share a colour, Okabe–Ito palette) or member (받침블럭 · 코핑 · 기둥 · 확대기초). The legend lists only the items the model has. Highlighting and the state colours are unchanged. Approved 2026-10-23. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d5ad7e997
commit
d35e27e7de
6 files changed
+176
-3
No files matched your search
@@ -152,6 +152,13 @@
|
|||||||
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
|
<p class="pnote">형상은 ② 형상계획 기준입니다. 형상이 바뀌면 "형상 바뀜"으로 표시합니다.</p>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="center-col">
|
<div class="center-col">
|
||||||
|
<div class="rebar-color" id="rebar-color" role="group" aria-label="철근 색 기준">
|
||||||
|
<span class="lbl">색 기준</span>
|
||||||
|
<button type="button" data-color="role" aria-pressed="true">역할</button>
|
||||||
|
<button type="button" data-color="diameter" aria-pressed="false">직경</button>
|
||||||
|
<button type="button" data-color="member" aria-pressed="false">부재</button>
|
||||||
|
<ul id="rebar-legend" class="rebar-legend"></ul>
|
||||||
|
</div>
|
||||||
<div class="viewer-slot" id="slot-rebar"></div>
|
<div class="viewer-slot" id="slot-rebar"></div>
|
||||||
<section class="panel viewer" aria-labelledby="rebar-title">
|
<section class="panel viewer" aria-labelledby="rebar-title">
|
||||||
<div class="viewer-head"><h2 id="rebar-title">철근표</h2></div>
|
<div class="viewer-head"><h2 id="rebar-title">철근표</h2></div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Colour keys of the ③ 3D rebar (spec §6.7). Pure: no three.js here. The palettes are in the view.
|
||||||
|
*/
|
||||||
|
export type RebarMemberKey = 'bearing' | 'coping' | 'column' | 'footing' | 'other';
|
||||||
|
|
||||||
|
/** Member of a bar from its 철근표 name (C = coping, P = column, B = footing, S = bearing block, 커플러 = column). */
|
||||||
|
export function memberOfBar(name: string): RebarMemberKey {
|
||||||
|
if (name.includes('커플러') || /^P\d/.test(name)) return 'column';
|
||||||
|
if (/^C\d/.test(name)) return 'coping';
|
||||||
|
if (/^B\d/.test(name)) return 'footing';
|
||||||
|
if (/^S\d/.test(name)) return 'bearing';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominal diameters (mm). H and S of the same size share one group (decision 2026-10-23): D13 12.7 · H16/S16 15.9 · …
|
||||||
|
* The order is the colour order of the 직경 mode.
|
||||||
|
*/
|
||||||
|
export const DIAMETER_GROUPS: readonly { mm: number; label: string }[] = [
|
||||||
|
{ mm: 12.7, label: 'D13' },
|
||||||
|
{ mm: 15.9, label: 'H16 · S16' },
|
||||||
|
{ mm: 19.1, label: 'H19 · S19' },
|
||||||
|
{ mm: 22.2, label: 'D22' },
|
||||||
|
{ mm: 25.4, label: 'H25 · S25' },
|
||||||
|
{ mm: 28.6, label: 'H29' },
|
||||||
|
{ mm: 31.8, label: 'S32' },
|
||||||
|
{ mm: 34.9, label: 'D35' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Index into {@link DIAMETER_GROUPS}, or -1 for a diameter that is not a rebar size (e.g. a coupler). */
|
||||||
|
export function diameterGroupIndex(dMm: number): number {
|
||||||
|
return DIAMETER_GROUPS.findIndex((g) => Math.abs(g.mm - dMm) < 0.05);
|
||||||
|
}
|
||||||
+33
-1
@@ -12,7 +12,7 @@ import { applyCopy, DEFAULT_COPY_ITEMS, planCopy, type CopyItem } from './core/c
|
|||||||
import { pileGeometry } from './core/geometry';
|
import { pileGeometry } from './core/geometry';
|
||||||
import { rebarSummary, type RebarRow } from './core/rebar/summary';
|
import { rebarSummary, type RebarRow } from './core/rebar/summary';
|
||||||
import { shearSegments } from './core/rebar/coping';
|
import { shearSegments } from './core/rebar/coping';
|
||||||
import { buildRebar3D, type RebarScene } from './view/rebar3d';
|
import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar3d';
|
||||||
import { DEFAULT_MATERIAL, FCK_MEMBERS, isAssumedMaterial, type FckByMember, type FckMember, type Material } from './core/rebar/common';
|
import { DEFAULT_MATERIAL, FCK_MEMBERS, isAssumedMaterial, type FckByMember, type FckMember, type Material } from './core/rebar/common';
|
||||||
import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection';
|
import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/selection';
|
||||||
import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options';
|
import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options';
|
||||||
@@ -80,6 +80,8 @@ const state = {
|
|||||||
fckByMember: {} as FckByMember,
|
fckByMember: {} as FckByMember,
|
||||||
/** Bar highlighted on ③ after [보기] (시안 ③ 알림). */
|
/** Bar highlighted on ③ after [보기] (시안 ③ 알림). */
|
||||||
rebarFocus: null as string | null,
|
rebarFocus: null as string | null,
|
||||||
|
/** Colour basis of the 3D rebar on ③: role (default), diameter, or member. */
|
||||||
|
rebarColor: 'role' as RebarColorMode,
|
||||||
/** Member tab on ③ (철근표). */
|
/** Member tab on ③ (철근표). */
|
||||||
rebarMember: 'coping' as RebarRow['member'],
|
rebarMember: 'coping' as RebarRow['member'],
|
||||||
/** Member card selected on ② (시안 ② 부재 목록). */
|
/** Member card selected on ② (시안 ② 부재 목록). */
|
||||||
@@ -229,6 +231,7 @@ function setupScene(): void {
|
|||||||
if (pier?.model) {
|
if (pier?.model) {
|
||||||
rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember);
|
rebar3d = buildRebar3D(pier.model, state.material, state.fckByMember);
|
||||||
rebar3d.group.visible = state.stage === 'rebar';
|
rebar3d.group.visible = state.stage === 'rebar';
|
||||||
|
rebar3d.setColorMode(state.rebarColor);
|
||||||
world.add(rebar3d.group);
|
world.add(rebar3d.group);
|
||||||
const scene = buildPierScene(pier.model);
|
const scene = buildPierScene(pier.model);
|
||||||
for (const key of Object.keys(state.visible) as PartName[]) {
|
for (const key of Object.keys(state.visible) as PartName[]) {
|
||||||
@@ -415,6 +418,7 @@ function renderAll(): void {
|
|||||||
renderEditCard();
|
renderEditCard();
|
||||||
renderPlan();
|
renderPlan();
|
||||||
renderRebar();
|
renderRebar();
|
||||||
|
renderRebarColor();
|
||||||
scheduleBackup();
|
scheduleBackup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,6 +769,34 @@ const REBAR_MEMBERS: { id: RebarRow['member']; label: string }[] = [
|
|||||||
{ id: 'footing', label: '확대기초' },
|
{ id: 'footing', label: '확대기초' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 3D colour basis buttons on ③ and their legend (spec §6.7). */
|
||||||
|
function renderRebarColor(): void {
|
||||||
|
for (const b of document.querySelectorAll<HTMLButtonElement>('#rebar-color button')) {
|
||||||
|
b.setAttribute('aria-pressed', String(b.dataset.color === state.rebarColor));
|
||||||
|
}
|
||||||
|
const legend = $<HTMLUListElement>('rebar-legend');
|
||||||
|
legend.replaceChildren();
|
||||||
|
const items = rebar3d?.legend(state.rebarColor) ?? [];
|
||||||
|
if (state.rebarColor === 'role') {
|
||||||
|
legend.appendChild(el('li', '역할별 색 (명세 §6.7)', 'muted small'));
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
const li = el('li', undefined, 'rebar-legend-item');
|
||||||
|
const swatch = el('span', undefined, 'swatch');
|
||||||
|
swatch.style.background = item.color;
|
||||||
|
li.append(swatch, el('span', item.label));
|
||||||
|
legend.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const b of document.querySelectorAll<HTMLButtonElement>('#rebar-color button')) {
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
state.rebarColor = b.dataset.color as RebarColorMode;
|
||||||
|
rebar3d?.setColorMode(state.rebarColor);
|
||||||
|
renderRebarColor();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderRebar(): void {
|
function renderRebar(): void {
|
||||||
const pier = activePier();
|
const pier = activePier();
|
||||||
// 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②.
|
// 교각 목록 with the 배근 status (시안 ③). The checks are the same as in ① and ②.
|
||||||
|
|||||||
@@ -697,3 +697,11 @@ body { background: #eef1f4; font-family: 'Noto Sans KR', system-ui, sans-serif;
|
|||||||
.center-col { flex: 999 1 560px; min-width: 0; display: flex; flex-direction: column; gap: 16px; }
|
.center-col { flex: 999 1 560px; min-width: 0; display: flex; flex-direction: column; gap: 16px; }
|
||||||
.center-col > .viewer { flex: none; }
|
.center-col > .viewer { flex: none; }
|
||||||
|
|
||||||
|
|
||||||
|
/* ③ 3D 색 기준 (spec §6.7): 조작 요소 44px */
|
||||||
|
.rebar-color { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 6px 0; }
|
||||||
|
.rebar-color button { min-height: 44px; min-width: 56px; padding: 0 12px; border: 1px solid #b9c4d0; border-radius: 6px; background: #fff; cursor: pointer; }
|
||||||
|
.rebar-color button[aria-pressed='true'] { background: #1f5fa8; border-color: #1f5fa8; color: #fff; }
|
||||||
|
.rebar-legend { display: flex; flex-wrap: wrap; gap: 10px; list-style: none; margin: 0; padding: 0; font-size: 13px; }
|
||||||
|
.rebar-legend-item { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.rebar-legend .swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; }
|
||||||
+39
-2
@@ -6,6 +6,7 @@ import { C14_INSET, c14Bars, CAP, COPING_REBAR, shearBars, shearBarsMulti, shear
|
|||||||
import { columnRebar, COLUMN_P1 } from '../core/rebar/column';
|
import { columnRebar, COLUMN_P1 } from '../core/rebar/column';
|
||||||
import { barsAcross, BLOCK_REBAR } from '../core/rebar/bearing-block';
|
import { barsAcross, BLOCK_REBAR } from '../core/rebar/bearing-block';
|
||||||
import { DEFAULT_MATERIAL, FckByMember, hookLength, Material, materialFor, NOMINAL_DIAMETER } from '../core/rebar/common';
|
import { DEFAULT_MATERIAL, FckByMember, hookLength, Material, materialFor, NOMINAL_DIAMETER } from '../core/rebar/common';
|
||||||
|
import { diameterGroupIndex, DIAMETER_GROUPS, memberOfBar, type RebarMemberKey } from '../core/rebar/color';
|
||||||
import { stretchMatrix } from './scene';
|
import { stretchMatrix } from './scene';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,8 +19,21 @@ export interface RebarScene {
|
|||||||
byName: Map<string, THREE.Mesh[]>;
|
byName: Map<string, THREE.Mesh[]>;
|
||||||
/** Highlight one bar name (or none): the others are dimmed. */
|
/** Highlight one bar name (or none): the others are dimmed. */
|
||||||
highlight(name: string | null): void;
|
highlight(name: string | null): void;
|
||||||
|
/** Colour the bars by role (default, spec §6.7), by nominal diameter, or by member. */
|
||||||
|
setColorMode(mode: RebarColorMode): void;
|
||||||
|
/** Legend entries of the mode, only for the items this model has. Role mode has none (the role colours are in §6.7). */
|
||||||
|
legend(mode: RebarColorMode): { label: string; color: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RebarColorMode = 'role' | 'diameter' | 'member';
|
||||||
|
|
||||||
|
/** Okabe–Ito colours (colour-blind safe), one per diameter group in order; grey for anything else. */
|
||||||
|
const DIAMETER_COLORS = [0xe69f00, 0x56b4e9, 0x009e73, 0x0072b2, 0xd55e00, 0xcc79a7, 0x000000, 0x8c8c8c];
|
||||||
|
const OTHER_COLOR = 0x8c8c8c;
|
||||||
|
const MEMBER_COLORS: Record<RebarMemberKey, number> = { bearing: 0xe69f00, coping: 0x0072b2, column: 0x009e73, footing: 0xcc79a7, other: OTHER_COLOR };
|
||||||
|
const MEMBER_LABELS: Record<RebarMemberKey, string> = { bearing: '받침블럭', coping: '코핑', column: '기둥', footing: '확대기초', other: '기타' };
|
||||||
|
const hex = (v: number) => `#${v.toString(16).padStart(6, '0')}`;
|
||||||
|
|
||||||
const COLOR = {
|
const COLOR = {
|
||||||
coping: 0xb4531f,
|
coping: 0xb4531f,
|
||||||
shear: 0x6b3fa0,
|
shear: 0x6b3fa0,
|
||||||
@@ -54,7 +68,7 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
geo.applyMatrix4(Ms[k]);
|
geo.applyMatrix4(Ms[k]);
|
||||||
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.6, metalness: 0.1 });
|
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.6, metalness: 0.1 });
|
||||||
const mesh = new THREE.Mesh(geo, mat);
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
mesh.userData.name = name;
|
mesh.userData = { name, roleColor: color, dMm, member: memberOfBar(name) };
|
||||||
group.add(mesh);
|
group.add(mesh);
|
||||||
const list = byName.get(name) ?? [];
|
const list = byName.get(name) ?? [];
|
||||||
list.push(mesh);
|
list.push(mesh);
|
||||||
@@ -436,5 +450,28 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return { group, byName, highlight };
|
const colorOf = (mesh: THREE.Mesh, mode: RebarColorMode): number => {
|
||||||
|
const data = mesh.userData as { roleColor: number; dMm: number; member: RebarMemberKey };
|
||||||
|
if (mode === 'role') return data.roleColor;
|
||||||
|
if (mode === 'member') return MEMBER_COLORS[data.member];
|
||||||
|
const idx = diameterGroupIndex(data.dMm);
|
||||||
|
return idx < 0 ? OTHER_COLOR : DIAMETER_COLORS[idx];
|
||||||
|
};
|
||||||
|
const setColorMode = (mode: RebarColorMode) => {
|
||||||
|
for (const meshes of byName.values()) {
|
||||||
|
for (const mesh of meshes) (mesh.material as THREE.MeshStandardMaterial).color.setHex(colorOf(mesh, mode));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const legend = (mode: RebarColorMode) => {
|
||||||
|
const all = [...byName.values()].flat().map((mesh) => mesh.userData as { dMm: number; member: RebarMemberKey });
|
||||||
|
if (mode === 'role') return [];
|
||||||
|
if (mode === 'member') {
|
||||||
|
const present = new Set(all.map((d) => d.member));
|
||||||
|
return (Object.keys(MEMBER_LABELS) as RebarMemberKey[]).filter((k) => present.has(k)).map((k) => ({ label: MEMBER_LABELS[k], color: hex(MEMBER_COLORS[k]) }));
|
||||||
|
}
|
||||||
|
const present = new Set(all.map((d) => diameterGroupIndex(d.dMm)));
|
||||||
|
const items = DIAMETER_GROUPS.map((g, i) => ({ label: g.label, color: hex(DIAMETER_COLORS[i]) })).filter((_, i) => present.has(i));
|
||||||
|
return present.has(-1) ? [...items, { label: '기타', color: hex(OTHER_COLOR) }] : items;
|
||||||
|
};
|
||||||
|
return { group, byName, highlight, setColorMode, legend };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { buildPier } from '../src/core/geometry';
|
||||||
|
import { DIAMETER_GROUPS, diameterGroupIndex, memberOfBar } from '../src/core/rebar/color';
|
||||||
|
import { buildRebar3D } from '../src/view/rebar3d';
|
||||||
|
import { loadP1 } from './helpers';
|
||||||
|
|
||||||
|
const rb = buildRebar3D(buildPier(loadP1()));
|
||||||
|
const colorOf = (name: string) => (rb.byName.get(name)![0].material as THREE.MeshStandardMaterial).color.getHex();
|
||||||
|
|
||||||
|
describe('③ 3D 색 기준 (spec §6.7 승인 2026-10-23)', () => {
|
||||||
|
it('every bar of P1 belongs to a known member (none falls into 기타)', () => {
|
||||||
|
const names = [...rb.byName.keys()];
|
||||||
|
expect(names.filter((n) => memberOfBar(n) === 'other')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('H and S of the same size share one diameter group; a non-rebar diameter has none', () => {
|
||||||
|
expect(DIAMETER_GROUPS[diameterGroupIndex(15.9)].label).toBe('H16 · S16');
|
||||||
|
expect(DIAMETER_GROUPS[diameterGroupIndex(25.4)].label).toBe('H25 · S25');
|
||||||
|
expect(DIAMETER_GROUPS).toHaveLength(8);
|
||||||
|
expect(diameterGroupIndex(60)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('role is the default colour; diameter and member modes recolour every bar and can switch back', () => {
|
||||||
|
const role = colorOf('C1');
|
||||||
|
rb.setColorMode('member');
|
||||||
|
expect(colorOf('C1')).not.toBe(role);
|
||||||
|
rb.setColorMode('diameter');
|
||||||
|
expect(colorOf('C1')).not.toBe(role);
|
||||||
|
rb.setColorMode('role');
|
||||||
|
expect(colorOf('C1')).toBe(role);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('member mode: all bars of one member share one colour', () => {
|
||||||
|
rb.setColorMode('member');
|
||||||
|
expect(colorOf('C1')).toBe(colorOf('C14'));
|
||||||
|
expect(colorOf('P1')).toBe(colorOf('P6'));
|
||||||
|
expect(colorOf('C1')).not.toBe(colorOf('P1'));
|
||||||
|
rb.setColorMode('role');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('diameter mode: bars of one size share a colour, a different size does not (C1 and C7 are H29, C9 is H25)', () => {
|
||||||
|
rb.setColorMode('diameter');
|
||||||
|
expect(colorOf('C1')).toBe(colorOf('C7'));
|
||||||
|
expect(colorOf('C1')).not.toBe(colorOf('C9'));
|
||||||
|
rb.setColorMode('role');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legend lists only the items the model has; role mode has none', () => {
|
||||||
|
expect(rb.legend('role')).toEqual([]);
|
||||||
|
expect(rb.legend('member').map((i) => i.label)).toEqual(['받침블럭', '코핑', '기둥', '확대기초']);
|
||||||
|
const sizes = rb.legend('diameter').map((i) => i.label);
|
||||||
|
expect(sizes.length).toBeGreaterThan(0);
|
||||||
|
expect(sizes.length).toBeLessThanOrEqual(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user