Skip to content

Commit 4889212

Browse files
kunifujiwaraCopilot
andcommitted
feat: enhance zoning functionality with surface selection and type badges
Co-authored-by: Copilot <copilot@github.com>
1 parent 7de8a7d commit 4889212

6 files changed

Lines changed: 151 additions & 25 deletions

File tree

app/frontend/src/index.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,9 @@ input:focus, select:focus {
633633
.zone-row.selected { background: rgba(0, 0, 0, 0.06); }
634634
.zone-row .swatch { width: 14px; height: 14px; border-radius: 3px; flex: 0 0 14px; border: 1px solid rgba(0,0,0,0.2); cursor: pointer; }
635635
.zone-row .name { flex: 1; }
636+
.zone-row .zone-type-badge { flex: 0 0 auto; min-width: 32px; padding: 1px 6px; border-radius: 4px; border: 1px solid rgba(0,0,0,0.1); background: rgba(0,0,0,0.05); color: #334155; font-size: 0.72rem; font-weight: 700; line-height: 1.45; text-align: center; }
637+
.zone-row .zone-type-badge.two-d { background: rgba(37, 99, 235, 0.12); border-color: rgba(37, 99, 235, 0.22); color: #1e3a8a; }
638+
.zone-row .zone-type-badge.building { background: rgba(5, 150, 105, 0.13); border-color: rgba(5, 150, 105, 0.24); color: #064e3b; }
636639
.zone-row button { background: none; border: none; cursor: pointer; padding: 2px 4px; }
637640

638641
.zone-stats-table { margin-top: 12px; font-size: 13px; }

app/frontend/src/tabs/ZoningTab.tsx

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,13 @@ import {
3030
toggleBulkSelector,
3131
buildingHasPositiveSelection,
3232
WallOrientation,
33+
resolveZoneGroupForMode,
34+
zoneGroupType,
35+
zoneTypeShortLabel,
3336
} from '../types/zones';
3437
import type { PickResult } from '../three/types';
3538
import { useSurfaceZoneSelection } from '../hooks/useSurfaceZoneSelection';
39+
import { getSurfaceZones, shouldEnableZoningSurfaceSelection } from '../three/surfaceSelection';
3640

3741
interface ZoningTabProps {
3842
hasModel: boolean;
@@ -113,24 +117,6 @@ const ZoningTab: React.FC<ZoningTabProps> = ({ hasModel, figureJson, zones, onZo
113117

114118
const interaction: MapInteraction = shape === 'rect' ? 'draw_rect_3pt' : 'draw_polygon';
115119

116-
// Render existing zones as paint_zone overlays.
117-
const pendingEdits: PendingEdit[] = useMemo(() => {
118-
if (!geo) return [];
119-
return zones.flatMap((z) => {
120-
if (z.type !== 'horizontal') return [];
121-
return [{
122-
kind: 'paint_zone' as const,
123-
cells: polygonToCells(z.ring_lonlat, geo.grid_geom),
124-
ring: z.ring_lonlat,
125-
selected:
126-
z.id === selectedId ||
127-
(activeGroupId != null && (z.groupId ?? z.id) === activeGroupId),
128-
color: z.color,
129-
target: 'evaluation' as const,
130-
}];
131-
});
132-
}, [geo, zones, selectedId, activeGroupId]);
133-
134120
/**
135121
* Group sibling zones (same `groupId`) together so the zone list shows
136122
* one row per logical zone. The first member's metadata acts as the
@@ -159,16 +145,37 @@ const ZoningTab: React.FC<ZoningTabProps> = ({ hasModel, figureJson, zones, onZo
159145
return [...committed, ...drafts];
160146
}, [committedGroups, draftGroups]);
161147

162-
// Resolve which group new polygons attach to: explicit `activeGroupId`,
163-
// else the most recent group, else "start a new one".
164-
const effectiveActiveGroupId =
165-
activeGroupId ?? (groups.length > 0 ? groups[groups.length - 1].id : null);
148+
const effectiveActiveGroupId = useMemo(
149+
() => resolveZoneGroupForMode({ zones, candidates: groups, activeGroupId, zoneType }),
150+
[zones, groups, activeGroupId, zoneType],
151+
);
152+
153+
// Render existing zones as paint_zone overlays.
154+
const pendingEdits: PendingEdit[] = useMemo(() => {
155+
if (!geo) return [];
156+
return zones.flatMap((z) => {
157+
if (z.type !== 'horizontal') return [];
158+
return [{
159+
kind: 'paint_zone' as const,
160+
cells: polygonToCells(z.ring_lonlat, geo.grid_geom),
161+
ring: z.ring_lonlat,
162+
selected: zoneType === 'horizontal' && (
163+
z.id === selectedId ||
164+
(effectiveActiveGroupId != null && (z.groupId ?? z.id) === effectiveActiveGroupId)
165+
),
166+
color: z.color,
167+
target: 'evaluation' as const,
168+
}];
169+
});
170+
}, [geo, zones, selectedId, effectiveActiveGroupId, zoneType]);
171+
const surfaceZoneCount = useMemo(() => getSurfaceZones(zones).length, [zones]);
172+
const surfaceSelectionEnabled = shouldEnableZoningSurfaceSelection({ zoneType, surfaceZoneCount });
166173

167174
const { surfaceSelection } = useSurfaceZoneSelection({
168175
hasModel,
169176
geometryToken,
170177
zones,
171-
enabled: zoneType === 'building_surface',
178+
enabled: surfaceSelectionEnabled,
172179
displayMode: 'fill',
173180
activeGroupId: effectiveActiveGroupId,
174181
requireSurfaceZones: false,
@@ -447,11 +454,13 @@ const ZoningTab: React.FC<ZoningTabProps> = ({ hasModel, figureJson, zones, onZo
447454
)}
448455
{groups.map((g) => {
449456
const isActive = (effectiveActiveGroupId ?? null) === g.id;
457+
const groupType = g.draft ? 'horizontal' : zoneGroupType(zones, g.id);
450458
return (
451459
<div
452460
key={g.id}
453461
className={`zone-row${isActive ? ' selected' : ''}`}
454462
onClick={() => {
463+
if (groupType) setZoneType(groupType);
455464
setActiveGroupId(g.id);
456465
setSelectedId(g.members[0]?.id ?? null);
457466
setRefiningBuildingId(null);
@@ -495,6 +504,12 @@ const ZoningTab: React.FC<ZoningTabProps> = ({ hasModel, figureJson, zones, onZo
495504
)}
496505
</span>
497506
)}
507+
<span
508+
className={`zone-type-badge ${groupType === 'building_surface' ? 'building' : 'two-d'}`}
509+
title={groupType === 'building_surface' ? 'Building surface zone' : '2D area zone'}
510+
>
511+
{groupType ? zoneTypeShortLabel(groupType) : 'Mixed'}
512+
</span>
498513
<button
499514
title="Rename"
500515
onClick={(e) => {
@@ -654,7 +669,7 @@ const ZoningTab: React.FC<ZoningTabProps> = ({ hasModel, figureJson, zones, onZo
654669
showZones
655670
colorOverride={colorOverride}
656671
onPick={zoneType === 'building_surface' ? handleSurfacePick : undefined}
657-
surfaceSelection={zoneType === 'building_surface' ? surfaceSelection : null}
672+
surfaceSelection={surfaceSelection}
658673
/>
659674
) : (
660675
<div className="alert alert-info" style={{ marginTop: 0 }}>

app/frontend/src/three/surfaceSelection.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getSurfaceZones,
1010
isSurfaceFaceSelected,
1111
shouldFetchSurfaceSelection,
12+
shouldEnableZoningSurfaceSelection,
1213
shouldMountPickableSurface,
1314
surfaceLoadErrorResult,
1415
surfaceTriangleCount,
@@ -91,6 +92,12 @@ describe('surface selection payload decisions', () => {
9192
expect(shouldFetchSurfaceSelection({ hasModel: true, enabled: true, surfaceZoneCount: 0, requireSurfaceZones: true })).toBe(false);
9293
});
9394

95+
it('keeps Zoning tab surface highlights enabled outside building-surface edit mode when surface zones exist', () => {
96+
expect(shouldEnableZoningSurfaceSelection({ zoneType: 'horizontal', surfaceZoneCount: 1 })).toBe(true);
97+
expect(shouldEnableZoningSurfaceSelection({ zoneType: 'building_surface', surfaceZoneCount: 0 })).toBe(true);
98+
expect(shouldEnableZoningSurfaceSelection({ zoneType: 'horizontal', surfaceZoneCount: 0 })).toBe(false);
99+
});
100+
94101
it('builds no enabled payload when geometry metadata length does not match triangle count', () => {
95102
const chunk: MeshChunkDto = {
96103
name: 'surface',

app/frontend/src/three/surfaceSelection.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as THREE from 'three';
22
import type { BuildingSurfacesResponse, MeshChunkDto, SurfaceFaceMetaDto } from '../api';
3-
import type { BuildingSurfaceZone, SurfaceSelector, WallOrientation, Zone } from '../types/zones';
3+
import type { BuildingSurfaceZone, SurfaceSelector, WallOrientation, Zone, ZoneType } from '../types/zones';
44
import type { SurfaceFaceMeta } from './types';
55

66
export type SurfaceSelectionDisplayMode = 'fill' | 'boundary';
@@ -103,6 +103,13 @@ export function shouldFetchSurfaceSelection(options: {
103103
return options.hasModel && options.enabled && (!options.requireSurfaceZones || options.surfaceZoneCount > 0);
104104
}
105105

106+
export function shouldEnableZoningSurfaceSelection(options: {
107+
zoneType: ZoneType;
108+
surfaceZoneCount: number;
109+
}): boolean {
110+
return options.zoneType === 'building_surface' || options.surfaceZoneCount > 0;
111+
}
112+
106113
export function shouldMountPickableSurface(
107114
onPick: unknown,
108115
surfaceSelection: SceneSurfaceSelectionSpec | null | undefined,

app/frontend/src/types/zones.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import {
55
toggleWholeBuilding,
66
toggleBulkSelector,
77
buildingHasPositiveSelection,
8+
resolveZoneGroupForMode,
9+
zoneGroupType,
10+
zoneTypeShortLabel,
811
type SurfaceSelector,
912
type SurfacePickMeta,
13+
type Zone,
1014
} from './zones';
1115

1216
describe('normalizeSurfaceSelectors', () => {
@@ -99,3 +103,64 @@ describe('buildingHasPositiveSelection', () => {
99103
).toBe(false);
100104
});
101105
});
106+
107+
describe('zone group edit mode compatibility', () => {
108+
const zones: Zone[] = [
109+
{
110+
id: 'h1',
111+
name: 'Area',
112+
color: '#ff0000',
113+
type: 'horizontal',
114+
shape: 'rect',
115+
ring_lonlat: [[0, 0], [1, 0], [1, 1]],
116+
groupId: 'area-group',
117+
},
118+
{
119+
id: 's1',
120+
name: 'Surface',
121+
color: '#00ff00',
122+
type: 'building_surface',
123+
selectors: [],
124+
groupId: 'surface-group',
125+
},
126+
];
127+
128+
it('reports the committed type for a zone group', () => {
129+
expect(zoneGroupType(zones, 'area-group')).toBe('horizontal');
130+
expect(zoneGroupType(zones, 'surface-group')).toBe('building_surface');
131+
});
132+
133+
it('does not resolve a building surface group while editing 2D areas', () => {
134+
expect(resolveZoneGroupForMode({
135+
zones,
136+
candidates: [
137+
{ id: 'area-group', draft: false },
138+
{ id: 'surface-group', draft: false },
139+
],
140+
activeGroupId: 'surface-group',
141+
zoneType: 'horizontal',
142+
})).toBeNull();
143+
});
144+
145+
it('treats draft groups as 2D area groups only', () => {
146+
expect(resolveZoneGroupForMode({
147+
zones,
148+
candidates: [{ id: 'draft-group', draft: true }],
149+
activeGroupId: 'draft-group',
150+
zoneType: 'horizontal',
151+
})).toBe('draft-group');
152+
expect(resolveZoneGroupForMode({
153+
zones,
154+
candidates: [{ id: 'draft-group', draft: true }],
155+
activeGroupId: 'draft-group',
156+
zoneType: 'building_surface',
157+
})).toBeNull();
158+
});
159+
});
160+
161+
describe('zone type display labels', () => {
162+
it('uses compact labels for zone-list badges', () => {
163+
expect(zoneTypeShortLabel('horizontal')).toBe('2D');
164+
expect(zoneTypeShortLabel('building_surface')).toBe('Building');
165+
});
166+
});

app/frontend/src/types/zones.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,35 @@ export function zoneGroupType(zones: Zone[], groupId: string): ZoneType | null {
110110
return types.size === 1 ? (members[0].type as ZoneType) : null;
111111
}
112112

113+
export function zoneTypeShortLabel(zoneType: ZoneType): '2D' | 'Building' {
114+
return zoneType === 'horizontal' ? '2D' : 'Building';
115+
}
116+
117+
export interface ZoneGroupModeCandidate {
118+
id: string;
119+
draft?: boolean;
120+
}
121+
122+
export function resolveZoneGroupForMode(options: {
123+
zones: Zone[];
124+
candidates: ZoneGroupModeCandidate[];
125+
activeGroupId: string | null;
126+
zoneType: ZoneType;
127+
}): string | null {
128+
const fallbackCandidate = options.candidates.length > 0
129+
? options.candidates[options.candidates.length - 1]
130+
: null;
131+
const candidateId = options.activeGroupId ?? fallbackCandidate?.id ?? null;
132+
if (!candidateId) return null;
133+
134+
const candidate = options.candidates.find((group) => group.id === candidateId);
135+
if (candidate?.draft) {
136+
return options.zoneType === 'horizontal' ? candidateId : null;
137+
}
138+
139+
return zoneGroupType(options.zones, candidateId) === options.zoneType ? candidateId : null;
140+
}
141+
113142
/** Human-readable summary for a building surface zone. */
114143
export function surfaceZoneSummary(zone: BuildingSurfaceZone): string {
115144
const buildingIds = [...new Set(zone.selectors.map((s) => s.buildingId))];

0 commit comments

Comments
 (0)