From 99604b396933f9bbb9bc5213bb2dc8e0f452c43c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Piellard?= Date: Tue, 7 Oct 2025 18:40:30 +0200 Subject: [PATCH 1/3] feat(las): new FilterOperator 'in' and 'not_in' --- src/sources/las/filter.ts | 81 ++++++++++++++++++++++++++------------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/src/sources/las/filter.ts b/src/sources/las/filter.ts index 3b0856658..4a19d6807 100644 --- a/src/sources/las/filter.ts +++ b/src/sources/las/filter.ts @@ -12,26 +12,50 @@ import type { DimensionName } from './dimension'; /** @internal */ export type FilterByIndex = (index: number) => boolean; -export type FilterOperator = 'equal' | 'less' | 'lessequal' | 'greater' | 'greaterequal' | 'not'; +export type FilterOperator = + | 'equal' + | 'less' + | 'lessequal' + | 'greater' + | 'greaterequal' + | 'not' + | 'in' + | 'not_in'; /** * A filter that can be applied to dimensions to filter out unwanted points during processing. */ -export type DimensionFilter = { - /** - * The dimension this filter applies to. - * If this dimension is not present in the source, the filter is ignored. - */ - dimension: DimensionName; - /** - * The operator of the predicate to apply to a specific dimension value. - */ - operator: FilterOperator; - /** - * The value to apply the predicate to. - */ - value: number; -}; +export type DimensionFilter = + | { + /** + * The dimension this filter applies to. + * If this dimension is not present in the source, the filter is ignored. + */ + dimension: DimensionName; + /** + * The operator of the predicate to apply to a specific dimension value. + */ + operator: Exclude; + /** + * The value to apply the predicate to. + */ + value: number; + } + | { + /** + * The dimension this filter applies to. + * If this dimension is not present in the source, the filter is ignored. + */ + dimension: DimensionName; + /** + * The operator of the predicate to apply to a specific dimension value. + */ + operator: Extract; + /** + * The values to apply the predicate to. + */ + values: Set; + }; /** * For a given point index, evaluate all filters in series. Returns `true` if all filters return @@ -45,23 +69,26 @@ export function evaluateFilters(filters: FilterByIndex[] | null, pointIndex: num return filters.every(f => f(pointIndex)); } -export function createPredicateFromFilter( - operator: FilterOperator, - value: number, -): (value: number) => boolean { +export function createPredicateFromFilter(filter: DimensionFilter): (value: number) => boolean { + const operator = filter.operator; + switch (operator) { case 'equal': - return x => x === value; + return x => x === filter.value; case 'less': - return x => x < value; + return x => x < filter.value; case 'lessequal': - return x => x <= value; + return x => x <= filter.value; case 'greater': - return x => x > value; + return x => x > filter.value; case 'greaterequal': - return x => x >= value; + return x => x >= filter.value; case 'not': - return x => x !== value; + return x => x !== filter.value; + case 'in': + return x => filter.values.has(x); + case 'not_in': + return x => !filter.values.has(x); default: throw new Error(`invalid filter operator: '${operator}'`); } @@ -78,7 +105,7 @@ export function getPerPointFilters(filters: DimensionFilter[], view: View): Filt const result: FilterByIndex[] = []; for (const filter of filters) { - const predicate = createPredicateFromFilter(filter.operator, filter.value); + const predicate = createPredicateFromFilter(filter); if (view.dimensions[filter.dimension] != null) { const getter = view.getter(filter.dimension); const filterFn = (i: number): boolean => predicate(getter(i)); -- GitLab From ba347329198db7b899ba26ba11e9743f33f044ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Piellard?= Date: Wed, 8 Oct 2025 17:07:41 +0200 Subject: [PATCH 2/3] docs(examples): enrich COPC example --- examples/copc.html | 28 ++++++++++--- examples/copc.js | 102 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 103 insertions(+), 27 deletions(-) diff --git a/examples/copc.html b/examples/copc.html index ce2391e33..4bee7dc0c 100644 --- a/examples/copc.html +++ b/examples/copc.html @@ -284,8 +284,8 @@ attribution: Autzen stadium dataset provided by + + + +
+ + diff --git a/examples/copc.js b/examples/copc.js index ede7d618d..d89f23d50 100644 --- a/examples/copc.js +++ b/examples/copc.js @@ -248,7 +248,7 @@ function updateDisplayedPointCounts(count, displayed) { activePointCountElement.title = numberFormat.format(displayed); } -let filters = [null, null, null]; +const filters = [null, null, null, null]; function updateFilters(source) { source.filters = options.enableFilters ? filters : null; @@ -436,27 +436,70 @@ async function load(url) { } } + const classificationFilterOperatorSelect = document.getElementById( + 'filter-classifications-operator', + ); + const updateClassificationFilter = () => { + const values = new Set(); + const checkedInputs = document.querySelectorAll('#filter-classifications input:checked'); + for (const input of checkedInputs) { + // @ts-expect-error we know this is a HTMLInputElement + values.add(parseInt(input.dataset.classification)); + } + + filters[3] = { + dimension: 'Classification', + // @ts-expect-error we know this is a select element + operator: classificationFilterOperatorSelect.value, + values, + }; + + updateFilters(source); + }; + classificationFilterOperatorSelect.addEventListener('change', updateClassificationFilter); + // Let's populate the classification list with default values from the ASPRS classifications. - addClassification(0, 'Created, never classified', entity.classifications); - addClassification(1, 'Unclassified', entity.classifications); - addClassification(2, 'Ground', entity.classifications); - addClassification(3, 'Low vegetation', entity.classifications); - addClassification(4, 'Medium vegetation', entity.classifications); - addClassification(5, 'High vegetation', entity.classifications); - addClassification(6, 'Building', entity.classifications); - addClassification(7, 'Low point (noise)', entity.classifications); - addClassification(8, 'Reserved', entity.classifications); - addClassification(9, 'Water', entity.classifications); - addClassification(10, 'Rail', entity.classifications); - addClassification(11, 'Road surface', entity.classifications); - addClassification(12, 'Reserved', entity.classifications); - addClassification(13, 'Wire - Guard (shield)', entity.classifications); - addClassification(14, 'Wire - Conductor (Phase)', entity.classifications); - addClassification(15, 'Transmission Tower', entity.classifications); - addClassification(16, 'Wire Structure connector (e.g Insulator)', entity.classifications); - addClassification(17, 'Bridge deck', entity.classifications); - addClassification(18, 'High noise', entity.classifications); + addClassification( + 0, + 'Created, never classified', + entity.classifications, + updateClassificationFilter, + ); + addClassification(1, 'Unclassified', entity.classifications, updateClassificationFilter); + addClassification(2, 'Ground', entity.classifications, updateClassificationFilter); + addClassification(3, 'Low vegetation', entity.classifications, updateClassificationFilter); + addClassification(4, 'Medium vegetation', entity.classifications, updateClassificationFilter); + addClassification(5, 'High vegetation', entity.classifications, updateClassificationFilter); + addClassification(6, 'Building', entity.classifications, updateClassificationFilter); + addClassification(7, 'Low point (noise)', entity.classifications, updateClassificationFilter); + addClassification(8, 'Reserved', entity.classifications, updateClassificationFilter); + addClassification(9, 'Water', entity.classifications, updateClassificationFilter); + addClassification(10, 'Rail', entity.classifications, updateClassificationFilter); + addClassification(11, 'Road surface', entity.classifications, updateClassificationFilter); + addClassification(12, 'Reserved', entity.classifications, updateClassificationFilter); + addClassification( + 13, + 'Wire - Guard (shield)', + entity.classifications, + updateClassificationFilter, + ); + addClassification( + 14, + 'Wire - Conductor (Phase)', + entity.classifications, + updateClassificationFilter, + ); + addClassification(15, 'Transmission Tower', entity.classifications, updateClassificationFilter); + addClassification( + 16, + 'Wire Structure connector (e.g Insulator)', + entity.classifications, + updateClassificationFilter, + ); + addClassification(17, 'Bridge deck', entity.classifications, updateClassificationFilter); + addClassification(18, 'High noise', entity.classifications, updateClassificationFilter); + updateClassificationFilter(); populateGUI(source); Inspector.attach('inspector', instance); @@ -488,7 +531,7 @@ document.getElementById('filename').innerText = fragments[fragments.length - 1]; const classificationNames = new Array(32); -function addClassification(number, name, array) { +function addClassification(number, name, array, updateClassificationFilter) { const currentColor = array[number].color.getHexString(); const template = ` @@ -523,6 +566,23 @@ function addClassification(number, name, array) { node.innerHTML = template; document.getElementById('classifications').appendChild(node); + const filter = document.createElement('div'); + filter.innerHTML = ` +
+ +
`; + filter.querySelector('input').addEventListener('change', updateClassificationFilter); + + document.getElementById(`filter-classifications`).appendChild(filter); + // Let's change the classification color with the color picker value bindColorPicker(`color-${number}`, v => { // Parse it into a THREE.js color -- GitLab From 54b973d656304254a0d02fba07ef3b31bf667238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Piellard?= Date: Wed, 8 Oct 2025 17:19:52 +0200 Subject: [PATCH 3/3] test: test createPredicateFromFilter --- test/unit/sources/las/filter.test.ts | 124 +++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 test/unit/sources/las/filter.test.ts diff --git a/test/unit/sources/las/filter.test.ts b/test/unit/sources/las/filter.test.ts new file mode 100644 index 000000000..7e553c3dc --- /dev/null +++ b/test/unit/sources/las/filter.test.ts @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2015-2018, IGN France. + * Copyright (c) 2018-2025, Giro3D team. + * SPDX-License-Identifier: MIT + */ + +import { expect, it } from 'vitest'; + +import { createPredicateFromFilter } from '@giro3d/giro3d/sources/las/filter'; + +it('filter equal', () => { + const predicate = createPredicateFromFilter({ + dimension: 'X', + operator: 'equal', + value: 10, + }); + + expect(predicate(10)).toEqual(true); + expect(predicate(9)).toEqual(false); + expect(predicate(11)).toEqual(false); +}); + +it('filter less', () => { + const predicate = createPredicateFromFilter({ + dimension: 'X', + operator: 'less', + value: 10, + }); + + expect(predicate(9)).toEqual(true); + expect(predicate(10)).toEqual(false); + expect(predicate(11)).toEqual(false); +}); + +it('filter lessequal', () => { + const predicate = createPredicateFromFilter({ + dimension: 'X', + operator: 'lessequal', + value: 10, + }); + + expect(predicate(9)).toEqual(true); + expect(predicate(10)).toEqual(true); + expect(predicate(11)).toEqual(false); +}); + +it('filter greater', () => { + const predicate = createPredicateFromFilter({ + dimension: 'X', + operator: 'greater', + value: 10, + }); + + expect(predicate(9)).toEqual(false); + expect(predicate(10)).toEqual(false); + expect(predicate(11)).toEqual(true); +}); + +it('filter greaterequal', () => { + const predicate = createPredicateFromFilter({ + dimension: 'X', + operator: 'greaterequal', + value: 10, + }); + + expect(predicate(9)).toEqual(false); + expect(predicate(10)).toEqual(true); + expect(predicate(11)).toEqual(true); +}); + +it('filter not', () => { + const predicate = createPredicateFromFilter({ + dimension: 'X', + operator: 'not', + value: 10, + }); + + expect(predicate(9)).toEqual(true); + expect(predicate(10)).toEqual(false); + expect(predicate(11)).toEqual(true); +}); + +it('filter in', () => { + const predicate = createPredicateFromFilter({ + dimension: 'X', + operator: 'in', + values: new Set([10, 20, 30]), + }); + + expect(predicate(9)).toEqual(false); + expect(predicate(10)).toEqual(true); + expect(predicate(11)).toEqual(false); + expect(predicate(20)).toEqual(true); + expect(predicate(21)).toEqual(false); + expect(predicate(30)).toEqual(true); + expect(predicate(31)).toEqual(false); +}); + +it('filter not_in', () => { + const predicate = createPredicateFromFilter({ + dimension: 'X', + operator: 'not_in', + values: new Set([10, 20, 30]), + }); + + expect(predicate(9)).toEqual(true); + expect(predicate(10)).toEqual(false); + expect(predicate(11)).toEqual(true); + expect(predicate(20)).toEqual(false); + expect(predicate(21)).toEqual(true); + expect(predicate(30)).toEqual(false); + expect(predicate(31)).toEqual(true); +}); + +it('should throw on unknown operator', () => { + expect(() => + createPredicateFromFilter({ + // @ts-expect-error we want to test an invalid operator + operator: 'foo', + dimension: 'X', + value: 10, + }), + ).toThrow("invalid filter operator: 'foo'"); +}); -- GitLab